diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index 10f6e0045..fa623adab 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -25,6 +25,7 @@ export(contents_shinychat) export(history_options) export(markdown_stream) export(output_markdown_stream) +export(register_slash_command) export(update_chat_user_input) if (getRversion() < "4.3.0") importFrom("S7", "@") import(S7) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 1dd5db8f7..bbc021fd8 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -11,6 +11,8 @@ * Added slash commands: a typeahead command palette that lets users trigger named shortcuts directly from the chat input. Type `/` to open the palette, filter by typing, and pick a command with arrow keys or click. Commands can expand into LLM prompts, trigger server-side side effects (clear chat, open a modal, export transcript), or be handled entirely client-side via the cancelable `shiny:chat-slash-command` DOM event. Register commands with `chat$slash_command()`, which accepts 0- or 1-argument handlers; 1-argument handlers receive a `ContentSlashCommand` object (a `ContentText` subclass with `command` and `user_text` slots) so handlers can mutate `content@text` before passing it to `client$stream()`. The `echo` parameter controls whether an invocation is recorded as a user message and triggers a loading state. Echoed commands are faithfully restored on bookmark/restore. (#239) +* Added `register_slash_command()`, a standalone way to register slash commands that doesn't require threading the value returned by `chat_server()` through your code -- call it from anywhere with the chat `id` and `session`. It also works entirely without `chat_server()`, so you can add the official palette to a `chat_ui()` you drive with your own server logic. It shares the same registration, dispatch, and client-sync machinery that `chat_server()`'s `$slash_command()` method uses internally. + * Added `submit_key` parameter to `chat_ui()`: `"enter"` (default, Enter submits) or `"enter+modifier"` (Ctrl/Cmd+Enter submits, plain Enter inserts a line break). The input remains editable while a response is streaming — only submission is blocked, not typing. (#251) ## Breaking changes diff --git a/pkg-r/R/chat_app.R b/pkg-r/R/chat_app.R index 33b7c9b18..5760ebaf5 100644 --- a/pkg-r/R/chat_app.R +++ b/pkg-r/R/chat_app.R @@ -525,79 +525,13 @@ chat_server <- function( session = session ) - # Registered slash commands. Each entry: list(handler, takes_args, definition). - # Using a reactiveVal lets multiple registrations during app startup coalesce - # into a single client sync on the next flush. Starts as NULL so the sync - # observer skips the redundant initial send (the client already initializes - # to []); an empty list is sent when the last command is removed. - slash_commands <- shiny::reactiveVal(NULL, label = "slash_commands") - - shiny::observeEvent( - session$input[[paste0(id, "_slash_command")]], - label = "on_chat_slash_command", - { - data <- session$input[[paste0(id, "_slash_command")]] - reg <- isolate(slash_commands())[[data$command]] - if (!is.null(reg) && is.function(reg$handler)) { - tryCatch( - { - if (isTRUE(reg$takes_args)) { - user_text <- data$userText %||% "" - content <- ContentSlashCommand( - command = data$command, - user_text = user_text, - text = paste0( - sprintf( - "The user entered the /%s slash command", - data$command - ), - if (nzchar(user_text)) { - paste0(" with arguments: ", user_text) - } else { - "." - } - ) - ) - reg$handler(content) - } else { - reg$handler() - } - }, - error = function(e) { - shiny::showNotification( - sanitized_error_message(e), - type = "error", - duration = NULL - ) - rlang::warn( - sprintf("Error in slash command '/%s'", data$command), - parent = e - ) - } - ) - } - send_chat_action( - id, - list(type = "remove_loading"), - session = session - ) - } - ) - - shiny::observe(label = "sync_slash_commands", { - cmds <- slash_commands() - if (!is.null(cmds)) { - defs <- lapply(cmds, `[[`, "definition") - send_chat_action( - id, - list(type = "update_slash_commands", commands = unname(defs)), - session = session - ) - } - }) - - # TODO: Support a standalone register_slash_command() that works outside the - # returned environment (e.g., so callers don't have to thread the return value) + # Slash-command palette. Registration, dispatch, and client sync live in + # register_slash_command() / slash_commands_registry() so the same machinery + # can be reused standalone (without chat_server()); see + # ?register_slash_command. Set up the registry + observers eagerly here so the + # palette is live for the whole session, then expose registration as a thin + # wrapper that doesn't require threading this returned environment around. + slash_commands_registry(id, session = session) slash_command_method <- function( name, description, @@ -606,57 +540,16 @@ chat_server <- function( echo = NULL, force = FALSE ) { - rlang::check_dots_empty() - if (!is.character(name) || length(name) != 1) { - cli::cli_abort("{.arg name} must be a single string.") - } - if (!grepl("^[a-zA-Z0-9_-]+$", name)) { - cli::cli_abort( - "{.arg name} must contain only alphanumeric characters, underscores, or hyphens, got {.val {name}}." - ) - } - if (!is.character(description) || length(description) != 1) { - cli::cli_abort("{.arg description} must be a single string.") - } - if (!is.null(handler) && !is.function(handler)) { - cli::cli_abort("{.arg handler} must be a function or {.code NULL}.") - } - - takes_args <- FALSE - if (is.function(handler)) { - handler_args <- names(formals(handler)) - if (length(handler_args) > 1 || identical(handler_args, "...")) { - cli::cli_abort("{.arg handler} must take 0 or 1 argument.") - } - takes_args <- length(handler_args) > 0 - } - - cmds <- isolate(slash_commands()) %||% list() - - if (!force && name %in% names(cmds)) { - cli::cli_abort( - "Slash command {.val {name}} is already registered. Use {.code force = TRUE} to overwrite it." - ) - } - - resolved_echo <- if (is.null(echo)) !is.null(handler) else isTRUE(echo) - - cmds[[name]] <- list( - handler = handler, - takes_args = takes_args, - definition = list( - name = name, - description = description, - echo = resolved_echo - ) + register_slash_command( + id, + name, + description, + handler, + ..., + echo = echo, + force = force, + session = session ) - slash_commands(cmds) - - function() { - cmds <- isolate(slash_commands()) - cmds[[name]] <- NULL - slash_commands(cmds) - } } client_clear <- function( diff --git a/pkg-r/R/slash-commands.R b/pkg-r/R/slash-commands.R new file mode 100644 index 000000000..c4fed1f2f --- /dev/null +++ b/pkg-r/R/slash-commands.R @@ -0,0 +1,228 @@ +#' Register a slash command +#' +#' @description +#' Register a command for shinychat's slash-command typeahead palette. This is a +#' standalone counterpart to [chat_server()]'s `$slash_command()` method: it can +#' be called from anywhere in your server function -- you don't have to thread +#' the value returned by `chat_server()` through your code. It also works +#' **without** `chat_server()` at all, so you can add the official palette to a +#' [chat_ui()] that you drive with your own server logic (custom streaming, an +#' external agent loop, etc.). +#' +#' The first time it is called for a given chat `id`, it wires up the two +#' observers that back the palette (a dispatcher for the submitted command and a +#' syncer that pushes command definitions to the client); subsequent calls just +#' register more commands. `chat_server()` uses this same machinery internally, +#' so both paths share a single implementation. +#' +#' @details +#' `handler` behaves exactly as in `chat_server()`'s `$slash_command()`: +#' +#' * `NULL`: the command is handled entirely on the client via the cancelable +#' `shiny:chat-slash-command` DOM event. +#' * a zero-argument function: run for its side effects when the command is +#' invoked. +#' * a one-argument function: receives a [ContentSlashCommand] object (not a +#' plain string) whose `text` you can mutate before passing it to +#' `client$stream()`. +#' +#' @param id The `chat_ui()` output id whose palette the command belongs to. +#' @param name The command name, without the leading `/`. Only alphanumeric +#' characters, underscores, and hyphens are allowed. +#' @param description Text shown for the command in the palette. +#' @param handler A function taking 0 or 1 argument, or `NULL`. See Details. +#' @param ... These dots are for future extensions and must be empty. +#' @param echo Whether invoking the command adds a user message and shows the +#' loading state. Defaults to `TRUE` when a `handler` is supplied and `FALSE` +#' otherwise. +#' @param force Overwrite an existing command with the same `name`. +#' @param session The Shiny session. Defaults to the current reactive domain. +#' +#' @returns A function that unregisters the command when called (invisibly). +#' +#' @seealso [chat_ui()], [chat_server()], [ContentSlashCommand] +#' +#' @examplesIf rlang::is_interactive() +#' library(shiny) +#' library(bslib) +#' library(shinychat) +#' +#' ui <- page_fillable(chat_ui("chat")) +#' +#' server <- function(input, output, session) { +#' # No chat_server(), no threaded return value -- just register. +#' register_slash_command("chat", "clear", "Clear the conversation", function() { +#' chat_clear("chat") +#' }) +#' +#' register_slash_command("chat", "greet", "Greet someone", function(content) { +#' chat_append("chat", paste("Hello,", content@user_text)) +#' }) +#' } +#' +#' shinyApp(ui, server) +#' +#' @export +register_slash_command <- function( + id, + name, + description, + handler, + ..., + echo = NULL, + force = FALSE, + session = getDefaultReactiveDomain() +) { + rlang::check_dots_empty() + if (!is.character(name) || length(name) != 1) { + cli::cli_abort("{.arg name} must be a single string.") + } + if (!grepl("^[a-zA-Z0-9_-]+$", name)) { + cli::cli_abort( + "{.arg name} must contain only alphanumeric characters, underscores, or hyphens, got {.val {name}}." + ) + } + if (!is.character(description) || length(description) != 1) { + cli::cli_abort("{.arg description} must be a single string.") + } + if (!is.null(handler) && !is.function(handler)) { + cli::cli_abort("{.arg handler} must be a function or {.code NULL}.") + } + + takes_args <- FALSE + if (is.function(handler)) { + handler_args <- names(formals(handler)) + if (length(handler_args) > 1 || identical(handler_args, "...")) { + cli::cli_abort("{.arg handler} must take 0 or 1 argument.") + } + takes_args <- length(handler_args) > 0 + } + + slash_commands <- slash_commands_registry(id, session = session) + cmds <- isolate(slash_commands()) %||% list() + + if (!force && name %in% names(cmds)) { + cli::cli_abort( + "Slash command {.val {name}} is already registered. Use {.code force = TRUE} to overwrite it." + ) + } + + resolved_echo <- if (is.null(echo)) !is.null(handler) else isTRUE(echo) + + cmds[[name]] <- list( + handler = handler, + takes_args = takes_args, + definition = list( + name = name, + description = description, + echo = resolved_echo + ) + ) + slash_commands(cmds) + + invisible(function() { + cmds <- isolate(slash_commands()) + cmds[[name]] <- NULL + slash_commands(cmds) + }) +} + +# Get-or-create the per-(session, id) slash-command registry, wiring up the +# dispatch + sync observers exactly once. Returns the reactiveVal holding the +# registered commands. State lives in session$userData$shinychat, keyed by the +# namespaced id -- the same idiom used for bookmark info in chat_restore.R -- +# so that register_slash_command() (and chat_server()) can reach it without +# threading any value through user code. +# +# Each registry entry is list(handler, takes_args, definition). The reactiveVal +# starts as NULL so the sync observer skips the redundant initial send (the +# client already initializes to []); an empty list is sent once the last +# command is removed. +slash_commands_registry <- function( + id, + session = getDefaultReactiveDomain() +) { + if (is.null(session)) { + cli::cli_abort("A Shiny {.cls session} is required to register slash commands.") + } + if (is.null(session$userData$shinychat)) { + session$userData$shinychat <- list() + } + key <- session$ns(id) + state <- session$userData$shinychat[[key]] %||% list() + + if (!is.null(state$slash_commands)) { + return(state$slash_commands) + } + + slash_commands <- shiny::reactiveVal(NULL, label = "slash_commands") + + shiny::observeEvent( + session$input[[paste0(id, "_slash_command")]], + label = "on_chat_slash_command", + domain = session, + { + data <- session$input[[paste0(id, "_slash_command")]] + reg <- isolate(slash_commands())[[data$command]] + if (!is.null(reg) && is.function(reg$handler)) { + tryCatch( + { + if (isTRUE(reg$takes_args)) { + user_text <- data$userText %||% "" + content <- ContentSlashCommand( + command = data$command, + user_text = user_text, + text = paste0( + sprintf( + "The user entered the /%s slash command", + data$command + ), + if (nzchar(user_text)) { + paste0(" with arguments: ", user_text) + } else { + "." + } + ) + ) + reg$handler(content) + } else { + reg$handler() + } + }, + error = function(e) { + shiny::showNotification( + sanitized_error_message(e), + type = "error", + duration = NULL + ) + rlang::warn( + sprintf("Error in slash command '/%s'", data$command), + parent = e + ) + } + ) + } + send_chat_action( + id, + list(type = "remove_loading"), + session = session + ) + } + ) + + shiny::observe(label = "sync_slash_commands", domain = session, { + cmds <- slash_commands() + if (!is.null(cmds)) { + defs <- lapply(cmds, `[[`, "definition") + send_chat_action( + id, + list(type = "update_slash_commands", commands = unname(defs)), + session = session + ) + } + }) + + state$slash_commands <- slash_commands + session$userData$shinychat[[key]] <- state + slash_commands +} diff --git a/pkg-r/man/register_slash_command.Rd b/pkg-r/man/register_slash_command.Rd new file mode 100644 index 000000000..6f2d343d3 --- /dev/null +++ b/pkg-r/man/register_slash_command.Rd @@ -0,0 +1,92 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/slash-commands.R +\name{register_slash_command} +\alias{register_slash_command} +\title{Register a slash command} +\usage{ +register_slash_command( + id, + name, + description, + handler, + ..., + echo = NULL, + force = FALSE, + session = getDefaultReactiveDomain() +) +} +\arguments{ +\item{id}{The \code{chat_ui()} output id whose palette the command belongs to.} + +\item{name}{The command name, without the leading \code{/}. Only alphanumeric +characters, underscores, and hyphens are allowed.} + +\item{description}{Text shown for the command in the palette.} + +\item{handler}{A function taking 0 or 1 argument, or \code{NULL}. See Details.} + +\item{...}{These dots are for future extensions and must be empty.} + +\item{echo}{Whether invoking the command adds a user message and shows the +loading state. Defaults to \code{TRUE} when a \code{handler} is supplied and \code{FALSE} +otherwise.} + +\item{force}{Overwrite an existing command with the same \code{name}.} + +\item{session}{The Shiny session. Defaults to the current reactive domain.} +} +\value{ +A function that unregisters the command when called (invisibly). +} +\description{ +Register a command for shinychat's slash-command typeahead palette. This is a +standalone counterpart to \code{\link[=chat_server]{chat_server()}}'s \verb{$slash_command()} method: it can +be called from anywhere in your server function -- you don't have to thread +the value returned by \code{chat_server()} through your code. It also works +\strong{without} \code{chat_server()} at all, so you can add the official palette to a +\code{\link[=chat_ui]{chat_ui()}} that you drive with your own server logic (custom streaming, an +external agent loop, etc.). + +The first time it is called for a given chat \code{id}, it wires up the two +observers that back the palette (a dispatcher for the submitted command and a +syncer that pushes command definitions to the client); subsequent calls just +register more commands. \code{chat_server()} uses this same machinery internally, +so both paths share a single implementation. +} +\details{ +\code{handler} behaves exactly as in \code{chat_server()}'s \verb{$slash_command()}: +\itemize{ +\item \code{NULL}: the command is handled entirely on the client via the cancelable +\code{shiny:chat-slash-command} DOM event. +\item a zero-argument function: run for its side effects when the command is +invoked. +\item a one-argument function: receives a \link{ContentSlashCommand} object (not a +plain string) whose \code{text} you can mutate before passing it to +\code{client$stream()}. +} +} +\examples{ +\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} +library(shiny) +library(bslib) +library(shinychat) + +ui <- page_fillable(chat_ui("chat")) + +server <- function(input, output, session) { + # No chat_server(), no threaded return value -- just register. + register_slash_command("chat", "clear", "Clear the conversation", function() { + chat_clear("chat") + }) + + register_slash_command("chat", "greet", "Greet someone", function(content) { + chat_append("chat", paste("Hello,", content@user_text)) + }) +} + +shinyApp(ui, server) +\dontshow{\}) # examplesIf} +} +\seealso{ +\code{\link[=chat_ui]{chat_ui()}}, \code{\link[=chat_server]{chat_server()}}, \link{ContentSlashCommand} +} diff --git a/pkg-r/tests/testthat/test-slash-commands.R b/pkg-r/tests/testthat/test-slash-commands.R index 3a69e4613..114265ee7 100644 --- a/pkg-r/tests/testthat/test-slash-commands.R +++ b/pkg-r/tests/testthat/test-slash-commands.R @@ -201,11 +201,9 @@ test_that("chat_server slash_command echo defaults to handler presence", { ) session$returned$slash_command("nohandler", "No handler", NULL) - # slash_commands lives in the module closure; read it via the function env - cmds <- get( - "slash_commands", - envir = environment(session$returned$slash_command) - )() + # slash_commands state now lives in session$userData, keyed by id; read + # it back through the same registry accessor the machinery uses. + cmds <- slash_commands_registry("chat", session = session)() expect_true(cmds[["withhandler"]]$definition$echo) expect_false(cmds[["nohandler"]]$definition$echo) expect_null(cmds[["nohandler"]]$handler) @@ -233,10 +231,7 @@ test_that("chat_server slash_command echo can be set explicitly", { function() NULL, echo = FALSE ) - cmds <- get( - "slash_commands", - envir = environment(session$returned$slash_command) - )() + cmds <- slash_commands_registry("chat", session = session)() expect_false(cmds[["sideeffect"]]$definition$echo) } ) @@ -289,10 +284,7 @@ test_that("chat_server slash_command with NULL handler does not run server-side" ) session$returned$slash_command("clientside", "Client side", NULL) - slash_commands <- get( - "slash_commands", - envir = environment(session$returned$slash_command) - )() + slash_commands <- slash_commands_registry("chat", session = session)() expect_null(slash_commands[["clientside"]]$handler) # Invoking the NULL-handler command must not error (the observer guard @@ -316,3 +308,135 @@ test_that("chat_server slash_command with NULL handler does not run server-side" } ) }) + + +# --------------------------------------------------------------------------- +# register_slash_command(): the standalone, free-function entry point. Works +# WITHOUT chat_server() and without threading any returned value -- callers +# just need the chat `id` and the `session`. chat_server() uses the same +# machinery internally. +# --------------------------------------------------------------------------- + +# A bare server function (no chat_server, no module wrapper) so these tests +# exercise register_slash_command() exactly as an app author would call it. +slash_only_server <- function(input, output, session) { + invisible(NULL) +} + +test_that("register_slash_command dispatches a zero-argument handler standalone", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + calls <- 0 + + shiny::testServer(slash_only_server, { + register_slash_command( + "chat", "clear", "Clear the conversation", + function() calls <<- calls + 1, + session = session + ) + session$setInputs( + chat_slash_command = list(command = "clear", userText = "ignored") + ) + expect_equal(calls, 1) + }) +}) + +test_that("register_slash_command passes a ContentSlashCommand to 1-arg handlers", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + received <- NULL + + shiny::testServer(slash_only_server, { + register_slash_command( + "chat", "greet", "Greet someone", + function(content) received <<- content, + session = session + ) + session$setInputs( + chat_slash_command = list(command = "greet", userText = "world") + ) + expect_s3_class(received, "shinychat::ContentSlashCommand") + expect_equal(received@command, "greet") + expect_equal(received@user_text, "world") + expect_match(received@text, "greet slash command with arguments: world") + }) +}) + +test_that("register_slash_command validates name, description, handler, arity", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + + shiny::testServer(slash_only_server, { + expect_error( + register_slash_command("chat", c("a", "b"), "d", NULL, session = session), + "single string" + ) + expect_error( + register_slash_command("chat", "bad name", "d", NULL, session = session), + "alphanumeric" + ) + expect_error( + register_slash_command("chat", "ok", 1, NULL, session = session), + "single string" + ) + expect_error( + register_slash_command("chat", "ok", "d", 42, session = session), + "function or" + ) + expect_error( + register_slash_command("chat", "ok", "d", function(a, b) NULL, session = session), + "0 or 1 argument" + ) + }) +}) + +test_that("register_slash_command echo defaults to handler presence", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + + shiny::testServer(slash_only_server, { + register_slash_command("chat", "withh", "has handler", function() NULL, session = session) + register_slash_command("chat", "noh", "no handler", NULL, session = session) + register_slash_command("chat", "forced", "explicit", NULL, echo = TRUE, session = session) + + cmds <- slash_commands_registry("chat", session = session)() + expect_true(cmds[["withh"]]$definition$echo) + expect_false(cmds[["noh"]]$definition$echo) + expect_true(cmds[["forced"]]$definition$echo) + }) +}) + +test_that("register_slash_command enforces force = TRUE on duplicate names", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + + shiny::testServer(slash_only_server, { + register_slash_command("chat", "dup", "first", NULL, session = session) + expect_error( + register_slash_command("chat", "dup", "second", NULL, session = session), + "already registered" + ) + expect_no_error( + register_slash_command("chat", "dup", "second", NULL, force = TRUE, session = session) + ) + }) +}) + +test_that("register_slash_command returns a working unregister function", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + + shiny::testServer(slash_only_server, { + unregister <- register_slash_command("chat", "tmp", "temp", NULL, session = session) + cmds <- slash_commands_registry("chat", session = session) + expect_true("tmp" %in% names(cmds())) + unregister() + expect_false("tmp" %in% names(cmds())) + }) +}) + +test_that("register_slash_command reuses one registry per (session, id)", { + local_mocked_bindings(send_chat_action = function(...) invisible(NULL)) + + shiny::testServer(slash_only_server, { + register_slash_command("chat", "one", "first", NULL, session = session) + register_slash_command("chat", "two", "second", NULL, session = session) + # Both commands land in the same registry -- no threading, no re-setup. + cmds <- slash_commands_registry("chat", session = session)() + expect_setequal(names(cmds), c("one", "two")) + }) +})