diff --git a/DESCRIPTION b/DESCRIPTION index d091735..fc75592 100755 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -39,6 +39,7 @@ Imports: withr, yaml Remotes: - posit-dev/shinychat/pkg-r + tidyverse/ellmer@d26b150, + posit-dev/shinychat/pkg-r@cf9d098 URL: https://simonpcouch.github.io/side/ VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index 7ac2c7b..cf92533 100755 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,13 @@ # Generated by roxygen2: do not edit by hand +S3method(supports_thinking,ProviderAnthropic) +S3method(supports_thinking,ProviderGoogleGemini) +S3method(supports_thinking,ProviderOpenAI) +S3method(supports_thinking,default) +S3method(toggle_thinking,ProviderAnthropic) +S3method(toggle_thinking,ProviderGoogleGemini) +S3method(toggle_thinking,ProviderOpenAI) +S3method(toggle_thinking,default) export(kick) import(ellmer) import(rlang) diff --git a/NEWS.md b/NEWS.md index 50ff21b..fb6a460 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # side (development version) +* Added thinking mode support for Anthropic, OpenAI, and Google Gemini. Toggle with Ctrl+T in the chat interface. When enabled, the model's reasoning process is streaming into a collapsible block above each response. For Anthropic and Google Gemini, the number of reasoning tokens is set to 1024 when reasoning is on. For OpenAI, the reasoning effort parameter is set to "medium." + # side 0.0.2 Aligned Skills with Claude's storage format. Skills now use a directory-based diff --git a/R/chat-module.R b/R/chat-module.R index 3508e44..6417bdb 100644 --- a/R/chat-module.R +++ b/R/chat-module.R @@ -1,6 +1,16 @@ -chat_mod_server_interruptible <- function(id, client, interrupt_flag) { +chat_mod_server_interruptible <- function( + id, + client, + interrupt_flag +) { append_stream_task <- shiny::ExtendedTask$new( - function(client, ui_id, user_input, interrupt_flag) { + function( + client, + ui_id, + user_input, + interrupt_flag, + assistant_index = NULL + ) { stream <- client$stream_async( user_input, stream = "content" @@ -13,18 +23,15 @@ chat_mod_server_interruptible <- function(id, client, interrupt_flag) { stream, interrupt_flag, client, - user_input + user_input, + assistant_index = assistant_index ) }) } ) shiny::moduleServer(id, function(input, output, session) { - shinychat::chat_restore( - "chat", - client, - session = session - ) + restore_chat_filtered(client, session) last_turn <- shiny::reactiveVal(NULL, label = "last_turn") last_input <- shiny::reactiveVal(NULL, label = "last_input") @@ -35,12 +42,14 @@ chat_mod_server_interruptible <- function(id, client, interrupt_flag) { } last_input(input$chat_user_input) + assistant_index <- thinking_next_assistant_index(client) append_stream_task$invoke( client, "chat", input$chat_user_input, - interrupt_flag + interrupt_flag, + assistant_index ) }) @@ -129,34 +138,7 @@ chat_mod_server_interruptible <- function(id, client, interrupt_flag) { # dynamic reloading. We expose this method to allow external code to reload # the chat UI while maintaining access to the module's session context. load_chat_ui <- function() { - shinychat::chat_clear("chat", session = session) - - msgs <- shinychat::contents_shinychat(client) - lapply(msgs, function(msg_turn) { - is_list <- is.list(msg_turn$content) && - !inherits(msg_turn$content, c("shiny.tag", "shiny.taglist")) - - if (is_list) { - stream <- coro::generator(function() { - for (x in msg_turn$content) { - coro::yield(x) - } - }) - shinychat::chat_append( - "chat", - stream(), - msg_turn$role, - session = session - ) - } else { - shinychat::chat_append( - "chat", - msg_turn$content, - role = msg_turn$role, - session = session - ) - } - }) + restore_chat_filtered(client, session) } list( @@ -179,7 +161,8 @@ chat_append_interruptible <- coro::async(function( user_input = NULL, role = "assistant", icon = NULL, - session = shiny::getDefaultReactiveDomain() + session = shiny::getDefaultReactiveDomain(), + assistant_index = NULL ) { chat_append_ <- function(content, chunk = TRUE, ...) { shinychat::chat_append_message( @@ -196,6 +179,12 @@ chat_append_interruptible <- coro::async(function( res <- fastmap::fastqueue(200) + if (is.null(assistant_index)) { + assistant_index <- thinking_next_assistant_index(client) + } + thinking_ctx <- thinking_context_new(session, assistant_index) + set_thinking_stream_callback(thinking_ctx) + interrupted <- FALSE for (msg in stream) { if (promises::is.promising(msg)) { @@ -210,14 +199,18 @@ chat_append_interruptible <- coro::async(function( break } - res$add(msg) - if (S7::S7_inherits(msg, ellmer::ContentToolResult)) { if (!is.null(msg@request)) { session$sendCustomMessage("shiny-tool-request-hide", msg@request@id) } } + if (S7::S7_inherits(msg, ellmer::ContentThinking)) { + next + } + + res$add(msg) + if (S7::S7_inherits(msg, ellmer::Content)) { msg <- shinychat::contents_shinychat(msg) } @@ -225,6 +218,9 @@ chat_append_interruptible <- coro::async(function( chat_append_(msg) } + clear_thinking_stream_callback() + thinking_context_finalize(thinking_ctx) + if (interrupted) { streamed_content <- res$as_list() @@ -282,3 +278,37 @@ as_ellmer_turns <- function(messages) { ellmer::Turn(role = role, contents = contents) }) } + +restore_chat_filtered <- function(client, session) { + original_turns <- client$get_turns() + + modified_turns <- list() + + for (turn in original_turns) { + if (turn@role == "assistant") { + turn@contents <- Filter( + function(c) !S7::S7_inherits(c, ellmer::ContentThinking), + turn@contents + ) + } + modified_turns <- c(modified_turns, list(turn)) + } + + client$set_turns(modified_turns) + on.exit(client$set_turns(original_turns), add = TRUE) + + msgs <- shinychat::contents_shinychat(client) + + shinychat::chat_clear("chat", session = session) + for (msg in msgs) { + if (is.null(msg$content) || length(msg$content) == 0) { + next + } + shinychat::chat_append( + "chat", + msg$content, + role = msg$role, + session = session + ) + } +} diff --git a/R/setup.R b/R/setup.R index 5ca233f..7a56523 100644 --- a/R/setup.R +++ b/R/setup.R @@ -39,17 +39,17 @@ prompt_provider_selection <- function() { } ), list( - name = "OpenAI (GPT 4.1)", + name = "OpenAI (GPT 5.2)", fn_name = "chat_openai", - model = "gpt-4.1", - create_client = function() ellmer::chat_openai(model = "gpt-4.1") + model = "gpt-5.2", + create_client = function() ellmer::chat_openai(model = "gpt-5.2") ), list( - name = "Google Gemini (Gemini 2.5 Pro)", + name = "Google Gemini (Gemini 3 Pro)", fn_name = "chat_google_gemini", - model = "gemini-2.5-pro", + model = "gemini-3-pro-preview", create_client = function() { - ellmer::chat_google_gemini(model = "gemini-2.5-pro") + ellmer::chat_google_gemini(model = "gemini-3-pro-preview") } ), list( @@ -103,7 +103,11 @@ prompt_provider_selection <- function() { options(side.client = client) - prompt_persistence_selection(client, selected_info$fn_name, selected_info$model) + prompt_persistence_selection( + client, + selected_info$fn_name, + selected_info$model + ) client } diff --git a/R/thinking.R b/R/thinking.R new file mode 100644 index 0000000..0fc568f --- /dev/null +++ b/R/thinking.R @@ -0,0 +1,472 @@ +install_thinking_stream_hook <- function() { + stream_text <- ellmer:::stream_text + + patched_anthropic <- function(provider, event) { + if (event$type == "content_block_start") { + is_thinking <- identical(event$content_block$type, "thinking") + options(side.current_block_is_thinking = is_thinking) + + if (is_thinking) { + start_callback <- getOption("side.thinking_start_callback") + if (is.function(start_callback)) { + id <- start_callback() + if (!is.null(id)) { + return(paste0( + '' + )) + } + } + } + return(NULL) + } + + if (event$type == "content_block_stop") { + if (isTRUE(getOption("side.current_block_is_thinking"))) { + done_callback <- getOption("side.thinking_done_callback") + if (is.function(done_callback)) { + done_callback() + } + } + options(side.current_block_is_thinking = NULL) + return(NULL) + } + + if (event$type == "content_block_delta") { + if (identical(event$delta$type, "thinking_delta")) { + callback <- getOption("side.thinking_stream_callback") + if (is.function(callback)) { + callback(event$delta$thinking) + } + return(NULL) + } else if (identical(event$delta$type, "text_delta")) { + return(event$delta$text) + } + } + NULL + } + + patched_openai <- function(provider, event) { + if (event$type == "response.output_text.delta") { + return(event$delta) + } else if (event$type == "response.reasoning_summary_part.added") { + summary_index <- event$summary_index %||% 0L + current_index <- getOption("side.openai_summary_index") %||% 0L + + if (summary_index != current_index) { + return(NULL) + } + + start_callback <- getOption("side.thinking_start_callback") + if (is.function(start_callback)) { + id <- start_callback() + if (!is.null(id)) { + return(paste0( + '' + )) + } + } + return(NULL) + } else if (event$type == "response.reasoning_summary_text.delta") { + summary_index <- event$summary_index %||% 0L + current_index <- getOption("side.openai_summary_index") %||% 0L + + if (summary_index != current_index) { + return(NULL) + } + + callback <- getOption("side.thinking_stream_callback") + if (is.function(callback)) { + callback(event$delta) + } + return(NULL) + } else if (event$type == "response.reasoning_summary_text.done") { + summary_index <- event$summary_index %||% 0L + current_index <- getOption("side.openai_summary_index") %||% 0L + + if (summary_index == current_index) { + done_callback <- getOption("side.thinking_done_callback") + if (is.function(done_callback)) { + done_callback() + } + options(side.openai_summary_index = current_index + 1L) + } + return(NULL) + } + NULL + } + + patched_gemini <- function(provider, event) { + parts <- event$candidates[[1]]$content$parts + if (is.null(parts) || length(parts) == 0) { + return(NULL) + } + + callback <- getOption("side.thinking_stream_callback") + text_parts <- character() + has_thinking <- FALSE + + for (part in parts) { + if (isTRUE(part$thought) && !is.null(part$text)) { + has_thinking <- TRUE + + start_callback <- getOption("side.thinking_start_callback") + if (is.function(start_callback)) { + id <- start_callback() + if (!is.null(id)) { + text_parts <- c( + text_parts, + paste0( + '' + ) + ) + } + } + + if (is.function(callback)) { + callback(part$text) + } + } else if (!is.null(part$text)) { + text_parts <- c(text_parts, part$text) + } + } + + was_thinking <- isTRUE(getOption("side.gemini_was_thinking")) + if (was_thinking && !has_thinking) { + done_callback <- getOption("side.thinking_done_callback") + if (is.function(done_callback)) { + done_callback() + } + } + options(side.gemini_was_thinking = has_thinking) + + if (length(text_parts) > 0) { + return(paste(text_parts, collapse = "")) + } + NULL + } + + patched_gemini_value_turn <- function(provider, result, has_type = FALSE) { + message <- result$candidates[[1]]$content + + contents <- lapply(message$parts, function(content) { + if (isTRUE(content$thought) && !is.null(content$text)) { + ellmer::ContentThinking(thinking = content$text) + } else if (!is.null(content$text)) { + if (has_type) { + ellmer::ContentJson(string = content$text) + } else { + ellmer::ContentText(content$text) + } + } else if (!is.null(content$functionCall)) { + extra <- if (!is.null(content$thoughtSignature)) { + list(thoughtSignature = content$thoughtSignature) + } else { + list() + } + ellmer::ContentToolRequest( + content$functionCall$name, + content$functionCall$name, + content$functionCall$args, + extra = extra + ) + } else if (!is.null(content$inlineData)) { + ellmer::ContentImageInline( + type = content$inlineData$mimeType, + data = content$inlineData$data + ) + } else { + NULL + } + }) + contents <- Filter(Negate(is.null), contents) + tokens <- ellmer:::value_tokens(provider, result) + cost <- ellmer:::get_token_cost(provider, tokens) + ellmer::AssistantTurn( + contents, + json = result, + tokens = unlist(tokens), + cost = cost + ) + } + + ProviderAnthropic <- ellmer:::ProviderAnthropic + ProviderOpenAI <- ellmer:::ProviderOpenAI + ProviderGoogleGemini <- ellmer:::ProviderGoogleGemini + value_turn <- ellmer:::value_turn + + S7::method(stream_text, ProviderAnthropic) <- patched_anthropic + S7::method(stream_text, ProviderOpenAI) <- patched_openai + S7::method(stream_text, ProviderGoogleGemini) <- patched_gemini + S7::method(value_turn, ProviderGoogleGemini) <- patched_gemini_value_turn + + invisible(NULL) +} + +set_thinking_stream_callback <- function(context) { + if (is.null(context)) { + options(side.thinking_stream_callback = NULL) + options(side.thinking_done_callback = NULL) + options(side.thinking_start_callback = NULL) + options(side.openai_summary_index = NULL) + return(invisible(NULL)) + } + + text_callback <- function(text) { + thinking_context_emit(context, text, done = FALSE) + } + + done_callback <- function() { + if (!is.null(context$id)) { + thinking_context_emit(context, "", done = TRUE) + context$id <- NULL + } + } + + start_callback <- function() { + if (is.null(context$id)) { + context$id <- paste0( + "think-live-", + format(Sys.time(), "%H%M%S%OS3"), + "-", + sample.int(1e6, 1) + ) + return(context$id) + } + NULL + } + + options(side.thinking_stream_callback = text_callback) + options(side.thinking_done_callback = done_callback) + options(side.thinking_start_callback = start_callback) + options(side.openai_summary_index = 0L) + invisible(NULL) +} + +clear_thinking_stream_callback <- function() { + options(side.thinking_stream_callback = NULL) + options(side.thinking_done_callback = NULL) + options(side.thinking_start_callback = NULL) + options(side.current_block_is_thinking = NULL) + options(side.gemini_was_thinking = NULL) + options(side.openai_summary_index = NULL) + invisible(NULL) +} + +thinking_next_assistant_index <- function(client) { + turns <- client$get_turns() + assistant_count <- sum(vapply( + turns, + function(turn) turn@role == "assistant", + logical(1) + )) + assistant_count + 1 +} + +thinking_context_new <- function(session, assistant_index) { + rlang::env( + session = session, + id = NULL, + assistant_index = assistant_index + ) +} + +thinking_context_emit <- function(context, text, done = FALSE) { + if (is.null(context$session)) { + return(invisible(NULL)) + } + + text <- paste0(text, collapse = "") + + if (is.null(context$id)) { + if (done && !nzchar(text)) { + return(invisible(NULL)) + } + + context$id <- paste0( + "think-live-", + format(Sys.time(), "%H%M%S%OS3"), + "-", + sample.int(1e6, 1) + ) + } + + context$session$sendCustomMessage( + "side-thinking-stream", + list( + id = context$id, + text = text, + done = done, + mode = "live", + order = context$assistant_index + ) + ) + + invisible(NULL) +} + +thinking_context_finalize <- function(context) { + if (!is.null(context$session) && !is.null(context$id)) { + thinking_context_emit(context, "", done = TRUE) + } + + invisible(NULL) +} + +thinking_replay_history <- function(client, session, defer = TRUE) { + invisible(NULL) +} + +toggle_thinking <- function(client, enable = TRUE) { + provider <- client$get_provider() + cls <- sub("^.*::", "", class(provider)[1]) + UseMethod("toggle_thinking", structure(list(), class = cls)) +} + +#' @export +toggle_thinking.ProviderAnthropic <- function(client, enable = TRUE) { + provider <- client$get_provider() + + if (enable && !supports_thinking(provider)) { + cli::cli_warn("This model may not support thinking tokens.") + } + + if (enable) { + provider@params$reasoning_tokens <- 1024 + } else { + provider@params$reasoning_tokens <- NULL + } + + set_provider(client, provider) + invisible(client) +} + +#' @export +toggle_thinking.ProviderOpenAI <- function(client, enable = TRUE) { + provider <- client$get_provider() + + if (enable && !supports_thinking(provider)) { + cli::cli_warn( + "This model does not support thinking tokens. Use an o-series or gpt-5+ model." + ) + return(invisible(client)) + } + + if (enable) { + provider@params$reasoning_effort <- "medium" + } else { + model <- provider@model + if ( + grepl("^gpt-5\\.[2-9]|^gpt-5\\.1[0-9]|^gpt-[6-9]|^gpt-[0-9]{2,}", model) + ) { + provider@params$reasoning_effort <- "none" + } else if (grepl("^gpt-5", model)) { + provider@params$reasoning_effort <- "low" + } else { + provider@params$reasoning_effort <- NULL + } + } + + set_provider(client, provider) + invisible(client) +} + +#' @export +toggle_thinking.ProviderGoogleGemini <- function(client, enable = TRUE) { + provider <- client$get_provider() + + if (enable && !supports_thinking(provider)) { + cli::cli_warn( + "This model may not support thinking tokens. Use gemini-2.5+ or later." + ) + } + + if (enable) { + provider@params$reasoning_tokens <- 1024 + } else { + provider@params$reasoning_tokens <- NULL + } + + set_provider(client, provider) + invisible(client) +} + +#' @export +toggle_thinking.default <- function(client, enable = TRUE) { + provider <- client$get_provider() + + if (enable && !supports_thinking(provider)) { + cli::cli_warn("Thinking is not supported for this provider.") + return(invisible(client)) + } + + invisible(client) +} + +supports_thinking <- function(provider) { + cls <- sub("^.*::", "", class(provider)[1]) + UseMethod("supports_thinking", structure(list(), class = cls)) +} + +#' @export +supports_thinking.ProviderAnthropic <- function(provider) { + model <- provider@model + grepl("claude-[a-z]+-([4-9]|[0-9]{2,})", model, ignore.case = TRUE) +} + +#' @export +supports_thinking.ProviderOpenAI <- function(provider) { + model <- provider@model + is_o_series <- grepl("^o[0-9]", model) + is_gpt5_plus <- grepl("^gpt-([5-9]|[0-9]{2,})", model) + is_o_series || is_gpt5_plus +} + +#' @export +supports_thinking.ProviderGoogleGemini <- function(provider) { + model <- provider@model + if (grepl("^gemini-([3-9]|[0-9]{2,})", model)) { + return(TRUE) + } + if (grepl("^gemini-2\\.([5-9]|[0-9]{2,})", model)) { + return(TRUE) + } + FALSE +} + +#' @export +supports_thinking.default <- function(provider) { + FALSE +} + +set_provider <- function(client, provider) { + private <- client$.__enclos_env__$private + private$provider <- provider + invisible(client) +} + +thinking_is_enabled <- function(client) { + provider <- client$get_provider() + cls <- sub("^.*::", "", class(provider)[1]) + + if (cls == "ProviderAnthropic") { + return(!is.null(provider@params$reasoning_tokens)) + } else if (cls == "ProviderOpenAI") { + effort <- provider@params$reasoning_effort + return(!is.null(effort) && effort != "none" && effort != "low") + } else if (cls == "ProviderGoogleGemini") { + return(!is.null(provider@params$reasoning_tokens)) + } + + FALSE +} + +client_supports_thinking <- function(client) { + provider <- client$get_provider() + supports_thinking(provider) +} diff --git a/R/utils.R b/R/utils.R index d520687..fc5e139 100755 --- a/R/utils.R +++ b/R/utils.R @@ -183,6 +183,9 @@ load_chat <- function(chat_file, fresh_client) { tryCatch( { fresh_client$set_turns(old_client$get_turns()) + if (thinking_is_enabled(old_client)) { + toggle_thinking(fresh_client, TRUE) + } }, error = function(e) { cli::cli_inform(c( diff --git a/README.Rmd b/README.Rmd index 35988ce..e48bc6a 100644 --- a/README.Rmd +++ b/README.Rmd @@ -62,6 +62,7 @@ Then, run `side::kick()`. You might place `side::kick()` in your `.Rprofile` (pe * **Persist state** across R sessions so that you can close RStudio and come back to a chat later. * Respond to **interrupts** so that you can stop execution and reroute the agent. * Read **skills**, markdown files that describe how to carry out a given task in the way you need it to. +* **Think out loud** with native reasoning support for Anthropic, OpenAI, and Google Gemini (Ctrl+T to toggle). --- diff --git a/README.md b/README.md index 6205361..acfbd2c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) [![CRAN status](https://www.r-pkg.org/badges/version/side)](https://CRAN.R-project.org/package=side) +[![R-CMD-check](https://github.com/simonpcouch/side/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/simonpcouch/side/actions/workflows/R-CMD-check.yaml) `side::kick()` is a coding agent for data science in RStudio, @@ -64,6 +65,8 @@ Dailies](https://dailies.rstudio.com/). Install RStudio, then search for the agent. - Read **skills**, markdown files that describe how to carry out a given task in the way you need it to. +- **Think out loud** with native reasoning support for Anthropic, + OpenAI, and Google Gemini (Ctrl+T to toggle). ------------------------------------------------------------------------ diff --git a/inst/client.R b/inst/client.R index f483092..c80bf5c 100644 --- a/inst/client.R +++ b/inst/client.R @@ -34,6 +34,8 @@ ui <- function(req) { tags$head( tags$script(src = "side/tool-approval.js"), tags$link(rel = "stylesheet", href = "side/tool-approval.css"), + tags$link(rel = "stylesheet", href = "side/thinking-mode.css"), + tags$script(src = "side/thinking-mode.js"), tags$style(HTML(" .chat-menu-btn { position: fixed; @@ -127,8 +129,39 @@ ui <- function(req) { } server <- function(input, output, session) { + side:::install_thinking_stream_hook() session$userData$approval_resolvers <- fastmap::fastmap() + thinking_supported <- side:::client_supports_thinking(client) + thinking_enabled <- reactiveVal(side:::thinking_is_enabled(client)) + + session$onFlushed(function() { + if (thinking_supported) { + session$sendCustomMessage( + "side-thinking-state", + list(enabled = shiny::isolate(thinking_enabled()), animate = FALSE) + ) + } else { + session$sendCustomMessage( + "side-thinking-state", + list(hidden = TRUE) + ) + } + }, once = TRUE) + + observeEvent(input$thinking_toggle, { + if (!thinking_supported) { + return() + } + new_state <- !thinking_enabled() + side:::toggle_thinking(client, new_state) + thinking_enabled(new_state) + session$sendCustomMessage( + "side-thinking-state", + list(enabled = new_state, animate = TRUE) + ) + }) + observeEvent(input$tool_approval_response, { response_data <- input$tool_approval_response request_id <- response_data$request_id @@ -145,7 +178,11 @@ server <- function(input, output, session) { side:::setup_tool_interrupt_callback(client, interrupt_flag, session) side:::setup_tool_approval_callback(client, session) - chat_server <- side:::chat_mod_server_interruptible("chat", client, interrupt_flag) + chat_server <- side:::chat_mod_server_interruptible( + "chat", + client, + interrupt_flag + ) observeEvent(input$interrupt_requested, { if (!interrupt_flag()) { @@ -235,6 +272,12 @@ server <- function(input, output, session) { current_file(input$load_chat_click) chat_server$load_chat() + restored_state <- side:::thinking_is_enabled(client) + thinking_enabled(restored_state) + session$sendCustomMessage( + "side-thinking-state", + list(enabled = restored_state, animate = FALSE) + ) menu_trigger(menu_trigger() + 1) }) diff --git a/inst/www/thinking-mode.css b/inst/www/thinking-mode.css new file mode 100644 index 0000000..7909e30 --- /dev/null +++ b/inst/www/thinking-mode.css @@ -0,0 +1,113 @@ +.side-thinking-indicator { + position: absolute; + top: calc(100% - 1px); + right: -14px; + left: auto; + width: min(170px, calc(100% - 48px)); + max-width: 240px; + font-size: 0.85rem; + color: var(--bs-gray-600, #6c757d); + font-style: italic; + pointer-events: none; + opacity: 0; + transition: color 0.5s ease-in-out, opacity 0.25s ease; + text-align: left; + display: flex; + justify-content: flex-start; + align-items: flex-start; + padding-right: 0; + margin: 0; +} + +.side-thinking-indicator .side-thinking-text { + display: block; + width: 100%; + text-align: left; +} + +.side-thinking-indicator.side-thinking-visible { + opacity: 1; +} + +.side-thinking-indicator--on { + color: #198754; +} + +.side-thinking-indicator--muted { + color: var(--bs-gray-600, #6c757d); +} + +.side-thinking-collapse { + margin-bottom: 0.75rem; + border-left: 2px solid var(--bs-border-color, #dee2e6); + padding-left: 0.75rem; + font-size: 0.9em; + color: var(--bs-gray-700, #495057); + font-style: italic; + width: 100%; + margin-left: 0; + margin-right: 0; + align-self: flex-start; + box-sizing: border-box; +} + +.side-thinking-collapse button { + background: none; + border: none; + padding: 0; + font-size: inherit; + color: inherit; + cursor: pointer; + display: block; + margin: 0; + width: 100%; + justify-self: stretch; +} + +.side-thinking-collapse .side-thinking-caret { + display: none; +} + +.side-thinking-collapse button:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 2px; +} + +.side-thinking-collapse .side-thinking-preview { + display: block; + width: 100%; + padding: 0; + background: transparent; + border-radius: 0; + border: none; + font-style: italic; + color: var(--bs-gray-600, #6c757d); + white-space: nowrap; + text-align: left; + max-height: 1.5rem; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + transition: color 0.2s ease; +} + +.side-thinking-collapse .side-thinking-preview:hover { + color: var(--bs-gray-900, #212529); + cursor: pointer; +} + +.side-thinking-collapse[data-open="true"] .side-thinking-preview { + max-height: none; + white-space: pre-wrap; +} + +.side-thinking-collapse .side-thinking-shimmer { + background: linear-gradient(110deg, rgba(0,0,0,0) 25%, rgba(255,255,255,0.4) 50%, rgba(0,0,0,0) 75%); + background-size: 200% 100%; + animation: side-thinking-shimmer 1.5s infinite; +} + +@keyframes side-thinking-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} diff --git a/inst/www/thinking-mode.js b/inst/www/thinking-mode.js new file mode 100644 index 0000000..ca8ba8d --- /dev/null +++ b/inst/www/thinking-mode.js @@ -0,0 +1,313 @@ +(function () { + let indicatorEl = null + let indicatorTextEl = null + let fadeTimer = null + let textareaEl = null + let lastIndicatorState = null + let thinkingHidden = false + const thinkingStreams = new Map() + let assistantCounter = 0 + + function sendToggle(source) { + if (!window.Shiny) return + if (thinkingHidden) return + window.Shiny.setInputValue( + 'thinking_toggle', + { source, ts: Date.now() }, + { priority: 'event' }, + ) + } + + function onKeyDown(event) { + if (event.defaultPrevented) return + if (thinkingHidden) return + + const isCtrlT = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 't' + + if (isCtrlT) { + event.preventDefault() + sendToggle('ctrl') + } + } + + function ensureIndicator(chatInput) { + if (!indicatorEl || !indicatorEl.isConnected) { + indicatorEl = document.createElement('div') + indicatorEl.className = 'side-thinking-indicator' + indicatorEl.setAttribute('role', 'status') + indicatorEl.setAttribute('aria-live', 'polite') + chatInput.appendChild(indicatorEl) + indicatorTextEl = null + } + + if (!indicatorTextEl || !indicatorTextEl.isConnected) { + indicatorTextEl = document.createElement('span') + indicatorTextEl.className = 'side-thinking-text' + indicatorEl.appendChild(indicatorTextEl) + } + + return indicatorEl + } + + function markAssistantMessages() { + const messages = document.querySelectorAll('shiny-chat-message[data-role="assistant"]') + assistantCounter = 0 + messages.forEach((msg) => { + assistantCounter += 1 + msg.dataset.sideAssistantIndex = assistantCounter + }) + } + + function applyIndicatorState(state) { + if (!state) return + + if (state.hidden) { + thinkingHidden = true + if (indicatorEl) { + indicatorEl.classList.remove('side-thinking-visible') + } + return + } + + thinkingHidden = false + const { enabled, animate = true } = state + const chatInput = document.querySelector('shiny-chat-input') + if (!chatInput) return + + const indicator = ensureIndicator(chatInput) + const textTarget = indicatorTextEl || indicator + indicator.classList.add('side-thinking-visible') + indicator.classList.toggle('side-thinking-indicator--on', !!enabled) + indicator.classList.remove('side-thinking-indicator--muted') + textTarget.textContent = enabled ? 'Thinking on' : 'Thinking off' + + if (fadeTimer) { + clearTimeout(fadeTimer) + fadeTimer = null + } + + if (animate) { + fadeTimer = setTimeout(() => { + indicator.classList.add('side-thinking-indicator--muted') + textTarget.textContent = + (enabled ? 'Thinking on' : 'Thinking off') + ' (Ctrl+T)' + }, 2500) + } else { + indicator.classList.add('side-thinking-indicator--muted') + textTarget.textContent = + (enabled ? 'Thinking on' : 'Thinking off') + ' (Ctrl+T)' + } + } + + function setIndicatorState(state) { + lastIndicatorState = state + applyIndicatorState(state) + } + + function attachListeners() { + const chatInput = document.querySelector('shiny-chat-input') + if (!chatInput) return + const textarea = chatInput.querySelector('textarea') + if (!textarea) return + + if (textareaEl === textarea) return + + textareaEl = textarea + if (lastIndicatorState && !thinkingHidden) { + ensureIndicator(chatInput) + applyIndicatorState(lastIndicatorState) + } + } + + function findAssistantMessage(order, mode) { + const messagesRoot = document.querySelector('shiny-chat-messages') + if (!messagesRoot) return null + + if (mode === 'live') { + const streaming = messagesRoot.querySelector('shiny-chat-message[streaming]') + if (streaming) return streaming + } + + if (order) { + const selector = `shiny-chat-message[data-side-assistant-index="${order}"]` + const targeted = messagesRoot.querySelector(selector) + if (targeted) return targeted + } + + const assistant = messagesRoot.querySelector('shiny-chat-message[data-role="assistant"]:last-of-type') + if (assistant) return assistant + + const fallback = messagesRoot.querySelector('shiny-chat-message:last-of-type') + return fallback + } + + function updateThinkingPreview(parts) { + if (!parts || !parts.preview) return + const fullText = parts.fullText || '' + const displayText = fullText.trim() ? fullText : 'Thinking…' + parts.preview.textContent = displayText + } + + function createThinkingContainer(payload) { + const message = findAssistantMessage(payload.order, payload.mode) + if (!message) return null + + let wrapper = message.querySelector(`.side-thinking-collapse[data-id=\"${payload.id}\"]`) + if (wrapper) { + return { + wrapper, + button: wrapper.querySelector('button'), + preview: wrapper.querySelector('.side-thinking-preview'), + caret: wrapper.querySelector('.side-thinking-caret'), + fullText: wrapper.dataset.fullText || '', + } + } + + wrapper = document.createElement('div') + wrapper.className = 'side-thinking-collapse' + wrapper.dataset.id = payload.id + wrapper.dataset.fullText = '' + wrapper.setAttribute('data-open', 'false') + + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('aria-expanded', 'false') + + const preview = document.createElement('div') + preview.className = 'side-thinking-preview side-thinking-shimmer' + preview.textContent = 'Thinking…' + + const caret = document.createElement('span') + caret.className = 'side-thinking-caret' + caret.textContent = '▾' + + button.appendChild(preview) + button.appendChild(caret) + button.addEventListener('click', () => { + const isOpen = wrapper.getAttribute('data-open') === 'true' + wrapper.setAttribute('data-open', (!isOpen).toString()) + button.setAttribute('aria-expanded', (!isOpen).toString()) + }) + + wrapper.appendChild(button) + + const existingThinking = message.querySelectorAll('.side-thinking-collapse') + if (existingThinking.length > 0) { + const lastThinking = existingThinking[existingThinking.length - 1] + lastThinking.insertAdjacentElement('afterend', wrapper) + } else { + const anchor = message.querySelector(`.side-thinking-anchor[data-id="${payload.id}"]`) + if (anchor) { + anchor.replaceWith(wrapper) + } else { + if ((payload._retries || 0) < 20) { + return null + } + + const container = + message.querySelector(':scope > div:not(.message-icon):not(.side-thinking-collapse)') || + message.querySelector('shiny-markdown-stream, shiny-user-message') + + if (container && container.parentNode === message) { + container.insertAdjacentElement('afterbegin', wrapper) + } else if (container && container !== message) { + container.insertAdjacentElement('afterbegin', wrapper) + } else { + message.appendChild(wrapper) + } + } + } + + return { wrapper, button, preview, caret, fullText: '' } + } + + function scheduleThinkingRetry(payload) { + const tries = payload._retries || 0 + if (tries > 20) return + const nextPayload = { ...payload, _retries: tries + 1 } + setTimeout(() => handleThinkingStream(nextPayload), 100) + } + + function handleThinkingStream(payload) { + const { id, text = '', done = false, mode = 'live' } = payload + let parts = thinkingStreams.get(id) + if (!parts) { + parts = createThinkingContainer(payload) + if (!parts) { + scheduleThinkingRetry(payload) + return + } + thinkingStreams.set(id, parts) + } + + if (text) { + parts.fullText = (parts.fullText || '') + text + parts.fullText = parts.fullText.trimStart() + parts.fullText = parts.fullText.replace(/^\s+/,'') + if (parts.wrapper) { + parts.wrapper.dataset.fullText = parts.fullText + } + updateThinkingPreview(parts) + } + + if (mode === 'live' && !done) { + parts.preview.classList.add('side-thinking-shimmer') + } else { + parts.preview.classList.remove('side-thinking-shimmer') + } + + // We don't delete the stream info on done anymore, because we might need + // to re-inject the wrapper if shinychat/markdown-stream re-renders the DOM. + // if (done) { + // thinkingStreams.delete(id) + // } + + processAnchors() + } + + function processAnchors() { + const anchors = document.querySelectorAll('.side-thinking-anchor') + anchors.forEach(anchor => { + const id = anchor.dataset.id + if (thinkingStreams.has(id)) { + const parts = thinkingStreams.get(id) + // If the wrapper is not currently connected (or we just found a new anchor), + // replace the anchor with the wrapper. + if (anchor.isConnected && parts.wrapper) { + anchor.replaceWith(parts.wrapper) + } + } + }) + } + + function init() { + attachListeners() + markAssistantMessages() + processAnchors() + } + + const observer = new MutationObserver(() => { + attachListeners() + markAssistantMessages() + processAnchors() + }) + + document.addEventListener('DOMContentLoaded', () => { + init() + document.addEventListener('keydown', onKeyDown) + observer.observe(document.body, { childList: true, subtree: true }) + }) + + function registerThinkingHandlers() { + if (!window.Shiny) return + Shiny.addCustomMessageHandler('side-thinking-state', setIndicatorState) + Shiny.addCustomMessageHandler('side-thinking-stream', handleThinkingStream) + } + + if (window.Shiny) { + registerThinkingHandlers() + } else { + document.addEventListener('shiny:connected', registerThinkingHandlers) + } +})() diff --git a/vignettes/side.Rmd b/vignettes/side.Rmd index f9b41c9..a611974 100644 --- a/vignettes/side.Rmd +++ b/vignettes/side.Rmd @@ -67,6 +67,12 @@ You can **stop execution mid-stream and reroute the agent** without losing conve Finally, the agent can **fetch and apply skills**--markdown files describing task-specific procedures. This allows you to customize the agent's behavior for project-specific workflows, ensuring it follows your preferred patterns and practices. You can add custom skills in `~/.config/side/skills` on macOS/Linux or `%APPDATA%/side/skills` on Windows. User skills override built-in skills with the same name. +## Thinking mode + +For complex tasks, you can enable **thinking mode** which shows the model's reasoning process before it responds. Press **Ctrl+T** in the chat input to toggle thinking on or off. When enabled, a collapsible "Thinking" block appears above each response showing how the model approached the problem. + +Thinking mode uses native reasoning settings from your provider. For Anthropic and Google Gemini, it sets the thinking budget to 1024, and for OpenAI, it sets the reasoning effort to "medium". Thinking is disabled automatically for models that don't support it and isn't supported for providers other than the big three. + ## Customizing behavior You can customize the assistant's behavior for your project by creating a `CLAUDE.md`, `btw.md`, `llms.txt`, or `AGENTS.md` file in your project directory. The assistant will read the first file it finds (searching in the order shown above) and include its contents in the system prompt, allowing you to provide project-specific instructions, coding conventions, or context.