From afedf313d6026eda0bbbdf08642c86137d0982c3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 18 May 2026 08:13:01 -0400 Subject: [PATCH 01/12] draft --- DESCRIPTION | 2 +- R/btw_client_app.R | 96 +++++++++++++++++++++-------------------- inst/js/app/btw_app.css | 11 +++-- 3 files changed, 58 insertions(+), 51 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index fc7d3aca..f0ba9fca 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -52,7 +52,7 @@ Imports: withr, xml2 Suggests: - bslib (>= 0.7.0), + bslib (>= 0.11.0), callr, chromote, covr, diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 0b147a03..0d1f212f 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -77,12 +77,7 @@ btw_app_from_client <- function( path_figures_installed <- system.file("help", "figures", package = "btw") path_figures_dev <- system.file("man", "figures", package = "btw") path_logo <- "btw_figures/logo.png" - - provider_model <- sprintf( - "%s/%s", - client$get_provider()@name, - client$get_model() - ) + current_model <- client$get_model() # Store original client tools (preserves configuration like closures) # $get_tools() returns a named list where names are tool names @@ -182,7 +177,7 @@ btw_app_from_client <- function( width = "min(750px, 100%)" ), if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_ui("status_bar", provider_model) + btw_status_bar_ui("status_bar", current_model = current_model) }, btw_app_html_dep(), ) @@ -470,51 +465,60 @@ notifier <- function(icon, action, error = NULL, ...) { bslib_show_toast(toast) } -btw_status_bar_ui <- function(id, provider_model) { +btw_status_bar_ui <- function(id, current_model) { ns <- shiny::NS(id) shiny::tagList( shiny::tags$footer( - class = "status-footer d-flex align-items-center gap-3 small text-muted", + class = "status-footer small text-muted", style = "width: min(725px, 100%); margin-inline: auto;", - bslib::tooltip( - shiny::actionLink(ns("show_sys_prompt"), tool_icon("quick-reference")), - "Show system prompt" - ), - shiny::div( - class = "status-provider-model", - shiny::span(class = "font-monospace", provider_model), - ), - shiny::div( - class = "ms-auto status-tokens font-monospace", - bslib::tooltip( - id = ns("status_tokens_input_tooltip"), - shiny::span( - id = ns("status_tokens_input"), - class = "status-countup", - "data-type" = "tokens_input" - ), - "Input tokens" + bslib::toolbar( + bslib::toolbar_input_button( + id = ns("show_sys_prompt"), + label = "Show system prompt", + icon = tool_icon("quick-reference") ), - bslib::tooltip( - shiny::span( - id = ns("status_tokens_output"), - class = "status-countup", - "data-type" = "tokens_output" - ), - "Output tokens" - ) - ), - shiny::div( - class = "status-cost font-monospace", - bslib::tooltip( - id = ns("status_cost_tooltip"), - shiny::span( - id = ns("status_cost"), - class = "status-countup", - "data-type" = "cost" + bslib::toolbar_divider(), + bslib::toolbar_input_select( + id = ns("model"), + label = "Model", + choices = current_model, + selected = current_model, + style = bslib::css(min_width = "12rem") + ), + bslib::toolbar_spacer(), + shiny::div( + class = "status-tokens font-monospace", + bslib::tooltip( + id = ns("status_tokens_input_tooltip"), + shiny::span( + id = ns("status_tokens_input"), + class = "status-countup", + "data-type" = "tokens_input" + ), + "Input tokens" ), - "Estimated cost" - ) + bslib::tooltip( + shiny::span( + id = ns("status_tokens_output"), + class = "status-countup", + "data-type" = "tokens_output" + ), + "Output tokens" + ) + ), + shiny::div( + class = "status-cost font-monospace", + bslib::tooltip( + id = ns("status_cost_tooltip"), + shiny::span( + id = ns("status_cost"), + class = "status-countup", + "data-type" = "cost" + ), + "Estimated cost" + ) + ), + width = "100%" ) ) ) diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index 538bb6c9..851ae3c8 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -66,10 +66,13 @@ shiny-chat-message .message-icon { height: 1em; width: 1em; } -.status-provider-model { - text-overflow: ellipsis; - text-wrap-mode: nowrap; - overflow: hidden; +.status-footer .status-tokens, +.status-footer .status-cost { + display: inline-flex; + gap: 0.75rem; +} +.status-footer .status-cost { + margin-left: 0.5rem; } .modal { --bs-modal-margin: min(2rem, 10%); From 76f75177441b73cb3973a96c07b7b235878c5c7d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 18 May 2026 10:19:00 -0400 Subject: [PATCH 02/12] feat(app): Improve toolbar --- R/btw_client_app.R | 81 ++++++++++++++++++++++++++++++++------- R/utils-ellmer.R | 66 ++++++++++++++++++++++++++++++- R/zzz.R | 12 ++++++ inst/icons/ink-eraser.svg | 1 + 4 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 inst/icons/ink-eraser.svg diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 0d1f212f..3ed3ff15 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -13,7 +13,7 @@ btw_app <- function( messages = list() ) { rlang::check_installed("shiny") - rlang::check_installed("bslib") + rlang::check_installed("bslib", version = "0.11.0") rlang::check_installed("htmltools") rlang::check_installed("shinychat", version = "0.3.0") @@ -77,7 +77,6 @@ btw_app_from_client <- function( path_figures_installed <- system.file("help", "figures", package = "btw") path_figures_dev <- system.file("man", "figures", package = "btw") path_logo <- "btw_figures/logo.png" - current_model <- client$get_model() # Store original client tools (preserves configuration like closures) # $get_tools() returns a named list where names are tool names @@ -177,7 +176,7 @@ btw_app_from_client <- function( width = "min(750px, 100%)" ), if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_ui("status_bar", current_model = current_model) + btw_status_bar_ui("status_bar", client = client) }, btw_app_html_dep(), ) @@ -187,7 +186,35 @@ btw_app_from_client <- function( chat <- shinychat::chat_mod_server("chat", client = client) if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_server("status_bar", chat) + res <- btw_status_bar_server("status_bar", chat) + + shiny::observeEvent(res$clear_chat(), { + chat$clear(client_history = "clear") + }) + + shiny::observeEvent(res$model(), { + new_model <- res$model() + if (is.null(new_model) || identical(new_model, client$get_model())) { + return() + } + + tryCatch( + { + client$set_model(new_model) + notifier( + shiny::icon("check"), + sprintf("Switched model to %s", new_model) + ) + }, + error = function(err) { + notifier( + shiny::icon("warning"), + sprintf("Failed to switch model to %s", new_model), + error = err + ) + } + ) + }) } shiny::observeEvent(input$show_sidebar, { @@ -465,27 +492,47 @@ notifier <- function(icon, action, error = NULL, ...) { bslib_show_toast(toast) } -btw_status_bar_ui <- function(id, current_model) { +btw_status_bar_ui <- function(id, client) { ns <- shiny::NS(id) + + models <- client_get_models(client) + shiny::tagList( shiny::tags$footer( class = "status-footer small text-muted", style = "width: min(725px, 100%); margin-inline: auto;", bslib::toolbar( + gap = "0.25em", + shiny::div( + class = "status-provider badge text-bg-default", + client$get_provider()@name, + ), + if (is.null(models)) { + shiny::div( + class = "status-model badge text-body-secondary fw-normal", + client$get_model() + ) + } else { + bslib::toolbar_input_select( + id = ns("model"), + label = "Model", + selected = client$get_model(), + choices = union(client$get_model(), sort(models$id)), + style = bslib::css(min_width = "12rem") + ) + }, + bslib::toolbar_spacer(), bslib::toolbar_input_button( id = ns("show_sys_prompt"), label = "Show system prompt", icon = tool_icon("quick-reference") ), - bslib::toolbar_divider(), - bslib::toolbar_input_select( - id = ns("model"), - label = "Model", - choices = current_model, - selected = current_model, - style = bslib::css(min_width = "12rem") + bslib::toolbar_input_button( + id = ns("clear_chat"), + label = "Clear chat", + icon = tool_icon("ink-eraser"), ), - bslib::toolbar_spacer(), + bslib::toolbar_divider(), shiny::div( class = "status-tokens font-monospace", bslib::tooltip( @@ -687,6 +734,14 @@ btw_status_bar_server <- function(id, chat) { ) } ) + + # Return model choice + return( + list( + model = reactive(input$model), + clear_chat = reactive(input$clear_chat) + ) + ) } ) } diff --git a/R/utils-ellmer.R b/R/utils-ellmer.R index e8ea6cde..4d4d9695 100644 --- a/R/utils-ellmer.R +++ b/R/utils-ellmer.R @@ -1,3 +1,66 @@ +client_get_models <- function(client) { + provider <- client$get_provider() + + models_fns <- list( + ProviderAnthropic = function(p) { + ellmer::models_anthropic( + base_url = p@base_url, + credentials = p@credentials + ) + }, + ProviderGoogleGemini = function(p) { + ellmer::models_google_gemini( + base_url = p@base_url, + credentials = p@credentials + ) + }, + ProviderAWSBedrock = function(p) { + base_url <- sub("bedrock-runtime", "bedrock", p@base_url) + ellmer::models_aws_bedrock(profile = p@profile, base_url = base_url) + }, + ProviderOpenAI = function(p) { + ellmer::models_openai(base_url = p@base_url, credentials = p@credentials) + }, + ProviderMistral = function(p) { + ellmer::models_mistral() + }, + ProviderLMStudio = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_lmstudio(base_url = base_url, credentials = p@credentials) + }, + ProviderVllm = function(p) { + ellmer::models_vllm(base_url = p@base_url, credentials = p@credentials) + }, + ProviderOllama = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_ollama(base_url = base_url, credentials = p@credentials) + }, + ProviderPortkeyAI = function(p) { + ellmer::models_portkey(base_url = p@base_url) + }, + ProviderOpenAICompatible = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_openai(base_url = p@base_url, credentials = p@credentials) + } + ) + + for (cls in names(models_fns)) { + if (inherits(provider, sprintf("ellmer::%s", cls))) { + return( + tryCatch(models_fns[[cls]](provider), error = function(e) { + cli::cli_warn( + "Failed to fetch models for provider {provider@name}", + parent = e + ) + NULL + }) + ) + } + } + + NULL +} + btw_prompt <- function(path, ..., .envir = parent.frame()) { path <- system.file("prompts", path, package = "btw") ellmer::interpolate_file(path, ..., .envir = .envir) @@ -78,7 +141,8 @@ BtwToolBuiltIn <- tryCatch( ) built_in_tool_info <- function(name) { - switch(name, + switch( + name, web_search = list( title = "Web Search", description = "Search the web for up-to-date information.", diff --git a/R/zzz.R b/R/zzz.R index 5d09c061..6ad5346c 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -7,6 +7,18 @@ assign(tool_def@name, tool_def, envir = pkg_env) } + # Patch ellmer:::Chat to add set_model() if it doesn't exist + ellmer_chat <- getFromNamespace("Chat", "ellmer") + if (!is.null(ellmer_chat)) { + if (!"set_model" %in% names(ellmer_chat$public_methods)) { + ellmer_chat$set("public", "set_model", function(model) { + old <- private$provider@model + private$provider@model <- model + invisible(old) + }) + } + } + rlang::run_on_load() } diff --git a/inst/icons/ink-eraser.svg b/inst/icons/ink-eraser.svg new file mode 100644 index 00000000..8969a7fe --- /dev/null +++ b/inst/icons/ink-eraser.svg @@ -0,0 +1 @@ + From 3f8aee199bd752c9d560e887e851697204f1d105 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 14:12:00 -0400 Subject: [PATCH 03/12] feat(app): Support switching between btw.md models --- DESCRIPTION | 4 +- R/btw_client_app.R | 159 ++++++++++++++++++++++++++++++++++----------- R/utils-ellmer.R | 88 +++++++++++++++++++++++++ man/btw_client.Rd | 14 +++- 4 files changed, 224 insertions(+), 41 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index f0ba9fca..9ed1eaef 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -73,7 +73,7 @@ Suggests: renv, roxygen2, shiny, - shinychat (>= 0.3.0), + shinychat (>= 0.3.0.9000), testthat (>= 3.0.0), tibble, usethis @@ -137,3 +137,5 @@ Collate: 'utils-r.R' 'utils.R' 'zzz.R' +Remotes: + posit-dev/shinychat diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 3ed3ff15..f55a0500 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -4,27 +4,37 @@ #' chat #' @param messages A list of initial messages to show in the chat, passed to #' [shinychat::chat_mod_ui()]. +#' @param model_choices Can be one of `"btw_md"` (model choices from your +#' `path_btw` configuration), `"provider"` (models from the provider API), +#' `"auto"` (uses `path_btw` if `client` comes from `path_btw`, otherwise +#' falling back to provider), or `"none"` (don't show model choices). #' @export btw_app <- function( ..., client = NULL, tools = NULL, path_btw = NULL, - messages = list() + messages = list(), + model_choices = c("auto", "btw_md", "provider", "none") ) { rlang::check_installed("shiny") rlang::check_installed("bslib", version = "0.11.0") rlang::check_installed("htmltools") - rlang::check_installed("shinychat", version = "0.3.0") + rlang::check_installed("shinychat", version = "0.3.0.9000") + + model_choices <- rlang::arg_match(model_choices) if (getOption("btw.app.close_on_session_end", FALSE)) { cli::cli_alert("Starting up {.fn btw::btw_app} ...") } + client_name <- if (is_string(client)) client + # Get reference tools for the app if (inherits(client, "AsIs")) { # When client is AsIs (pre-configured), use btw_tools() as reference reference_tools <- btw_tools() + app_models <- app_resolve_model_choices(model_choices, path_btw = FALSE) } else { client <- btw_client( client = client, @@ -44,12 +54,24 @@ btw_app <- function( ) reference_tools <- reference_client$get_tools() }) + + app_models <- app_resolve_model_choices( + model_choices, + path_btw, + client_name = client_name + ) + } + + selected_client <- if (is.list(app_models) && !is.null(client_name)) { + resolve_model_choice_name(client_name, names(app_models)) } btw_app_from_client( client, messages = messages, allowed_tools = reference_tools, + app_models = app_models, + selected_client = selected_client, ... ) } @@ -72,6 +94,8 @@ btw_app_from_client <- function( client, messages = list(), allowed_tools = btw_tools(), + app_models = "provider", + selected_client = NULL, ... ) { path_figures_installed <- system.file("help", "figures", package = "btw") @@ -176,7 +200,12 @@ btw_app_from_client <- function( width = "min(750px, 100%)" ), if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_ui("status_bar", client = client) + btw_status_bar_ui( + "status_bar", + client = client, + models = app_models, + selected = selected_client + ) }, btw_app_html_dep(), ) @@ -186,35 +215,11 @@ btw_app_from_client <- function( chat <- shinychat::chat_mod_server("chat", client = client) if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - res <- btw_status_bar_server("status_bar", chat) + res <- btw_status_bar_server("status_bar", chat, app_models) shiny::observeEvent(res$clear_chat(), { chat$clear(client_history = "clear") }) - - shiny::observeEvent(res$model(), { - new_model <- res$model() - if (is.null(new_model) || identical(new_model, client$get_model())) { - return() - } - - tryCatch( - { - client$set_model(new_model) - notifier( - shiny::icon("check"), - sprintf("Switched model to %s", new_model) - ) - }, - error = function(err) { - notifier( - shiny::icon("warning"), - sprintf("Failed to switch model to %s", new_model), - error = err - ) - } - ) - }) } shiny::observeEvent(input$show_sidebar, { @@ -492,10 +497,23 @@ notifier <- function(icon, action, error = NULL, ...) { bslib_show_toast(toast) } -btw_status_bar_ui <- function(id, client) { +btw_status_bar_ui <- function( + id, + client, + models = "provider", + selected = NULL +) { ns <- shiny::NS(id) - models <- client_get_models(client) + if (identical(models, "provider")) { + selected <- client$get_model() + provider_df <- client_get_models(client) + choices <- if (!is.null(provider_df)) sort(provider_df$id) else NULL + choices <- union(selected, choices) + } else if (length(models) > 0) { + selected <- selected %||% names(models)[[1]] + choices <- names(models) + } shiny::tagList( shiny::tags$footer( @@ -503,10 +521,7 @@ btw_status_bar_ui <- function(id, client) { style = "width: min(725px, 100%); margin-inline: auto;", bslib::toolbar( gap = "0.25em", - shiny::div( - class = "status-provider badge text-bg-default", - client$get_provider()@name, - ), + shiny::uiOutput(ns("provider")), if (is.null(models)) { shiny::div( class = "status-model badge text-body-secondary fw-normal", @@ -516,8 +531,8 @@ btw_status_bar_ui <- function(id, client) { bslib::toolbar_input_select( id = ns("model"), label = "Model", - selected = client$get_model(), - choices = union(client$get_model(), sort(models$id)), + selected = selected, + choices = choices, style = bslib::css(min_width = "12rem") ) }, @@ -571,10 +586,78 @@ btw_status_bar_ui <- function(id, client) { ) } -btw_status_bar_server <- function(id, chat) { +btw_status_bar_server <- function(id, chat, models = "provider") { shiny::moduleServer( id, function(input, output, session) { + provider_name <- shiny::reactiveVal({ + # chat$client is not reactive, will be updated manually on model change + chat$client$get_provider()@name + }) + + model_name <- shiny::reactiveVal({ + chat$client$get_model() + }) + + output$provider <- shiny::renderUI({ + badge <- shiny::div( + class = "status-provider badge text-bg-default", + provider_name() + ) + if (identical(models, "provider")) { + badge + } else { + bslib::tooltip(badge, model_name(), placement = "top") + } + }) + + shiny::observeEvent(input$model, ignoreInit = TRUE, { + tryCatch( + { + old_provider <- chat$client$get_provider()@name + + if (identical(models, "provider")) { + new_client <- chat$client$clone() + new_client$set_model(input$model) + } else { + new_config <- models[[input$model]] + new_client <- btw_client(client = new_config, tools = FALSE) + new_client$set_system_prompt(chat$client$get_system_prompt()) + turns <- chat$client$get_turns() + new_provider <- new_client$get_provider()@name + if (!identical(old_provider, new_provider)) { + turns <- turns_replace_thinking(turns) + } + new_client$set_turns(turns) + new_client$set_tools(chat$client$get_tools()) + } + + chat$set_client(new_client, sync = FALSE) + new_provider <- chat$client$get_provider()@name + provider_name(new_provider) + model_name(chat$client$get_model()) + + notifier( + shiny::icon("check"), + shiny::HTML( + sprintf( + "Switched model to %s from %s.", + new_client$get_model(), + new_provider + ) + ) + ) + }, + error = function(err) { + notifier( + shiny::icon("warning"), + sprintf("Failed to switch model to %s", input$model), + error = err + ) + } + ) + }) + chat_tokens <- shiny::reactiveVal( chat_get_tokens(chat$client), label = "btw_app_tokens" @@ -735,10 +818,8 @@ btw_status_bar_server <- function(id, chat) { } ) - # Return model choice return( list( - model = reactive(input$model), clear_chat = reactive(input$clear_chat) ) ) diff --git a/R/utils-ellmer.R b/R/utils-ellmer.R index 4d4d9695..898e19c3 100644 --- a/R/utils-ellmer.R +++ b/R/utils-ellmer.R @@ -61,6 +61,94 @@ client_get_models <- function(client) { NULL } +# Returns NULL (no selector), "provider" (lazy fetch), or list of btw.md client configs +app_resolve_model_choices <- function( + model_choices, + path_btw, + client_name = NULL +) { + if (model_choices == "none") { + return(NULL) + } + if (model_choices == "provider") { + return("provider") + } + + config <- read_btw_file(path_btw) + btw_models <- config$client + + if (is.null(btw_models)) { + return("provider") + } + + if (is_list(btw_models)) { + if (all(nzchar(names2(btw_models)))) { + if ( + model_choices == "auto" && + !is.null(client_name) && + is.null(resolve_model_choice_name(client_name, names(btw_models))) + ) { + return("provider") + } + return(btw_models) + } + cli::cli_inform( + "Model choices in `client` in {.path {btw_md}} must be named for model selection to work." + ) + } + + "provider" +} + +resolve_model_choice_name <- function(name, choices) { + idx <- match(tolower(name), tolower(choices)) + if (is.na(idx)) NULL else choices[[idx]] +} + +client_models_from_config <- function(client_config) { + aliases <- client_aliases(client_config) + if (is.null(aliases)) { + return(NULL) + } + + model_ids <- vapply( + client_config, + function(cfg) { + if (is_string(cfg)) { + parts <- strsplit(cfg, "/", fixed = TRUE)[[1]] + if (length(parts) > 1) paste(parts[-1], collapse = "/") else "" + } else if (is.list(cfg) && !inherits(cfg, "Chat")) { + cfg$model %||% "" + } else if (inherits(cfg, "Chat")) { + cfg$get_model() + } else { + "" + } + }, + character(1) + ) + + valid <- nzchar(model_ids) + if (!any(valid)) { + return(NULL) + } + + model_ids[valid] +} + +turns_replace_thinking <- function(turns) { + lapply(turns, function(turn) { + turn@contents <- lapply(turn@contents, function(content) { + if (S7::S7_inherits(content, ellmer::ContentThinking)) { + ellmer::ContentText(format(content)) + } else { + content + } + }) + turn + }) +} + btw_prompt <- function(path, ..., .envir = parent.frame()) { path <- system.file("prompts", path, package = "btw") ellmer::interpolate_file(path, ..., .envir = .envir) diff --git a/man/btw_client.Rd b/man/btw_client.Rd index 9391d9d3..dfa06260 100644 --- a/man/btw_client.Rd +++ b/man/btw_client.Rd @@ -13,7 +13,14 @@ btw_client( path_llms_txt = NULL ) -btw_app(..., client = NULL, tools = NULL, path_btw = NULL, messages = list()) +btw_app( + ..., + client = NULL, + tools = NULL, + path_btw = NULL, + messages = list(), + model_choices = c("auto", "btw_md", "provider", "none") +) } \arguments{ \item{...}{In \code{btw_app()}, additional arguments are passed to @@ -54,6 +61,11 @@ the your current working directory or its parents. Set \code{path_llms_txt = FAL \item{messages}{A list of initial messages to show in the chat, passed to \code{\link[shinychat:chat_mod_ui]{shinychat::chat_mod_ui()}}.} + +\item{model_choices}{Can be one of \code{"btw_md"} (model choices from your +\code{path_btw} configuration), \code{"provider"} (models from the provider API), +\code{"auto"} (uses \code{path_btw} if \code{client} comes from \code{path_btw}, otherwise +falling back to provider), or \code{"none"} (don't show model choices).} } \value{ Returns an \link[ellmer:Chat]{ellmer::Chat} object with additional tools registered From 6c2da091fe2501082aab85bde6fd010114525eae Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 14:54:07 -0400 Subject: [PATCH 04/12] feat(app): Enhance status countup initialization with retry logic and improve UI integration --- R/btw_client_app.R | 18 +++++++++--------- R/utils-ellmer.R | 18 ++++++++++++++++-- inst/js/app/btw_app.js | 23 +++++++++++++++++++++-- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index f55a0500..af94f5c5 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -197,16 +197,16 @@ btw_app_from_client <- function( shinychat::chat_mod_ui( "chat", messages = messages, - width = "min(750px, 100%)" + width = "min(750px, 100%)", + footer = if (utils::packageVersion("shinychat") >= "0.3.0.9000") { + btw_status_bar_ui( + "status_bar", + client = client, + models = app_models, + selected = selected_client + ) + } ), - if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_ui( - "status_bar", - client = client, - models = app_models, - selected = selected_client - ) - }, btw_app_html_dep(), ) } diff --git a/R/utils-ellmer.R b/R/utils-ellmer.R index 898e19c3..b395cd92 100644 --- a/R/utils-ellmer.R +++ b/R/utils-ellmer.R @@ -44,6 +44,20 @@ client_get_models <- function(client) { } ) + try_get_models <- function(fn, provider) { + tryCatch(fn(provider), error = function(e) { + cli::cli_warn( + "Failed to fetch models for provider {provider@name}", + parent = e + ) + NULL + }) + } + + if (provider@name == "LM Studio") { + return(try_get_models(models_fns$ProviderLMStudio, provider)) + } + for (cls in names(models_fns)) { if (inherits(provider, sprintf("ellmer::%s", cls))) { return( @@ -85,8 +99,8 @@ app_resolve_model_choices <- function( if (all(nzchar(names2(btw_models)))) { if ( model_choices == "auto" && - !is.null(client_name) && - is.null(resolve_model_choice_name(client_name, names(btw_models))) + !is.null(client_name) && + is.null(resolve_model_choice_name(client_name, names(btw_models))) ) { return("provider") } diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index fc06c84a..18abfa93 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -20,11 +20,30 @@ function initializeCountUp(element, initialValue, options) { return counter } -document.addEventListener("DOMContentLoaded", function () { - document.querySelectorAll(".status-countup").forEach((element) => { +function initializeStatusCountups() { + const elements = document.querySelectorAll(".status-countup") + elements.forEach((element) => { + if (statusCounters.has(element)) return const counter = initializeCountUp(element, 0) statusCounters.set(element, counter) }) + return elements.length > 0 +} + +document.addEventListener("DOMContentLoaded", function () { + if (initializeStatusCountups()) return + + let attempt = 0 + const maxAttempts = 5 + const baseDelay = 100 + + function retry() { + attempt++ + if (initializeStatusCountups() || attempt >= maxAttempts) return + setTimeout(retry, baseDelay * Math.pow(2, attempt)) + } + + setTimeout(retry, baseDelay) }) if (typeof Shiny !== "undefined") { From 7938a2f037cdf30b934823c9873764e5c463bf69 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:09:17 -0400 Subject: [PATCH 05/12] feat(app): Clearing chat resets token counters --- R/btw_client_app.R | 7 +++++++ inst/js/app/btw_app.js | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index af94f5c5..f31a8f58 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -693,6 +693,13 @@ btw_status_bar_server <- function(id, chat, models = "provider") { } }) + shiny::observeEvent(input$clear_chat, { + ids <- sprintf("status_%s", c("tokens_input", "tokens_output", "cost")) + for (id in ids) { + send_status_message(id, "ready", value = 0) + } + }) + shiny::observeEvent(chat_tokens(), { tokens <- chat_tokens() diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index 18abfa93..8afe43f1 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -53,9 +53,10 @@ if (typeof Shiny !== "undefined") { const counter = statusCounters.get(element) const lastValue = parseFloat(element.dataset.value | "0") - if (counter && message.value) { + if (counter && Object.hasOwn(message, "value")) { if ( element.dataset.type === "cost" && + message.value > 0 && (lastValue < 0.1 || message.value < 0.1) ) { const newCounter = initializeCountUp(element, lastValue, { From 964ddaa6ea7fa502843c1da4b29a432dc87991a3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:21:37 -0400 Subject: [PATCH 06/12] chore: remove btw.md --- btw.md | 136 --------------------------------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 btw.md diff --git a/btw.md b/btw.md deleted file mode 100644 index 6439220c..00000000 --- a/btw.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -client: - sonnet: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-6 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - haiku: - provider: aws_bedrock - model: us.anthropic.claude-haiku-4-5-20251001-v1:0 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - opus: - provider: aws_bedrock - model: us.anthropic.claude-opus-4-5-20251101-v1:0 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - - sonnet-4.5: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-5-20250929-v1:0 - sonnet-4: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-20250514-v1:0 - opus-4.5: - provider: aws_bedrock - model: us.anthropic.claude-opus-4-5-20251101-v1:0 - - gemma4: - provider: openai_compatible - model: google/gemma-4-26b-a4b - # base_url: http://127.0.0.1:1234/v1 - base_url: http://remy.local:1234/v1 - name: "LM Studio" - - qwen3.6: - provider: openai_compatible - model: qwen/qwen3.6-35b-a3b - base_url: http://remy.local:1234/v1 - name: "LM Studio" - preserve_thinking: true - - qwen-3-5-35b: - provider: openai_compatible - model: qwen/qwen3.5-35b-a3b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - qwen-3-5-9b: - provider: openai_compatible - model: qwen/qwen3.5-9b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - params: - reasoning_effort: medium - - glm-4-7-flash: - provider: openai_compatible - model: zai-org/glm-4.7-flash - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - nemotron-3-nano-4b: - provider: openai_compatible - model: nvidia/nemotron-3-nano-4b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - lm-glm4v: - provider: openai_compatible - model: zai-org/glm-4.6v-flash - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - qwen3: - provider: openai_compatible - model: qwen/qwen3-vl-30b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - gpt-oss-20b: - provider: openai_compatible - model: openai/gpt-oss-20b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - -tools: - # - agent - - skills - - files_list - - files_read - - files_write - # - files_edit - - files_replace - - docs_help_page - - docs_package_help_topics - -options: - skills: - paths: ["~/.agents/skills"] - subagent: - # client: openai/gpt-5.4-mini - tools_allowed: [docs, files_search, files_list] ---- - -## Overview - -btw is an R package that helps humans and LLMs work together with R by providing utilities to describe R objects, package documentation, and workspace state in LLM-friendly plain text. The package offers a flexible collection of tools that can be used interactively (copy-paste workflows), programmatically (direct function calls), or as enhanced chat clients (via ellmer or MCP servers). - -The primary goal is creating a collection of tools useful to both LLMs and humans when working together with R, with an emphasis on flexibility of usage across different workflows and platforms. - -## Quick Reference - -- **Project type:** R Package -- **Language:** R (≥ 4.1.0) -- **Key frameworks:** ellmer (LLM chat integration), mcptools (Model Context Protocol), shiny and shinychat (chat app) - -## Purpose and Design Philosophy - -btw prioritizes flexibility of usage through multiple entry points: - -- **`btw()`** - Interactive copy-paste workflow: gather context from R and paste into any chat interface -- **`btw_tools()`** - Register tools with ellmer chat clients for custom applications -- **`btw_client()` / `btw_app()`** - Batteries-included chat clients with your preferred LLM provider, model, and project context -- **MCP server** - Expose tools to third-party coding agents like Claude Desktop or Continue via `btw_mcp_server()` - -Project configuration via `btw.md` files provides conversation stability across sessions by defining default provider, model, tools, and project-specific instructions. These files are treated as instructions for coding assistants and help avoid repeating context. - -btw also serves as a laboratory for discovering best practices in LLM tool design - output formats and approaches evolve based on experimentation with what works best across different models. \ No newline at end of file From 4c470065f13260a865f74686bf55c6e4dbdebfa5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:26:55 -0400 Subject: [PATCH 07/12] feat(app): better provider badge appearance --- R/btw_client_app.R | 2 +- inst/js/app/btw_app.css | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index f31a8f58..4cf7832a 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -601,7 +601,7 @@ btw_status_bar_server <- function(id, chat, models = "provider") { output$provider <- shiny::renderUI({ badge <- shiny::div( - class = "status-provider badge text-bg-default", + class = "status-provider badge", provider_name() ) if (identical(models, "provider")) { diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index 851ae3c8..273dcac6 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -78,6 +78,12 @@ shiny-chat-message .message-icon { --bs-modal-margin: min(2rem, 10%); } +.status-provider.badge { + color: var(--bs-body-color); + background-color: RGBA(var(--bs-body-color-rgb), 0.1); +} + + /* ---------------- Tool Results ---------------- */ .btw-tool-result-write-file > .card > .card-body { padding: 0; From f07c9c398dbf83d15f15c74d6e573995026f7c28 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:32:50 -0400 Subject: [PATCH 08/12] fix(app): use logical OR instead of bitwise OR for lastValue default --- inst/js/app/btw_app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index 8afe43f1..e84b009d 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -51,7 +51,7 @@ if (typeof Shiny !== "undefined") { const element = document.getElementById(message.id) if (element) { const counter = statusCounters.get(element) - const lastValue = parseFloat(element.dataset.value | "0") + const lastValue = parseFloat(element.dataset.value || "0") if (counter && Object.hasOwn(message, "value")) { if ( From 1acbe102088c86296c8b3a62e5d31d0d1f566905 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:34:22 -0400 Subject: [PATCH 09/12] fix(app): populate provider model choices asynchronously after startup Previously, btw_status_bar_ui() made a live API call to fetch available models during UI construction, blocking rendering on slow or unavailable networks. Now the select input is seeded with just the current model, and an observe() in the server populates the full list after the session starts. --- R/btw_client_app.R | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 4cf7832a..542d35ec 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -507,9 +507,7 @@ btw_status_bar_ui <- function( if (identical(models, "provider")) { selected <- client$get_model() - provider_df <- client_get_models(client) - choices <- if (!is.null(provider_df)) sort(provider_df$id) else NULL - choices <- union(selected, choices) + choices <- selected # full list populated asynchronously in server } else if (length(models) > 0) { selected <- selected %||% names(models)[[1]] choices <- names(models) @@ -599,6 +597,17 @@ btw_status_bar_server <- function(id, chat, models = "provider") { chat$client$get_model() }) + if (identical(models, "provider")) { + shiny::observe({ + provider_df <- client_get_models(chat$client) + if (!is.null(provider_df)) { + current <- shiny::isolate(model_name()) + all_choices <- union(current, sort(provider_df$id)) + shiny::updateSelectInput(session, "model", choices = all_choices, selected = current) + } + }) + } + output$provider <- shiny::renderUI({ badge <- shiny::div( class = "status-provider badge", From edaa757421ba7f307077a379ae46e8f6b628761e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:35:55 -0400 Subject: [PATCH 10/12] fix(app): add btw_reset_status message handler for clearing counters Replaces individual per-counter reset messages with a single btw_reset_status message. The JS handler resets all status-countup elements matching the module namespace to zero and clears all state classes (btw-status-unknown, btw-status-recalculating), fixing a bug where clearing chat would display $0.00 for providers with unknown pricing. --- R/btw_client_app.R | 5 +---- inst/js/app/btw_app.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 542d35ec..734f4015 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -703,10 +703,7 @@ btw_status_bar_server <- function(id, chat, models = "provider") { }) shiny::observeEvent(input$clear_chat, { - ids <- sprintf("status_%s", c("tokens_input", "tokens_output", "cost")) - for (id in ids) { - send_status_message(id, "ready", value = 0) - } + session$sendCustomMessage("btw_reset_status", list(ns = session$ns(""))) }) shiny::observeEvent(chat_tokens(), { diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index e84b009d..2e2a9412 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -81,6 +81,19 @@ if (typeof Shiny !== "undefined") { } } }) + + Shiny.addCustomMessageHandler("btw_reset_status", function (message) { + const elements = document.querySelectorAll(".status-countup") + elements.forEach((element) => { + if (!element.id.startsWith(message.ns)) return + element.classList.remove("btw-status-recalculating", "btw-status-unknown") + element.dataset.value = 0 + const counter = statusCounters.get(element) + if (counter) { + counter.update(0) + } + }) + }) } // Open File Buttons ---------------------------------------------------------- From eb960de51610b82be49290b74c1483fdcf7ef41e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:37:25 -0400 Subject: [PATCH 11/12] fix(app): align shinychat version guards in UI and server to 0.3.0.9000 --- R/btw_client_app.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 734f4015..9f946616 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -214,7 +214,7 @@ btw_app_from_client <- function( server <- function(input, output, session) { chat <- shinychat::chat_mod_server("chat", client = client) - if (utils::packageVersion("shinychat") >= "0.2.0.9000") { + if (utils::packageVersion("shinychat") >= "0.3.0.9000") { res <- btw_status_bar_server("status_bar", chat, app_models) shiny::observeEvent(res$clear_chat(), { From 691d5eb7c0a3c45c06de6b64efa825e5d2a96254 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 27 May 2026 15:38:11 -0400 Subject: [PATCH 12/12] chore: css style --- inst/js/app/btw_app.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index 273dcac6..1a25352d 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -80,10 +80,9 @@ shiny-chat-message .message-icon { .status-provider.badge { color: var(--bs-body-color); - background-color: RGBA(var(--bs-body-color-rgb), 0.1); + background-color: rgba(var(--bs-body-color-rgb), 0.1); } - /* ---------------- Tool Results ---------------- */ .btw-tool-result-write-file > .card > .card-body { padding: 0;