diff --git a/NEWS.md b/NEWS.md index ea6c6712..b36e7a3f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## New features +* `btw_app()` now keeps tool selection synchronized when switching models and prevents model, prompt, chat, or tool changes while a response is streaming. Long-form tool results can be expanded fullscreen, and file actions now appear in tool result footers. Fullscreen R results include keyboard focus trapping and focus restoration (#208). + * Added `btw pkg desc ` to show useful DESCRIPTION metadata for installed packages. By default, the command includes package identity, authorship, licensing, URLs, dependencies, and system requirements; use `--fields` to select other fields or `--fields=all` for the complete DESCRIPTION. Multiple terminal results are separated by `---`, and `--json` returns an object keyed by package name (#209). * Added a `btw pkg src` CLI command family for inspecting R namespace implementations in installed packages and the current development package. Use `list` to discover objects, `get` to retrieve exact source when available or deparsed functions otherwise, `methods` to inspect package-owned S3 and S4 implementations, `path` to locate package installations, and `search` to search real or reconstructed R source. All commands support JSON output, and `methods --source` returns method implementations directly (#207). diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 99e77d06..af921261 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -147,31 +147,36 @@ btw_app_from_client <- function( height = "100%", style = bslib::css(max_height = "100%"), open = "closed", - shiny::div( - class = "btn-group", - shiny::actionButton( - "select_all", - "Select All", - icon = shiny::icon("check-square"), - class = "btn-sm" + shiny::tags$fieldset( + id = "tools_controls", + class = "btw-tools-controls", + shiny::div( + class = "btn-group", + shiny::actionButton( + "select_all", + "Select All", + icon = shiny::icon("check-square"), + class = "btn-sm" + ), + shiny::actionButton( + "deselect_all", + "Select none", + icon = shiny::icon("square"), + class = "btn-sm" + ) ), - shiny::actionButton( - "deselect_all", - "Select none", - icon = shiny::icon("square"), - class = "btn-sm" + shiny::div( + class = "overflow-y-auto overflow-x-visible", + app_tool_group_inputs( + btw_tools_df(all_available_tools), + # names(list()) is NULL, but NULL means "select all" in + # app_tool_group_choice_input; use character(0) for "select none". + initial_tool_names = names(original_client_tools) %||% + character(0) + ), + shiny::uiOutput("ui_other_tools") ) ), - shiny::div( - class = "overflow-y-auto overflow-x-visible", - app_tool_group_inputs( - btw_tools_df(all_available_tools), - # names(list()) is NULL, but NULL means "select all" in - # app_tool_group_choice_input; use character(0) for "select none". - initial_tool_names = names(original_client_tools) %||% character(0) - ), - shiny::uiOutput("ui_other_tools") - ), bslib::input_dark_mode(style = "display: none") ), shiny::actionButton( @@ -194,14 +199,12 @@ btw_app_from_client <- function( messages = messages, greeting = btw_app_greeting(path_logo), width = "min(750px, 100%)", - footer = if (utils::packageVersion("shinychat") >= "0.4.0") { - btw_status_bar_ui( - "status_bar", - client = client, - models = app_models, - selected = selected_client - ) - } + footer = btw_status_bar_ui( + "status_bar", + client = client, + models = app_models, + selected = selected_client + ) ), btw_app_html_dep(), ) @@ -210,13 +213,21 @@ btw_app_from_client <- function( server <- function(input, output, session) { chat <- shinychat::chat_mod_server("chat", client = client) - if (utils::packageVersion("shinychat") >= "0.4.0") { - res <- btw_status_bar_server("status_bar", chat, app_models) + res <- btw_status_bar_server("status_bar", chat, app_models) - shiny::observeEvent(res$clear_chat(), { + shiny::observeEvent(res$clear_chat(), { + if (identical(chat$status(), "idle")) { chat$clear(client_history = "clear") - }) - } + } + }) + + shiny::observe({ + app_set_disabled( + session, + "tools_controls", + identical(chat$status(), "streaming") + ) + }) shiny::observeEvent(input$show_sidebar, { bslib::toggle_sidebar("tools_sidebar") @@ -261,6 +272,10 @@ btw_app_from_client <- function( }) shiny::observeEvent(input$select_all, { + if (identical(chat$status(), "streaming")) { + return() + } + tools <- btw_tools_df(all_available_tools) for (group in tool_groups) { shiny::updateCheckboxGroupInput( @@ -272,6 +287,10 @@ btw_app_from_client <- function( }) shiny::observeEvent(input$deselect_all, { + if (identical(chat$status(), "streaming")) { + return() + } + tools <- btw_tools_df(all_available_tools) for (group in tool_groups) { shiny::updateCheckboxGroupInput( @@ -287,31 +306,25 @@ btw_app_from_client <- function( current <- input[[paste0("tools_", group)]] all_tools <- btw_tools_df(all_available_tools) group_tools <- all_tools[all_tools$group == group, ][["name"]] - if (length(current) == length(group_tools)) { - # All selected, so deselect all - shiny::updateCheckboxGroupInput( - session = session, - inputId = paste0("tools_", group), - selected = "" - ) - } else { - # Not all selected, so select all - shiny::updateCheckboxGroupInput( - session = session, - inputId = paste0("tools_", group), - selected = group_tools - ) + selected <- app_toggle_tool_group(current, group_tools, chat$status()) + if (is.null(selected)) { + return() } + + shiny::updateCheckboxGroupInput( + session = session, + inputId = paste0("tools_", group), + selected = selected + ) }) }) shiny::observe({ - if (!length(selected_tools())) { - client$set_tools(list()) - } else { - sel_tools <- all_available_tools[selected_tools()] - client$set_tools(sel_tools) + if (identical(chat$status(), "streaming")) { + return() } + + app_set_client_tools(chat, selected_tools(), all_available_tools) }) skills_read_file_mismatch <- shiny::reactive({ @@ -520,6 +533,29 @@ btw_app_greeting <- function(path_logo) { ) } +app_set_disabled <- function(session, id, disabled) { + session$sendCustomMessage( + "btw_set_disabled", + list( + ids = as.list(unname(vapply(id, session$ns, character(1)))), + disabled = isTRUE(disabled) + ) + ) +} + +app_set_client_tools <- function(chat, selected, available) { + tools <- if (length(selected)) available[selected] else list() + chat$client$set_tools(tools) +} + +app_toggle_tool_group <- function(current, group_tools, status) { + if (identical(status, "streaming")) { + return(NULL) + } + + if (length(current) == length(group_tools)) character() else group_tools +} + # Status Bar ---- notifier <- function(icon, action, error = NULL, ...) { @@ -692,6 +728,10 @@ btw_status_bar_server <- function(id, chat, models = "provider") { }) shiny::observeEvent(input$model, ignoreInit = TRUE, { + if (identical(chat$status(), "streaming")) { + return() + } + tryCatch( { old_provider <- chat$client$get_provider()@name @@ -713,9 +753,9 @@ btw_status_bar_server <- function(id, chat, models = "provider") { } chat$set_client(new_client, sync = FALSE) - new_provider <- chat$client$get_provider()@name + new_provider <- new_client$get_provider()@name provider_name(new_provider) - model_name(chat$client$get_model()) + model_name(new_client$get_model()) notifier( shiny::icon("check"), @@ -738,6 +778,14 @@ btw_status_bar_server <- function(id, chat, models = "provider") { ) }) + shiny::observe({ + app_set_disabled( + session, + c("model", "show_sys_prompt", "clear_chat"), + identical(chat$status(), "streaming") + ) + }) + chat_tokens <- shiny::reactiveVal( chat_get_tokens(chat$client), label = "btw_app_tokens" diff --git a/R/tool-agent-custom.R b/R/tool-agent-custom.R index 75121966..501ca7e5 100644 --- a/R/tool-agent-custom.R +++ b/R/tool-agent-custom.R @@ -282,7 +282,11 @@ btw_tool_agent_custom_impl <- function( provider = result$provider, model = result$model, tokens = result$tokens, - display = list(markdown = display_md, show_request = FALSE) + display = list( + markdown = display_md, + show_request = FALSE, + full_screen = TRUE + ) ) ) } diff --git a/R/tool-agent-subagent.R b/R/tool-agent-subagent.R index d410f158..1d853afc 100644 --- a/R/tool-agent-subagent.R +++ b/R/tool-agent-subagent.R @@ -359,7 +359,11 @@ btw_tool_agent_subagent_impl <- function( provider = result$provider, model = result$model, tokens = result$tokens, - display = list(markdown = display_md, show_request = FALSE) + display = list( + markdown = display_md, + show_request = FALSE, + full_screen = TRUE + ) ) ) } diff --git a/R/tool-docs-news.R b/R/tool-docs-news.R index 2d12e0bd..8eb8817b 100644 --- a/R/tool-docs-news.R +++ b/R/tool-docs-news.R @@ -61,7 +61,7 @@ btw_tool_docs_package_news_impl <- function(package_name, search_term = "") { BtwPackageNewsToolResult( result, - extra = list(display = list(markdown = result)) + extra = list(display = list(markdown = result, full_screen = TRUE)) ) } diff --git a/R/tool-docs.R b/R/tool-docs.R index 8f3a8a69..96cb267b 100644 --- a/R/tool-docs.R +++ b/R/tool-docs.R @@ -196,7 +196,8 @@ btw_tool_docs_help_page_impl <- function(topic, package_name = "") { package = resolved$package, display = list( title = HTML(sprintf('?%s', help_call)), - markdown = paste(md, collapse = "\n") + markdown = paste(md, collapse = "\n"), + full_screen = TRUE ) ) ) @@ -444,7 +445,8 @@ btw_tool_docs_available_vignettes_impl <- function(package_name) { data = df, display = list( title = sprintf("{%s} Vignettes", package_name), - markdown = md_table(df) + markdown = md_table(df), + full_screen = TRUE ) ) } @@ -511,7 +513,8 @@ btw_tool_docs_vignette_impl <- function( data = vignette_info, display = list( title = sprintf("{%s} Vignette: %s", package_name, vignette_info$Title), - markdown = paste(md_vignette, collapse = "\n") + markdown = paste(md_vignette, collapse = "\n"), + full_screen = TRUE ) ) } diff --git a/R/tool-env-df.R b/R/tool-env-df.R index e2493eee..8810962a 100644 --- a/R/tool-env-df.R +++ b/R/tool-env-df.R @@ -136,7 +136,8 @@ btw_tool_env_describe_data_frame_impl <- function( data = data_frame, display = list( title = "View Data Frame", - markdown = data_frame_md + markdown = data_frame_md, + full_screen = TRUE ) ) ) @@ -160,7 +161,8 @@ btw_tool_env_describe_data_frame_impl <- function( value = res, data = data_frame, display = list( - title = "View Data Frame" + title = "View Data Frame", + full_screen = TRUE ) ) } diff --git a/R/tool-files-edit.R b/R/tool-files-edit.R index 0ce2f1a5..baccde15 100644 --- a/R/tool-files-edit.R +++ b/R/tool-files-edit.R @@ -182,7 +182,9 @@ btw_tool_files_edit_impl <- function(path, edits) { previous_content = previous_content, display = list( markdown = md_code_block(fs::path_ext(path), new_content), - title = HTML(title_with_open_file_button("Edit", path)), + title = file_result_title("Edit", path), + footer = file_result_footer(path), + full_screen = TRUE, show_request = FALSE, icon = tool_icon("file-save") ) diff --git a/R/tool-files-patch.R b/R/tool-files-patch.R index 2192e5bd..dd1af237 100644 --- a/R/tool-files-patch.R +++ b/R/tool-files-patch.R @@ -100,7 +100,8 @@ btw_tool_files_patch_impl <- function(patch) { extra = list( display = list( markdown = paste(display_md, collapse = "\n"), - show_request = FALSE + show_request = FALSE, + full_screen = TRUE ) ) ) @@ -142,6 +143,7 @@ btw_tool_files_patch_impl <- function(patch) { display = list( markdown = paste(display_md, collapse = "\n"), show_request = FALSE, + full_screen = TRUE, icon = tool_icon("file-save") ) ) diff --git a/R/tool-files-read.R b/R/tool-files-read.R index 51c0fc7d..595f165c 100644 --- a/R/tool-files-read.R +++ b/R/tool-files-read.R @@ -103,7 +103,9 @@ btw_tool_files_read_impl <- function( path = fs::path_rel(path), display = list( markdown = display_md, - title = HTML(title_with_open_file_button("Read", path)) + title = file_result_title("Read", path), + footer = file_result_footer(path), + full_screen = TRUE ) ) ) @@ -187,30 +189,34 @@ format_hashlines <- function(lines, start_line = 1L) { paste0(line_nums, ":", hashes, "|", lines) } -title_with_open_file_button <- function(verb, path) { +file_result_title <- function(verb, path) { path_file <- fs::path_file(path) + HTML(sprintf( + "%s %s", + htmltools::htmlEscape(verb), + htmltools::htmlEscape(path_file) + )) +} - icon <- tool_icon("codicons/go-to-file") - - if (rstudioapi::hasFun("navigateToFile")) { - res <- glue_( - r"( - {{verb}} - {{path_file}} - - - - - )" - ) - } else { - res <- glue_('{{verb}} {{path_file}}') +file_result_footer <- function(path) { + if (!rstudioapi::hasFun("navigateToFile")) { + return(NULL) } - HTML(res) + + path_file <- fs::path_file(path) + shiny::tags$a( + href = "#", + class = "btw-open-file action-button action-link", + `data-path` = path, + `aria-label` = sprintf("Open %s in the IDE", path_file), + shiny::span(class = "action-icon", tool_icon("codicons/go-to-file")), + shiny::span( + class = "action-label", + "Open ", + shiny::tags$code(path_file), + " in the IDE" + ) + ) } is_text_file <- function(file_path) { diff --git a/R/tool-files-replace.R b/R/tool-files-replace.R index 27f99751..62f9cc30 100644 --- a/R/tool-files-replace.R +++ b/R/tool-files-replace.R @@ -110,7 +110,9 @@ btw_tool_files_replace_impl <- function( previous_content = previous_content, display = list( markdown = md_code_block(fs::path_ext(path), new_content), - title = HTML(title_with_open_file_button("Replace", path)), + title = file_result_title("Replace", path), + footer = file_result_footer(path), + full_screen = TRUE, show_request = FALSE, icon = tool_icon("file-save") ) diff --git a/R/tool-files-search.R b/R/tool-files-search.R index 37893138..c8d21142 100644 --- a/R/tool-files-search.R +++ b/R/tool-files-search.R @@ -158,7 +158,8 @@ btw_tool_files_search_factory <- function( if (nrow(res) > max_display) { paste0("\n\n... and ", nrow(res) - max_display, " more matches.") } - ) + ), + full_screen = TRUE ) ) ) diff --git a/R/tool-files-write.R b/R/tool-files-write.R index adde8a75..f76ff03c 100644 --- a/R/tool-files-write.R +++ b/R/tool-files-write.R @@ -50,7 +50,9 @@ btw_tool_files_write_impl <- function(path, content) { previous_content = previous_content, display = list( markdown = md_code_block(fs::path_ext(path), content), - title = HTML(title_with_open_file_button("Write", path)), + title = file_result_title("Write", path), + footer = file_result_footer(path), + full_screen = TRUE, show_request = FALSE, icon = tool_icon("file-save") ) @@ -135,5 +137,6 @@ S7::method(contents_shinychat, BtwFileDiffToolResult) <- function(content) { res$value <- diffviewer::visual_diff(path_old, path_new) res$value_type <- "html" res$class <- "btw-tool-result-file-diff" + res$full_screen <- NA res } diff --git a/R/tool-git.R b/R/tool-git.R index 23f0939e..94a61506 100644 --- a/R/tool-git.R +++ b/R/tool-git.R @@ -178,7 +178,8 @@ btw_tool_git_diff_impl <- function(ref = NULL) { title = HTML(sprintf( "Git Diff%s", if (!is.null(ref)) sprintf(" (%s)", ref) else "" - )) + )), + full_screen = TRUE ) ) } @@ -303,7 +304,8 @@ btw_tool_git_log_impl <- function( md_kv_table(log_display[fields]), data = log, display = list( - markdown = md_table(log_display[rev(fields)]) + markdown = md_table(log_display[rev(fields)]), + full_screen = TRUE ) ) } diff --git a/R/tool-run.R b/R/tool-run.R index 73257d8c..9feacbd6 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -271,7 +271,11 @@ btw_tool_run_r_impl <- function( # 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", - display = list(open = !had_error, copy_code = TRUE) + display = list( + open = !had_error, + copy_code = TRUE, + full_screen = TRUE + ) ) ) } @@ -519,7 +523,27 @@ S7::method(contents_html, ContentError) <- function(content, ...) { } S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { - code <- content@extra$code + display <- content@extra$display %||% list() + + # Results created outside a chat do not have a tool request, but shinychat's + # built-in card requires one. Supply a minimal request for direct rendering. + if (is.null(content@request)) { + content@request <- ellmer::ContentToolRequest( + id = "btw_tool_run_r", + name = "btw_tool_run_r", + arguments = list(`_intent` = ""), + tool = NULL, + extra = list() + ) + content@extra$display <- utils::modifyList( + display, + list(title = display$title %||% "Run R Code") + ) + } + + res <- shinychat::contents_shinychat( + S7::super(content, ellmer::ContentToolResult) + ) # Render all content objects to HTML contents <- content@extra$contents @@ -530,50 +554,42 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { 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 - - display <- content@extra$display %||% list() - annotations <- list() - intent <- "" - - if (!is.null(content@request)) { - request_id <- content@request@id - - tool_title <- NULL - tool <- content@request@tool - annotations <- tool@annotations - if (!is.null(content@request@arguments$`_intent`)) { - intent <- content@request@arguments$`_intent` - } + res$status <- content@extra$status + res$value <- htmltools::attachDependencies( + htmltools::tagList( + htmltools::div( + class = "btw-run-output", + htmltools::HTML(output_html) + ) + ), + btw_run_r_dep() + ) + res$value_type <- "html" + + if (isTRUE(display$copy_code)) { + copy_link <- shiny::tags$a( + href = "#", + class = "btw-copy-reprex action-button action-link", + `aria-label` = "Copy as reprex", + shiny::span(class = "action-icon", shiny::icon("clipboard")), + shiny::span(class = "action-label", "Copy as reprex") + ) + res$footer <- htmltools::tagList( + display$footer, + copy_link + ) } - htmltools::tag( - "btw-run-r-result", - list( - `request-id` = request_id, - code = code, - status = status, - intent = intent, - `tool-title` = display$title %||% annotations$title %||% "Run R Code", - icon = display$icon %||% annotations$icon, - expanded = if (isTRUE(display$open)) NA, - `copy-code` = if (isTRUE(display$copy_code)) NA, - htmltools::HTML(output_html), - btw_run_tool_card_dep() - ) - ) + res } -btw_run_tool_card_dep <- function() { +btw_run_r_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", diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index fb0d60cb..ea4c4171 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -23,6 +23,14 @@ aside#tools_sidebar { box-shadow: 2px 2px 5px rgba(var(--bs-emphasis-color-rgb), 10%); } +.btw-tools-controls { + display: contents; +} + +.btw-tools-controls[disabled] a { + pointer-events: none; +} + shiny-chat-message .message-icon { background-color: var(--bs-white); box-shadow: 2px 2px 5px rgba(var(--bs-emphasis-color-rgb), 10%); @@ -85,31 +93,13 @@ shiny-chat-message .message-icon { padding: 0; } -.collapse-toggle-btn[aria-expanded=true] > .collapse-indicator { +.collapse-toggle-btn[aria-expanded="true"] > .collapse-indicator { transform: rotate(-90deg); } -.collapse-toggle-btn[aria-expanded=true] > .collapse-indicator .horizontal { +.collapse-toggle-btn[aria-expanded="true"] > .collapse-indicator .horizontal { transform: scale(0); } -.btw-in-ide .btw-open-file.btn { - --bs-btn-hover-color: var(--bs-emphasis-color); - --_icon-size: var(--shiny-tool-card-icon-size, 16px); - --_padding: 3px; - --_display: inline-flex; - width: calc(var(--_icon-size) + var(--_padding)); - height: calc(var(--_icon-size) + var(--_padding)); - align-items: center; - flex: none; - padding: var(--_padding); -} - -.btw-in-ide .btw-open-file.btn svg { - height: 100%; - margin: 0; - padding: 0; -} - pre:has(.code-action-wrapper) { position: relative; } @@ -149,8 +139,7 @@ pre:has(.code-action-wrapper) { cursor: pointer; } -.code-action-wrapper .code-copy-button, -.code-action-wrapper .btw-block-copy-btn { +.code-action-wrapper .code-copy-button { position: static; top: auto; right: auto; @@ -170,16 +159,10 @@ pre:has(.code-action-wrapper) { } /* @vscode/codicons: copy, insert-at-cursor, new-file, terminal (MIT) */ -.code-copy-button > .bi::after, -.btw-block-copy-btn > .bi::after { +.code-copy-button > .bi::after { mask-image: url('data:image/svg+xml,'); } -.btw-block-copy-btn-checked > .bi::after { - mask-image: url('data:image/svg+xml,'); - background-color: var(--bs-success, #198754); -} - .insert-at-cursor-button > .bi::after { mask-image: url('data:image/svg+xml,'); } diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index 60878208..54f044a6 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -94,6 +94,46 @@ if (typeof Shiny !== "undefined") { } }) }) + + Shiny.addCustomMessageHandler("btw_set_disabled", function (message) { + message.ids.forEach((id) => { + const element = document.getElementById(id) + if (!element) return + + const control = element.matches(".bslib-toolbar-input-select") + ? element.querySelector("select") + : element + if (!control) return + + control.toggleAttribute("disabled", message.disabled) + if (message.disabled) { + control.setAttribute("aria-disabled", "true") + } else { + control.removeAttribute("aria-disabled") + } + + if (control instanceof HTMLFieldSetElement) { + control.querySelectorAll("a").forEach((link) => { + link.classList.toggle("disabled", message.disabled) + + if (message.disabled) { + link.dataset.btwTabindex = link.getAttribute("tabindex") || "" + link.setAttribute("tabindex", "-1") + link.setAttribute("aria-disabled", "true") + } else { + const tabindex = link.dataset.btwTabindex + if (tabindex) { + link.setAttribute("tabindex", tabindex) + } else { + link.removeAttribute("tabindex") + } + link.removeAttribute("aria-disabled") + delete link.dataset.btwTabindex + } + }) + } + }) + }) } // Open File Buttons ---------------------------------------------------------- @@ -125,18 +165,6 @@ if (inIframe && inIDE) { enhanceCodeActions(streamEl) }) - // New React shinychat: btw-run-r-result dispatches this event after rendering - // its per-block copy buttons. Add IDE action buttons to source code blocks only. - document.addEventListener("btw-run-r-rendered", (e) => { - enhanceBtwCodeActions(e.target) - }) - - // Backfill any btw-run-r-result elements that rendered before this listener - // was installed (e.g. initial page load when btw-run-r executes first). - document.querySelectorAll("btw-run-r-result").forEach((result) => { - enhanceBtwCodeActions(result) - }) - function isMarkdownStream(el) { return el.matches(STREAM_SELECTOR) } @@ -187,9 +215,7 @@ if (inIframe && inIDE) { attachObserver(node) } - node - .querySelectorAll?.(STREAM_SELECTOR) - .forEach(attachObserver) + node.querySelectorAll?.(STREAM_SELECTOR).forEach(attachObserver) }) }) }) @@ -217,21 +243,6 @@ if (inIframe && inIDE) { }) } - function enhanceBtwCodeActions(result) { - result.querySelectorAll(".btw-block-copy-btn").forEach((copyButton) => { - const pre = copyButton.closest("pre") - if (!pre) return - - const wrapper = ensureWrapper(pre) - - // Install IDE action buttons first, then copy button so it appears last - if (pre.closest(".btw-output-source")) { - installActionButtons(wrapper, pre) - } - moveCopyButton(wrapper, copyButton) - }) - } - function ensureWrapper(pre) { let wrapper = pre.querySelector(".code-action-wrapper") if (wrapper) return wrapper diff --git a/inst/js/run-r/btw-icons.js b/inst/js/run-r/btw-icons.js deleted file mode 100644 index 75962795..00000000 --- a/inst/js/run-r/btw-icons.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * SVG icons used in the component - */ -export const ICONS = { - code: ` - -`, - playCircle: ``, - exclamationCircleFill: ` - -`, - plus: ` - - -`, - copy: ` - -`, - check: ` - -` -} diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index 3bb2f221..9933fffd 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -1,28 +1,4 @@ -/** - * Styles for btw-run-r-result custom element - */ - -btw-run-r-result { - --shiny-tool-card-max-height: auto; - display: block; - margin-bottom: var(--bslib-spacer, 1em); - font-size: 0.8em; -} - -/* Make header clickable for collapsing */ -btw-run-r-result .card-header { - cursor: pointer; -} - -/* Remove horizontal padding from card body for full-width code */ -btw-run-r-result .card-body { - padding: 0; - gap: 0; -} - -/* Output container with visual treatment */ - -/* Code output blocks */ +/* Rich R output */ .btw-run-output pre { margin: 0; padding: 0.5rem; @@ -34,7 +10,6 @@ btw-run-r-result .card-body { .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; } @@ -42,93 +17,44 @@ btw-run-r-result .card-body { background-color: var(--bs-black); } -.btw-run-output .code-copy-button { - /* Hide shinychat's pipeline copy button — btw adds its own via addBlockCopyButtons() */ - display: none; -} - -/* Per-block copy buttons */ -.btw-block-copy-btn { - position: absolute; - top: 0.25rem; - right: 0.25rem; - width: 1.5rem; - height: 1.5rem; - background: none; - border: none; - padding: 0; - cursor: pointer; - color: var(--bs-body-color); - opacity: 0; - transition: opacity 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.25rem; -} - -.btw-run-output pre:hover .btw-block-copy-btn { - opacity: 0.6; -} - -.btw-block-copy-btn:hover { - opacity: 1 !important; -} - -.btw-block-copy-btn:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; - opacity: 1; -} - -.btw-block-copy-btn > .bi::after { - content: ""; - display: block; - width: 1rem; - height: 1rem; - background-color: var(--bs-body-color, #222); - mask-image: url('data:image/svg+xml,'); -} - -.btw-block-copy-btn-checked > .bi::after { - mask-image: url('data:image/svg+xml,'); - background-color: var(--bs-success, #198754); -} - .btw-run-output pre code { - font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-family: var( + --bs-font-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + "Courier New", + monospace + ); white-space: pre; word-break: break-word; padding: 0; } .btw-run-output > img:last-child { - margin-bottom: 0; + margin-bottom: 0; } .btw-output-output:has(> code:empty) { - display: none; + display: none; } .btw-run-output pre.btw-output-source { background-color: unset; } -/* Message output (blue left border) */ +/* Messages, warnings, and errors */ .btw-run-output pre.btw-output-message { - border-left: 3px solid var(--bs-info, #0dcaf0); background-color: rgba(13, 202, 240, 0.1); } -/* Warning output (yellow/orange left border) */ .btw-run-output pre.btw-output-warning { - border-left: 3px solid var(--bs-warning, #ffc107); background-color: rgba(255, 193, 7, 0.1); } -/* Error output (red left border) */ .btw-run-output pre.btw-output-error { - border-left: 3px solid var(--bs-danger, #dc3545); background-color: rgba(220, 53, 69, 0.1); } @@ -154,12 +80,32 @@ btw-run-r-result .card-body { /* 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-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-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) */ @@ -174,151 +120,297 @@ btw-run-r-result .card-body { /* 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-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-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-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-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%); + --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-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); } +.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); } +.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); } +.btw-ansi-bg-black { + background-color: var(--btw-ansi-bg-black); +} +.btw-ansi-bg-red { + background-color: var(--btw-ansi-bg-red); +} +.btw-ansi-bg-green { + background-color: var(--btw-ansi-bg-green); +} +.btw-ansi-bg-yellow { + background-color: var(--btw-ansi-bg-yellow); +} +.btw-ansi-bg-blue { + background-color: var(--btw-ansi-bg-blue); +} +.btw-ansi-bg-magenta { + background-color: var(--btw-ansi-bg-magenta); +} +.btw-ansi-bg-cyan { + background-color: var(--btw-ansi-bg-cyan); +} +.btw-ansi-bg-white { + background-color: var(--btw-ansi-bg-white); +} /* ANSI Bright Background Colors (8-15) */ -.btw-ansi-bg-bright-black { background-color: var(--btw-ansi-bg-bright-black); } -.btw-ansi-bg-bright-red { background-color: var(--btw-ansi-bg-bright-red); } -.btw-ansi-bg-bright-green { background-color: var(--btw-ansi-bg-bright-green); } -.btw-ansi-bg-bright-yellow { background-color: var(--btw-ansi-bg-bright-yellow); } -.btw-ansi-bg-bright-blue { background-color: var(--btw-ansi-bg-bright-blue); } -.btw-ansi-bg-bright-magenta { background-color: var(--btw-ansi-bg-bright-magenta); } -.btw-ansi-bg-bright-cyan { background-color: var(--btw-ansi-bg-bright-cyan); } -.btw-ansi-bg-bright-white { background-color: var(--btw-ansi-bg-bright-white); } - -/* Copy code button in header */ -.copy-code-btn { - width: 14px; - background: none; - border: none; - padding: 0; - margin: 0; - cursor: pointer; - color: var(--bs-body-color); - opacity: 0.6; - transition: opacity 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; +.btw-ansi-bg-bright-black { + background-color: var(--btw-ansi-bg-bright-black); } - -.copy-code-btn:hover { - opacity: 1; +.btw-ansi-bg-bright-red { + background-color: var(--btw-ansi-bg-bright-red); } - -.copy-code-btn:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; - opacity: 1; +.btw-ansi-bg-bright-green { + background-color: var(--btw-ansi-bg-bright-green); } - -.copy-code-btn svg { - display: block; +.btw-ansi-bg-bright-yellow { + background-color: var(--btw-ansi-bg-bright-yellow); } - -/* 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; +.btw-ansi-bg-bright-blue { + background-color: var(--btw-ansi-bg-bright-blue); } - -.collapse-toggle-btn:hover .collapse-indicator { - opacity: 1; +.btw-ansi-bg-bright-magenta { + background-color: var(--btw-ansi-bg-bright-magenta); } - -.collapse-toggle-btn:focus { - outline: 2px solid var(--bs-primary); - outline-offset: 2px; +.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); } -.collapse-toggle-btn:focus .collapse-indicator { - opacity: 1; +.btw-copy-reprex.code-copy-button-checked { + color: var(--bs-success); } diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index a4ba106b..6891f9b8 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -1,440 +1,146 @@ /** - * Custom element for displaying btw_tool_run_r results in shinychat. + * Progressive enhancements for rich R tool results rendered by shinychat. * @module btw-run-r */ -import { ICONS } from "./btw-icons.js" +const RUN_OUTPUT_SELECTOR = ".btw-run-output" -/** - * Formats code as a Markdown code block for rendering. - * @param {string} content - The code content - * @param {string} [language="r"] - The language for syntax highlighting - * @returns {string} Markdown code block - */ -function markdownCodeBlock(content, language = "r") { - const backticks = "`".repeat(8) - return `${backticks}${language}\n${content}\n${backticks}` -} - -/** - * Web component that displays the result of btw_tool_run_r execution. - * - * @element btw-run-r-result - * @attr {string} request-id - Unique identifier linking to the tool request - * @attr {string} code - The R code that was executed - * @attr {string} status - Execution status: "success" or "error" - * @attr {string} tool-title - (Optional) Title of the tool to display - * @attr {string} icon - (Optional) SVG icon HTML to display in the header - * @attr {string} intent - (Optional) Intent associated with the tool - * @attr {boolean} expanded - (Optional) Whether the output is expanded by default - * - * @example - * - *
[1] 2
- *
- */ -class BtwRunRResult extends HTMLElement { - /** @type {boolean} */ - expanded = true - - constructor() { - super() +function outputBlocks(root) { + const outputs = [] - this.toolTitle = this.getAttribute("tool-title") || "Run R Code" - this.icon = this.getAttribute("icon") || ICONS.playCircle - this.intent = this.getAttribute("intent") || "" - this.expanded = this.hasAttribute("expanded") - this.copyCode = this.hasAttribute("copy-code") + if (root instanceof Element && root.matches(RUN_OUTPUT_SELECTOR)) { + outputs.push(root) } - 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.titleTemplate = "{title}" - } - - this.render() - - // Signal that chat may need to scroll - 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 - */ - toggleCollapse(e) { - e.preventDefault() - this.expanded = !this.expanded - this.updateCollapseState() - } - - /** - * Update the collapse state without re-rendering content - */ - updateCollapseState() { - const collapseBtn = this.querySelector(".collapse-toggle-btn") - const cardBody = this.querySelector(".card-body") - - if (collapseBtn) { - collapseBtn.setAttribute("aria-expanded", this.expanded.toString()) - collapseBtn.setAttribute( - "aria-label", - `${this.expanded ? "Collapse" : "Expand"} tool output`, - ) - } - - if (cardBody) { - if (this.expanded) { - cardBody.classList.remove("collapsed") - cardBody.removeAttribute("inert") - } else { - cardBody.classList.add("collapsed") - cardBody.setAttribute("inert", "") - } - } - } - - /** - * 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 - } + root.querySelectorAll?.(RUN_OUTPUT_SELECTOR).forEach((output) => { + outputs.push(output) + }) - // 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 outputs +} - return parts.join("\n") +function copyToClipboard(text) { + if (window.isSecureContext && navigator.clipboard) { + return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)) } - /** - * Copy code to clipboard - * @param {Event} e - */ - async copyCode(e) { - e.preventDefault() - e.stopPropagation() // Prevent triggering collapse toggle + return fallbackCopy(text) +} - // Save reference to button before async operation - // (e.currentTarget becomes null after await) - const copyBtn = e.currentTarget +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 originalHtml = copyBtn.innerHTML - const reprexOutput = this.generateReprexOutput() - await copyToClipboard(reprexOutput) - - // Get the tooltip instance - const tooltip = window.bootstrap?.Tooltip?.getInstance(copyBtn) - - // Visual feedback - change icon briefly and update tooltip - copyBtn.innerHTML = ICONS.check - - // 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) + if (!document.execCommand("copy")) { + throw new Error("execCommand copy failed") } - } catch (err) { - console.error("Failed to copy code:", err) + resolve() + } catch (error) { + reject(error) + } finally { + textArea.remove() } - } - - /** - * 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 displayTitle = `${ - this.toolTitle || "Run R Code" - }` - return this.titleTemplate.replace("{title}", displayTitle) - } + }) +} - /** - * 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}` +function sourceText(pre) { + return pre.querySelector("code")?.textContent ?? pre.textContent ?? "" +} - // Get the output HTML from child content (set during initial render) - const outputHtml = this._outputHtml || this.innerHTML - this._outputHtml = outputHtml +function reprexText(output) { + const parts = [] - const collapsedClass = this.expanded ? "" : " collapsed" + output.querySelectorAll("pre").forEach((pre) => { + const text = sourceText(pre) + if (!text.trim()) return - // 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() - } + if (pre.classList.contains("btw-output-source")) { + parts.push(text.trimEnd()) + return } - this.innerHTML = ` -
-
-
${this.icon}
-
${this.formatTitle()}
-
- ${this.intent ? `
${this.intent}
` : ""} - ${ - this.copyCode - ? ` - ` - : "" - } - -
-
-
- ${outputHtml} -
-
-
- ` + const lines = text.trimEnd().split("\n") + parts.push(lines.map((line) => "#> " + line).join("\n")) + }) - const collapseBtn = this.querySelector(".collapse-toggle-btn") - if (collapseBtn) { - collapseBtn.addEventListener("click", (e) => this.toggleCollapse(e)) - } + return parts.join("\n") +} - const copyBtn = this.querySelector(".copy-code-btn") - if (copyBtn) { - copyBtn.addEventListener("click", (e) => this.copyCode(e)) +function markCopied(button) { + button.classList.add("code-copy-button-checked") + window.setTimeout(() => { + button.classList.remove("code-copy-button-checked") + }, 1500) +} - // Initialize Bootstrap tooltip - if (window.bootstrap?.Tooltip) { - new window.bootstrap.Tooltip(copyBtn) - } - } +function addCopyButton(pre) { + if (pre.querySelector(".code-copy-button")) return - this.addBlockCopyButtons() + const button = document.createElement("button") + button.type = "button" + button.className = "code-copy-button" + button.setAttribute("aria-label", "Copy to clipboard") + button.innerHTML = '' + button.addEventListener("click", async (event) => { + event.stopPropagation() - // Allow clicking anywhere on the header to toggle, except on action buttons - const header = this.querySelector(".card-header") - if (header) { - 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) - }) + try { + await copyToClipboard(sourceText(pre)) + markCopied(button) + } catch (error) { + console.error("Failed to copy R result block:", error) } - } - - /** - * Add per-block copy buttons to each pre element in the output container. - * Idempotent: skips any pre that already has a button (safe to call after re-render). - */ - addBlockCopyButtons() { - const outputContainer = this.querySelector(".btw-run-output") - if (!outputContainer) return - - outputContainer.querySelectorAll("pre").forEach((pre) => { - if (pre.querySelector(".btw-block-copy-btn")) return - - const btn = document.createElement("button") - btn.className = "btw-block-copy-btn" - btn.setAttribute("aria-label", "Copy to clipboard") - btn.innerHTML = '' - - btn.addEventListener("click", async (e) => { - e.stopPropagation() - const code = pre.querySelector("code") - const text = code?.textContent ?? pre.textContent ?? "" - try { - await copyToClipboard(text) - btn.classList.add("btw-block-copy-btn-checked") - setTimeout(() => { - btn.classList.remove("btw-block-copy-btn-checked") - }, 1500) - } catch (err) { - console.error("Failed to copy:", err) - } - }) - - pre.appendChild(btn) - }) - - this.dispatchEvent(new CustomEvent("btw-run-r-rendered", { bubbles: true })) - } + }) - /** - * 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, ">") - } + pre.appendChild(button) } -/** - * 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) - } +function enhanceRunOutput(root) { + outputBlocks(root).forEach((output) => { + output.querySelectorAll("pre").forEach(addCopyButton) + }) } -/** - * 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") +enhanceRunOutput(document) + +const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + enhanceRunOutput(node) } - } 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) -} +}) + +observer.observe(document.documentElement, { + childList: true, + subtree: true, +}) + +document.addEventListener("click", async (event) => { + const button = event.target.closest(".btw-copy-reprex") + if (!button) return + + event.preventDefault() + event.stopPropagation() + + const output = button + .closest(".shiny-tool-card") + ?.querySelector(RUN_OUTPUT_SELECTOR) + if (!output) return + + try { + await copyToClipboard(reprexText(output)) + markCopied(button) + } catch (error) { + console.error("Failed to copy reprex:", error) + } +}) diff --git a/tests/testthat/test-btw_client_app.R b/tests/testthat/test-btw_client_app.R new file mode 100644 index 00000000..123aa037 --- /dev/null +++ b/tests/testthat/test-btw_client_app.R @@ -0,0 +1,53 @@ +test_that("app_set_disabled() namespaces controls and preserves an array payload", { + message <- NULL + session <- list( + ns = function(id) paste0("module-", id), + sendCustomMessage = function(type, value) { + message <<- list(type = type, value = value) + } + ) + + app_set_disabled(session, c("model", "clear_chat"), TRUE) + + expect_equal(message$type, "btw_set_disabled") + expect_equal( + message$value, + list( + ids = list("module-model", "module-clear_chat"), + disabled = TRUE + ) + ) + + app_set_disabled(session, "tools_controls", FALSE) + + expect_equal( + message$value, + list( + ids = list("module-tools_controls"), + disabled = FALSE + ) + ) +}) + +test_that("app_set_client_tools() updates the active client", { + active_tools <- NULL + active_client <- list( + set_tools = function(tools) active_tools <<- tools + ) + chat <- list(client = active_client) + available <- list(one = "first", two = "second") + + app_set_client_tools(chat, "two", available) + expect_equal(active_tools, list(two = "second")) + + app_set_client_tools(chat, character(), available) + expect_equal(active_tools, list()) +}) + +test_that("app_toggle_tool_group() ignores toggles while streaming", { + tools <- c("one", "two") + + expect_null(app_toggle_tool_group("one", tools, "streaming")) + expect_equal(app_toggle_tool_group("one", tools, "idle"), tools) + expect_equal(app_toggle_tool_group(tools, tools, "idle"), character()) +}) diff --git a/tests/testthat/test-tool-docs.R b/tests/testthat/test-tool-docs.R index 995726cd..3e58a446 100644 --- a/tests/testthat/test-tool-docs.R +++ b/tests/testthat/test-tool-docs.R @@ -14,6 +14,7 @@ test_that("btw_tool_docs_help_page() works", { expect_equal(res@extra$topic, "Normal") expect_equal(res@extra$package, "stats") expect_type(res@extra$help_text, "character") + expect_true(res@extra$display$full_screen) skip_if_not_snapshot_env() expect_snapshot(cli::cat_line(res@value)) @@ -32,6 +33,7 @@ test_that("btw_tool_docs_available_vignettes() works", { res <- btw_tool_docs_available_vignettes("dplyr") expect_btw_tool_result(res) + expect_true(res@extra$display$full_screen) expect_match(res@value, '"vignette":"dplyr"', fixed = TRUE, all = FALSE) expect_match( @@ -58,6 +60,7 @@ test_that("btw_tool_docs_vignette() works", { res_dplyr <- btw_tool_docs_vignette("dplyr") expect_btw_tool_result(res_dplyr) + expect_true(res_dplyr@extra$display$full_screen) expect_match( res_dplyr@value, "Introduction to dplyr", diff --git a/tests/testthat/test-tool-files-read.R b/tests/testthat/test-tool-files-read.R index 19fcb33a..c704ba93 100644 --- a/tests/testthat/test-tool-files-read.R +++ b/tests/testthat/test-tool-files-read.R @@ -53,6 +53,40 @@ test_that("btw_tool_files_read() works", { ) }) +test_that("file result actions use footers and support fullscreen", { + title <- file_result_title("Read", "path/to/file.R") + expect_match(as.character(title), "Read file.R", fixed = TRUE) + expect_false(grepl("btw-open-file", as.character(title), fixed = TRUE)) + + local_mocked_bindings( + hasFun = function(name) identical(name, "navigateToFile"), + .package = "rstudioapi" + ) + footer <- file_result_footer("path/to/file.R") + expect_match(as.character(footer), "btw-open-file", fixed = TRUE) + expect_match(as.character(footer), "action-button action-link", fixed = TRUE) + expect_match( + as.character(footer), + "data-path=\"path/to/file.R\"", + fixed = TRUE + ) + expect_match( + as.character(footer), + "file.R", + fixed = TRUE + ) + expect_match( + as.character(footer), + "in the IDE", + fixed = TRUE + ) + + withr::local_dir(withr::local_tempdir()) + writeLines("contents", "file.R") + result <- btw_tool_files_read("file.R") + expect_true(result@extra$display$full_screen) +}) + # is_text_file() and CJK multi-byte UTF-8 boundary truncation # https://github.com/posit-dev/btw/issues/170 describe("is_text_file()", { diff --git a/tests/testthat/test-tool-files-write.R b/tests/testthat/test-tool-files-write.R index f67bfa1d..4b873e83 100644 --- a/tests/testthat/test-tool-files-write.R +++ b/tests/testthat/test-tool-files-write.R @@ -7,6 +7,14 @@ test_that("btw_tool_files_write() works", { expect_equal(res_write_data@extra$path, "test.txt") expect_equal(res_write_data@extra$content, "Hello\nWorld!") expect_null(res_write_data@extra$previous_content, NULL) + expect_true(res_write_data@extra$display$full_screen) + expect_false( + grepl( + "btw-open-file", + as.character(res_write_data@extra$display$title), + fixed = TRUE + ) + ) expect_equal( read_lines("test.txt"), diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index 24021142..596d9060 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -1,3 +1,26 @@ +new_run_r_test_result <- function(code, copy_code = NULL) { + result <- btw_tool_run_r_impl(code) + result@request <- ellmer::ContentToolRequest( + id = "test-run-r", + name = "btw_tool_run_r", + arguments = list(code = code, `_intent` = ""), + tool = ellmer::tool( + function(code) NULL, + name = "btw_tool_run_r", + description = "Run R code", + arguments = list(code = ellmer::type_string("The R code to run")) + ) + ) + + if (!is.null(copy_code)) { + display <- result@extra$display + display$copy_code <- copy_code + result@extra$display <- display + } + + result +} + test_that("btw_tool_run_r() returns simple calculations", { skip_if_not_installed("evaluate") @@ -17,6 +40,65 @@ test_that("btw_tool_run_r() returns simple calculations", { expect_length(output_contents, 1) expect_s7_class(output_contents[[1]], ContentOutput) expect_match(output_contents[[1]]@text, "4") + expect_true(res@extra$display$full_screen) + + rendered <- shinychat::contents_shinychat( + new_run_r_test_result("2 + 2") + ) + expect_s3_class(rendered, "shinychat_tool_result") + expect_s3_class(rendered, "shinychat_tool_card") + expect_identical(rendered$type, "result") + expect_identical(rendered$value_type, "html") + rendered_value <- as.character(rendered$value) + expect_match(rendered_value, 'class="btw-output-source"', fixed = TRUE) + expect_match(rendered_value, 'class="language-r"', fixed = TRUE) + expect_match(rendered_value, 'class="btw-output-output"', fixed = TRUE) + expect_match(rendered_value, ">[1] 4<", fixed = TRUE) + expect_true(is.na(rendered$full_screen)) + + rendered_tags <- as.character(htmltools::as.tags(rendered)) + expect_match( + rendered_tags, + "Copy as reprex", fixed = TRUE) +}) + +test_that("R result cards omit copy-reprex when copying is disabled", { + skip_if_not_installed("evaluate") + + rendered <- shinychat::contents_shinychat( + new_run_r_test_result("2 + 2", copy_code = FALSE) + ) + + expect_null(rendered$footer) +}) + +test_that("R result cards preserve failed evaluation status", { + skip_if_not_installed("evaluate") + + result <- new_run_r_test_result('stop("boom")') + expect_null(result@error) + expect_identical(result@extra$status, "error") + + rendered <- shinychat::contents_shinychat(result) + expect_identical(rendered$status, "error") }) test_that("btw_tool_run_r() captures messages", {