diff --git a/.Rbuildignore b/.Rbuildignore index e9376c3b..fc0fe07f 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -16,3 +16,5 @@ ^scripts$ ^\.claude$ ^CRAN-SUBMISSION$ +^\.prettierrc$ +^node_modules$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 39824e5e..bb15a0bf 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -18,7 +18,7 @@ jobs: runs-on: ${{ matrix.config.os }} name: ${{ matrix.config.os }} (${{ matrix.config.r }}) - timeout-minutes: 45 + timeout-minutes: 180 strategy: fail-fast: false diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..6879b154 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "organizeImportsSkipDestructiveCodeActions": true, + "singleQuote": false, + "semi": false, + "trailingComma": "all", + "overrides": [ + { + "files": "**/*.scss", + "options": { + "printWidth": 150 + } + } + ] +} diff --git a/DESCRIPTION b/DESCRIPTION index 8756d168..3178bfb5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -53,10 +53,13 @@ Suggests: chromote, DBI, duckdb, + evaluate, + fansi, gert, gh, htmltools, pandoc, + ragg, shiny, shinychat (>= 0.2.0), testthat (>= 3.0.0), @@ -93,6 +96,7 @@ Collate: 'tool-git.R' 'tool-github.R' 'tool-rstudioapi.R' + 'tool-run.R' 'tool-search-packages.R' 'tool-session-package-installed.R' 'tool-sessioninfo.R' diff --git a/NAMESPACE b/NAMESPACE index 8d546f34..e9999b2b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -47,6 +47,7 @@ export(btw_tool_git_log) export(btw_tool_git_status) export(btw_tool_github) export(btw_tool_ide_read_current_editor) +export(btw_tool_run_r) export(btw_tool_search_package_info) export(btw_tool_search_packages) export(btw_tool_session_check_package_installed) diff --git a/NEWS.md b/NEWS.md index 44a2172b..cfaace6d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # btw (development version) +* New `btw_tool_run_r()` tool allows LLMs to run R code and to see the output, including of plots. Because this tool lets LLMs run R arbitrary R code in the global environment (which can be great but can also have security implications), it is opt-in and disabled by default. See `?btw_tool_run_r` for more details (#126). + * `btw_tool_docs_help_page()` now uses markdown headings and sections for argument descriptions, rather than a table. This is considerably more token efficient when the argument descriptions have more than one paragraph and can't be converted into a markdown table (@jeanchristophe13v, #123). * btw now removes large inline base64-encoded images, replacing them with a placeholder containing the image's alt text (@jeanchristophe13v, #119). diff --git a/R/btw_client.R b/R/btw_client.R index 916555bd..71cf0dea 100644 --- a/R/btw_client.R +++ b/R/btw_client.R @@ -133,7 +133,7 @@ btw_client <- function( } btw_client_config <- function(client = NULL, tools = NULL, config = list()) { - config$options <- flatten_config_options(config$options) + # Options should be flattened and btw-prefixed by `read_btw_file()`. withr::local_options(config$options) config$tools <- @@ -275,7 +275,11 @@ flatten_config_options <- function(opts, prefix = "btw", sep = ".") { } for (i in seq_along(x)) { - new_key <- paste(key_prefix, nm[i], sep = sep) + if (nzchar(key_prefix)) { + new_key <- paste(key_prefix, nm[i], sep = sep) + } else { + new_key <- nm[i] + } recurse(x[[i]], new_key) } } else { diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 689a8495..32b45ebc 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -118,6 +118,7 @@ btw_app_from_client <- function(client, messages = list(), ...) { class = "btn-close", style = "position: fixed; top: 6px; right: 6px;" ), + class = "bslib-page-dashboard", btw_title(FALSE), shinychat::chat_mod_ui( "chat", @@ -137,6 +138,13 @@ btw_app_from_client <- function(client, messages = list(), ...) { .sidebar-collapsed > .main > main .sidebar-title { display: block; } .bslib-sidebar-layout.sidebar-collapsed>.collapse-toggle { top: 1.8rem; } .bslib-page-main { gap: 0.5rem; } + aside#tools_sidebar { + box-shadow: 2px 2px 5px rgba(var(--bs-emphasis-color-rgb), 10%); + } + shiny-chat-message .message-icon { + background-color: var(--bs-white); + box-shadow: 2px 2px 5px rgba(var(--bs-emphasis-color-rgb), 10%); + } " )), ) @@ -215,13 +223,16 @@ btw_app_from_client <- function(client, messages = list(), ...) { if (!length(selected_tools())) { client$set_tools(list()) } else { - .btw_tools <- keep(btw_tools(), function(tool) { - tool@name %in% selected_tools() - }) - .other_tools <- keep(other_tools, function(tool) { + sel_btw_tools <- btw_tools( + intersect(names(.btw_tools), selected_tools()) + ) + sel_other_tools <- keep(other_tools, function(tool) { tool@name %in% selected_tools() }) - client$set_tools(c(.btw_tools, other_tools)) + sel_tools <- c(sel_btw_tools, sel_other_tools) + # tool_names <- map_chr(tools, S7::prop, "name") + # cli::cli_inform("Setting {.field client} tools to: {.val {tool_names}}") + client$set_tools(sel_tools) } }) @@ -269,6 +280,10 @@ btw_app_from_client <- function(client, messages = list(), ...) { save.interface = old_save )) + if (identical(Sys.getenv("BTW_IN_TESTING"), "true")) { + return(list(ui = ui, server = server)) + } + app <- shiny::shinyApp(ui, server, ...) if (getOption("btw.app.in_addin", FALSE)) { shiny::runApp(app, launch.browser = function(url) { @@ -597,10 +612,12 @@ app_tool_group_choice_input <- function( group, "docs" = shiny::span(label_icon, "Documentation"), "env" = shiny::span(label_icon, "Environment"), + "eval" = shiny::span(label_icon, "Code Evaluation"), "files" = shiny::span(label_icon, "Files"), "git" = shiny::span(label_icon, "Git"), "github" = shiny::span(label_icon, "GitHub"), "ide" = shiny::span(label_icon, "IDE"), + "run" = shiny::span(label_icon, "Run Code"), "search" = shiny::span(label_icon, "Search"), "session" = shiny::span(label_icon, "Session Info"), "web" = shiny::span(label_icon, "Web Tools"), diff --git a/R/tool-run.R b/R/tool-run.R new file mode 100644 index 00000000..01ed9fc6 --- /dev/null +++ b/R/tool-run.R @@ -0,0 +1,673 @@ +#' Tool: Run R code +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' This tool runs R code and returns results as a list of [ellmer::Content()] +#' objects. It captures text output, plots, messages, warnings, and errors. Code +#' execution stops on the first error, returning all results up to that point. +#' +#' @section Security Considerations: +#' Executing arbitrary R code can pose significant security risks, especially +#' in shared or multi-user environments. Furthermore, neither \pkg{shinychat} +#' (as of v0.4.0) or nor \pkg{ellmer} (as of v0.4.0) provide a mechanism to +#' review and reject the code before execution. Even more, the code is executed +#' in the global environment and does not have any sandboxing or R code +#' limitations applied. +#' +#' It is your responsibility to ensure that you are taking appropriate measures +#' to reduce the risk of the LLM writing arbitrary code. Most often, this means +#' not prompting the model to take large or potentially destructive actions. +#' At this time, we do not recommend that you enable this tool in a publicly- +#' available environment without strong safeguards in place. +#' +#' That said, this tool is very powerful and can greatly enhance the +#' capabilities of your btw chatbots. Please use it responsibly! If you'd like +#' to enable the tool, please read the instructions below. +#' +#' @section Enabling this tool: +#' This tool is not enabled by default in [btw_tools()], [btw_app()] or +#' [btw_client()]. To enable the function, you have a few options: +#' +#' 1. Set the `btw.run_r.enabled` option to `TRUE` in your R session, or in your +#' `.Rprofile` file to enable it globally. +#' 2. Set the `BTW_RUN_R_ENABLED` environment variable to `true` in your +#' `.Renviron` file or your system environment. +#' 3. Explicitly include the tool when calling `btw_tools("run")` (unless the +#' above options disable it). +#' +#' In your [btw.md file][use_btw_md], you can explicitly enable the tool by +#' naming it in the tools option +#' +#' ```md +#' --- +#' tools: +#' - run_r +#' --- +#' ``` +#' +#' or you can enable the tool by setting the `btw.run_r.enabled` option from the +#' `options` list in `btw.md` (this approach is useful if you've globally +#' disabled the tool but want to enable it for a specific btw chat): +#' +#' ```md +#' --- +#' options: +#' run_r: +#' enabled: true +#' --- +#' ``` +#' +#' @details +#' ## Configuration Options +#' +#' The behavior of the `btw_tool_run_r` tool can be customized using the +#' following R options: +#' +#' * `btw.run_r.graphics_device`: A function that creates a graphics device used +#' for rendering plots. By default, it uses `ragg::agg_png()` if the `ragg` +#' package is installed, otherwise it falls back to `grDevices::png()`. +#' * `btw.run_r.plot_aspect_ratio`: Aspect ratio for plots created during code +#' execution. Can be a character string of the form `"w:h"` (e.g., `"16:9"`) +#' or a numeric value representing width/height (e.g., `16/9`). Default is +#' `"3:2"`. +#' * `btw.run_r.plot_size`: Integer pixel size for the longest side of plots. +#' Default is `768L`. This image size was selected to match [OpenAI's image +#' resizing rules](https://platform.openai.com/docs/guides/images-vision?api-mode=responses), +#' where images are resized such that the largest size is 768px. Another +#' common choice is 512px. Larger images may be used but will result in +#' increased token sizes. +#' * `btw.run_r.enabled`: Logical flag to enable or disable the tool globally. +#' +#' These values can be set using [options()] in your R session or `.Rprofile` or +#' in a [btw.md file][use_btw_md] under the `options` section. +#' +#' ```md +#' --- +#' options: +#' run_r: +#' enabled: true +#' plot_aspect_ratio: "16:9" +#' plot_size: 512 +#' --- +#' ``` +#' +#' @param code A character string containing R code to run. +#' @param _intent Intent description (automatically added by ellmer). +#' +#' @returns A list of ellmer Content objects: +#' - `ContentText`: visible return values and text output +#' - `ContentMessage`: messages from `message()` +#' - `ContentWarning`: warnings from `warning()` +#' - `ContentError`: errors from `stop()` +#' - `ContentImageInline`: plots created during execution +#' +#' @examples +#' \dontrun{ +#' # Simple calculation +#' btw_tool_run_r("2 + 2") +#' +#' # Code with plot +#' btw_tool_run_r("hist(rnorm(100))") +#' +#' # Code with warning +#' btw_tool_run_r("mean(c(1, 2, NA))") +#' } +#' +#' @seealso [btw_tools()] +#' @family Tools +#' @export +btw_tool_run_r <- function(code, `_intent`) {} + +btw_tool_run_r_impl <- function(code, .envir = global_env()) { + check_string(code) + check_installed("evaluate", "to run R code.") + + last_value <- NULL # Store the last value for potential use + had_error <- FALSE # Track if an error occurred + + # Content results from evaluating the R code + contents <- list() + append_content <- function(x) contents <<- c(contents, list(x)) + + last_plot <- NULL + append_last_plot <- function() { + if (is.null(last_plot)) { + return() + } + + dims <- btw_run_r_plot_dimensions( + ratio = getOption("btw.run_r.plot_aspect_ratio", "3:2"), + longest_side = getOption("btw.run_r.plot_size", 768L) + ) + + path_plot <- withr::local_tempfile(fileext = ".png") + run_r_plot_device( + filename = path_plot, + width = dims$width, + height = dims$height + ) + tryCatch( + grDevices::replayPlot(last_plot), + finally = { + grDevices::dev.off() + } + ) + + append_content(ellmer::content_image_file(path_plot, resize = "none")) + last_plot <<- NULL + } + + local_reproducible_output(disable_ansi_features = !is_installed("fansi")) + + # Ensure working directory, options, envvar are restored after execution + withr::local_dir(getwd()) + withr::local_options() + withr::local_envvar() + + # Create output handler that converts to Content types as outputs are generated + handler <- evaluate::new_output_handler( + source = function(src, expr) { + # Skip source code echoing by returning NULL + src_code <- sub("\n$", "", src$src) + append_content(ContentSource(text = src_code)) + }, + text = function(text) { + append_last_plot() + # Text output (from print, cat, etc.) + append_content(ContentOutput(text = text)) + text + }, + graphics = function(plot) { + if (!is.null(last_plot)) { + if (!last_plot %is_plot_prefix_of% plot) { + # New plot is not an extension of the last plot, so add the last plot + append_last_plot() + } + } + + last_plot <<- plot + plot + }, + message = function(msg) { + append_last_plot() + msg_text <- conditionMessage(msg) + # Remove trailing newline that message() adds + msg_text <- sub("\n$", "", msg_text) + append_content(ContentMessage(text = msg_text)) + msg + }, + warning = function(warn) { + append_last_plot() + append_content(ContentWarning(conditionMessage(warn))) + warn + }, + error = function(err) { + append_last_plot() + had_error <<- TRUE + append_content(ContentError(conditionMessage(err))) + err + }, + value = function(value, visible) { + # Store the actual value when it's visible (meaningful output) + # Invisible values include assignments and side-effect returns + if (visible) { + last_value <<- value + # Also add as code content + value_text <- paste( + utils::capture.output(print(value)), + collapse = "\n" + ) + append_content(ContentOutput(text = value_text)) + } + } + ) + + # Evaluate the R code, collecting results along the way + evaluate::evaluate( + code, + envir = .envir, + stop_on_error = 1, + new_device = TRUE, + output_handler = handler + ) + + # Ensure last plot is added if not caught by other handlers + append_last_plot() + + # Merge adjacent content of the same type + contents <- merge_adjacent_content(contents) + + # For `value`, drop source code blocks and remove all ANSI codes + value <- keep(contents, function(x) !S7::S7_inherits(x, ContentSource)) + value <- map(value, run_r_content_handle_ansi) + + if (length(value) == 0) { + value <- if (had_error) { + "(The code encountered an error but did not produce any output.)" + } else { + "(The code ran successfully but did not produce any output.)" + } + } + + BtwRunToolResult( + value = value, + extra = list( + data = last_value, + code = code, + contents = contents, + # We always return contents up to the error as `value` because `error` + # cannot handle rich output. We'll show status separately in the UI. + status = if (had_error) "error" else "success" + ) + ) +} + +`%is_plot_prefix_of%` <- function(x, y) { + # See https://github.com/r-lib/evaluate/blob/20333c/R/graphics.R#L87-L88 + + stopifnot(inherits(x, "recordedplot")) + stopifnot(inherits(y, "recordedplot")) + + x <- x[[1]] + y <- y[[1]] + + if (length(x) > length(y)) { + return(FALSE) + } + + identical(x[], y[seq_along(x)]) +} + +run_r_plot_device <- function(...) { + dev_fn <- getOption("btw.run_r.graphics_device", default = NULL) + if (!is.null(dev_fn)) { + check_function(dev_fn) + return(dev_fn(...)) + } + + if (rlang::is_installed("ragg")) { + return(ragg::agg_png(..., scaling = 1.5)) + } + + grDevices::png(...) +} + +btw_can_register_run_r_tool <- function() { + rlang::is_installed("evaluate") && + btw_run_r_tool_is_enabled() +} + +btw_run_r_tool_is_enabled <- function() { + opt <- getOption("btw.run_r.enabled", default = NULL) + if (!is.null(opt)) { + return(isTRUE(opt)) + } + + envvar <- Sys.getenv("BTW_RUN_R_ENABLED", unset = "") + if (nzchar(envvar)) { + return(tolower(trimws(envvar)) %in% c("true", "1")) + } + + switch( + getOption(".btw_tools.match_mode", default = "default"), + "explicit" = TRUE, + FALSE + ) +} + +run_r_content_handle_ansi <- function(x, plain = TRUE) { + if (!S7::S7_inherits(x, ellmer::ContentText)) { + return(x) + } + + text <- + if (isTRUE(plain)) { + htmltools::htmlEscape(strip_ansi(x@text)) + } else { + fansi_to_html(x@text) + } + + S7::set_props(x, text = text) +} + +#' Convert ANSI text to HTML with btw CSS classes +#' +#' Wrapper around fansi::to_html() that uses btw's CSS classes for ANSI colors. +#' Supports all 16 ANSI colors (basic + bright) with Bootstrap 5 theme integration. +#' +#' @param text Character string with ANSI escape codes +#' @returns Character string with HTML span elements using btw ANSI CSS classes +#' @noRd +fansi_to_html <- function(text) { + # Define 32 class names for all ANSI 16 colors (foreground + background). + # Order must alternate fg/bg for each color: black, red, green, yellow, blue, + # magenta, cyan, white, then bright versions of each + + # Color names for basic (0-7) and bright (8-15) colors + colors_basic <- c( + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white" + ) + colors_bright <- paste("bright", colors_basic, sep = "-") + colors_all <- c(colors_basic, colors_bright) + + # Generate class names: for each color, create fg and bg class + classes_32 <- paste0( + "btw-ansi-", + c(rbind(paste0("fg-", colors_all), paste0("bg-", colors_all))) + ) + + fansi::to_html(fansi::html_esc(text), classes = classes_32) +} + +.btw_add_to_tools( + name = "btw_tool_run_r", + group = "run", + tool = function() { + ellmer::tool( + function(code) { + btw_tool_run_r_impl(code) + }, + name = "btw_tool_run_r", + description = r"---(Run R code. + +Executes R code and captures printed values, text output, plots, messages, warnings, and errors. + +## CORE RULES (FOLLOW STRICTLY) +- MUST work incrementally: each call should do one small, well-defined task +- MUST create no more than one rendered figure per tool call. Use separate calls for multiple figures. +- MUST NOT use this tool to "talk to the user". Explanations and interpretation belong in the assistant message +- MUST read any error messages carefully +- MUST NOT make more than 2 attempts to fix an error + - After 2 failed attempts: stop, summarize what you tried, include the error(s), and propose the next change without executing it. + +## SAFETY REQUIREMENTS (MUST FOLLOW) +- This code runs in a global environment. Write code that is safe, reversible, and non-destructive +- MUST NOT perform any of the following UNLESS the user explicitly requests it and you first show the code and target paths/URLs: + - File writes or modifications (persistent output, overwriting, deleting) + - System/shell execution (system, system2, pipe, shell) + - Network requests + - Package installation or updates +- SHOULD NOT change global state (options, environment variables, working directory, etc.) + - Working directory, options and environment variables are reset between tool calls +- MUST use temporary files for any ephemeral storage needs (`tempfile()`) + +## CODE AND OUTPUT STYLE +- ALWAYS write clear, concise, and idiomatic R code, preferring packages and functions from the tidyverse ecosystem when available +- PREFER less than 50 lines of code per tool call +- SHOULD use code comments to explain only the non-obvious parts of the code + - AVOID using comments to literally describe the code +- DO return results implicitly (`x`, not `print(x)`) +- DO make the last expression the object you want to show (e.g. a data frame, tibble, list or scalar) +- AVOID `print()` and `cat()` unless necessary. If `cat()` is unavoidable, you MUST use a SINGLE `cat()` call and keep it concise +- PREFER returning structured objects (tibbles, data frames, lists) and brief summaries (`head()`, `str()`, `summary()`) +- AVOID extremely large outputs; show summaries and return key results + )---", + annotations = ellmer::tool_annotations( + title = "Run R Code", + read_only_hint = FALSE, + open_world_hint = FALSE, + btw_can_register = btw_can_register_run_r_tool + ), + arguments = list( + code = ellmer::type_string("The R code to run") + ) + ) + } +) + +# ---- Content Types ---- +ContentSource <- S7::new_class( + "ContentSource", + parent = ellmer::ContentText +) + +ContentOutput <- S7::new_class( + "ContentOutput", + parent = ellmer::ContentText +) + +ContentMessage <- S7::new_class( + "ContentMessage", + parent = ellmer::ContentText +) + +ContentWarning <- S7::new_class( + "ContentWarning", + parent = ellmer::ContentText +) + +ContentError <- S7::new_class( + "ContentError", + parent = ellmer::ContentText +) + +BtwRunToolResult <- S7::new_class( + "BtwRunToolResult", + parent = ellmer::ContentToolResult +) + +contents_html <- S7::new_external_generic( + package = "ellmer", + name = "contents_html", + dispatch_args = "content" +) + +trim_outer_nl <- function(x) { + x <- sub("^\r?\n", "", x) + sub("\r?\n$", "", x) +} + +btw_pre_output <- function(text, pre_class, code_class = "nohighlight") { + text <- trim_outer_nl(text) + if (!nzchar(text)) { + return("") + } + + sprintf( + '
%s',
+ pre_class,
+ code_class,
+ text
+ )
+}
+
+S7::method(contents_html, ContentSource) <- function(content, ...) {
+ btw_pre_output(content@text, pre_class = "source", code_class = "language-r")
+}
+
+S7::method(contents_html, ContentOutput) <- function(content, ...) {
+ btw_pre_output(content@text, pre_class = "output")
+}
+
+S7::method(contents_html, ContentMessage) <- function(content, ...) {
+ btw_pre_output(content@text, pre_class = "message")
+}
+
+S7::method(contents_html, ContentWarning) <- function(content, ...) {
+ btw_pre_output(content@text, pre_class = "warning")
+}
+
+S7::method(contents_html, ContentError) <- function(content, ...) {
+ btw_pre_output(content@text, pre_class = "error")
+}
+
+contents_shinychat <- S7::new_external_generic(
+ package = "shinychat",
+ name = "contents_shinychat",
+ dispatch_args = "content"
+)
+
+S7::method(contents_shinychat, BtwRunToolResult) <- function(content) {
+ code <- content@extra$code
+
+ # Render all content objects to HTML
+ contents <- content@extra$contents
+ # ---- Deal with ANSI codes in content objects
+ contents <- map(contents, function(x) {
+ run_r_content_handle_ansi(x, plain = !is_installed("fansi"))
+ })
+ output_html <- map_chr(contents, ellmer::contents_html)
+ output_html <- paste(output_html, collapse = "\n")
+
+ status <- content@extra$status
+ request_id <- NULL
+ tool_title <- NULL
+
+ if (!is.null(content@request)) {
+ request_id <- content@request@id
+
+ tool_title <- NULL
+ tool <- content@request@tool
+ if (!is.null(tool)) {
+ tool_title <- tool@annotations$title
+ }
+ }
+
+ htmltools::tag(
+ "btw-run-r-result",
+ list(
+ `request-id` = request_id,
+ code = code,
+ status = status,
+ `tool-title` = tool_title,
+ htmltools::HTML(output_html),
+ btw_run_tool_card_dep()
+ )
+ )
+}
+
+btw_run_tool_card_dep <- function() {
+ htmltools::htmlDependency(
+ name = "btw-run-r",
+ version = utils::packageVersion("btw"),
+ package = "btw",
+ src = "js/run-r",
+ script = list(
+ list(src = "btw-icons.js", type = "module"),
+ list(src = "btw-run-r.js", type = "module")
+ ),
+ stylesheet = "btw-run-r.css",
+ all_files = FALSE
+ )
+}
+
+is_mergeable_content <- function(x, y) {
+ mergeable_content_types <- list(
+ ContentSource,
+ ContentOutput,
+ ContentMessage,
+ ContentWarning,
+ ContentError
+ )
+
+ for (cls in mergeable_content_types) {
+ if (S7::S7_inherits(x, cls) && S7::S7_inherits(y, cls)) {
+ return(TRUE)
+ }
+ }
+
+ FALSE
+}
+
+#' Merge adjacent content of the same type
+#'
+#' Reduces a list of Content objects by concatenating adjacent elements
+#' of the same mergeable type (ContentOutput, ContentMessage, ContentWarning,
+#' ContentError) into single elements.
+#'
+#' @param contents List of Content objects
+#' @returns List of Content objects with adjacent same-type elements merged
+#' @noRd
+merge_adjacent_content <- function(contents) {
+ if (length(contents) <= 1) {
+ return(contents)
+ }
+
+ reduce(
+ contents,
+ function(acc, item) {
+ if (length(acc) == 0) {
+ return(list(item))
+ }
+
+ last <- acc[[length(acc)]]
+
+ if (is_mergeable_content(last, item)) {
+ # Merge by concatenating text with newline
+ merged_text <- paste(last@text, item@text, sep = "\n")
+ S7::prop(acc[[length(acc)]], "text") <- merged_text
+ acc
+ } else {
+ append(acc, list(item))
+ }
+ },
+ .init = list()
+ )
+}
+
+#' Compute plot dimensions from aspect ratio
+#'
+#' @param ratio Either:
+#' - character of the form "w:h" (e.g. "16:9", "5:9"), or
+#' - numeric giving width/height (e.g. 16/9, 1.777...).
+#' @param longest_side Integer pixel size for the longest side (default 768).
+#'
+#' @return Named list with `width` and `height` in pixels, where
+#' max(width, height) == longest_side.
+#' @noRd
+btw_run_r_plot_dimensions <- function(ratio, longest_side = 768L) {
+ r <- parse_ratio(ratio)
+
+ if (r >= 1) {
+ # Width is longer
+ width <- longest_side
+ height <- longest_side / r
+ } else {
+ # Height is longer
+ height <- longest_side
+ width <- longest_side * r
+ }
+
+ list(
+ width = as.integer(round(width)),
+ height = as.integer(round(height))
+ )
+}
+
+#' Parse an aspect ratio specification
+#'
+#' @param ratio Either:
+#' - character of the form "w:h" (e.g. "16:9", "5:9"), or
+#' - numeric giving width/height (e.g. 16/9, 1.777...).
+#'
+#' @return Numeric scalar giving width/height.
+#' @noRd
+parse_ratio <- function(ratio) {
+ if (is.character(ratio)) {
+ parts <- strsplit(ratio, ":", fixed = TRUE)[[1]]
+ if (length(parts) != 2L) {
+ cli::cli_abort(
+ "Invalid ratio string '{ratio}'. Use the form 'w:h', e.g. '16:9'.",
+ call = caller_env(n = 2)
+ )
+ }
+ nums <- suppressWarnings(as.numeric(parts))
+ if (any(is.na(nums)) || any(nums <= 0)) {
+ cli::cli_abort(
+ "Both sides of the ratio must be positive numbers, e.g. '16:9'.",
+ call = caller_env(n = 2)
+ )
+ }
+ return(nums[1] / nums[2])
+ }
+
+ check_number_decimal(ratio, allow_infinite = FALSE, min = 0)
+ ratio
+}
diff --git a/R/tools.R b/R/tools.R
index 48a06f71..1b6f088e 100644
--- a/R/tools.R
+++ b/R/tools.R
@@ -45,8 +45,10 @@ btw_tools <- function(...) {
check_character(tools, allow_null = TRUE)
if (length(tools) == 0) {
+ withr::local_options(.btw_tools.match_mode = "all")
tools <- names(.btw_tools)
} else {
+ withr::local_options(.btw_tools.match_mode = "explicit")
tool_names <- map_chr(.btw_tools, function(x) x$name)
tool_groups <- map_chr(.btw_tools, function(x) x$group)
@@ -135,6 +137,7 @@ tool_group_icon <- function(group, default = NULL) {
group,
"docs" = tool_icon("dictionary"),
"env" = tool_icon("source-environment"),
+ "eval" = tool_icon("play-circle"),
"files" = tool_icon("folder-open"),
"git" = tool_icon("git"),
"github" = tool_icon("github"),
diff --git a/R/utils.R b/R/utils.R
index cc0ed250..ba8a8cb4 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -189,22 +189,42 @@ path_btw_cache <- function(...) {
local_reproducible_output <- function(
width = 80L,
max.print = 100,
+ disable_ansi_features = TRUE,
.env = parent.frame()
) {
# Replicating testthat::local_reproducible_output()
withr::local_options(width = width, cli.width = width, .local_envir = .env)
withr::local_envvar(RSTUDIO_CONSOLE_WIDTH = width, .local_envir = .env)
- withr::local_envvar(list(NO_COLOR = "true"), .local_envir = .env)
+
+ if (disable_ansi_features) {
+ withr::local_envvar(list(NO_COLOR = "true"), .local_envir = .env)
+ withr::local_options(
+ crayon.enabled = FALSE,
+ cli.dynamic = FALSE,
+ cli.unicode = FALSE,
+ cli.condition_width = Inf,
+ cli.num_colors = 1L,
+ .local_envir = .env
+ )
+ } else {
+ withr::local_envvar(list(NO_COLOR = NA), .local_envir = .env)
+ withr::local_options(
+ crayon.enabled = TRUE,
+ cli.ansi = TRUE,
+ cli.unicode = TRUE,
+ cli.condition_width = width,
+ cli.num_colors = 16L,
+ .local_envir = .env
+ )
+ }
+
withr::local_options(
- crayon.enabled = FALSE,
+ cil.dynamic = FALSE,
+ cli.spinner = FALSE,
cli.hyperlink = FALSE,
cli.hyperlink_run = FALSE,
cli.hyperlink_help = FALSE,
cli.hyperlink_vignette = FALSE,
- cli.dynamic = FALSE,
- cli.unicode = FALSE,
- cli.condition_width = Inf,
- cli.num_colors = 1L,
useFancyQuotes = FALSE,
lifecycle_verbosity = "warning",
OutDec = ".",
@@ -214,6 +234,12 @@ local_reproducible_output <- function(
)
}
+strip_ansi <- function(text) {
+ # Matches codes like "\x1B[31;43m", "\x1B[1;3;4m"
+ ansi_pattern <- "(\x1B|\x033)\\[[0-9;?=<>]*[@-~]"
+ gsub(ansi_pattern, "", text)
+}
+
to_title_case <- function(x) {
paste0(toupper(substring(x, 1, 1)), substring(x, 2))
}
diff --git a/inst/icons/play-circle.svg b/inst/icons/play-circle.svg
new file mode 100644
index 00000000..70d4bd19
--- /dev/null
+++ b/inst/icons/play-circle.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/inst/js/run-r/btw-icons.js b/inst/js/run-r/btw-icons.js
new file mode 100644
index 00000000..75962795
--- /dev/null
+++ b/inst/js/run-r/btw-icons.js
@@ -0,0 +1,22 @@
+/**
+ * SVG icons used in the component
+ */
+export const ICONS = {
+ code: ``,
+ playCircle: ``,
+ exclamationCircleFill: ``,
+ plus: ``,
+ copy: ``,
+ check: ``
+}
diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css
new file mode 100644
index 00000000..ba40c1fb
--- /dev/null
+++ b/inst/js/run-r/btw-run-r.css
@@ -0,0 +1,274 @@
+/**
+ * Styles for btw-run-r-result custom element
+ */
+
+btw-run-r-result {
+ --shiny-tool-card-max-height: auto;
+ display: block;
+ margin-bottom: var(--bslib-spacer, 1em);
+}
+
+/* Make header clickable for collapsing */
+btw-run-r-result .card-header {
+ cursor: pointer;
+}
+
+/* Remove horizontal padding from card body for full-width code */
+btw-run-r-result .card-body {
+ padding: 0;
+ gap: 0;
+}
+
+/* Output container with visual treatment */
+
+/* Code output blocks */
+.btw-run-output pre {
+ margin: 0;
+ padding: 0.5rem;
+ border: none;
+ border-radius: 0;
+ overflow-x: auto;
+}
+
+.btw-run-output pre:not(.btw-output-source) {
+ background-color: var(--bs-light, #f8f9fa);
+ border-left: 3px solid var(--bs-secondary-border-subtle, #b3b3b3);
+ font-size: 0.875em;
+}
+
+[data-bs-theme="dark"] .btw-run-output pre {
+ background-color: var(--bs-black);
+}
+
+.btw-run-output .code-copy-button {
+ /* TODO: Figure out how to disable markdown-stream code copy button */
+ display: none;
+}
+
+.btw-run-output pre code {
+ font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
+ white-space: pre;
+ word-break: break-word;
+ padding: 0;
+}
+
+.btw-run-output > img:last-child {
+ margin-bottom: 0;
+}
+
+.btw-output-output:has(> code:empty) {
+ display: none;
+}
+
+.btw-run-output pre.btw-output-source {
+ background-color: unset;
+}
+
+/* Message output (blue left border) */
+.btw-run-output pre.btw-output-message {
+ border-left: 3px solid var(--bs-info, #0dcaf0);
+ background-color: rgba(13, 202, 240, 0.1);
+}
+
+/* Warning output (yellow/orange left border) */
+.btw-run-output pre.btw-output-warning {
+ border-left: 3px solid var(--bs-warning, #ffc107);
+ background-color: rgba(255, 193, 7, 0.1);
+}
+
+/* Error output (red left border) */
+.btw-run-output pre.btw-output-error {
+ border-left: 3px solid var(--bs-danger, #dc3545);
+ background-color: rgba(220, 53, 69, 0.1);
+}
+
+/* Images in output */
+.btw-run-output img {
+ max-width: 100%;
+ height: auto;
+ margin: 0.5rem 0;
+ border-radius: 0.25rem;
+}
+
+/* ANSI color variables */
+:root {
+ /* Basic foreground colors (0-7) - Bootstrap defaults work well on white */
+ --btw-ansi-fg-black: var(--bs-dark);
+ --btw-ansi-fg-red: var(--bs-danger);
+ --btw-ansi-fg-green: var(--bs-success);
+ --btw-ansi-fg-yellow: var(--bs-warning);
+ --btw-ansi-fg-blue: var(--bs-primary);
+ --btw-ansi-fg-magenta: var(--bs-pink);
+ --btw-ansi-fg-cyan: var(--bs-info);
+ --btw-ansi-fg-white: var(--bs-light);
+
+ /* Bright foreground colors (8-15) */
+ --btw-ansi-fg-bright-black: var(--bs-secondary);
+ --btw-ansi-fg-bright-red: color-mix(in srgb, var(--bs-danger) 70%, var(--bs-white) 30%);
+ --btw-ansi-fg-bright-green: color-mix(in srgb, var(--bs-success) 70%, var(--bs-white) 30%);
+ --btw-ansi-fg-bright-yellow: color-mix(in srgb, var(--bs-warning) 70%, var(--bs-white) 30%);
+ --btw-ansi-fg-bright-blue: color-mix(in srgb, var(--bs-primary) 70%, var(--bs-white) 30%);
+ --btw-ansi-fg-bright-magenta: var(--bs-purple);
+ --btw-ansi-fg-bright-cyan: color-mix(in srgb, var(--bs-info) 70%, var(--bs-white) 30%);
+ --btw-ansi-fg-bright-white: var(--bs-white);
+
+ /* Basic background colors (0-7) */
+ --btw-ansi-bg-black: var(--bs-dark);
+ --btw-ansi-bg-red: var(--bs-danger);
+ --btw-ansi-bg-green: var(--bs-success);
+ --btw-ansi-bg-yellow: var(--bs-warning);
+ --btw-ansi-bg-blue: var(--bs-primary);
+ --btw-ansi-bg-magenta: var(--bs-pink);
+ --btw-ansi-bg-cyan: var(--bs-info);
+ --btw-ansi-bg-white: var(--bs-light);
+
+ /* Bright background colors (8-15) */
+ --btw-ansi-bg-bright-black: var(--bs-secondary);
+ --btw-ansi-bg-bright-red: color-mix(in srgb, var(--bs-danger) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-bright-green: color-mix(in srgb, var(--bs-success) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-bright-yellow: color-mix(in srgb, var(--bs-warning) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-bright-blue: color-mix(in srgb, var(--bs-primary) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-bright-magenta: var(--bs-purple);
+ --btw-ansi-bg-bright-cyan: color-mix(in srgb, var(--bs-info) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-bright-white: var(--bs-white);
+}
+
+[data-bs-theme="dark"] {
+ /* Basic foreground colors (0-7) - Lighten for better contrast on dark background */
+ --btw-ansi-fg-black: color-mix(in srgb, var(--bs-dark) 40%, var(--bs-white) 60%);
+ --btw-ansi-fg-red: color-mix(in srgb, var(--bs-danger) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-green: color-mix(in srgb, var(--bs-success) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-yellow: color-mix(in srgb, var(--bs-warning) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-blue: color-mix(in srgb, var(--bs-primary) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-magenta: color-mix(in srgb, var(--bs-pink) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-cyan: color-mix(in srgb, var(--bs-info) 60%, var(--bs-white) 40%);
+ --btw-ansi-fg-white: var(--bs-light);
+
+ /* Bright foreground colors (8-15) - Even lighter for bright variants */
+ --btw-ansi-fg-bright-black: color-mix(in srgb, var(--bs-secondary) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-red: color-mix(in srgb, var(--bs-danger) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-green: color-mix(in srgb, var(--bs-success) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-yellow: color-mix(in srgb, var(--bs-warning) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-blue: color-mix(in srgb, var(--bs-primary) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-magenta: color-mix(in srgb, var(--bs-purple) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-cyan: color-mix(in srgb, var(--bs-info) 50%, var(--bs-white) 50%);
+ --btw-ansi-fg-bright-white: var(--bs-white);
+
+ /* Basic background colors (0-7) - Lighten for visibility on dark background */
+ --btw-ansi-bg-black: color-mix(in srgb, var(--bs-dark) 70%, var(--bs-white) 30%);
+ --btw-ansi-bg-red: color-mix(in srgb, var(--bs-danger) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-green: color-mix(in srgb, var(--bs-success) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-yellow: color-mix(in srgb, var(--bs-warning) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-blue: color-mix(in srgb, var(--bs-primary) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-magenta: color-mix(in srgb, var(--bs-pink) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-cyan: color-mix(in srgb, var(--bs-info) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-white: color-mix(in srgb, var(--bs-light) 80%, var(--bs-white) 20%);
+
+ /* Bright background colors (8-15) - Even lighter */
+ --btw-ansi-bg-bright-black: color-mix(in srgb, var(--bs-secondary) 60%, var(--bs-white) 40%);
+ --btw-ansi-bg-bright-red: color-mix(in srgb, var(--bs-danger) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-green: color-mix(in srgb, var(--bs-success) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-yellow: color-mix(in srgb, var(--bs-warning) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-blue: color-mix(in srgb, var(--bs-primary) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-magenta: color-mix(in srgb, var(--bs-purple) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-cyan: color-mix(in srgb, var(--bs-info) 50%, var(--bs-white) 50%);
+ --btw-ansi-bg-bright-white: var(--bs-white);
+}
+
+/* ANSI Basic Foreground Colors (0-7) */
+.btw-ansi-fg-black { color: var(--btw-ansi-fg-black); }
+.btw-ansi-fg-red { color: var(--btw-ansi-fg-red); }
+.btw-ansi-fg-green { color: var(--btw-ansi-fg-green); }
+.btw-ansi-fg-yellow { color: var(--btw-ansi-fg-yellow); }
+.btw-ansi-fg-blue { color: var(--btw-ansi-fg-blue); }
+.btw-ansi-fg-magenta { color: var(--btw-ansi-fg-magenta); }
+.btw-ansi-fg-cyan { color: var(--btw-ansi-fg-cyan); }
+.btw-ansi-fg-white { color: var(--btw-ansi-fg-white); }
+
+/* ANSI Bright Foreground Colors (8-15) */
+.btw-ansi-fg-bright-black { color: var(--btw-ansi-fg-bright-black); }
+.btw-ansi-fg-bright-red { color: var(--btw-ansi-fg-bright-red); }
+.btw-ansi-fg-bright-green { color: var(--btw-ansi-fg-bright-green); }
+.btw-ansi-fg-bright-yellow { color: var(--btw-ansi-fg-bright-yellow); }
+.btw-ansi-fg-bright-blue { color: var(--btw-ansi-fg-bright-blue); }
+.btw-ansi-fg-bright-magenta { color: var(--btw-ansi-fg-bright-magenta); }
+.btw-ansi-fg-bright-cyan { color: var(--btw-ansi-fg-bright-cyan); }
+.btw-ansi-fg-bright-white { color: var(--btw-ansi-fg-bright-white); }
+
+/* ANSI Basic Background Colors (0-7) */
+.btw-ansi-bg-black { background-color: var(--btw-ansi-bg-black); }
+.btw-ansi-bg-red { background-color: var(--btw-ansi-bg-red); }
+.btw-ansi-bg-green { background-color: var(--btw-ansi-bg-green); }
+.btw-ansi-bg-yellow { background-color: var(--btw-ansi-bg-yellow); }
+.btw-ansi-bg-blue { background-color: var(--btw-ansi-bg-blue); }
+.btw-ansi-bg-magenta { background-color: var(--btw-ansi-bg-magenta); }
+.btw-ansi-bg-cyan { background-color: var(--btw-ansi-bg-cyan); }
+.btw-ansi-bg-white { background-color: var(--btw-ansi-bg-white); }
+
+/* ANSI Bright Background Colors (8-15) */
+.btw-ansi-bg-bright-black { background-color: var(--btw-ansi-bg-bright-black); }
+.btw-ansi-bg-bright-red { background-color: var(--btw-ansi-bg-bright-red); }
+.btw-ansi-bg-bright-green { background-color: var(--btw-ansi-bg-bright-green); }
+.btw-ansi-bg-bright-yellow { background-color: var(--btw-ansi-bg-bright-yellow); }
+.btw-ansi-bg-bright-blue { background-color: var(--btw-ansi-bg-bright-blue); }
+.btw-ansi-bg-bright-magenta { background-color: var(--btw-ansi-bg-bright-magenta); }
+.btw-ansi-bg-bright-cyan { background-color: var(--btw-ansi-bg-bright-cyan); }
+.btw-ansi-bg-bright-white { background-color: var(--btw-ansi-bg-bright-white); }
+
+/* Copy code button in header */
+.copy-code-btn {
+ width: 14px;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ color: var(--bs-body-color);
+ opacity: 0.6;
+ transition: opacity 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.copy-code-btn:hover {
+ opacity: 1;
+}
+
+.copy-code-btn:focus {
+ outline: 2px solid var(--bs-primary);
+ outline-offset: 2px;
+ opacity: 1;
+}
+
+.copy-code-btn svg {
+ display: block;
+}
+
+/* Collapse toggle button */
+.collapse-toggle-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.collapse-toggle-btn:hover .collapse-indicator {
+ opacity: 1;
+}
+
+.collapse-toggle-btn:focus {
+ outline: 2px solid var(--bs-primary);
+ outline-offset: 2px;
+}
+
+.collapse-toggle-btn:focus .collapse-indicator {
+ opacity: 1;
+}
diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js
new file mode 100644
index 00000000..6c0dc5fe
--- /dev/null
+++ b/inst/js/run-r/btw-run-r.js
@@ -0,0 +1,374 @@
+/**
+ * Custom element for displaying btw_tool_run_r results in shinychat.
+ * @module btw-run-r
+ */
+
+import { ICONS } from "./btw-icons.js"
+
+// Ensure shinychat's hidden requests set exists
+window.shinychat = window.shinychat || {}
+window.shinychat.hiddenToolRequests =
+ window.shinychat.hiddenToolRequests || new Set()
+
+/**
+ * Formats code as a Markdown code block for rendering.
+ * @param {string} content - The code content
+ * @param {string} [language="r"] - The language for syntax highlighting
+ * @returns {string} Markdown code block
+ */
+function markdownCodeBlock(content, language = "r") {
+ const backticks = "`".repeat(8)
+ return `${backticks}${language}\n${content}\n${backticks}`
+}
+
+/**
+ * Web component that displays the result of btw_tool_run_r execution.
+ *
+ * @element btw-run-r-result
+ * @attr {string} request-id - Unique identifier linking to the tool request
+ * @attr {string} code - The R code that was executed
+ * @attr {string} status - Execution status: "success" or "error"
+ *
+ * @example
+ * [1] 2
+ *