From 4ee39fd9449e95d7a9fdca6aa8a80e3aa8a3a21e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 25 Nov 2025 07:48:27 -0500 Subject: [PATCH 01/49] feat: evaluate tool --- R/tool-evaluate.R | 175 ++++++++++++++++++++++++++++ tests/testthat/test-tool-evaluate.R | 111 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 R/tool-evaluate.R create mode 100644 tests/testthat/test-tool-evaluate.R diff --git a/R/tool-evaluate.R b/R/tool-evaluate.R new file mode 100644 index 00000000..189e31ee --- /dev/null +++ b/R/tool-evaluate.R @@ -0,0 +1,175 @@ +#' S7 Content Types for Code Evaluation Results +#' +#' These S7 classes extend ellmer's ContentText to provide semantic meaning +#' to different types of text output from code evaluation. +#' +#' @name ContentTypes +#' @keywords internal +NULL + +#' @rdname ContentTypes +ContentMessage <- S7::new_class( + "ContentMessage", + parent = ellmer::ContentText +) + +#' @rdname ContentTypes +ContentWarning <- S7::new_class( + "ContentWarning", + parent = ellmer::ContentText +) + +#' @rdname ContentTypes +ContentError <- S7::new_class( + "ContentError", + parent = ellmer::ContentText +) + +#' Tool: Evaluate R code +#' +#' This tool evaluates R code and returns results as ellmer Content objects. +#' It captures text output, plots, messages, warnings, and errors. +#' Code evaluation stops on the first error, returning all results up to that point. +#' +#' @param code A character string containing R code to evaluate. +#' @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 evaluation +#' +#' @examples +#' \dontrun{ +#' # Simple calculation +#' btw_tool_evaluate("2 + 2") +#' +#' # Code with plot +#' btw_tool_evaluate("hist(rnorm(100))") +#' +#' # Code with warning +#' btw_tool_evaluate("mean(c(1, 2, NA))") +#' } +#' +#' @seealso [btw_tools()] +#' @family Tools +#' @export +btw_tool_evaluate <- function(code, `_intent`) {} + +btw_tool_evaluate_impl <- function(code, ...) { + check_dots_empty() + check_string(code) + + # Check if evaluate package is available + check_installed("evaluate", "to evaluate R code.") + + # Evaluate the code, stopping on first error + result <- evaluate::evaluate( + code, + envir = global_env(), + stop_on_error = 1, + new_device = TRUE + ) + + # Initialize list to store Content objects + contents <- list() + # Store the last value for potential use + last_value <- NULL + + # Process each output + for (output in result) { + if (is.character(output)) { + # Text output (from print, cat, etc.) + contents <- append(contents, list(ellmer::ContentText(output))) + + } else if (inherits(output, "source")) { + # Skip source code echoing + next + + } else if (inherits(output, "recordedplot")) { + # Save plot to temporary file + tmp <- withr::local_tempfile(fileext = ".png") + grDevices::png(tmp, width = 768, height = 768) + grDevices::replayPlot(output) + grDevices::dev.off() + + # Read and encode as base64 + img_data <- base64enc::base64encode(tmp) + contents <- append(contents, list( + ellmer::ContentImageInline(type = "image/png", data = img_data) + )) + + } else if (inherits(output, "warning")) { + # Warning message + warn_text <- conditionMessage(output) + contents <- append(contents, list( + ContentWarning(text = warn_text) + )) + + } else if (inherits(output, "message")) { + # Message output + msg_text <- conditionMessage(output) + # Remove trailing newline that message() adds + msg_text <- sub("\n$", "", msg_text) + contents <- append(contents, list( + ContentMessage(text = msg_text) + )) + + } else if (inherits(output, "error")) { + # Error message + err_text <- conditionMessage(output) + contents <- append(contents, list( + ContentError(text = err_text) + )) + + } else if (is.null(output)) { + # NULL values - skip + next + + } else { + # Other output types - capture as text + # This handles visible return values + last_value <- output + output_text <- paste(utils::capture.output(print(output)), collapse = "\n") + contents <- append(contents, list( + ellmer::ContentText(output_text) + )) + } + } + + # Return as ContentToolResult with contents in extra + # The value is stored but the contents list is what gets displayed + BtwToolResult( + contents, + extra = list( + value = last_value + ) + ) +} + +.btw_add_to_tools( + name = "btw_tool_evaluate", + group = "env", + tool = function() { + ellmer::tool( + function(code) { + btw_tool_evaluate_impl(code = code) + }, + name = "btw_tool_evaluate", + description = "Execute R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.", + annotations = ellmer::tool_annotations( + title = "Evaluate R Code", + read_only_hint = FALSE, + open_world_hint = FALSE, + btw_can_register = function() TRUE + ), + arguments = list( + code = ellmer::type_string( + "R code to evaluate as a string." + ) + ) + ) + } +) diff --git a/tests/testthat/test-tool-evaluate.R b/tests/testthat/test-tool-evaluate.R new file mode 100644 index 00000000..f03d6d06 --- /dev/null +++ b/tests/testthat/test-tool-evaluate.R @@ -0,0 +1,111 @@ +test_that("btw_tool_evaluate() returns simple calculations", { + skip_if_not_installed("evaluate") + + res <- btw_tool_evaluate_impl("2 + 2") + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + # The value is captured as text output for visible values + expect_length(res@value, 1) + expect_s7_class(res@value[[1]], ellmer::ContentText) + # Check that the output contains "4" + expect_match(res@value[[1]]@text, "4") +}) + +test_that("btw_tool_evaluate() captures messages", { + skip_if_not_installed("evaluate") + + res <- btw_tool_evaluate_impl('message("hello")') + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + expect_length(res@value, 1) + expect_s7_class(res@value[[1]], ContentMessage) + expect_equal(res@value[[1]]@text, "hello") +}) + +test_that("btw_tool_evaluate() captures warnings", { + skip_if_not_installed("evaluate") + + res <- btw_tool_evaluate_impl('warning("beware")') + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + expect_length(res@value, 1) + expect_s7_class(res@value[[1]], ContentWarning) + expect_match(res@value[[1]]@text, "beware") +}) + +test_that("btw_tool_evaluate() captures errors and stops", { + skip_if_not_installed("evaluate") + + res <- btw_tool_evaluate_impl('x <- 1; stop("error"); y <- 2') + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + # Should have the error content + has_error <- any(vapply( + res@value, + function(x) S7::S7_inherits(x, ContentError), + logical(1) + )) + expect_true(has_error) + # y should not be assigned (code stopped at error) + expect_false(exists("y", envir = globalenv())) +}) + +test_that("btw_tool_evaluate() captures plots", { + skip_if_not_installed("evaluate") + + res <- btw_tool_evaluate_impl('plot(1:10)') + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + # Should have at least one ContentImageInline + has_plot <- any(vapply( + res@value, + function(x) S7::S7_inherits(x, ellmer::ContentImageInline), + logical(1) + )) + expect_true(has_plot) +}) + +test_that("btw_tool_evaluate() handles multiple outputs", { + skip_if_not_installed("evaluate") + + code <- ' + message("starting") + x <- 1:10 + mean(x) + warning("careful") + ' + res <- btw_tool_evaluate_impl(code) + expect_s7_class(res, BtwToolResult) + expect_type(res@value, "list") + expect_gte(length(res@value), 3) + + # Check we have message, text output, and warning + has_message <- any(vapply(res@value, function(x) S7::S7_inherits(x, ContentMessage), logical(1))) + has_text <- any(vapply(res@value, function(x) S7::S7_inherits(x, ellmer::ContentText), logical(1))) + has_warning <- any(vapply(res@value, function(x) S7::S7_inherits(x, ContentWarning), logical(1))) + + expect_true(has_message) + expect_true(has_text) + expect_true(has_warning) +}) + +test_that("btw_tool_evaluate() requires string input", { + skip_if_not_installed("evaluate") + + expect_error(btw_tool_evaluate_impl(123), class = "rlang_error") + expect_error(btw_tool_evaluate_impl(NULL), class = "rlang_error") +}) + +test_that("ContentMessage, ContentWarning, ContentError inherit from ContentText", { + msg <- ContentMessage(text = "hello") + warn <- ContentWarning(text = "warning") + err <- ContentError(text = "error") + + expect_s7_class(msg, ellmer::ContentText) + expect_s7_class(warn, ellmer::ContentText) + expect_s7_class(err, ellmer::ContentText) + + expect_equal(msg@text, "hello") + expect_equal(warn@text, "warning") + expect_equal(err@text, "error") +}) From ad727b8716e21ed9f9c3152debf1d4056a793a97 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 25 Nov 2025 07:55:21 -0500 Subject: [PATCH 02/49] feat: Use `new_output_handler()` pattern --- R/tool-evaluate.R | 135 +++++++++++++++++----------- tests/testthat/test-tool-evaluate.R | 23 +++-- 2 files changed, 99 insertions(+), 59 deletions(-) diff --git a/R/tool-evaluate.R b/R/tool-evaluate.R index 189e31ee..3ea07d58 100644 --- a/R/tool-evaluate.R +++ b/R/tool-evaluate.R @@ -65,86 +65,113 @@ btw_tool_evaluate_impl <- function(code, ...) { # Check if evaluate package is available check_installed("evaluate", "to evaluate R code.") - # Evaluate the code, stopping on first error - result <- evaluate::evaluate( - code, - envir = global_env(), - stop_on_error = 1, - new_device = TRUE - ) - # Initialize list to store Content objects contents <- list() # Store the last value for potential use last_value <- NULL - # Process each output - for (output in result) { - if (is.character(output)) { + # 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 + NULL + }, + text = function(text) { # Text output (from print, cat, etc.) - contents <- append(contents, list(ellmer::ContentText(output))) - - } else if (inherits(output, "source")) { - # Skip source code echoing - next - - } else if (inherits(output, "recordedplot")) { + contents <<- append(contents, list(ellmer::ContentText(text))) + text + }, + graphics = function(plot) { # Save plot to temporary file tmp <- withr::local_tempfile(fileext = ".png") grDevices::png(tmp, width = 768, height = 768) - grDevices::replayPlot(output) + grDevices::replayPlot(plot) grDevices::dev.off() # Read and encode as base64 img_data <- base64enc::base64encode(tmp) - contents <- append(contents, list( - ellmer::ContentImageInline(type = "image/png", data = img_data) - )) - - } else if (inherits(output, "warning")) { - # Warning message - warn_text <- conditionMessage(output) - contents <- append(contents, list( - ContentWarning(text = warn_text) - )) + contents <<- append( + contents, + list( + ellmer::ContentImageInline(type = "image/png", data = img_data) + ) + ) - } else if (inherits(output, "message")) { + plot + }, + message = function(msg) { # Message output - msg_text <- conditionMessage(output) + msg_text <- conditionMessage(msg) # Remove trailing newline that message() adds msg_text <- sub("\n$", "", msg_text) - contents <- append(contents, list( - ContentMessage(text = msg_text) - )) - - } else if (inherits(output, "error")) { + contents <<- append( + contents, + list( + ContentMessage(text = msg_text) + ) + ) + msg + }, + warning = function(warn) { + # Warning message + warn_text <- conditionMessage(warn) + contents <<- append( + contents, + list( + ContentWarning(text = warn_text) + ) + ) + warn + }, + error = function(err) { # Error message - err_text <- conditionMessage(output) - contents <- append(contents, list( - ContentError(text = err_text) - )) - - } else if (is.null(output)) { - # NULL values - skip - next + err_text <- conditionMessage(err) + contents <<- append( + contents, + list( + ContentError(text = err_text) + ) + ) + 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 text content + value_text <- paste( + utils::capture.output(print(value)), + collapse = "\n" + ) + contents <<- append( + contents, + list( + ellmer::ContentText(value_text) + ) + ) + } - } else { - # Other output types - capture as text - # This handles visible return values - last_value <- output - output_text <- paste(utils::capture.output(print(output)), collapse = "\n") - contents <- append(contents, list( - ellmer::ContentText(output_text) - )) + if (visible) value } - } + ) + + # Evaluate the code with our custom handler + evaluate::evaluate( + code, + envir = global_env(), + stop_on_error = 1, + new_device = TRUE, + output_handler = handler + ) # Return as ContentToolResult with contents in extra # The value is stored but the contents list is what gets displayed BtwToolResult( contents, extra = list( - value = last_value + data = last_value, + code = code ) ) } diff --git a/tests/testthat/test-tool-evaluate.R b/tests/testthat/test-tool-evaluate.R index f03d6d06..bae7f00b 100644 --- a/tests/testthat/test-tool-evaluate.R +++ b/tests/testthat/test-tool-evaluate.R @@ -4,10 +4,11 @@ test_that("btw_tool_evaluate() returns simple calculations", { res <- btw_tool_evaluate_impl("2 + 2") expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") - # The value is captured as text output for visible values + # The actual value is stored in extra + expect_equal(res@extra$value, 4) + # The visible output is captured as text expect_length(res@value, 1) expect_s7_class(res@value[[1]], ellmer::ContentText) - # Check that the output contains "4" expect_match(res@value[[1]]@text, "4") }) @@ -80,9 +81,21 @@ test_that("btw_tool_evaluate() handles multiple outputs", { expect_gte(length(res@value), 3) # Check we have message, text output, and warning - has_message <- any(vapply(res@value, function(x) S7::S7_inherits(x, ContentMessage), logical(1))) - has_text <- any(vapply(res@value, function(x) S7::S7_inherits(x, ellmer::ContentText), logical(1))) - has_warning <- any(vapply(res@value, function(x) S7::S7_inherits(x, ContentWarning), logical(1))) + has_message <- any(vapply( + res@value, + function(x) S7::S7_inherits(x, ContentMessage), + logical(1) + )) + has_text <- any(vapply( + res@value, + function(x) S7::S7_inherits(x, ellmer::ContentText), + logical(1) + )) + has_warning <- any(vapply( + res@value, + function(x) S7::S7_inherits(x, ContentWarning), + logical(1) + )) expect_true(has_message) expect_true(has_text) From 61aa3f31d7c84e7ba211fa8e1b3d01f6c30b1fb1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 25 Nov 2025 15:55:34 -0500 Subject: [PATCH 03/49] chore: Add icon, improve name --- R/btw_client_app.R | 1 + R/tool-evaluate.R | 18 +++++++-------- R/tools.R | 1 + inst/icons/play-circle.svg | 1 + tests/testthat/test-tool-evaluate.R | 34 ++++++++++++++--------------- 5 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 inst/icons/play-circle.svg diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 689a8495..6d8ac81e 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -597,6 +597,7 @@ 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"), diff --git a/R/tool-evaluate.R b/R/tool-evaluate.R index 3ea07d58..7e7362da 100644 --- a/R/tool-evaluate.R +++ b/R/tool-evaluate.R @@ -44,21 +44,21 @@ ContentError <- S7::new_class( #' @examples #' \dontrun{ #' # Simple calculation -#' btw_tool_evaluate("2 + 2") +#' btw_tool_evaluate_r("2 + 2") #' #' # Code with plot -#' btw_tool_evaluate("hist(rnorm(100))") +#' btw_tool_evaluate_r("hist(rnorm(100))") #' #' # Code with warning -#' btw_tool_evaluate("mean(c(1, 2, NA))") +#' btw_tool_evaluate_r("mean(c(1, 2, NA))") #' } #' #' @seealso [btw_tools()] #' @family Tools #' @export -btw_tool_evaluate <- function(code, `_intent`) {} +btw_tool_evaluate_r <- function(code, `_intent`) {} -btw_tool_evaluate_impl <- function(code, ...) { +btw_tool_evaluate_r_impl <- function(code, ...) { check_dots_empty() check_string(code) @@ -177,14 +177,14 @@ btw_tool_evaluate_impl <- function(code, ...) { } .btw_add_to_tools( - name = "btw_tool_evaluate", - group = "env", + name = "btw_tool_evaluate_r", + group = "eval", tool = function() { ellmer::tool( function(code) { - btw_tool_evaluate_impl(code = code) + btw_tool_evaluate_r_impl(code = code) }, - name = "btw_tool_evaluate", + name = "btw_tool_evaluate_r", description = "Execute R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.", annotations = ellmer::tool_annotations( title = "Evaluate R Code", diff --git a/R/tools.R b/R/tools.R index 48a06f71..e003fb9b 100644 --- a/R/tools.R +++ b/R/tools.R @@ -135,6 +135,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/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/tests/testthat/test-tool-evaluate.R b/tests/testthat/test-tool-evaluate.R index bae7f00b..3e670e06 100644 --- a/tests/testthat/test-tool-evaluate.R +++ b/tests/testthat/test-tool-evaluate.R @@ -1,21 +1,21 @@ -test_that("btw_tool_evaluate() returns simple calculations", { +test_that("btw_tool_evaluate_r() returns simple calculations", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_impl("2 + 2") + res <- btw_tool_evaluate_r_impl("2 + 2") expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") - # The actual value is stored in extra - expect_equal(res@extra$value, 4) + # The actual value is stored in extra$data + expect_equal(res@extra$data, 4) # The visible output is captured as text expect_length(res@value, 1) expect_s7_class(res@value[[1]], ellmer::ContentText) expect_match(res@value[[1]]@text, "4") }) -test_that("btw_tool_evaluate() captures messages", { +test_that("btw_tool_evaluate_r() captures messages", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_impl('message("hello")') + res <- btw_tool_evaluate_r_impl('message("hello")') expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") expect_length(res@value, 1) @@ -23,10 +23,10 @@ test_that("btw_tool_evaluate() captures messages", { expect_equal(res@value[[1]]@text, "hello") }) -test_that("btw_tool_evaluate() captures warnings", { +test_that("btw_tool_evaluate_r() captures warnings", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_impl('warning("beware")') + res <- btw_tool_evaluate_r_impl('warning("beware")') expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") expect_length(res@value, 1) @@ -34,10 +34,10 @@ test_that("btw_tool_evaluate() captures warnings", { expect_match(res@value[[1]]@text, "beware") }) -test_that("btw_tool_evaluate() captures errors and stops", { +test_that("btw_tool_evaluate_r() captures errors and stops", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_impl('x <- 1; stop("error"); y <- 2') + res <- btw_tool_evaluate_r_impl('x <- 1; stop("error"); y <- 2') expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") # Should have the error content @@ -51,10 +51,10 @@ test_that("btw_tool_evaluate() captures errors and stops", { expect_false(exists("y", envir = globalenv())) }) -test_that("btw_tool_evaluate() captures plots", { +test_that("btw_tool_evaluate_r() captures plots", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_impl('plot(1:10)') + res <- btw_tool_evaluate_r_impl('plot(1:10)') expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") # Should have at least one ContentImageInline @@ -66,7 +66,7 @@ test_that("btw_tool_evaluate() captures plots", { expect_true(has_plot) }) -test_that("btw_tool_evaluate() handles multiple outputs", { +test_that("btw_tool_evaluate_r() handles multiple outputs", { skip_if_not_installed("evaluate") code <- ' @@ -75,7 +75,7 @@ test_that("btw_tool_evaluate() handles multiple outputs", { mean(x) warning("careful") ' - res <- btw_tool_evaluate_impl(code) + res <- btw_tool_evaluate_r_impl(code) expect_s7_class(res, BtwToolResult) expect_type(res@value, "list") expect_gte(length(res@value), 3) @@ -102,11 +102,11 @@ test_that("btw_tool_evaluate() handles multiple outputs", { expect_true(has_warning) }) -test_that("btw_tool_evaluate() requires string input", { +test_that("btw_tool_evaluate_r() requires string input", { skip_if_not_installed("evaluate") - expect_error(btw_tool_evaluate_impl(123), class = "rlang_error") - expect_error(btw_tool_evaluate_impl(NULL), class = "rlang_error") + expect_error(btw_tool_evaluate_r_impl(123), class = "rlang_error") + expect_error(btw_tool_evaluate_r_impl(NULL), class = "rlang_error") }) test_that("ContentMessage, ContentWarning, ContentError inherit from ContentText", { From b504d5d85a391af73505489178d61c4dbd1519d4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 25 Nov 2025 15:56:04 -0500 Subject: [PATCH 04/49] chore: document() --- DESCRIPTION | 1 + NAMESPACE | 1 + man/ContentTypes.Rd | 20 ++++++++ man/btw_tool_docs_package_news.Rd | 1 + man/btw_tool_env_describe_data_frame.Rd | 1 + man/btw_tool_env_describe_environment.Rd | 1 + man/btw_tool_evaluate_r.Rd | 61 ++++++++++++++++++++++++ man/btw_tool_files_code_search.Rd | 1 + man/btw_tool_files_list_files.Rd | 1 + man/btw_tool_files_read_text_file.Rd | 1 + man/btw_tool_files_write_text_file.Rd | 1 + man/btw_tool_ide_read_current_editor.Rd | 1 + man/btw_tool_package_docs.Rd | 1 + man/btw_tool_search_packages.Rd | 1 + man/btw_tool_session_package_info.Rd | 1 + man/btw_tool_session_platform_info.Rd | 1 + man/btw_tool_web_read_url.Rd | 1 + man/btw_tools.Rd | 8 ++++ 18 files changed, 104 insertions(+) create mode 100644 man/ContentTypes.Rd create mode 100644 man/btw_tool_evaluate_r.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 8756d168..fbee67ab 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -88,6 +88,7 @@ Collate: 'tool-docs-news.R' 'tool-docs.R' 'tool-environment.R' + 'tool-evaluate.R' 'tool-files-code-search.R' 'tool-files.R' 'tool-git.R' diff --git a/NAMESPACE b/NAMESPACE index 8d546f34..1c2b6869 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -34,6 +34,7 @@ export(btw_tool_docs_package_news) export(btw_tool_docs_vignette) export(btw_tool_env_describe_data_frame) export(btw_tool_env_describe_environment) +export(btw_tool_evaluate_r) export(btw_tool_files_code_search) export(btw_tool_files_list_files) export(btw_tool_files_read_text_file) diff --git a/man/ContentTypes.Rd b/man/ContentTypes.Rd new file mode 100644 index 00000000..db04d5b0 --- /dev/null +++ b/man/ContentTypes.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-evaluate.R +\name{ContentTypes} +\alias{ContentTypes} +\alias{ContentMessage} +\alias{ContentWarning} +\alias{ContentError} +\title{S7 Content Types for Code Evaluation Results} +\usage{ +ContentMessage(text = stop("Required")) + +ContentWarning(text = stop("Required")) + +ContentError(text = stop("Required")) +} +\description{ +These S7 classes extend ellmer's ContentText to provide semantic meaning +to different types of text output from code evaluation. +} +\keyword{internal} diff --git a/man/btw_tool_docs_package_news.Rd b/man/btw_tool_docs_package_news.Rd index f2ef2b2c..5d0b877e 100644 --- a/man/btw_tool_docs_package_news.Rd +++ b/man/btw_tool_docs_package_news.Rd @@ -51,6 +51,7 @@ btw_tool_docs_package_news("dplyr", "join_by") Other Tools: \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_env_describe_data_frame.Rd b/man/btw_tool_env_describe_data_frame.Rd index e6ae3437..1e76d51c 100644 --- a/man/btw_tool_env_describe_data_frame.Rd +++ b/man/btw_tool_env_describe_data_frame.Rd @@ -62,6 +62,7 @@ btw_tool_env_describe_data_frame(mtcars) Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_env_describe_environment.Rd b/man/btw_tool_env_describe_environment.Rd index 8870f9d7..1ad553cb 100644 --- a/man/btw_tool_env_describe_environment.Rd +++ b/man/btw_tool_env_describe_environment.Rd @@ -36,6 +36,7 @@ btw_tool_env_describe_environment("my_cars") Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_evaluate_r.Rd b/man/btw_tool_evaluate_r.Rd new file mode 100644 index 00000000..076c5606 --- /dev/null +++ b/man/btw_tool_evaluate_r.Rd @@ -0,0 +1,61 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-evaluate.R +\name{btw_tool_evaluate_r} +\alias{btw_tool_evaluate_r} +\title{Tool: Evaluate R code} +\usage{ +btw_tool_evaluate_r(code, `_intent` = "") +} +\arguments{ +\item{code}{A character string containing R code to evaluate.} + +\item{`_intent`}{Intent description (automatically added by ellmer).} +} +\value{ +A list of ellmer Content objects: +\itemize{ +\item \code{ContentText}: visible return values and text output +\item \code{ContentMessage}: messages from \code{message()} +\item \code{ContentWarning}: warnings from \code{warning()} +\item \code{ContentError}: errors from \code{stop()} +\item \code{ContentImageInline}: plots created during evaluation +} +} +\description{ +This tool evaluates R code and returns results as ellmer Content objects. +It captures text output, plots, messages, warnings, and errors. +Code evaluation stops on the first error, returning all results up to that point. +} +\examples{ +\dontrun{ +# Simple calculation +btw_tool_evaluate_r("2 + 2") + +# Code with plot +btw_tool_evaluate_r("hist(rnorm(100))") + +# Code with warning +btw_tool_evaluate_r("mean(c(1, 2, NA))") +} + +} +\seealso{ +\code{\link[=btw_tools]{btw_tools()}} + +Other Tools: +\code{\link{btw_tool_docs_package_news}()}, +\code{\link{btw_tool_env_describe_data_frame}()}, +\code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_files_code_search}()}, +\code{\link{btw_tool_files_list_files}()}, +\code{\link{btw_tool_files_read_text_file}()}, +\code{\link{btw_tool_files_write_text_file}()}, +\code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_search_packages}()}, +\code{\link{btw_tool_session_package_info}()}, +\code{\link{btw_tool_session_platform_info}()}, +\code{\link{btw_tool_web_read_url}()}, +\code{\link{btw_tools}()} +} +\concept{Tools} diff --git a/man/btw_tool_files_code_search.Rd b/man/btw_tool_files_code_search.Rd index 7b50b089..7551c56e 100644 --- a/man/btw_tool_files_code_search.Rd +++ b/man/btw_tool_files_code_search.Rd @@ -96,6 +96,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, diff --git a/man/btw_tool_files_list_files.Rd b/man/btw_tool_files_list_files.Rd index fce29cd1..b2d85b6f 100644 --- a/man/btw_tool_files_list_files.Rd +++ b/man/btw_tool_files_list_files.Rd @@ -46,6 +46,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, diff --git a/man/btw_tool_files_read_text_file.Rd b/man/btw_tool_files_read_text_file.Rd index 915ac99b..489d5cbe 100644 --- a/man/btw_tool_files_read_text_file.Rd +++ b/man/btw_tool_files_read_text_file.Rd @@ -45,6 +45,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_write_text_file}()}, diff --git a/man/btw_tool_files_write_text_file.Rd b/man/btw_tool_files_write_text_file.Rd index 608852b1..1c6ae938 100644 --- a/man/btw_tool_files_write_text_file.Rd +++ b/man/btw_tool_files_write_text_file.Rd @@ -35,6 +35,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_ide_read_current_editor.Rd b/man/btw_tool_ide_read_current_editor.Rd index 8e9a2501..8e2f1bf1 100644 --- a/man/btw_tool_ide_read_current_editor.Rd +++ b/man/btw_tool_ide_read_current_editor.Rd @@ -41,6 +41,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_package_docs.Rd b/man/btw_tool_package_docs.Rd index ca597d50..a18fae7c 100644 --- a/man/btw_tool_package_docs.Rd +++ b/man/btw_tool_package_docs.Rd @@ -75,6 +75,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_search_packages.Rd b/man/btw_tool_search_packages.Rd index 380b984a..04cf9393 100644 --- a/man/btw_tool_search_packages.Rd +++ b/man/btw_tool_search_packages.Rd @@ -58,6 +58,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_session_package_info.Rd b/man/btw_tool_session_package_info.Rd index a60897f3..424e7445 100644 --- a/man/btw_tool_session_package_info.Rd +++ b/man/btw_tool_session_package_info.Rd @@ -42,6 +42,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_session_platform_info.Rd b/man/btw_tool_session_platform_info.Rd index 7ac03c43..1d7e8cae 100644 --- a/man/btw_tool_session_platform_info.Rd +++ b/man/btw_tool_session_platform_info.Rd @@ -30,6 +30,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tool_web_read_url.Rd b/man/btw_tool_web_read_url.Rd index 5a7b5bbe..015a8c1e 100644 --- a/man/btw_tool_web_read_url.Rd +++ b/man/btw_tool_web_read_url.Rd @@ -38,6 +38,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index efc27d79..425e3c44 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -49,6 +49,13 @@ this function have access to the tools: } +\subsection{Group: eval}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_evaluate_r]{btw_tool_evaluate_r()}} \tab Execute R code and return results as Content objects. \cr +} + +} + \subsection{Group: files}{\tabular{ll}{ Name \tab Description \cr \code{\link[=btw_tool_files_code_search]{btw_tool_files_code_search()}} \tab Search code files in the project. \cr @@ -130,6 +137,7 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, From 6be186bc8666c13470610088c31e50a61dc5db32 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 26 Nov 2025 15:32:18 -0500 Subject: [PATCH 05/49] feat: Add rich evaluation tool output UI --- R/tool-evaluate.R | 205 +++++++++++++++++++++----- inst/js/evaluate-r/btw-evaluate-r.css | 84 +++++++++++ inst/js/evaluate-r/btw-evaluate-r.js | 186 +++++++++++++++++++++++ tests/testthat/test-tool-evaluate.R | 72 +++++++-- 4 files changed, 500 insertions(+), 47 deletions(-) create mode 100644 inst/js/evaluate-r/btw-evaluate-r.css create mode 100644 inst/js/evaluate-r/btw-evaluate-r.js diff --git a/R/tool-evaluate.R b/R/tool-evaluate.R index 7e7362da..d2f00c6f 100644 --- a/R/tool-evaluate.R +++ b/R/tool-evaluate.R @@ -1,30 +1,3 @@ -#' S7 Content Types for Code Evaluation Results -#' -#' These S7 classes extend ellmer's ContentText to provide semantic meaning -#' to different types of text output from code evaluation. -#' -#' @name ContentTypes -#' @keywords internal -NULL - -#' @rdname ContentTypes -ContentMessage <- S7::new_class( - "ContentMessage", - parent = ellmer::ContentText -) - -#' @rdname ContentTypes -ContentWarning <- S7::new_class( - "ContentWarning", - parent = ellmer::ContentText -) - -#' @rdname ContentTypes -ContentError <- S7::new_class( - "ContentError", - parent = ellmer::ContentText -) - #' Tool: Evaluate R code #' #' This tool evaluates R code and returns results as ellmer Content objects. @@ -69,6 +42,8 @@ btw_tool_evaluate_r_impl <- function(code, ...) { contents <- list() # Store the last value for potential use last_value <- NULL + # Track if an error occurred + had_error <- FALSE # Create output handler that converts to Content types as outputs are generated handler <- evaluate::new_output_handler( @@ -78,7 +53,7 @@ btw_tool_evaluate_r_impl <- function(code, ...) { }, text = function(text) { # Text output (from print, cat, etc.) - contents <<- append(contents, list(ellmer::ContentText(text))) + contents <<- append(contents, list(ContentCode(text = text))) text }, graphics = function(plot) { @@ -126,6 +101,7 @@ btw_tool_evaluate_r_impl <- function(code, ...) { error = function(err) { # Error message err_text <- conditionMessage(err) + had_error <<- TRUE contents <<- append( contents, list( @@ -139,7 +115,7 @@ btw_tool_evaluate_r_impl <- function(code, ...) { # Invisible values include assignments and side-effect returns if (visible) { last_value <<- value - # Also add as text content + # Also add as code content value_text <- paste( utils::capture.output(print(value)), collapse = "\n" @@ -147,7 +123,7 @@ btw_tool_evaluate_r_impl <- function(code, ...) { contents <<- append( contents, list( - ellmer::ContentText(value_text) + ContentCode(text = value_text) ) ) } @@ -165,13 +141,25 @@ btw_tool_evaluate_r_impl <- function(code, ...) { output_handler = handler ) - # Return as ContentToolResult with contents in extra - # The value is stored but the contents list is what gets displayed - BtwToolResult( + # Merge adjacent content of the same type + contents <- merge_adjacent_content(contents) + + # Render all content objects to HTML + output_html <- vapply( contents, + function(content) ellmer::contents_html(content), + character(1) + ) + output_html <- paste(output_html, collapse = "\n") + + # Return as BtwEvaluateToolResult + BtwEvaluateToolResult( + value = contents, + error = if (had_error) contents[[length(contents)]]@text else NULL, extra = list( data = last_value, - code = code + code = code, + output_html = output_html ) ) } @@ -200,3 +188,152 @@ btw_tool_evaluate_r_impl <- function(code, ...) { ) } ) + +# ---- Content Types ---- +ContentCode <- S7::new_class( + "ContentCode", + 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 +) + +BtwEvaluateToolResult <- S7::new_class( + "BtwEvaluateToolResult", + parent = ellmer::ContentToolResult +) + +contents_html <- S7::new_external_generic( + package = "ellmer", + name = "contents_html", + dispatch_args = "content" +) + +S7::method(contents_html, ContentCode) <- function(content, ...) { + text <- htmltools::htmlEscape(content@text) + sprintf('
%s
', trimws(text)) +} + +S7::method(contents_html, ContentMessage) <- function(content, ...) { + text <- htmltools::htmlEscape(content@text) + + sprintf( + '
%s
', + trimws(text) + ) +} + +S7::method(contents_html, ContentWarning) <- function(content, ...) { + text <- htmltools::htmlEscape(content@text) + sprintf( + '
%s
', + trimws(text) + ) +} + +S7::method(contents_html, ContentError) <- function(content, ...) { + text <- htmltools::htmlEscape(content@text) + sprintf( + '
%s
', + trimws(text) + ) +} + +contents_shinychat <- S7::new_external_generic( + package = "shinychat", + name = "contents_shinychat", + dispatch_args = "content" +) + +S7::method(contents_shinychat, BtwEvaluateToolResult) <- function(content) { + code <- content@extra$code + output_html <- content@extra$output_html + request_id <- content@request@id + status <- if (!is.null(content@error)) "error" else "success" + + dep <- htmltools::htmlDependency( + name = "btw-evaluate-r", + version = utils::packageVersion("btw"), + package = "btw", + src = "js/evaluate-r", + script = list(list(src = "btw-evaluate-r.js", type = "module")), + stylesheet = "btw-evaluate-r.css", + all_files = FALSE + ) + + htmltools::tag( + "btw-evaluate-r-result", + list( + `request-id` = request_id, + code = code, + status = status, + htmltools::HTML(output_html), + dep + ) + ) +} + +is_mergeable_content <- function(x, y) { + mergeable_content_types <- list( + ContentCode, + 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 (ContentCode, ContentMessage, ContentWarning, +#' ContentError) into single elements. +#' +#' @param contents List of Content objects +#' @returns List of Content objects with adjacent same-type elements merged +#' @keywords internal +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() + ) +} diff --git a/inst/js/evaluate-r/btw-evaluate-r.css b/inst/js/evaluate-r/btw-evaluate-r.css new file mode 100644 index 00000000..6e387ebe --- /dev/null +++ b/inst/js/evaluate-r/btw-evaluate-r.css @@ -0,0 +1,84 @@ +/** + * Styles for btw-evaluate-r-result custom element + */ + +btw-evaluate-r-result { + --shiny-tool-card-max-height: auto; + display: block; + margin-bottom: var(--bslib-spacer, 1em); +} + +/* Remove horizontal padding from card body for full-width code */ +btw-evaluate-r-result .card-body { + padding: 0; + gap: 0; +} + +/* Output container with visual treatment */ + +/* Code output blocks */ +.btw-evaluate-output pre { + margin: 0.25rem 0; + padding: 0.5rem; + background-color: var(--bs-light, #f8f9fa); + border-radius: 0.25rem; + overflow-x: auto; +} + +.btw-evaluate-output .code-copy-button { + /* TODO: Figure out how to disable markdown-stream code copy button */ + display: none; +} + +.btw-evaluate-output pre code { + font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-size: 0.875em; + white-space: pre-wrap; + word-break: break-word; + padding: 0; +} + +.btw-evaluate-output pre { + margin-block: 0; + border-radius: 0; + border: none; + border-left: 3px solid var(--bs-secondary-border-subtle, #b3b3b3); +} + +/* Message output (blue left border) */ +.btw-evaluate-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-evaluate-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-evaluate-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-evaluate-output img { + max-width: 100%; + height: auto; + margin: 0.5rem 0; + border-radius: 0.25rem; +} + +/* Code section styling */ +.btw-evaluate-code { + pre { + border: none; + border-radius: 0; + + &, code { + background-color: var(--bs-body-bg, white) !important; + } + } +} diff --git a/inst/js/evaluate-r/btw-evaluate-r.js b/inst/js/evaluate-r/btw-evaluate-r.js new file mode 100644 index 00000000..8f163688 --- /dev/null +++ b/inst/js/evaluate-r/btw-evaluate-r.js @@ -0,0 +1,186 @@ +/** + * Custom element for displaying btw_tool_evaluate_r results in shinychat. + * @module btw-evaluate-r + */ + +// Ensure shinychat's hidden requests set exists +window.shinychat = window.shinychat || {} +window.shinychat.hiddenToolRequests = + window.shinychat.hiddenToolRequests || new Set() + +/** + * SVG icons used in the component + */ +const ICONS = { + code: ` + +`, + playCircle: ``, + exclamationCircleFill: ` + +`, + plus: ` + + +` +} + +/** + * 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_evaluate_r execution. + * + * @element btw-evaluate-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
+ *
+ */ +class BtwEvaluateRResult extends HTMLElement { + /** @type {boolean} */ + expanded = true + + constructor() { + super() + } + + connectedCallback() { + // Set status-based styling + const status = this.getAttribute("status") + if (status === "error") { + this.classStatus = "text-danger" + this.icon = ICONS.exclamationCircleFill + this.titleTemplate = "{title} failed" + } else { + this.classStatus = "" + this.icon = ICONS.playCircle + this.titleTemplate = "{title}" + } + + // Hide the corresponding tool request + const requestId = this.getAttribute("request-id") + if (requestId) { + window.shinychat.hiddenToolRequests.add(requestId) + this.dispatchEvent( + new CustomEvent("shiny-tool-request-hide", { + detail: { request_id: requestId }, + bubbles: true, + cancelable: true + }) + ) + } + + this.render() + + // Signal that chat may need to scroll + this.dispatchEvent(new CustomEvent("shiny-chat-maybe-scroll-to-bottom")) + } + + /** + * Toggle the collapsed/expanded state + * @param {Event} e + */ + toggleCollapse(e) { + e.preventDefault() + this.expanded = !this.expanded + this.render() + } + + /** + * Format the title for display + * @returns {string} + */ + formatTitle() { + const title = 'Evaluate R Code' + return this.titleTemplate.replace("{title}", title) + } + + /** + * Render the component + */ + render() { + const requestId = this.getAttribute("request-id") || "unknown" + const code = this.getAttribute("code") || "" + const headerId = `tool-header-${requestId}` + const contentId = `tool-content-${requestId}` + + // Get the output HTML from child content (set during initial render) + const outputHtml = this._outputHtml || this.innerHTML + this._outputHtml = outputHtml + + const collapsedClass = this.expanded ? "" : " collapsed" + + this.innerHTML = ` +
+ +
+
+ +
+
+ ${outputHtml} +
+
+
+ ` + + // Add click handler to header + const header = this.querySelector(".card-header") + if (header) { + header.addEventListener("click", (e) => this.toggleCollapse(e)) + } + } + + /** + * Escape a string for use in an HTML attribute + * @param {string} str + * @returns {string} + */ + escapeAttr(str) { + return str + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">") + } +} + +// Register the custom element +customElements.define("btw-evaluate-r-result", BtwEvaluateRResult) diff --git a/tests/testthat/test-tool-evaluate.R b/tests/testthat/test-tool-evaluate.R index 3e670e06..4466224c 100644 --- a/tests/testthat/test-tool-evaluate.R +++ b/tests/testthat/test-tool-evaluate.R @@ -2,21 +2,23 @@ test_that("btw_tool_evaluate_r() returns simple calculations", { skip_if_not_installed("evaluate") res <- btw_tool_evaluate_r_impl("2 + 2") - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") # The actual value is stored in extra$data expect_equal(res@extra$data, 4) - # The visible output is captured as text + # The visible output is captured as ContentCode expect_length(res@value, 1) - expect_s7_class(res@value[[1]], ellmer::ContentText) + expect_s7_class(res@value[[1]], ContentCode) expect_match(res@value[[1]]@text, "4") + # Output HTML is rendered + expect_true(nzchar(res@extra$output_html)) }) test_that("btw_tool_evaluate_r() captures messages", { skip_if_not_installed("evaluate") res <- btw_tool_evaluate_r_impl('message("hello")') - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentMessage) @@ -27,7 +29,7 @@ test_that("btw_tool_evaluate_r() captures warnings", { skip_if_not_installed("evaluate") res <- btw_tool_evaluate_r_impl('warning("beware")') - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentWarning) @@ -38,7 +40,7 @@ test_that("btw_tool_evaluate_r() captures errors and stops", { skip_if_not_installed("evaluate") res <- btw_tool_evaluate_r_impl('x <- 1; stop("error"); y <- 2') - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") # Should have the error content has_error <- any(vapply( @@ -49,13 +51,15 @@ test_that("btw_tool_evaluate_r() captures errors and stops", { expect_true(has_error) # y should not be assigned (code stopped at error) expect_false(exists("y", envir = globalenv())) + # Error should be set on result + expect_false(is.null(res@error)) }) test_that("btw_tool_evaluate_r() captures plots", { skip_if_not_installed("evaluate") res <- btw_tool_evaluate_r_impl('plot(1:10)') - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") # Should have at least one ContentImageInline has_plot <- any(vapply( @@ -76,19 +80,19 @@ test_that("btw_tool_evaluate_r() handles multiple outputs", { warning("careful") ' res <- btw_tool_evaluate_r_impl(code) - expect_s7_class(res, BtwToolResult) + expect_s7_class(res, BtwEvaluateToolResult) expect_type(res@value, "list") expect_gte(length(res@value), 3) - # Check we have message, text output, and warning + # Check we have message, code output, and warning has_message <- any(vapply( res@value, function(x) S7::S7_inherits(x, ContentMessage), logical(1) )) - has_text <- any(vapply( + has_code <- any(vapply( res@value, - function(x) S7::S7_inherits(x, ellmer::ContentText), + function(x) S7::S7_inherits(x, ContentCode), logical(1) )) has_warning <- any(vapply( @@ -98,7 +102,7 @@ test_that("btw_tool_evaluate_r() handles multiple outputs", { )) expect_true(has_message) - expect_true(has_text) + expect_true(has_code) expect_true(has_warning) }) @@ -109,16 +113,58 @@ test_that("btw_tool_evaluate_r() requires string input", { expect_error(btw_tool_evaluate_r_impl(NULL), class = "rlang_error") }) -test_that("ContentMessage, ContentWarning, ContentError inherit from ContentText", { +test_that("ContentCode, ContentMessage, ContentWarning, ContentError inherit from ContentText", { + code <- ContentCode(text = "output") msg <- ContentMessage(text = "hello") warn <- ContentWarning(text = "warning") err <- ContentError(text = "error") + expect_s7_class(code, ellmer::ContentText) expect_s7_class(msg, ellmer::ContentText) expect_s7_class(warn, ellmer::ContentText) expect_s7_class(err, ellmer::ContentText) + expect_equal(code@text, "output") expect_equal(msg@text, "hello") expect_equal(warn@text, "warning") expect_equal(err@text, "error") }) + +test_that("contents_html() renders Content types correctly", { + code <- ContentCode(text = "[1] 42") + msg <- ContentMessage(text = "info message") + warn <- ContentWarning(text = "warning message") + err <- ContentError(text = "error message") + + code_html <- ellmer::contents_html(code) + msg_html <- ellmer::contents_html(msg) + warn_html <- ellmer::contents_html(warn) + err_html <- ellmer::contents_html(err) + + expect_match(code_html, "
")
+  expect_match(msg_html, 'class="btw-output-message"')
+  expect_match(warn_html, 'class="btw-output-warning"')
+  expect_match(err_html, 'class="btw-output-error"')
+})
+
+test_that("adjacent content of same type is merged", {
+  skip_if_not_installed("evaluate")
+
+  # Multiple messages should be merged
+  res <- btw_tool_evaluate_r_impl('message("a"); message("b")')
+  expect_length(res@value, 1)
+  expect_s7_class(res@value[[1]], ContentMessage)
+  expect_match(res@value[[1]]@text, "a\nb")
+
+  # Multiple code outputs should be merged
+  res <- btw_tool_evaluate_r_impl('1 + 1; 2 + 2')
+  expect_length(res@value, 1)
+  expect_s7_class(res@value[[1]], ContentCode)
+
+  # Different types should not be merged
+  res <- btw_tool_evaluate_r_impl('message("a"); 1 + 1; warning("b")')
+  expect_length(res@value, 3)
+  expect_s7_class(res@value[[1]], ContentMessage)
+  expect_s7_class(res@value[[2]], ContentCode)
+  expect_s7_class(res@value[[3]], ContentWarning)
+})

From ac9fe4ec1d06d10781bc73f53411a96d45b2871d Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Wed, 26 Nov 2025 15:34:06 -0500
Subject: [PATCH 06/49] rename: tool-run.R

---
 R/{tool-evaluate.R => tool-run.R}                        | 0
 tests/testthat/{test-tool-evaluate.R => test-tool-run.R} | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 rename R/{tool-evaluate.R => tool-run.R} (100%)
 rename tests/testthat/{test-tool-evaluate.R => test-tool-run.R} (100%)

diff --git a/R/tool-evaluate.R b/R/tool-run.R
similarity index 100%
rename from R/tool-evaluate.R
rename to R/tool-run.R
diff --git a/tests/testthat/test-tool-evaluate.R b/tests/testthat/test-tool-run.R
similarity index 100%
rename from tests/testthat/test-tool-evaluate.R
rename to tests/testthat/test-tool-run.R

From 3049d9f365a81685bb098b0ada980df73ac6c696 Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Wed, 26 Nov 2025 15:40:25 -0500
Subject: [PATCH 07/49] chore: update docs and tools, etc.

---
 DESCRIPTION                                   |  2 +-
 NAMESPACE                                     |  2 +-
 R/tool-run.R                                  | 56 +++++++++----------
 .../btw-run-r.css}                            | 24 ++++----
 .../btw-evaluate-r.js => run-r/btw-run-r.js}  | 22 ++++----
 man/ContentTypes.Rd                           | 20 -------
 man/btw_tool_docs_package_news.Rd             |  2 +-
 man/btw_tool_env_describe_data_frame.Rd       |  2 +-
 man/btw_tool_env_describe_environment.Rd      |  2 +-
 man/btw_tool_files_code_search.Rd             |  2 +-
 man/btw_tool_files_list_files.Rd              |  2 +-
 man/btw_tool_files_read_text_file.Rd          |  2 +-
 man/btw_tool_files_write_text_file.Rd         |  2 +-
 man/btw_tool_ide_read_current_editor.Rd       |  2 +-
 man/btw_tool_package_docs.Rd                  |  2 +-
 ...w_tool_evaluate_r.Rd => btw_tool_run_r.Rd} | 24 ++++----
 man/btw_tool_search_packages.Rd               |  2 +-
 man/btw_tool_session_package_info.Rd          |  2 +-
 man/btw_tool_session_platform_info.Rd         |  2 +-
 man/btw_tool_web_read_url.Rd                  |  2 +-
 man/btw_tools.Rd                              | 16 +++---
 man/merge_adjacent_content.Rd                 | 20 +++++++
 tests/testthat/test-tool-run.R                | 48 ++++++++--------
 23 files changed, 130 insertions(+), 130 deletions(-)
 rename inst/js/{evaluate-r/btw-evaluate-r.css => run-r/btw-run-r.css} (78%)
 rename inst/js/{evaluate-r/btw-evaluate-r.js => run-r/btw-run-r.js} (91%)
 delete mode 100644 man/ContentTypes.Rd
 rename man/{btw_tool_evaluate_r.Rd => btw_tool_run_r.Rd} (69%)
 create mode 100644 man/merge_adjacent_content.Rd

diff --git a/DESCRIPTION b/DESCRIPTION
index fbee67ab..f78203db 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -88,12 +88,12 @@ Collate:
     'tool-docs-news.R'
     'tool-docs.R'
     'tool-environment.R'
-    'tool-evaluate.R'
     'tool-files-code-search.R'
     'tool-files.R'
     '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 1c2b6869..e9999b2b 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -34,7 +34,6 @@ export(btw_tool_docs_package_news)
 export(btw_tool_docs_vignette)
 export(btw_tool_env_describe_data_frame)
 export(btw_tool_env_describe_environment)
-export(btw_tool_evaluate_r)
 export(btw_tool_files_code_search)
 export(btw_tool_files_list_files)
 export(btw_tool_files_read_text_file)
@@ -48,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/R/tool-run.R b/R/tool-run.R
index d2f00c6f..e10fb320 100644
--- a/R/tool-run.R
+++ b/R/tool-run.R
@@ -1,10 +1,10 @@
-#' Tool: Evaluate R code
+#' Tool: Run R code
 #'
-#' This tool evaluates R code and returns results as ellmer Content objects.
+#' This tool runs R code and returns results as ellmer Content objects.
 #' It captures text output, plots, messages, warnings, and errors.
-#' Code evaluation stops on the first error, returning all results up to that point.
+#' Code execution stops on the first error, returning all results up to that point.
 #'
-#' @param code A character string containing R code to evaluate.
+#' @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:
@@ -12,31 +12,31 @@
 #'   - `ContentMessage`: messages from `message()`
 #'   - `ContentWarning`: warnings from `warning()`
 #'   - `ContentError`: errors from `stop()`
-#'   - `ContentImageInline`: plots created during evaluation
+#'   - `ContentImageInline`: plots created during execution
 #'
 #' @examples
 #' \dontrun{
 #' # Simple calculation
-#' btw_tool_evaluate_r("2 + 2")
+#' btw_tool_run_r("2 + 2")
 #'
 #' # Code with plot
-#' btw_tool_evaluate_r("hist(rnorm(100))")
+#' btw_tool_run_r("hist(rnorm(100))")
 #'
 #' # Code with warning
-#' btw_tool_evaluate_r("mean(c(1, 2, NA))")
+#' btw_tool_run_r("mean(c(1, 2, NA))")
 #' }
 #'
 #' @seealso [btw_tools()]
 #' @family Tools
 #' @export
-btw_tool_evaluate_r <- function(code, `_intent`) {}
+btw_tool_run_r <- function(code, `_intent`) {}
 
-btw_tool_evaluate_r_impl <- function(code, ...) {
+btw_tool_run_r_impl <- function(code, ...) {
   check_dots_empty()
   check_string(code)
 
   # Check if evaluate package is available
-  check_installed("evaluate", "to evaluate R code.")
+  check_installed("evaluate", "to run R code.")
 
   # Initialize list to store Content objects
   contents <- list()
@@ -152,8 +152,8 @@ btw_tool_evaluate_r_impl <- function(code, ...) {
   )
   output_html <- paste(output_html, collapse = "\n")
 
-  # Return as BtwEvaluateToolResult
-  BtwEvaluateToolResult(
+  # Return as BtwRunToolResult
+  BtwRunToolResult(
     value = contents,
     error = if (had_error) contents[[length(contents)]]@text else NULL,
     extra = list(
@@ -165,24 +165,24 @@ btw_tool_evaluate_r_impl <- function(code, ...) {
 }
 
 .btw_add_to_tools(
-  name = "btw_tool_evaluate_r",
-  group = "eval",
+  name = "btw_tool_run_r",
+  group = "run",
   tool = function() {
     ellmer::tool(
       function(code) {
-        btw_tool_evaluate_r_impl(code = code)
+        btw_tool_run_r_impl(code = code)
       },
-      name = "btw_tool_evaluate_r",
-      description = "Execute R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.",
+      name = "btw_tool_run_r",
+      description = "Run R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.",
       annotations = ellmer::tool_annotations(
-        title = "Evaluate R Code",
+        title = "Run R Code",
         read_only_hint = FALSE,
         open_world_hint = FALSE,
         btw_can_register = function() TRUE
       ),
       arguments = list(
         code = ellmer::type_string(
-          "R code to evaluate as a string."
+          "R code to run as a string."
         )
       )
     )
@@ -210,8 +210,8 @@ ContentError <- S7::new_class(
   parent = ellmer::ContentText
 )
 
-BtwEvaluateToolResult <- S7::new_class(
-  "BtwEvaluateToolResult",
+BtwRunToolResult <- S7::new_class(
+  "BtwRunToolResult",
   parent = ellmer::ContentToolResult
 )
 
@@ -257,24 +257,24 @@ contents_shinychat <- S7::new_external_generic(
   dispatch_args = "content"
 )
 
-S7::method(contents_shinychat, BtwEvaluateToolResult) <- function(content) {
+S7::method(contents_shinychat, BtwRunToolResult) <- function(content) {
   code <- content@extra$code
   output_html <- content@extra$output_html
   request_id <- content@request@id
   status <- if (!is.null(content@error)) "error" else "success"
 
   dep <- htmltools::htmlDependency(
-    name = "btw-evaluate-r",
+    name = "btw-run-r",
     version = utils::packageVersion("btw"),
     package = "btw",
-    src = "js/evaluate-r",
-    script = list(list(src = "btw-evaluate-r.js", type = "module")),
-    stylesheet = "btw-evaluate-r.css",
+    src = "js/run-r",
+    script = list(list(src = "btw-run-r.js", type = "module")),
+    stylesheet = "btw-run-r.css",
     all_files = FALSE
   )
 
   htmltools::tag(
-    "btw-evaluate-r-result",
+    "btw-run-r-result",
     list(
       `request-id` = request_id,
       code = code,
diff --git a/inst/js/evaluate-r/btw-evaluate-r.css b/inst/js/run-r/btw-run-r.css
similarity index 78%
rename from inst/js/evaluate-r/btw-evaluate-r.css
rename to inst/js/run-r/btw-run-r.css
index 6e387ebe..4bf7a677 100644
--- a/inst/js/evaluate-r/btw-evaluate-r.css
+++ b/inst/js/run-r/btw-run-r.css
@@ -1,15 +1,15 @@
 /**
- * Styles for btw-evaluate-r-result custom element
+ * Styles for btw-run-r-result custom element
  */
 
-btw-evaluate-r-result {
+btw-run-r-result {
   --shiny-tool-card-max-height: auto;
   display: block;
   margin-bottom: var(--bslib-spacer, 1em);
 }
 
 /* Remove horizontal padding from card body for full-width code */
-btw-evaluate-r-result .card-body {
+btw-run-r-result .card-body {
   padding: 0;
   gap: 0;
 }
@@ -17,7 +17,7 @@ btw-evaluate-r-result .card-body {
 /* Output container with visual treatment */
 
 /* Code output blocks */
-.btw-evaluate-output pre {
+.btw-run-output pre {
   margin: 0.25rem 0;
   padding: 0.5rem;
   background-color: var(--bs-light, #f8f9fa);
@@ -25,12 +25,12 @@ btw-evaluate-r-result .card-body {
   overflow-x: auto;
 }
 
-.btw-evaluate-output .code-copy-button {
+.btw-run-output .code-copy-button {
   /* TODO: Figure out how to disable markdown-stream code copy button */
   display: none;
 }
 
-.btw-evaluate-output pre code {
+.btw-run-output pre code {
   font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
   font-size: 0.875em;
   white-space: pre-wrap;
@@ -38,7 +38,7 @@ btw-evaluate-r-result .card-body {
   padding: 0;
 }
 
-.btw-evaluate-output pre {
+.btw-run-output pre {
   margin-block: 0;
   border-radius: 0;
   border: none;
@@ -46,25 +46,25 @@ btw-evaluate-r-result .card-body {
 }
 
 /* Message output (blue left border) */
-.btw-evaluate-output pre.btw-output-message {
+.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-evaluate-output pre.btw-output-warning {
+.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-evaluate-output pre.btw-output-error {
+.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-evaluate-output img {
+.btw-run-output img {
   max-width: 100%;
   height: auto;
   margin: 0.5rem 0;
@@ -72,7 +72,7 @@ btw-evaluate-r-result .card-body {
 }
 
 /* Code section styling */
-.btw-evaluate-code {
+.btw-run-code {
   pre {
     border: none;
     border-radius: 0;
diff --git a/inst/js/evaluate-r/btw-evaluate-r.js b/inst/js/run-r/btw-run-r.js
similarity index 91%
rename from inst/js/evaluate-r/btw-evaluate-r.js
rename to inst/js/run-r/btw-run-r.js
index 8f163688..d34bf083 100644
--- a/inst/js/evaluate-r/btw-evaluate-r.js
+++ b/inst/js/run-r/btw-run-r.js
@@ -1,6 +1,6 @@
 /**
- * Custom element for displaying btw_tool_evaluate_r results in shinychat.
- * @module btw-evaluate-r
+ * Custom element for displaying btw_tool_run_r results in shinychat.
+ * @module btw-run-r
  */
 
 // Ensure shinychat's hidden requests set exists
@@ -37,23 +37,23 @@ function markdownCodeBlock(content, language = "r") {
 }
 
 /**
- * Web component that displays the result of btw_tool_evaluate_r execution.
+ * Web component that displays the result of btw_tool_run_r execution.
  *
- * @element btw-evaluate-r-result
+ * @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
- *
+ * */ -class BtwEvaluateRResult extends HTMLElement { +class BtwRunRResult extends HTMLElement { /** @type {boolean} */ expanded = true @@ -108,7 +108,7 @@ class BtwEvaluateRResult extends HTMLElement { * @returns {string} */ formatTitle() { - const title = 'Evaluate R Code' + const title = 'Run R Code' return this.titleTemplate.replace("{title}", title) } @@ -147,13 +147,13 @@ class BtwEvaluateRResult extends HTMLElement { aria-labelledby="${headerId}" ${!this.expanded ? 'inert=""' : ""} > -
+
-
+
${outputHtml}
@@ -183,4 +183,4 @@ class BtwEvaluateRResult extends HTMLElement { } // Register the custom element -customElements.define("btw-evaluate-r-result", BtwEvaluateRResult) +customElements.define("btw-run-r-result", BtwRunRResult) diff --git a/man/ContentTypes.Rd b/man/ContentTypes.Rd deleted file mode 100644 index db04d5b0..00000000 --- a/man/ContentTypes.Rd +++ /dev/null @@ -1,20 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tool-evaluate.R -\name{ContentTypes} -\alias{ContentTypes} -\alias{ContentMessage} -\alias{ContentWarning} -\alias{ContentError} -\title{S7 Content Types for Code Evaluation Results} -\usage{ -ContentMessage(text = stop("Required")) - -ContentWarning(text = stop("Required")) - -ContentError(text = stop("Required")) -} -\description{ -These S7 classes extend ellmer's ContentText to provide semantic meaning -to different types of text output from code evaluation. -} -\keyword{internal} diff --git a/man/btw_tool_docs_package_news.Rd b/man/btw_tool_docs_package_news.Rd index 5d0b877e..4035ff34 100644 --- a/man/btw_tool_docs_package_news.Rd +++ b/man/btw_tool_docs_package_news.Rd @@ -51,13 +51,13 @@ btw_tool_docs_package_news("dplyr", "join_by") Other Tools: \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_env_describe_data_frame.Rd b/man/btw_tool_env_describe_data_frame.Rd index 1e76d51c..3f529c65 100644 --- a/man/btw_tool_env_describe_data_frame.Rd +++ b/man/btw_tool_env_describe_data_frame.Rd @@ -62,13 +62,13 @@ btw_tool_env_describe_data_frame(mtcars) Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_env_describe_environment.Rd b/man/btw_tool_env_describe_environment.Rd index 1ad553cb..00660f75 100644 --- a/man/btw_tool_env_describe_environment.Rd +++ b/man/btw_tool_env_describe_environment.Rd @@ -36,13 +36,13 @@ btw_tool_env_describe_environment("my_cars") Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_files_code_search.Rd b/man/btw_tool_files_code_search.Rd index 7551c56e..9e44cc48 100644 --- a/man/btw_tool_files_code_search.Rd +++ b/man/btw_tool_files_code_search.Rd @@ -96,12 +96,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_files_list_files.Rd b/man/btw_tool_files_list_files.Rd index b2d85b6f..14dd050c 100644 --- a/man/btw_tool_files_list_files.Rd +++ b/man/btw_tool_files_list_files.Rd @@ -46,12 +46,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_files_read_text_file.Rd b/man/btw_tool_files_read_text_file.Rd index 489d5cbe..d65c994b 100644 --- a/man/btw_tool_files_read_text_file.Rd +++ b/man/btw_tool_files_read_text_file.Rd @@ -45,12 +45,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_files_write_text_file.Rd b/man/btw_tool_files_write_text_file.Rd index 1c6ae938..082e05c0 100644 --- a/man/btw_tool_files_write_text_file.Rd +++ b/man/btw_tool_files_write_text_file.Rd @@ -35,12 +35,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_ide_read_current_editor.Rd b/man/btw_tool_ide_read_current_editor.Rd index 8e2f1bf1..a13b5b7e 100644 --- a/man/btw_tool_ide_read_current_editor.Rd +++ b/man/btw_tool_ide_read_current_editor.Rd @@ -41,12 +41,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_package_docs.Rd b/man/btw_tool_package_docs.Rd index a18fae7c..e60900aa 100644 --- a/man/btw_tool_package_docs.Rd +++ b/man/btw_tool_package_docs.Rd @@ -75,12 +75,12 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_evaluate_r.Rd b/man/btw_tool_run_r.Rd similarity index 69% rename from man/btw_tool_evaluate_r.Rd rename to man/btw_tool_run_r.Rd index 076c5606..d1e3644c 100644 --- a/man/btw_tool_evaluate_r.Rd +++ b/man/btw_tool_run_r.Rd @@ -1,13 +1,13 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tool-evaluate.R -\name{btw_tool_evaluate_r} -\alias{btw_tool_evaluate_r} -\title{Tool: Evaluate R code} +% Please edit documentation in R/tool-run.R +\name{btw_tool_run_r} +\alias{btw_tool_run_r} +\title{Tool: Run R code} \usage{ -btw_tool_evaluate_r(code, `_intent` = "") +btw_tool_run_r(code, `_intent` = "") } \arguments{ -\item{code}{A character string containing R code to evaluate.} +\item{code}{A character string containing R code to run.} \item{`_intent`}{Intent description (automatically added by ellmer).} } @@ -18,24 +18,24 @@ A list of ellmer Content objects: \item \code{ContentMessage}: messages from \code{message()} \item \code{ContentWarning}: warnings from \code{warning()} \item \code{ContentError}: errors from \code{stop()} -\item \code{ContentImageInline}: plots created during evaluation +\item \code{ContentImageInline}: plots created during execution } } \description{ -This tool evaluates R code and returns results as ellmer Content objects. +This tool runs R code and returns results as ellmer Content objects. It captures text output, plots, messages, warnings, and errors. -Code evaluation stops on the first error, returning all results up to that point. +Code execution stops on the first error, returning all results up to that point. } \examples{ \dontrun{ # Simple calculation -btw_tool_evaluate_r("2 + 2") +btw_tool_run_r("2 + 2") # Code with plot -btw_tool_evaluate_r("hist(rnorm(100))") +btw_tool_run_r("hist(rnorm(100))") # Code with warning -btw_tool_evaluate_r("mean(c(1, 2, NA))") +btw_tool_run_r("mean(c(1, 2, NA))") } } diff --git a/man/btw_tool_search_packages.Rd b/man/btw_tool_search_packages.Rd index 04cf9393..13ace3c6 100644 --- a/man/btw_tool_search_packages.Rd +++ b/man/btw_tool_search_packages.Rd @@ -58,13 +58,13 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, \code{\link{btw_tool_web_read_url}()}, diff --git a/man/btw_tool_session_package_info.Rd b/man/btw_tool_session_package_info.Rd index 424e7445..f27d91a4 100644 --- a/man/btw_tool_session_package_info.Rd +++ b/man/btw_tool_session_package_info.Rd @@ -42,13 +42,13 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_platform_info}()}, \code{\link{btw_tool_web_read_url}()}, diff --git a/man/btw_tool_session_platform_info.Rd b/man/btw_tool_session_platform_info.Rd index 1d7e8cae..dd02853f 100644 --- a/man/btw_tool_session_platform_info.Rd +++ b/man/btw_tool_session_platform_info.Rd @@ -30,13 +30,13 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_web_read_url}()}, diff --git a/man/btw_tool_web_read_url.Rd b/man/btw_tool_web_read_url.Rd index 015a8c1e..be98bb00 100644 --- a/man/btw_tool_web_read_url.Rd +++ b/man/btw_tool_web_read_url.Rd @@ -38,13 +38,13 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index 425e3c44..af262144 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -49,13 +49,6 @@ this function have access to the tools: } -\subsection{Group: eval}{\tabular{ll}{ - Name \tab Description \cr - \code{\link[=btw_tool_evaluate_r]{btw_tool_evaluate_r()}} \tab Execute R code and return results as Content objects. \cr -} - -} - \subsection{Group: files}{\tabular{ll}{ Name \tab Description \cr \code{\link[=btw_tool_files_code_search]{btw_tool_files_code_search()}} \tab Search code files in the project. \cr @@ -93,6 +86,13 @@ this function have access to the tools: } +\subsection{Group: run}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_run_r]{btw_tool_run_r()}} \tab Run R code and return results as Content objects. \cr +} + +} + \subsection{Group: search}{\tabular{ll}{ Name \tab Description \cr \code{\link[=btw_tool_search_package_info]{btw_tool_search_package_info()}} \tab Describe a CRAN package. \cr @@ -137,13 +137,13 @@ Other Tools: \code{\link{btw_tool_docs_package_news}()}, \code{\link{btw_tool_env_describe_data_frame}()}, \code{\link{btw_tool_env_describe_environment}()}, -\code{\link{btw_tool_evaluate_r}()}, \code{\link{btw_tool_files_code_search}()}, \code{\link{btw_tool_files_list_files}()}, \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/merge_adjacent_content.Rd b/man/merge_adjacent_content.Rd new file mode 100644 index 00000000..8ac4cd2b --- /dev/null +++ b/man/merge_adjacent_content.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-run.R +\name{merge_adjacent_content} +\alias{merge_adjacent_content} +\title{Merge adjacent content of the same type} +\usage{ +merge_adjacent_content(contents) +} +\arguments{ +\item{contents}{List of Content objects} +} +\value{ +List of Content objects with adjacent same-type elements merged +} +\description{ +Reduces a list of Content objects by concatenating adjacent elements +of the same mergeable type (ContentCode, ContentMessage, ContentWarning, +ContentError) into single elements. +} +\keyword{internal} diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 4466224c..784604a9 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -1,8 +1,8 @@ -test_that("btw_tool_evaluate_r() returns simple calculations", { +test_that("btw_tool_run_r() returns simple calculations", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_r_impl("2 + 2") - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl("2 + 2") + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") # The actual value is stored in extra$data expect_equal(res@extra$data, 4) @@ -14,33 +14,33 @@ test_that("btw_tool_evaluate_r() returns simple calculations", { expect_true(nzchar(res@extra$output_html)) }) -test_that("btw_tool_evaluate_r() captures messages", { +test_that("btw_tool_run_r() captures messages", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_r_impl('message("hello")') - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl('message("hello")') + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentMessage) expect_equal(res@value[[1]]@text, "hello") }) -test_that("btw_tool_evaluate_r() captures warnings", { +test_that("btw_tool_run_r() captures warnings", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_r_impl('warning("beware")') - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl('warning("beware")') + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentWarning) expect_match(res@value[[1]]@text, "beware") }) -test_that("btw_tool_evaluate_r() captures errors and stops", { +test_that("btw_tool_run_r() captures errors and stops", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_r_impl('x <- 1; stop("error"); y <- 2') - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl('x <- 1; stop("error"); y <- 2') + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") # Should have the error content has_error <- any(vapply( @@ -55,11 +55,11 @@ test_that("btw_tool_evaluate_r() captures errors and stops", { expect_false(is.null(res@error)) }) -test_that("btw_tool_evaluate_r() captures plots", { +test_that("btw_tool_run_r() captures plots", { skip_if_not_installed("evaluate") - res <- btw_tool_evaluate_r_impl('plot(1:10)') - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl('plot(1:10)') + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") # Should have at least one ContentImageInline has_plot <- any(vapply( @@ -70,7 +70,7 @@ test_that("btw_tool_evaluate_r() captures plots", { expect_true(has_plot) }) -test_that("btw_tool_evaluate_r() handles multiple outputs", { +test_that("btw_tool_run_r() handles multiple outputs", { skip_if_not_installed("evaluate") code <- ' @@ -79,8 +79,8 @@ test_that("btw_tool_evaluate_r() handles multiple outputs", { mean(x) warning("careful") ' - res <- btw_tool_evaluate_r_impl(code) - expect_s7_class(res, BtwEvaluateToolResult) + res <- btw_tool_run_r_impl(code) + expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") expect_gte(length(res@value), 3) @@ -106,11 +106,11 @@ test_that("btw_tool_evaluate_r() handles multiple outputs", { expect_true(has_warning) }) -test_that("btw_tool_evaluate_r() requires string input", { +test_that("btw_tool_run_r() requires string input", { skip_if_not_installed("evaluate") - expect_error(btw_tool_evaluate_r_impl(123), class = "rlang_error") - expect_error(btw_tool_evaluate_r_impl(NULL), class = "rlang_error") + expect_error(btw_tool_run_r_impl(123), class = "rlang_error") + expect_error(btw_tool_run_r_impl(NULL), class = "rlang_error") }) test_that("ContentCode, ContentMessage, ContentWarning, ContentError inherit from ContentText", { @@ -151,18 +151,18 @@ test_that("adjacent content of same type is merged", { skip_if_not_installed("evaluate") # Multiple messages should be merged - res <- btw_tool_evaluate_r_impl('message("a"); message("b")') + res <- btw_tool_run_r_impl('message("a"); message("b")') expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentMessage) expect_match(res@value[[1]]@text, "a\nb") # Multiple code outputs should be merged - res <- btw_tool_evaluate_r_impl('1 + 1; 2 + 2') + res <- btw_tool_run_r_impl('1 + 1; 2 + 2') expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentCode) # Different types should not be merged - res <- btw_tool_evaluate_r_impl('message("a"); 1 + 1; warning("b")') + res <- btw_tool_run_r_impl('message("a"); 1 + 1; warning("b")') expect_length(res@value, 3) expect_s7_class(res@value[[1]], ContentMessage) expect_s7_class(res@value[[2]], ContentCode) From fb9db6b43e37af07a3be6fd9f5450c7bf9866504 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 26 Nov 2025 15:43:56 -0500 Subject: [PATCH 08/49] limit height of source code block --- inst/js/run-r/btw-run-r.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index 4bf7a677..866de91f 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -73,6 +73,9 @@ btw-run-r-result .card-body { /* Code section styling */ .btw-run-code { + max-height: var(--btw-run-code-source-max-height, 300px); + overflow: auto; + pre { border: none; border-radius: 0; From e081bfa5930311faa9226d5537aba0297cfff854 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 26 Nov 2025 15:45:09 -0500 Subject: [PATCH 09/49] chore: use class `btw-run-source` instead of `btw-run-code` --- inst/js/run-r/btw-run-r.css | 6 +++--- inst/js/run-r/btw-run-r.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index 866de91f..6f710575 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -71,9 +71,9 @@ btw-run-r-result .card-body { border-radius: 0.25rem; } -/* Code section styling */ -.btw-run-code { - max-height: var(--btw-run-code-source-max-height, 300px); +/* Source code section styling */ +.btw-run-source { + max-height: var(--btw-run-source-max-height, 300px); overflow: auto; pre { diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index d34bf083..3b7031c2 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -147,7 +147,7 @@ class BtwRunRResult extends HTMLElement { aria-labelledby="${headerId}" ${!this.expanded ? 'inert=""' : ""} > -
+
Date: Mon, 1 Dec 2025 09:24:18 -0500 Subject: [PATCH 10/49] chore: general fixups and review --- DESCRIPTION | 1 + R/tool-run.R | 67 ++++++++++++++-------------------------------------- 2 files changed, 19 insertions(+), 49 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index f78203db..7aacf01e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -53,6 +53,7 @@ Suggests: chromote, DBI, duckdb, + evaluate, gert, gh, htmltools, diff --git a/R/tool-run.R b/R/tool-run.R index e10fb320..c544436b 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -31,15 +31,14 @@ #' @export btw_tool_run_r <- function(code, `_intent`) {} -btw_tool_run_r_impl <- function(code, ...) { - check_dots_empty() +btw_tool_run_r_impl <- function(code) { check_string(code) - - # Check if evaluate package is available check_installed("evaluate", "to run R code.") # Initialize list to store Content objects contents <- list() + append_content <- function(x) contents <<- c(contents, list(x)) + # Store the last value for potential use last_value <- NULL # Track if an error occurred @@ -53,25 +52,17 @@ btw_tool_run_r_impl <- function(code, ...) { }, text = function(text) { # Text output (from print, cat, etc.) - contents <<- append(contents, list(ContentCode(text = text))) + append_content(ContentCode(text = text)) text }, graphics = function(plot) { # Save plot to temporary file - tmp <- withr::local_tempfile(fileext = ".png") - grDevices::png(tmp, width = 768, height = 768) + path_plot <- withr::local_tempfile(fileext = ".png") + grDevices::png(path_plot, width = 768, height = 768) grDevices::replayPlot(plot) grDevices::dev.off() - # Read and encode as base64 - img_data <- base64enc::base64encode(tmp) - contents <<- append( - contents, - list( - ellmer::ContentImageInline(type = "image/png", data = img_data) - ) - ) - + append_content(ellmer::content_image_file(path_plot)) plot }, message = function(msg) { @@ -79,35 +70,18 @@ btw_tool_run_r_impl <- function(code, ...) { msg_text <- conditionMessage(msg) # Remove trailing newline that message() adds msg_text <- sub("\n$", "", msg_text) - contents <<- append( - contents, - list( - ContentMessage(text = msg_text) - ) - ) + append_content(ContentMessage(text = msg_text)) msg }, warning = function(warn) { # Warning message - warn_text <- conditionMessage(warn) - contents <<- append( - contents, - list( - ContentWarning(text = warn_text) - ) - ) + append_content(ContentWarning(conditionMessage(warn))) warn }, error = function(err) { # Error message - err_text <- conditionMessage(err) had_error <<- TRUE - contents <<- append( - contents, - list( - ContentError(text = err_text) - ) - ) + append_content(ContentError(conditionMessage(err))) err }, value = function(value, visible) { @@ -120,12 +94,7 @@ btw_tool_run_r_impl <- function(code, ...) { utils::capture.output(print(value)), collapse = "\n" ) - contents <<- append( - contents, - list( - ContentCode(text = value_text) - ) - ) + append_content(ContentCode(text = value_text)) } if (visible) value @@ -164,26 +133,26 @@ btw_tool_run_r_impl <- function(code, ...) { ) } +btw_can_register_run_r_tool <- function() { + rlang::is_installed("evaluate") +} + .btw_add_to_tools( name = "btw_tool_run_r", group = "run", tool = function() { ellmer::tool( - function(code) { - btw_tool_run_r_impl(code = code) - }, + btw_tool_run_r_impl, name = "btw_tool_run_r", description = "Run R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.", annotations = ellmer::tool_annotations( title = "Run R Code", read_only_hint = FALSE, open_world_hint = FALSE, - btw_can_register = function() TRUE + btw_can_register = btw_can_register_run_r_tool ), arguments = list( - code = ellmer::type_string( - "R code to run as a string." - ) + code = ellmer::type_string("R code to run as a string.") ) ) } From 290896f5ee0f8723f330f5b0c8ae039d76b34db8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 1 Dec 2025 10:06:41 -0500 Subject: [PATCH 11/49] tests: fix plot image test --- tests/testthat/test-tool-run.R | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 784604a9..00bb3e67 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -61,10 +61,9 @@ test_that("btw_tool_run_r() captures plots", { res <- btw_tool_run_r_impl('plot(1:10)') expect_s7_class(res, BtwRunToolResult) expect_type(res@value, "list") - # Should have at least one ContentImageInline has_plot <- any(vapply( res@value, - function(x) S7::S7_inherits(x, ellmer::ContentImageInline), + function(x) S7::S7_inherits(x, ellmer::ContentImage), logical(1) )) expect_true(has_plot) From ceec3f7094a8b1f76c2e144ef59628b4a7358ef2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 1 Dec 2025 22:34:33 -0500 Subject: [PATCH 12/49] fix: dial in plot sizing and device --- DESCRIPTION | 1 + R/tool-run.R | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 7aacf01e..dee9d076 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -58,6 +58,7 @@ Suggests: gh, htmltools, pandoc, + ragg, shiny, shinychat (>= 0.2.0), testthat (>= 3.0.0), diff --git a/R/tool-run.R b/R/tool-run.R index c544436b..f6d5d613 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -58,11 +58,15 @@ btw_tool_run_r_impl <- function(code) { graphics = function(plot) { # Save plot to temporary file path_plot <- withr::local_tempfile(fileext = ".png") - grDevices::png(path_plot, width = 768, height = 768) - grDevices::replayPlot(plot) - grDevices::dev.off() + run_r_plot_device(filename = path_plot, width = 768, height = 768) + tryCatch( + grDevices::replayPlot(plot), + finally = { + grDevices::dev.off() + } + ) - append_content(ellmer::content_image_file(path_plot)) + append_content(ellmer::content_image_file(path_plot, resize = "none")) plot }, message = function(msg) { @@ -133,6 +137,20 @@ btw_tool_run_r_impl <- function(code) { ) } +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(...)) + } + + grDevices::png(...) +} + btw_can_register_run_r_tool <- function() { rlang::is_installed("evaluate") } From f2dd1190162cbbf77e1115dd512e78e76590150e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 1 Dec 2025 22:34:47 -0500 Subject: [PATCH 13/49] docs: fix arg --- R/tool-run.R | 2 +- man/btw_tool_run_r.Rd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/tool-run.R b/R/tool-run.R index f6d5d613..12ff834a 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -5,7 +5,7 @@ #' Code execution stops on the first error, returning all results up to that point. #' #' @param code A character string containing R code to run. -#' @param `_intent` Intent description (automatically added by ellmer). +#' @param _intent Intent description (automatically added by ellmer). #' #' @returns A list of ellmer Content objects: #' - `ContentText`: visible return values and text output diff --git a/man/btw_tool_run_r.Rd b/man/btw_tool_run_r.Rd index d1e3644c..15de523f 100644 --- a/man/btw_tool_run_r.Rd +++ b/man/btw_tool_run_r.Rd @@ -9,7 +9,7 @@ btw_tool_run_r(code, `_intent` = "") \arguments{ \item{code}{A character string containing R code to run.} -\item{`_intent`}{Intent description (automatically added by ellmer).} +\item{_intent}{Intent description (automatically added by ellmer).} } \value{ A list of ellmer Content objects: From 52cbcb2ed202d16a4256f1879f393989533fb747 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 2 Dec 2025 17:35:44 -0500 Subject: [PATCH 14/49] feat: Use fansi for ANSI-styled text, drop intermediate plots --- .prettierrc | 14 +++ R/tool-run.R | 162 +++++++++++++++++++++++---------- R/utils.R | 36 ++++++-- inst/js/run-r/btw-run-r.js | 21 +++-- tests/testthat/test-tool-run.R | 32 ++++++- 5 files changed, 201 insertions(+), 64 deletions(-) create mode 100644 .prettierrc 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/R/tool-run.R b/R/tool-run.R index 12ff834a..4ab59c12 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -31,18 +31,37 @@ #' @export btw_tool_run_r <- function(code, `_intent`) {} -btw_tool_run_r_impl <- function(code) { +btw_tool_run_r_impl <- function(code, .envir = global_env()) { check_string(code) check_installed("evaluate", "to run R code.") - # Initialize list to store Content objects + 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)) - # Store the last value for potential use - last_value <- NULL - # Track if an error occurred - had_error <- FALSE + last_plot <- NULL + append_last_plot <- function() { + if (is.null(last_plot)) { + return() + } + + path_plot <- withr::local_tempfile(fileext = ".png") + run_r_plot_device(filename = path_plot, width = 768, height = 768) + 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")) # Create output handler that converts to Content types as outputs are generated handler <- evaluate::new_output_handler( @@ -51,26 +70,24 @@ btw_tool_run_r_impl <- function(code) { NULL }, text = function(text) { + append_last_plot() # Text output (from print, cat, etc.) append_content(ContentCode(text = text)) text }, graphics = function(plot) { - # Save plot to temporary file - path_plot <- withr::local_tempfile(fileext = ".png") - run_r_plot_device(filename = path_plot, width = 768, height = 768) - tryCatch( - grDevices::replayPlot(plot), - finally = { - grDevices::dev.off() + 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() } - ) + } - append_content(ellmer::content_image_file(path_plot, resize = "none")) + last_plot <<- plot plot }, message = function(msg) { - # Message output + append_last_plot() msg_text <- conditionMessage(msg) # Remove trailing newline that message() adds msg_text <- sub("\n$", "", msg_text) @@ -78,12 +95,12 @@ btw_tool_run_r_impl <- function(code) { msg }, warning = function(warn) { - # Warning message + append_last_plot() append_content(ContentWarning(conditionMessage(warn))) warn }, error = function(err) { - # Error message + append_last_plot() had_error <<- TRUE append_content(ContentError(conditionMessage(err))) err @@ -105,38 +122,53 @@ btw_tool_run_r_impl <- function(code) { } ) - # Evaluate the code with our custom handler + # Evaluate the R code, collecting results along the way evaluate::evaluate( code, - envir = global_env(), + 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) - # Render all content objects to HTML - output_html <- vapply( - contents, - function(content) ellmer::contents_html(content), - character(1) - ) - output_html <- paste(output_html, collapse = "\n") + # For `value`, remove all ANSI codes + value <- map(contents, run_r_content_handle_ansi) - # Return as BtwRunToolResult BtwRunToolResult( - value = contents, - error = if (had_error) contents[[length(contents)]]@text else NULL, + value = value, extra = list( data = last_value, code = code, - output_html = output_html + 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)) { @@ -155,12 +187,29 @@ btw_can_register_run_r_tool <- function() { rlang::is_installed("evaluate") } +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(fansi::html_esc(x@text)) + } + + S7::set_props(x, text = text) +} + .btw_add_to_tools( name = "btw_tool_run_r", group = "run", tool = function() { ellmer::tool( - btw_tool_run_r_impl, + function(code) { + btw_tool_run_r_impl(code) + }, name = "btw_tool_run_r", description = "Run R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.", annotations = ellmer::tool_annotations( @@ -209,32 +258,30 @@ contents_html <- S7::new_external_generic( ) S7::method(contents_html, ContentCode) <- function(content, ...) { - text <- htmltools::htmlEscape(content@text) - sprintf('
%s
', trimws(text)) + sprintf( + '
%s
', + trimws(content@text) + ) } S7::method(contents_html, ContentMessage) <- function(content, ...) { - text <- htmltools::htmlEscape(content@text) - sprintf( - '
%s
', - trimws(text) + '
%s
', + trimws(content@text) ) } S7::method(contents_html, ContentWarning) <- function(content, ...) { - text <- htmltools::htmlEscape(content@text) sprintf( - '
%s
', - trimws(text) + '
%s
', + trimws(content@text) ) } S7::method(contents_html, ContentError) <- function(content, ...) { - text <- htmltools::htmlEscape(content@text) sprintf( - '
%s
', - trimws(text) + '
%s
', + trimws(content@text) ) } @@ -246,9 +293,29 @@ contents_shinychat <- S7::new_external_generic( S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { code <- content@extra$code - output_html <- content@extra$output_html - request_id <- content@request@id - status <- if (!is.null(content@error)) "error" else "success" + + # 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 + } + } dep <- htmltools::htmlDependency( name = "btw-run-r", @@ -266,6 +333,7 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { `request-id` = request_id, code = code, status = status, + `tool-title` = tool_title, htmltools::HTML(output_html), dep ) diff --git a/R/utils.R b/R/utils.R index cc0ed250..b326d2c1 100644 --- a/R/utils.R +++ b/R/utils.R @@ -189,22 +189,40 @@ 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, + cil.dynamic = TRUE, + cli.unicode = TRUE, + cli.condition_width = width, + cli.num_colors = 16L, + .local_envir = .env + ) + } + withr::local_options( - crayon.enabled = 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 +232,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/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 3b7031c2..f5623325 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -22,7 +22,7 @@ const ICONS = { plus: ` -` +`, } /** @@ -59,6 +59,8 @@ class BtwRunRResult extends HTMLElement { constructor() { super() + + this.toolTitle = this.getAttribute("tool-title") || "Run R Code" } connectedCallback() { @@ -82,8 +84,8 @@ class BtwRunRResult extends HTMLElement { new CustomEvent("shiny-tool-request-hide", { detail: { request_id: requestId }, bubbles: true, - cancelable: true - }) + cancelable: true, + }), ) } @@ -104,12 +106,15 @@ class BtwRunRResult extends HTMLElement { } /** - * Format the title for display + * Formats the title for display in the card header. Uses the `titleTemplate`, + * replacing `{title}` with the actual title or name of the tool. * @returns {string} */ formatTitle() { - const title = 'Run R Code' - return this.titleTemplate.replace("{title}", title) + const displayTitle = `${ + this.toolTitle || "Run R Code" + }` + return this.titleTemplate.replace("{title}", displayTitle) } /** @@ -136,7 +141,9 @@ class BtwRunRResult extends HTMLElement { aria-controls="${contentId}" >
${this.icon}
-
${this.formatTitle()}
+
${this.formatTitle()}
${ICONS.plus}
diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 00bb3e67..77896990 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -10,8 +10,8 @@ test_that("btw_tool_run_r() returns simple calculations", { expect_length(res@value, 1) expect_s7_class(res@value[[1]], ContentCode) expect_match(res@value[[1]]@text, "4") - # Output HTML is rendered - expect_true(nzchar(res@extra$output_html)) + # The contents in extra should match value + expect_equal(res@value, res@extra$contents) }) test_that("btw_tool_run_r() captures messages", { @@ -52,7 +52,7 @@ test_that("btw_tool_run_r() captures errors and stops", { # y should not be assigned (code stopped at error) expect_false(exists("y", envir = globalenv())) # Error should be set on result - expect_false(is.null(res@error)) + expect_equal(res@extra$status, "error") }) test_that("btw_tool_run_r() captures plots", { @@ -140,7 +140,7 @@ test_that("contents_html() renders Content types correctly", { warn_html <- ellmer::contents_html(warn) err_html <- ellmer::contents_html(err) - expect_match(code_html, "
")
+  expect_match(code_html, "
")
   expect_match(msg_html, 'class="btw-output-message"')
   expect_match(warn_html, 'class="btw-output-warning"')
   expect_match(err_html, 'class="btw-output-error"')
@@ -167,3 +167,27 @@ test_that("adjacent content of same type is merged", {
   expect_s7_class(res@value[[2]], ContentCode)
   expect_s7_class(res@value[[3]], ContentWarning)
 })
+
+test_that("intermediate plots are dropped", {
+  skip_if_not_installed("evaluate")
+
+  code <- "
+plot(1:3)
+text(1, 1, 'x')
+text(1, 1, 'y')"
+
+  res <- btw_tool_run_r_impl(code)
+  expect_s7_class(res, BtwRunToolResult)
+
+  expect_type(res@value, "list")
+  plot_contents <- keep(res@value, S7::S7_inherits, ellmer::ContentImage)
+  expect_length(plot_contents, 1)
+
+  expect_type(res@extra$contents, "list")
+  plot_contents_all <- keep(
+    res@extra$contents,
+    S7::S7_inherits,
+    ellmer::ContentImage
+  )
+  expect_length(plot_contents_all, 1)
+})

From 4647aef4bb531c067e6f990779dad2d1150a5b18 Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Thu, 4 Dec 2025 18:10:23 -0500
Subject: [PATCH 15/49] feat: Use CSS tied to Bootstrap for ANSI colors

---
 R/tool-run.R                |  38 ++++++++++-
 inst/js/run-r/btw-run-r.css | 129 ++++++++++++++++++++++++++++++++++++
 2 files changed, 166 insertions(+), 1 deletion(-)

diff --git a/R/tool-run.R b/R/tool-run.R
index 4ab59c12..c0bb2615 100644
--- a/R/tool-run.R
+++ b/R/tool-run.R
@@ -196,12 +196,48 @@ run_r_content_handle_ansi <- function(x, plain = TRUE) {
     if (isTRUE(plain)) {
       htmltools::htmlEscape(strip_ansi(x@text))
     } else {
-      fansi::to_html(fansi::html_esc(x@text))
+      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",
diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css
index 6f710575..b63eec2b 100644
--- a/inst/js/run-r/btw-run-r.css
+++ b/inst/js/run-r/btw-run-r.css
@@ -25,6 +25,10 @@ btw-run-r-result .card-body {
   overflow-x: auto;
 }
 
+[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;
@@ -85,3 +89,128 @@ btw-run-r-result .card-body {
     }
   }
 }
+
+/* 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); }

From 62236532d7bc06d3459ab3fe1595df80d01041ff Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Thu, 4 Dec 2025 21:23:54 -0500
Subject: [PATCH 16/49] chore: Use `.bslib-page-dashboard` class

---
 R/btw_client_app.R | 1 +
 1 file changed, 1 insertion(+)

diff --git a/R/btw_client_app.R b/R/btw_client_app.R
index 6d8ac81e..a9b44848 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",

From 83c2b332bf3482cec60b4d3cd5eae03aa4e785e0 Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Thu, 4 Dec 2025 23:31:17 -0500
Subject: [PATCH 17/49] feat: Tool is opt-in

---
 R/btw_client.R                 |   8 +-
 R/tool-run.R                   |  61 ++++++++++++++-
 R/tools.R                      |   2 +
 man/btw_tool_run_r.Rd          |  40 +++++++++-
 tests/testthat/test-tool-run.R | 133 +++++++++++++++++++++++++++++++++
 5 files changed, 235 insertions(+), 9 deletions(-)

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/tool-run.R b/R/tool-run.R
index c0bb2615..8c8c5458 100644
--- a/R/tool-run.R
+++ b/R/tool-run.R
@@ -1,8 +1,42 @@
 #' Tool: Run R code
 #'
-#' This tool runs R code and returns results as 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.
+#' @description
+#' 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 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
+#' ---
+#' ```
 #'
 #' @param code A character string containing R code to run.
 #' @param _intent Intent description (automatically added by ellmer).
@@ -184,7 +218,26 @@ run_r_plot_device <- function(...) {
 }
 
 btw_can_register_run_r_tool <- function() {
-  rlang::is_installed("evaluate")
+  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) {
diff --git a/R/tools.R b/R/tools.R
index e003fb9b..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)
 
diff --git a/man/btw_tool_run_r.Rd b/man/btw_tool_run_r.Rd
index 15de523f..95ac949c 100644
--- a/man/btw_tool_run_r.Rd
+++ b/man/btw_tool_run_r.Rd
@@ -22,10 +22,44 @@ A list of ellmer Content objects:
 }
 }
 \description{
-This tool runs R code and returns results as 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.
+This tool runs R code and returns results as a list of \code{\link[ellmer:Content]{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{Enabling this tool}{
+
+This tool is not enabled by default in \code{\link[=btw_tools]{btw_tools()}}, \code{\link[=btw_app]{btw_app()}} or
+\code{\link[=btw_client]{btw_client()}}. To enable the function, you have a few options:
+\enumerate{
+\item Set the \code{btw.run_r.enabled} option to \code{TRUE} in your R session, or in your
+\code{.Rprofile} file to enable it globally.
+\item Set the \code{BTW_RUN_R_ENABLED} environment variable to \code{true} in your
+\code{.Renviron} file or your system environment.
+\item Explicitly include the tool when calling \code{btw_tools("run")} (unless the
+above options disable it).
+}
+
+In your \link[=use_btw_md]{btw.md file}, you can explicitly enable the tool by
+naming it in the tools option
+
+\if{html}{\out{
}}\preformatted{--- +tools: + - run_r +--- +}\if{html}{\out{
}} + +or you can enable the tool by setting the \code{btw.run_r.enabled} option from the +\code{options} list in \code{btw.md} (this approach is useful if you've globally +disabled the tool but want to enable it for a specific btw chat): + +\if{html}{\out{
}}\preformatted{--- +options: + run_r: + enabled: true +--- +}\if{html}{\out{
}} +} + \examples{ \dontrun{ # Simple calculation diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 77896990..15be3b45 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -191,3 +191,136 @@ text(1, 1, 'y')" ) expect_length(plot_contents_all, 1) }) + +test_that("btw_tool_run_r() is not included in btw_tools() by default", { + local_mocked_bindings(is_installed = function(...) TRUE) + withr::local_envvar(BTW_RUN_R_ENABLED = NULL) + withr::local_options(btw.run_r.enabled = NULL) + + tools <- btw_tools() + tool_names <- map_chr(tools, function(x) x@name) + expect_false("btw_tool_run_r" %in% tool_names) +}) + +test_that("btw_tool_run_r() is included in btw_tools() when requested", { + local_mocked_bindings(is_installed = function(...) TRUE) + withr::local_envvar(BTW_RUN_R_ENABLED = NULL) + withr::local_options(btw.run_r.enabled = NULL) + + tools <- btw_tools("run") + tool_names <- map_chr(tools, function(x) x@name) + expect_true("btw_tool_run_r" %in% tool_names) + + tools <- btw_tools("btw_tool_run_r") + tool_names <- map_chr(tools, function(x) x@name) + expect_true("btw_tool_run_r" %in% tool_names) +}) + +describe("btw_tool_run_r() in btw_tools()", { + local_mocked_bindings(is_installed = function(...) TRUE) + + it("can be enabled via option", { + withr::local_options(btw.run_r.enabled = TRUE) + tools <- btw_tools() + tool_names <- map_chr(tools, function(x) x@name) + expect_true("btw_tool_run_r" %in% tool_names) + }) + + it("can be enabled via environment variable", { + withr::local_envvar(BTW_RUN_R_ENABLED = "TRUE") + tools <- btw_tools() + expect_true("btw_tool_run_r" %in% names(tools)) + }) + + it("can be enabled via btw.md", { + path_btw <- withr::local_tempfile( + lines = c( + "---", + "options:", + " run_r:", + " enabled: true", + "---" + ) + ) + + withr::local_envvar(ANTHROPIC_API_KEY = "boop") + client <- btw_client(path_btw = path_btw) + + tools <- client$get_tools() + expect_true("btw_tool_run_r" %in% names(tools)) + }) + + it("is not included if explicitly disabled", { + path_btw <- withr::local_tempfile( + lines = c( + "---", + "tools: ['run']", + "options:", + " run_r:", + " enabled: false", + "---" + ) + ) + + withr::local_envvar(ANTHROPIC_API_KEY = "boop") + client <- btw_client(path_btw = path_btw) + + tools <- client$get_tools() + expect_false("btw_tool_run_r" %in% names(tools)) + }) + + it("is included if explicitly mentioned", { + path_btw <- withr::local_tempfile( + lines = c( + "---", + "tools: ['run']", + "---" + ) + ) + + withr::local_envvar(ANTHROPIC_API_KEY = "boop") + client <- btw_client(path_btw = path_btw) + + tools <- client$get_tools() + expect_true("btw_tool_run_r" %in% names(tools)) + }) + + it("is not included if explicitly mentioned but disabled", { + path_btw <- withr::local_tempfile( + lines = c( + "---", + "tools: ['run']", + "---" + ) + ) + + withr::local_envvar(BTW_RUN_R_ENABLED = "false") + withr::local_envvar(ANTHROPIC_API_KEY = "boop") + client <- btw_client(path_btw = path_btw) + + tools <- client$get_tools() + expect_false("btw_tool_run_r" %in% names(tools)) + }) + + it("is included if mentioned and enabled, even if globally disabled", { + path_btw <- withr::local_tempfile( + lines = c( + "---", + "tools: ['run']", + "options:", + " run_r:", + " enabled: true", + "---" + ) + ) + + withr::local_options(btw.run_r.enabled = FALSE) + withr::local_envvar(ANTHROPIC_API_KEY = "boop") + client <- btw_client(path_btw = path_btw) + + expect_equal(getOption("btw.run_r.enabled"), FALSE) + + tools <- client$get_tools() + expect_true("btw_tool_run_r" %in% names(tools)) + }) +}) From 1836910f5bacd12fd8440712d4077f60989c5338 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 13:46:31 -0500 Subject: [PATCH 18/49] chore: build ignore local dev things --- .Rbuildignore | 2 ++ 1 file changed, 2 insertions(+) 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$ From 0a1b3f2415027d42640a8fc761a1ffaad86d706c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 13:46:57 -0500 Subject: [PATCH 19/49] chore: app styles --- R/btw_client_app.R | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index a9b44848..794bcf9e 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -138,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%); + } " )), ) From 882328263fb0e0ba8cb4711f129f2646b42c8b15 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 13:47:24 -0500 Subject: [PATCH 20/49] chore: improve cli settings --- R/utils.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index b326d2c1..ba8a8cb4 100644 --- a/R/utils.R +++ b/R/utils.R @@ -210,7 +210,7 @@ local_reproducible_output <- function( withr::local_envvar(list(NO_COLOR = NA), .local_envir = .env) withr::local_options( crayon.enabled = TRUE, - cil.dynamic = TRUE, + cli.ansi = TRUE, cli.unicode = TRUE, cli.condition_width = width, cli.num_colors = 16L, @@ -219,6 +219,8 @@ local_reproducible_output <- function( } withr::local_options( + cil.dynamic = FALSE, + cli.spinner = FALSE, cli.hyperlink = FALSE, cli.hyperlink_run = FALSE, cli.hyperlink_help = FALSE, From 900446e34013caffe0927ff10c79b9ff60b9fccd Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 17:18:23 -0500 Subject: [PATCH 21/49] tests: fix checking of all tools --- tests/testthat/helpers.R | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index cbd3e3c2..60e31a73 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -90,6 +90,7 @@ local_enable_tools <- function( rstudioapi_has_source_editor_context = TRUE, btw_can_register_git_tool = TRUE, btw_can_register_gh_tool = TRUE, + btw_can_register_run_r_tool = TRUE, .env = caller_env() ) { local_mocked_bindings( @@ -99,6 +100,7 @@ local_enable_tools <- function( }, btw_can_register_git_tool = function() btw_can_register_git_tool, btw_can_register_gh_tool = function() btw_can_register_gh_tool, + btw_can_register_run_r_tool = function() btw_can_register_run_r_tool, .env = .env ) } From ec08f97790b5a6d9ccb0d949609ae58f5839a03c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 17:21:39 -0500 Subject: [PATCH 22/49] chore: Add NEWS item --- NEWS.md | 2 ++ 1 file changed, 2 insertions(+) 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). From 57cfd2a64a54dba4546219e25a45045c451f9486 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 17:38:45 -0500 Subject: [PATCH 23/49] docs: Document and address security implications --- R/tool-run.R | 36 ++++++++++++++++++++++++++++++++++-- man/btw_tool_run_r.Rd | 21 +++++++++++++++++++++ man/btw_tools.Rd | 2 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/R/tool-run.R b/R/tool-run.R index 8c8c5458..25e78912 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -1,10 +1,29 @@ #' 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: @@ -300,7 +319,20 @@ fansi_to_html <- function(text) { btw_tool_run_r_impl(code) }, name = "btw_tool_run_r", - description = "Run R code and return results as Content objects. Captures text output, plots, messages, warnings, and errors. Stops on first error.", + description = r"---(Run R code. + +This tool executes R code and returns the results, including text output, +plots, messages, warnings, and errors. + +With great power comes great responsibility: the R code you write should be +safe and appropriate for execution in a shared environment. Do not write files +or perform dangerous or irreversible actions. Always consider the security +implications of the code that you write. If you have any doubts, consult the +user with a preview of the code you would like to write before executing it. + +If an error occurs during execution, the tool will return all results up to +the point of the error. Inspect the error message to understand what went wrong. + )---", annotations = ellmer::tool_annotations( title = "Run R Code", read_only_hint = FALSE, @@ -308,7 +340,7 @@ fansi_to_html <- function(text) { btw_can_register = btw_can_register_run_r_tool ), arguments = list( - code = ellmer::type_string("R code to run as a string.") + code = ellmer::type_string("The R code to run") ) ) } diff --git a/man/btw_tool_run_r.Rd b/man/btw_tool_run_r.Rd index 95ac949c..adcba3d1 100644 --- a/man/btw_tool_run_r.Rd +++ b/man/btw_tool_run_r.Rd @@ -22,10 +22,31 @@ A list of ellmer Content objects: } } \description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} This tool runs R code and returns results as a list of \code{\link[ellmer:Content]{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 \code{\link[=btw_tools]{btw_tools()}}, \code{\link[=btw_app]{btw_app()}} or diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index af262144..6d154d1c 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -88,7 +88,7 @@ this function have access to the tools: \subsection{Group: run}{\tabular{ll}{ Name \tab Description \cr - \code{\link[=btw_tool_run_r]{btw_tool_run_r()}} \tab Run R code and return results as Content objects. \cr + \code{\link[=btw_tool_run_r]{btw_tool_run_r()}} \tab Run R code. \cr } } From d7f8b6f08c2c5adb8df9a9c3a849999a8d2af3ce Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 5 Dec 2025 18:58:48 -0500 Subject: [PATCH 24/49] chore: use fansi (suggests) --- DESCRIPTION | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION b/DESCRIPTION index dee9d076..3178bfb5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -54,6 +54,7 @@ Suggests: DBI, duckdb, evaluate, + fansi, gert, gh, htmltools, From e3b216951c461ef48a8875575aa643b0e2ad406c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Dec 2025 15:50:34 -0500 Subject: [PATCH 25/49] fix(app): Fix registration of run tool in app --- R/btw_client_app.R | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 794bcf9e..32b45ebc 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -223,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) } }) @@ -277,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) { @@ -610,6 +617,7 @@ app_tool_group_choice_input <- function( "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"), From 535d05df7ecfc4720261c4bab99efe7a875bb5d1 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 8 Dec 2025 15:53:10 -0500 Subject: [PATCH 26/49] feat(tool-run): return something to the LLM if nothing is printed --- R/tool-run.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/R/tool-run.R b/R/tool-run.R index 25e78912..6b0dd239 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -193,6 +193,14 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { # For `value`, remove all ANSI codes value <- map(contents, 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( From ceef5c291f4d17d32430d7ba0d7ae7c25ebee454 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 9 Dec 2025 09:12:29 -0500 Subject: [PATCH 27/49] chore(js): Avoid double definition of custom element --- inst/js/run-r/btw-run-r.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index f5623325..1605b617 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -79,6 +79,7 @@ class BtwRunRResult extends HTMLElement { // Hide the corresponding tool request const requestId = this.getAttribute("request-id") if (requestId) { + // TODO: Remove after next shinychat release (posit-dev/shinychat#163) window.shinychat.hiddenToolRequests.add(requestId) this.dispatchEvent( new CustomEvent("shiny-tool-request-hide", { @@ -167,7 +168,6 @@ class BtwRunRResult extends HTMLElement {
` - // Add click handler to header const header = this.querySelector(".card-header") if (header) { header.addEventListener("click", (e) => this.toggleCollapse(e)) @@ -189,5 +189,6 @@ class BtwRunRResult extends HTMLElement { } } -// Register the custom element -customElements.define("btw-run-r-result", BtwRunRResult) +if (!customElements.get("btw-run-r-result")) { + customElements.define("btw-run-r-result", BtwRunRResult) +} From 89fe30c4e601e714b92d9515d3055cc44e6f664d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 13:33:28 -0500 Subject: [PATCH 28/49] feat: interleaved source/output --- R/tool-run.R | 38 ++++++++++++++++++++++++--------- inst/js/run-r/btw-run-r.css | 39 ++++++++++++---------------------- inst/js/run-r/btw-run-r.js | 6 ------ tests/testthat/test-tool-run.R | 23 +++++++++++--------- 4 files changed, 54 insertions(+), 52 deletions(-) diff --git a/R/tool-run.R b/R/tool-run.R index 6b0dd239..77ad4649 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -120,12 +120,12 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { handler <- evaluate::new_output_handler( source = function(src, expr) { # Skip source code echoing by returning NULL - NULL + append_content(ContentSource(text = src$src)) }, text = function(text) { append_last_plot() # Text output (from print, cat, etc.) - append_content(ContentCode(text = text)) + append_content(ContentOutput(text = text)) text }, graphics = function(plot) { @@ -168,7 +168,7 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { utils::capture.output(print(value)), collapse = "\n" ) - append_content(ContentCode(text = value_text)) + append_content(ContentOutput(text = value_text)) } if (visible) value @@ -190,8 +190,9 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { # Merge adjacent content of the same type contents <- merge_adjacent_content(contents) - # For `value`, remove all ANSI codes - value <- map(contents, run_r_content_handle_ansi) + # 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) { @@ -340,6 +341,10 @@ user with a preview of the code you would like to write before executing it. If an error occurs during execution, the tool will return all results up to the point of the error. Inspect the error message to understand what went wrong. + +**Formatted output**: When creating formatted output, use a single `cat()` call +to emit the complete formatted text, rather than multiple `cat()` calls. This is +much easier for the user to read. )---", annotations = ellmer::tool_annotations( title = "Run R Code", @@ -355,8 +360,13 @@ the point of the error. Inspect the error message to understand what went wrong. ) # ---- Content Types ---- -ContentCode <- S7::new_class( - "ContentCode", +ContentSource <- S7::new_class( + "ContentSource", + parent = ellmer::ContentText +) + +ContentOutput <- S7::new_class( + "ContentOutput", parent = ellmer::ContentText ) @@ -386,7 +396,14 @@ contents_html <- S7::new_external_generic( dispatch_args = "content" ) -S7::method(contents_html, ContentCode) <- function(content, ...) { +S7::method(contents_html, ContentSource) <- function(content, ...) { + sprintf( + '
%s
', + trimws(content@text) + ) +} + +S7::method(contents_html, ContentOutput) <- function(content, ...) { sprintf( '
%s
', trimws(content@text) @@ -471,7 +488,8 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { is_mergeable_content <- function(x, y) { mergeable_content_types <- list( - ContentCode, + ContentSource, + ContentOutput, ContentMessage, ContentWarning, ContentError @@ -489,7 +507,7 @@ is_mergeable_content <- function(x, y) { #' Merge adjacent content of the same type #' #' Reduces a list of Content objects by concatenating adjacent elements -#' of the same mergeable type (ContentCode, ContentMessage, ContentWarning, +#' of the same mergeable type (ContentOutput, ContentMessage, ContentWarning, #' ContentError) into single elements. #' #' @param contents List of Content objects diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index b63eec2b..9ebcff96 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -18,35 +18,37 @@ btw-run-r-result .card-body { /* Code output blocks */ .btw-run-output pre { - margin: 0.25rem 0; + margin: 0; padding: 0.5rem; - background-color: var(--bs-light, #f8f9fa); - border-radius: 0.25rem; + 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 { +.btw-run-output pre:not(.btw-output-source) .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); - font-size: 0.875em; - white-space: pre-wrap; + white-space: pre; word-break: break-word; padding: 0; } -.btw-run-output pre { - margin-block: 0; - border-radius: 0; - border: none; - border-left: 3px solid var(--bs-secondary-border-subtle, #b3b3b3); +.btw-run-output pre.btw-output-source { + background-color: unset; } /* Message output (blue left border) */ @@ -75,21 +77,6 @@ btw-run-r-result .card-body { border-radius: 0.25rem; } -/* Source code section styling */ -.btw-run-source { - max-height: var(--btw-run-source-max-height, 300px); - overflow: auto; - - pre { - border: none; - border-radius: 0; - - &, code { - background-color: var(--bs-body-bg, white) !important; - } - } -} - /* ANSI color variables */ :root { /* Basic foreground colors (0-7) - Bootstrap defaults work well on white */ diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 1605b617..54ea7528 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -155,12 +155,6 @@ class BtwRunRResult extends HTMLElement { aria-labelledby="${headerId}" ${!this.expanded ? 'inert=""' : ""} > -
- -
${outputHtml}
diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 15be3b45..9545b141 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -6,12 +6,15 @@ test_that("btw_tool_run_r() returns simple calculations", { expect_type(res@value, "list") # The actual value is stored in extra$data expect_equal(res@extra$data, 4) - # The visible output is captured as ContentCode + # The visible output is captured as ContentOutput expect_length(res@value, 1) - expect_s7_class(res@value[[1]], ContentCode) + expect_s7_class(res@value[[1]], ContentOutput) expect_match(res@value[[1]]@text, "4") - # The contents in extra should match value - expect_equal(res@value, res@extra$contents) + # The contents in extra should match value (except source blocks) + expect_equal( + res@value, + keep(res@extra$contents, Negate(S7::S7_inherits), ContentSource) + ) }) test_that("btw_tool_run_r() captures messages", { @@ -91,7 +94,7 @@ test_that("btw_tool_run_r() handles multiple outputs", { )) has_code <- any(vapply( res@value, - function(x) S7::S7_inherits(x, ContentCode), + function(x) S7::S7_inherits(x, ContentOutput), logical(1) )) has_warning <- any(vapply( @@ -112,8 +115,8 @@ test_that("btw_tool_run_r() requires string input", { expect_error(btw_tool_run_r_impl(NULL), class = "rlang_error") }) -test_that("ContentCode, ContentMessage, ContentWarning, ContentError inherit from ContentText", { - code <- ContentCode(text = "output") +test_that("ContentOutput, ContentMessage, ContentWarning, ContentError inherit from ContentText", { + code <- ContentOutput(text = "output") msg <- ContentMessage(text = "hello") warn <- ContentWarning(text = "warning") err <- ContentError(text = "error") @@ -130,7 +133,7 @@ test_that("ContentCode, ContentMessage, ContentWarning, ContentError inherit fro }) test_that("contents_html() renders Content types correctly", { - code <- ContentCode(text = "[1] 42") + code <- ContentOutput(text = "[1] 42") msg <- ContentMessage(text = "info message") warn <- ContentWarning(text = "warning message") err <- ContentError(text = "error message") @@ -158,13 +161,13 @@ test_that("adjacent content of same type is merged", { # Multiple code outputs should be merged res <- btw_tool_run_r_impl('1 + 1; 2 + 2') expect_length(res@value, 1) - expect_s7_class(res@value[[1]], ContentCode) + expect_s7_class(res@value[[1]], ContentOutput) # Different types should not be merged res <- btw_tool_run_r_impl('message("a"); 1 + 1; warning("b")') expect_length(res@value, 3) expect_s7_class(res@value[[1]], ContentMessage) - expect_s7_class(res@value[[2]], ContentCode) + expect_s7_class(res@value[[2]], ContentOutput) expect_s7_class(res@value[[3]], ContentWarning) }) From c26abb9537e48cf1e24ae7ab2671307db4cccc6f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 14:01:29 -0500 Subject: [PATCH 29/49] feat: Add copy source code button --- inst/js/run-r/btw-run-r.css | 62 +++++++++++++++++++ inst/js/run-r/btw-run-r.js | 116 +++++++++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 9 deletions(-) diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index 9ebcff96..b42cd536 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -8,6 +8,11 @@ btw-run-r-result { 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; @@ -201,3 +206,60 @@ btw-run-r-result .card-body { .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 { + 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 index 54ea7528..644bf794 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -22,6 +22,10 @@ const ICONS = { plus: ` +`, + copy: ` + + `, } @@ -106,6 +110,58 @@ class BtwRunRResult extends HTMLElement { this.render() } + /** + * Copy code to clipboard + * @param {Event} e + */ + async copyCode(e) { + e.preventDefault() + e.stopPropagation() // Prevent triggering collapse toggle + + // Save reference to button before async operation + // (e.currentTarget becomes null after await) + const copyBtn = e.currentTarget + const code = this.getAttribute("code") || "" + + try { + const originalHtml = copyBtn.innerHTML + await navigator.clipboard.writeText(code) + + // Get the tooltip instance + const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn) + + // Visual feedback - change icon briefly and update tooltip + copyBtn.innerHTML = ` + + ` + + // Update tooltip to show success message + if (tooltip) { + const originalTitle = copyBtn.getAttribute("data-bs-original-title") + copyBtn.setAttribute("data-bs-original-title", "Copied code!") + tooltip.setContent({ ".tooltip-inner": "Copied code!" }) + if (copyBtn.matches(":hover")) { + tooltip.show() + } + + setTimeout(() => { + copyBtn.innerHTML = originalHtml + copyBtn.setAttribute("data-bs-original-title", originalTitle || "Copy source code") + tooltip.setContent({ + ".tooltip-inner": originalTitle || "Copy source code", + }) + tooltip.hide() + }, 1500) + } else { + setTimeout(() => { + copyBtn.innerHTML = originalHtml + }, 1500) + } + } catch (err) { + console.error("Failed to copy code:", err) + } + } + /** * Formats the title for display in the card header. Uses the `titleTemplate`, * replacing `{title}` with the actual title or name of the tool. @@ -133,21 +189,41 @@ class BtwRunRResult extends HTMLElement { const collapsedClass = this.expanded ? "" : " collapsed" + // Dispose of existing tooltip before re-rendering + const oldCopyBtn = this.querySelector(".copy-code-btn") + if (oldCopyBtn) { + const oldTooltip = window.bootstrap?.Tooltip?.getInstance(oldCopyBtn) + if (oldTooltip) { + oldTooltip.dispose() + } + } + this.innerHTML = `
- + + +
` + const collapseBtn = this.querySelector(".collapse-toggle-btn") + if (collapseBtn) { + collapseBtn.addEventListener("click", (e) => this.toggleCollapse(e)) + } + + const copyBtn = this.querySelector(".copy-code-btn") + if (copyBtn) { + copyBtn.addEventListener("click", (e) => this.copyCode(e)) + + // Initialize Bootstrap tooltip + if (window.bootstrap?.Tooltip) { + new window.bootstrap.Tooltip(copyBtn) + } + } + + // Allow clicking anywhere on the header to toggle, except on action buttons const header = this.querySelector(".card-header") if (header) { - header.addEventListener("click", (e) => this.toggleCollapse(e)) + header.addEventListener("click", (e) => { + // Don't toggle if clicking on a button + if (e.target.closest(".copy-code-btn") || e.target.closest(".collapse-toggle-btn")) { + return + } + this.toggleCollapse(e) + }) } } From 74ff8ab15744872aaa7b2abd82d47d9037336b32 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 17:37:06 -0500 Subject: [PATCH 30/49] chore: fix extra leading/trailing newlines, don't strip all whitespace, just the ones around the edges that make things look weird --- R/tool-run.R | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/R/tool-run.R b/R/tool-run.R index 77ad4649..717b06d3 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -120,7 +120,8 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { handler <- evaluate::new_output_handler( source = function(src, expr) { # Skip source code echoing by returning NULL - append_content(ContentSource(text = src$src)) + src_code <- sub("\n$", "", src$src) + append_content(ContentSource(text = src_code)) }, text = function(text) { append_last_plot() @@ -170,8 +171,6 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { ) append_content(ContentOutput(text = value_text)) } - - if (visible) value } ) @@ -396,38 +395,43 @@ contents_html <- S7::new_external_generic( dispatch_args = "content" ) +trim_outer_nl <- function(x) { + x <- sub("^\r?\n", "", x) + sub("\r?\n$", "", x) +} + S7::method(contents_html, ContentSource) <- function(content, ...) { sprintf( '
%s
', - trimws(content@text) + trim_outer_nl(content@text) ) } S7::method(contents_html, ContentOutput) <- function(content, ...) { sprintf( - '
%s
', - trimws(content@text) + '
%s
', + trim_outer_nl(content@text) ) } S7::method(contents_html, ContentMessage) <- function(content, ...) { sprintf( '
%s
', - trimws(content@text) + trim_outer_nl(content@text) ) } S7::method(contents_html, ContentWarning) <- function(content, ...) { sprintf( '
%s
', - trimws(content@text) + trim_outer_nl(content@text) ) } S7::method(contents_html, ContentError) <- function(content, ...) { sprintf( '
%s
', - trimws(content@text) + trim_outer_nl(content@text) ) } From 5c8dcb32083cf9db3d766d359ae28c321c72736f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 17:37:48 -0500 Subject: [PATCH 31/49] chore: clean up tooltips if the result card is removed this is important because the tool card is re-rendered frequently when streaming in a result --- inst/js/run-r/btw-run-r.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 644bf794..30a7bb52 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -100,6 +100,17 @@ class BtwRunRResult extends HTMLElement { this.dispatchEvent(new CustomEvent("shiny-chat-maybe-scroll-to-bottom")) } + disconnectedCallback() { + // Clean up tooltip when component is removed from DOM + const copyBtn = this.querySelector(".copy-code-btn") + if (copyBtn) { + const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn) + if (tooltip) { + tooltip.dispose() + } + } + } + /** * Toggle the collapsed/expanded state * @param {Event} e From f7694f64e7f4817a403f2127fd951e13fca915b0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 17:44:33 -0500 Subject: [PATCH 32/49] feat: copy code+result in reprex style --- inst/js/run-r/btw-run-r.js | 51 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 30a7bb52..babdab9f 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -121,6 +121,53 @@ class BtwRunRResult extends HTMLElement { this.render() } + /** + * Generate reprex-style output from the code and results + * @returns {string} Formatted reprex output + */ + generateReprexOutput() { + const outputContainer = this.querySelector(".btw-run-output") + if (!outputContainer) { + return this.getAttribute("code") || "" + } + + const parts = [] + const preElements = outputContainer.querySelectorAll("pre") + + preElements.forEach((pre) => { + // Skip if this is inside an image or other non-text content + if (pre.closest("img")) { + return + } + + // Get the text content + const code = pre.querySelector("code") + const text = code ? code.textContent : pre.textContent + + if (!text.trim()) { + return + } + + // Source code is added as-is + if (pre.classList.contains("btw-output-source")) { + parts.push(text.trimEnd()) + } + // Other outputs get #> prefix on each line + else if ( + pre.classList.contains("btw-output-output") || + pre.classList.contains("btw-output-message") || + pre.classList.contains("btw-output-warning") || + pre.classList.contains("btw-output-error") + ) { + const lines = text.trimEnd().split("\n") + const prefixed = lines.map((line) => "#> " + line).join("\n") + parts.push(prefixed) + } + }) + + return parts.join("\n") + } + /** * Copy code to clipboard * @param {Event} e @@ -132,11 +179,11 @@ class BtwRunRResult extends HTMLElement { // Save reference to button before async operation // (e.currentTarget becomes null after await) const copyBtn = e.currentTarget - const code = this.getAttribute("code") || "" try { const originalHtml = copyBtn.innerHTML - await navigator.clipboard.writeText(code) + const reprexOutput = this.generateReprexOutput() + await navigator.clipboard.writeText(reprexOutput) // Get the tooltip instance const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn) From 2dd7c1f2c52224861bcb9100ecd2d67590c6e899 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 17:56:35 -0500 Subject: [PATCH 33/49] chore: copy button icon --- inst/js/run-r/btw-run-r.css | 1 + inst/js/run-r/btw-run-r.js | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index b42cd536..e870927a 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -209,6 +209,7 @@ btw-run-r-result .card-body { /* Copy code button in header */ .copy-code-btn { + width: 14px; background: none; border: none; padding: 0; diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index babdab9f..aff8090f 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -23,10 +23,12 @@ const ICONS = { `, - copy: ` - - + copy: ` + `, + check: ` + +` } /** @@ -189,9 +191,7 @@ class BtwRunRResult extends HTMLElement { const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn) // Visual feedback - change icon briefly and update tooltip - copyBtn.innerHTML = ` - - ` + copyBtn.innerHTML = ICONS.check // Update tooltip to show success message if (tooltip) { From 551400afc9fa6bd563af200e60cd3d15bdec1721 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 18:06:06 -0500 Subject: [PATCH 34/49] refactor: pull out icons into a separate file these are wasted tokens if using coding assistants to edit the main js --- R/tool-run.R | 29 +++++++++++++++++------------ inst/js/run-r/btw-icons.js | 22 ++++++++++++++++++++++ inst/js/run-r/btw-run-r.js | 25 ++----------------------- 3 files changed, 41 insertions(+), 35 deletions(-) create mode 100644 inst/js/run-r/btw-icons.js diff --git a/R/tool-run.R b/R/tool-run.R index 717b06d3..babd38cb 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -467,16 +467,6 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { } } - dep <- htmltools::htmlDependency( - name = "btw-run-r", - version = utils::packageVersion("btw"), - package = "btw", - src = "js/run-r", - script = list(list(src = "btw-run-r.js", type = "module")), - stylesheet = "btw-run-r.css", - all_files = FALSE - ) - htmltools::tag( "btw-run-r-result", list( @@ -485,17 +475,32 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { status = status, `tool-title` = tool_title, htmltools::HTML(output_html), - dep + 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, + btw_run_tool_card_dep() ContentError ) 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.js b/inst/js/run-r/btw-run-r.js index aff8090f..7d520c72 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -3,34 +3,13 @@ * @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() -/** - * SVG icons used in the component - */ -const ICONS = { - code: ` - -`, - playCircle: ``, - exclamationCircleFill: ` - -`, - plus: ` - - -`, - copy: ` - -`, - check: ` - -` -} - /** * Formats code as a Markdown code block for rendering. * @param {string} content - The code content From cb1022cf6d7018cfbeb34740c390cc9f8814bd1e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 18:06:42 -0500 Subject: [PATCH 35/49] chore: Take suggestions about tool instructions --- R/tool-run.R | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/R/tool-run.R b/R/tool-run.R index babd38cb..e77f77ae 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -341,9 +341,14 @@ user with a preview of the code you would like to write before executing it. If an error occurs during execution, the tool will return all results up to the point of the error. Inspect the error message to understand what went wrong. -**Formatted output**: When creating formatted output, use a single `cat()` call -to emit the complete formatted text, rather than multiple `cat()` calls. This is -much easier for the user to read. +A few style guidelines to keep in mind when using this tool: + +* Return results implicitly, like `x`, rather than with `print(x)` or `cat(x)`. +* Return plots implicitly rather than assigning them to intermediate variables and then displaying the variable. +* Do not communicate with the user via the `code` argument to this tool, instead explaining choices you've made and interpretations of output in a message to them directly. +* Do not decorate output with custom displays, e.g. avoid using `cat()` and instead create data frames, tibbles or simple lists. +* If you *need* to use `cat()`, you MUST group all output into a SINGLE `cat()` call for better readability. +* Respect the user's environment. Do not set environment variables, change options, or modify global state without explicit instruction to do so. )---", annotations = ellmer::tool_annotations( title = "Run R Code", @@ -500,7 +505,7 @@ is_mergeable_content <- function(x, y) { ContentSource, ContentOutput, ContentMessage, - btw_run_tool_card_dep() + ContentWarning, ContentError ) From d3c20e30dccc2ce5b27da7c2c9366e63fcf57bbe Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 18:14:30 -0500 Subject: [PATCH 36/49] chore: hide code copy buttons in output --- inst/js/run-r/btw-run-r.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index e870927a..6c9f27f2 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -40,7 +40,7 @@ btw-run-r-result .card-body { background-color: var(--bs-black); } -.btw-run-output pre:not(.btw-output-source) .code-copy-button { +.btw-run-output .code-copy-button { /* TODO: Figure out how to disable markdown-stream code copy button */ display: none; } From 42ad141b548899dfe7d5cae8d0e319d6dfff0489 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 11 Dec 2025 18:18:55 -0500 Subject: [PATCH 37/49] chore: remove docs for internal function --- R/tool-run.R | 2 +- man/merge_adjacent_content.Rd | 20 -------------------- 2 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 man/merge_adjacent_content.Rd diff --git a/R/tool-run.R b/R/tool-run.R index e77f77ae..8b4b125f 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -526,7 +526,7 @@ is_mergeable_content <- function(x, y) { #' #' @param contents List of Content objects #' @returns List of Content objects with adjacent same-type elements merged -#' @keywords internal +#' @noRd merge_adjacent_content <- function(contents) { if (length(contents) <= 1) { return(contents) diff --git a/man/merge_adjacent_content.Rd b/man/merge_adjacent_content.Rd deleted file mode 100644 index 8ac4cd2b..00000000 --- a/man/merge_adjacent_content.Rd +++ /dev/null @@ -1,20 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tool-run.R -\name{merge_adjacent_content} -\alias{merge_adjacent_content} -\title{Merge adjacent content of the same type} -\usage{ -merge_adjacent_content(contents) -} -\arguments{ -\item{contents}{List of Content objects} -} -\value{ -List of Content objects with adjacent same-type elements merged -} -\description{ -Reduces a list of Content objects by concatenating adjacent elements -of the same mergeable type (ContentCode, ContentMessage, ContentWarning, -ContentError) into single elements. -} -\keyword{internal} From f8f6659eed38f6fffe818910e449c5ea36ee4866 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Dec 2025 10:03:39 -0500 Subject: [PATCH 38/49] tests(tool-run): Fix tests --- tests/testthat/test-tool-run.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 9545b141..485d4ec1 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -143,7 +143,8 @@ test_that("contents_html() renders Content types correctly", { warn_html <- ellmer::contents_html(warn) err_html <- ellmer::contents_html(err) - expect_match(code_html, "
")
+  expect_match(code_html, 'code class="nohighlight"')
+  expect_match(code_html, 'pre class="btw-output-output"')
   expect_match(msg_html, 'class="btw-output-message"')
   expect_match(warn_html, 'class="btw-output-warning"')
   expect_match(err_html, 'class="btw-output-error"')
@@ -196,7 +197,10 @@ text(1, 1, 'y')"
 })
 
 test_that("btw_tool_run_r() is not included in btw_tools() by default", {
-  local_mocked_bindings(is_installed = function(...) TRUE)
+  local_mocked_bindings(
+    is_installed = function(...) TRUE,
+    btw_can_register_gh_tool = function() FALSE
+  )
   withr::local_envvar(BTW_RUN_R_ENABLED = NULL)
   withr::local_options(btw.run_r.enabled = NULL)
 

From 48ff48a538b2640070c7425172961c58bfa481e2 Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Fri, 12 Dec 2025 11:31:25 -0500
Subject: [PATCH 39/49] chore(tool-run): Tweak tool description prompt

---
 R/tool-run.R | 50 ++++++++++++++++++++++++++++++--------------------
 1 file changed, 30 insertions(+), 20 deletions(-)

diff --git a/R/tool-run.R b/R/tool-run.R
index 8b4b125f..2bfee2a4 100644
--- a/R/tool-run.R
+++ b/R/tool-run.R
@@ -329,26 +329,36 @@ fansi_to_html <- function(text) {
       name = "btw_tool_run_r",
       description = r"---(Run R code.
 
-This tool executes R code and returns the results, including text output,
-plots, messages, warnings, and errors.
-
-With great power comes great responsibility: the R code you write should be
-safe and appropriate for execution in a shared environment. Do not write files
-or perform dangerous or irreversible actions. Always consider the security
-implications of the code that you write. If you have any doubts, consult the
-user with a preview of the code you would like to write before executing it.
-
-If an error occurs during execution, the tool will return all results up to
-the point of the error. Inspect the error message to understand what went wrong.
-
-A few style guidelines to keep in mind when using this tool:
-
-* Return results implicitly, like `x`, rather than with `print(x)` or `cat(x)`.
-* Return plots implicitly rather than assigning them to intermediate variables and then displaying the variable.
-* Do not communicate with the user via the `code` argument to this tool, instead explaining choices you've made and interpretations of output in a message to them directly.
-* Do not decorate output with custom displays, e.g. avoid using `cat()` and instead create data frames, tibbles or simple lists.
-* If you *need* to use `cat()`, you MUST group all output into a SINGLE `cat()` call for better readability.
-* Respect the user's environment. Do not set environment variables, change options, or modify global state without explicit instruction to do so.
+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.)
+- 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",

From bcdb00636f362f1e8ac43d4ff833f9496a3ca0bd Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Fri, 12 Dec 2025 16:34:34 -0500
Subject: [PATCH 40/49] feat: copy-to-clipboard in positron

---
 inst/js/run-r/btw-run-r.js | 53 +++++++++++++++++++++++++++++++++++++-
 1 file changed, 52 insertions(+), 1 deletion(-)

diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js
index 7d520c72..6c0dc5fe 100644
--- a/inst/js/run-r/btw-run-r.js
+++ b/inst/js/run-r/btw-run-r.js
@@ -164,7 +164,7 @@ class BtwRunRResult extends HTMLElement {
     try {
       const originalHtml = copyBtn.innerHTML
       const reprexOutput = this.generateReprexOutput()
-      await navigator.clipboard.writeText(reprexOutput)
+      await copyToClipboard(reprexOutput)
 
       // Get the tooltip instance
       const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn)
@@ -318,6 +318,57 @@ class BtwRunRResult extends HTMLElement {
   }
 }
 
+/**
+ * Copy text to clipboard with fallback for older browsers
+ * @param {string} text - The text to copy
+ * @returns {Promise}
+ */
+function copyToClipboard(text) {
+  if (window.isSecureContext && navigator.clipboard) {
+    return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
+  } else {
+    return fallbackCopy(text)
+  }
+}
+
+/**
+ * Fallback clipboard copy using document.execCommand
+ * @param {string} text - The text to copy
+ * @returns {Promise}
+ */
+function fallbackCopy(text) {
+  return new Promise((resolve, reject) => {
+    const textArea = document.createElement("textarea")
+    textArea.value = text
+    textArea.style.position = "fixed"
+    textArea.style.opacity = "0"
+    document.body.appendChild(textArea)
+    textArea.focus()
+    textArea.select()
+    try {
+      const successful = document.execCommand("copy")
+      document.body.removeChild(textArea)
+      if (successful) {
+        resolve()
+      } else {
+        throw new Error("execCommand copy failed")
+      }
+    } catch (err) {
+      document.body.removeChild(textArea)
+      window.dispatchEvent(
+        new CustomEvent("shiny:client-message", {
+          detail: {
+            headline: "Could not copy text",
+            message: "Unfortunately, this browser does not support copying to the clipboard automatically. Please copy the text manually.",
+            status: "warning"
+          },
+        }),
+      )
+      reject(err)
+    }
+  })
+}
+
 if (!customElements.get("btw-run-r-result")) {
   customElements.define("btw-run-r-result", BtwRunRResult)
 }

From 9de80eafae90b71521db645def1b829fd798f352 Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Fri, 12 Dec 2025 16:34:57 -0500
Subject: [PATCH 41/49] chore: tweak css

---
 inst/js/run-r/btw-run-r.css | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css
index 6c9f27f2..ba40c1fb 100644
--- a/inst/js/run-r/btw-run-r.css
+++ b/inst/js/run-r/btw-run-r.css
@@ -52,6 +52,14 @@ btw-run-r-result .card-body {
   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;
 }

From 3aa8dab9967e57603463be37059a22a9a2adfd8d Mon Sep 17 00:00:00 2001
From: Garrick Aden-Buie 
Date: Fri, 12 Dec 2025 17:05:14 -0500
Subject: [PATCH 42/49] feat(tool-run): Add option to set plot dimensions/size

---
 R/tool-run.R                   | 144 ++++++++++++++++++++++++++++-----
 man/btw_tool_run_r.Rd          |  34 ++++++++
 tests/testthat/test-tool-run.R |  33 ++++++++
 3 files changed, 190 insertions(+), 21 deletions(-)

diff --git a/R/tool-run.R b/R/tool-run.R
index 2bfee2a4..9026000c 100644
--- a/R/tool-run.R
+++ b/R/tool-run.R
@@ -57,6 +57,40 @@
 #' ---
 #' ```
 #'
+#' @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).
 #'
@@ -101,8 +135,17 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) {
       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 = 768, height = 768)
+    run_r_plot_device(
+      filename = path_plot,
+      width = dims$width,
+      height = dims$height
+    )
     tryCatch(
       grDevices::replayPlot(last_plot),
       finally = {
@@ -238,7 +281,7 @@ run_r_plot_device <- function(...) {
   }
 
   if (rlang::is_installed("ragg")) {
-    return(ragg::agg_png(...))
+    return(ragg::agg_png(..., scaling = 1.5))
   }
 
   grDevices::png(...)
@@ -415,39 +458,38 @@ trim_outer_nl <- function(x) {
   sub("\r?\n$", "", x)
 }
 
-S7::method(contents_html, ContentSource) <- function(content, ...) {
+btw_pre_output <- function(text, pre_class, code_class = "nohighlight") {
+  text <- trim_outer_nl(text)
+  if (!nzchar(text)) {
+    return("")
+  }
+
   sprintf(
-    '
%s
', - trim_outer_nl(content@text) + '
%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, ...) { - sprintf( - '
%s
', - trim_outer_nl(content@text) - ) + btw_pre_output(content@text, pre_class = "output") } S7::method(contents_html, ContentMessage) <- function(content, ...) { - sprintf( - '
%s
', - trim_outer_nl(content@text) - ) + btw_pre_output(content@text, pre_class = "message") } S7::method(contents_html, ContentWarning) <- function(content, ...) { - sprintf( - '
%s
', - trim_outer_nl(content@text) - ) + btw_pre_output(content@text, pre_class = "warning") } S7::method(contents_html, ContentError) <- function(content, ...) { - sprintf( - '
%s
', - trim_outer_nl(content@text) - ) + btw_pre_output(content@text, pre_class = "error") } contents_shinychat <- S7::new_external_generic( @@ -563,3 +605,63 @@ merge_adjacent_content <- function(contents) { .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/man/btw_tool_run_r.Rd b/man/btw_tool_run_r.Rd index adcba3d1..96a4c65e 100644 --- a/man/btw_tool_run_r.Rd +++ b/man/btw_tool_run_r.Rd @@ -27,6 +27,40 @@ This tool runs R code and returns results as a list of \code{\link[ellmer:Conten objects. It captures text output, plots, messages, warnings, and errors. Code execution stops on the first error, returning all results up to that point. } +\details{ +\subsection{Configuration Options}{ + +The behavior of the \code{btw_tool_run_r} tool can be customized using the +following R options: +\itemize{ +\item \code{btw.run_r.graphics_device}: A function that creates a graphics device used +for rendering plots. By default, it uses \code{ragg::agg_png()} if the \code{ragg} +package is installed, otherwise it falls back to \code{grDevices::png()}. +\item \code{btw.run_r.plot_aspect_ratio}: Aspect ratio for plots created during code +execution. Can be a character string of the form \code{"w:h"} (e.g., \code{"16:9"}) +or a numeric value representing width/height (e.g., \code{16/9}). Default is +\code{"3:2"}. +\item \code{btw.run_r.plot_size}: Integer pixel size for the longest side of plots. +Default is \code{768L}. This image size was selected to match \href{https://platform.openai.com/docs/guides/images-vision?api-mode=responses}{OpenAI's image resizing rules}, +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. +\item \code{btw.run_r.enabled}: Logical flag to enable or disable the tool globally. +} + +These values can be set using \code{\link[=options]{options()}} in your R session or \code{.Rprofile} or +in a \link[=use_btw_md]{btw.md file} under the \code{options} section. + +\if{html}{\out{
}}\preformatted{--- +options: + run_r: + enabled: true + plot_aspect_ratio: "16:9" + plot_size: 512 +--- +}\if{html}{\out{
}} +} +} \section{Security Considerations}{ Executing arbitrary R code can pose significant security risks, especially diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 485d4ec1..e96c2c1c 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -331,3 +331,36 @@ describe("btw_tool_run_r() in btw_tools()", { expect_true("btw_tool_run_r" %in% names(tools)) }) }) + + +test_that("parse_ratio correctly parses 'w:h' strings", { + expect_equal(parse_ratio("16:9"), 16 / 9) + expect_equal(parse_ratio("5:9"), 5 / 9) +}) + +test_that("parse_ratio accepts numeric ratios", { + expect_equal(parse_ratio(16 / 9), 16 / 9) +}) + +test_that("btw_run_r_plot_dimensions computes correct dimensions for landscape ratio", { + dims <- btw_run_r_plot_dimensions("16:9") + exp_width <- 768L + exp_height <- as.integer(round(768 / (16 / 9))) + + expect_equal(dims$width, !!exp_width) + expect_equal(dims$height, !!exp_height) + expect_equal(max(unlist(dims)), 768L) +}) + +test_that("btw_run_r_plot_dimensions computes correct dimensions for portrait ratio", { + dims <- btw_run_r_plot_dimensions("5:9") + expect_equal(dims$height, 768L) + expect_equal(dims$width, as.integer(round(768 * (5 / 9)))) + expect_equal(max(unlist(dims)), 768L) +}) + +test_that("btw_run_r_plot_dimensions works with numeric ratio input", { + dims <- btw_run_r_plot_dimensions(16 / 9) + expect_equal(dims$width, 768L) + expect_equal(dims$height, as.integer(round(768 / (16 / 9)))) +}) From b606290513cf41b97708a1284b4a1b177f78c1e6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Dec 2025 17:24:24 -0500 Subject: [PATCH 43/49] feat(tool-run): Prevent running R code from changing working dir, options or envvars --- R/tool-run.R | 5 +++++ tests/testthat/test-tool-run.R | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/R/tool-run.R b/R/tool-run.R index 9026000c..685a53e4 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -159,6 +159,11 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { 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) { diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index e96c2c1c..aa7315f5 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -364,3 +364,42 @@ test_that("btw_run_r_plot_dimensions works with numeric ratio input", { expect_equal(dims$width, 768L) expect_equal(dims$height, as.integer(round(768 / (16 / 9)))) }) + +test_that("btw_tool_run_r() restores working directory, options, and envvars", { + skip_if_not_installed("evaluate") + + # Save original state + orig_wd <- withr::local_tempdir() + orig_opt <- "original_option" + orig_env <- "original_env" + + withr::local_dir(orig_wd) + withr::local_options(".test_option" = orig_opt) + withr::local_envvar("_TEST_ENV_VAR" = orig_env) + + # Set test values + options(test_option = "original_value") + Sys.setenv(TEST_ENV_VAR = "original_env") + + # Create a temporary directory for testing + temp_dir <- withr::local_tempdir() + + # Code that modifies working directory, options, and envvars + code <- sprintf( + ' + setwd("%s") + options(.test_option = "modified_value") + Sys.setenv("_TEST_ENV_VAR" = "modified_env") + getwd() + ', + temp_dir + ) + + res <- btw_tool_run_r_impl(code) + expect_s7_class(res, BtwRunToolResult) + + # Verify the state was restored + expect_equal(fs::path_real(getwd()), fs::path_real(orig_wd)) + expect_equal(getOption(".test_option"), orig_opt) + expect_equal(Sys.getenv("_TEST_ENV_VAR"), orig_env) +}) From ed07a84bf1dab04e0971b77367a4b1ed4ce797f5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 12 Dec 2025 17:25:42 -0500 Subject: [PATCH 44/49] chore(tool-run): Let model know that wd, opts, envvars are restored --- R/tool-run.R | 1 + 1 file changed, 1 insertion(+) diff --git a/R/tool-run.R b/R/tool-run.R index 685a53e4..01ed9fc6 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -395,6 +395,7 @@ Executes R code and captures printed values, text output, plots, messages, warni - 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 From f9bc896f9f2c9949d79851cf98023954d8d49293 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 15 Dec 2025 07:59:56 -0500 Subject: [PATCH 45/49] ci: why isn't duckdb installing via binaries? --- .github/workflows/R-CMD-check.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 39824e5e..1638b64c 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -54,6 +54,11 @@ jobs: http-user-agent: ${{ matrix.config.http-user-agent }} use-public-rspm: true + - name: Install duckdb on ubuntu-latest + if: matrix.config.os == 'ubuntu-latest' + shell: Rscript {0} + run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", type = "binary") + - uses: r-lib/actions/setup-r-dependencies@v2 with: extra-packages: any::rcmdcheck From 46acc256362b5dc0d1e5ba93ed3a89c6d6577488 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 15 Dec 2025 08:01:29 -0500 Subject: [PATCH 46/49] ci: try again --- .github/workflows/R-CMD-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 1638b64c..84edd86f 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -57,7 +57,7 @@ jobs: - name: Install duckdb on ubuntu-latest if: matrix.config.os == 'ubuntu-latest' shell: Rscript {0} - run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", type = "binary") + run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest") - uses: r-lib/actions/setup-r-dependencies@v2 with: From 45f6388955781787cf4ac08012b4d7efbdced57f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 15 Dec 2025 08:15:24 -0500 Subject: [PATCH 47/49] ci: install into R_LIBS_SITE --- .github/workflows/R-CMD-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 84edd86f..13529f43 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -57,7 +57,7 @@ jobs: - name: Install duckdb on ubuntu-latest if: matrix.config.os == 'ubuntu-latest' shell: Rscript {0} - run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest") + run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", lib = Sys.getenv("R_LIBS_SITE")) - uses: r-lib/actions/setup-r-dependencies@v2 with: From 7ae80249e2403c2c91bb052d54f141a7490b587d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 15 Dec 2025 08:18:33 -0500 Subject: [PATCH 48/49] ci: try again --- .github/workflows/R-CMD-check.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 13529f43..3a2f4710 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -57,7 +57,9 @@ jobs: - name: Install duckdb on ubuntu-latest if: matrix.config.os == 'ubuntu-latest' shell: Rscript {0} - run: install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", lib = Sys.getenv("R_LIBS_SITE")) + run: | + dir.create(Sys.getenv("R_LIBS_SITE"), recursive = TRUE, showWarnings = FALSE) + install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", lib = Sys.getenv("R_LIBS_SITE")) - uses: r-lib/actions/setup-r-dependencies@v2 with: From 5b63e7a9d641ae3625cacd551068e4f688778b32 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 15 Dec 2025 08:48:19 -0500 Subject: [PATCH 49/49] ci: just let it take longer --- .github/workflows/R-CMD-check.yaml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 3a2f4710..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 @@ -54,13 +54,6 @@ jobs: http-user-agent: ${{ matrix.config.http-user-agent }} use-public-rspm: true - - name: Install duckdb on ubuntu-latest - if: matrix.config.os == 'ubuntu-latest' - shell: Rscript {0} - run: | - dir.create(Sys.getenv("R_LIBS_SITE"), recursive = TRUE, showWarnings = FALSE) - install.packages("duckdb", repos = "https://packagemanager.posit.co/cran/__linux__/noble/latest", lib = Sys.getenv("R_LIBS_SITE")) - - uses: r-lib/actions/setup-r-dependencies@v2 with: extra-packages: any::rcmdcheck