From 95be07463bdfc15b03d92ed4183d38bc65c0d15b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 17:38:12 -0400 Subject: [PATCH] feat(tui): add interactive toast actions (#42407) --- packages/tui/src/app.tsx | 2 + packages/tui/src/plugin/context.tsx | 1 + packages/tui/src/ui/toast.tsx | 187 +++++++++++++++++++---- packages/tui/test/cli/tui/toast.test.tsx | 65 ++++++++ 4 files changed, 223 insertions(+), 32 deletions(-) create mode 100644 packages/tui/test/cli/tui/toast.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0608195609..7b115fe139 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -497,12 +497,14 @@ function App(props: { pair?: DialogPairCredentials }) { variant: "warning", title: "MCP server needs authentication", message: `Connect "${server.name}" to use its tools.`, + action: { label: "Open MCP servers", run: () => keymap.dispatch("mcp.list") }, }) else toast.show({ variant: "error", title: `MCP server failed: ${server.name}`, message: "Run /mcps to view details.", + action: { label: "Open MCP servers", run: () => keymap.dispatch("mcp.list") }, }) } }) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index ee96a4e8ba..d64adadc6b 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -395,6 +395,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d variant: "error", title: `Plugin failed: ${state.target}`, message: "Run /plugins to view details.", + action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") }, }) setStore("states", reconcileStore(states)) } diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 9c54ad8cfa..eee2787b82 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -1,50 +1,125 @@ -import { createContext, useContext, type ParentProps, Show } from "solid-js" +import { createContext, createSignal, onCleanup, useContext, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../context/theme" import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "./border" import { TextAttributes } from "@opentui/core" +import { tint } from "../theme/color" export type ToastOptions = { title?: string message: string variant: "info" | "success" | "warning" | "error" duration: number + action?: { + label: string + run: () => void + } } type ToastInput = Omit & { duration?: number } -export function Toast() { - const toast = useToast() +function ToastSurface(props: { + toast: ToastOptions + pending?: number + onHover?: (hovered: boolean) => void + onActivate: () => void +}) { const theme = useTheme("overlay") const dimensions = useTerminalDimensions() + const [hovered, setHovered] = createSignal(false) + const hover = (value: boolean) => { + setHovered(value) + props.onHover?.(value) + } + + return ( + hover(true)} + onMouseOut={() => hover(false)} + onMouseUp={props.onActivate} + > + + + + {props.toast.message} + + + + {hovered() && props.toast.action ? "› " : ""} + {props.toast.action?.label ?? "x"} + + + + } + > + + + {props.toast.title} + + + + + {hovered() && props.toast.action ? "› " : ""} + {props.toast.action?.label ?? "x"} + + + + + {props.toast.message} + + + + + +{props.pending} more + + + + + ) +} + +export function Toast() { + const toast = useToast() return ( {(current) => ( - - - - {current().title} - - - - {current().message} - - + (hovered ? toast.pause() : toast.resume())} + onActivate={toast.activate} + /> )} ) @@ -53,18 +128,45 @@ export function Toast() { function init() { const [store, setStore] = createStore({ currentToast: null as ToastOptions | null, + queue: [] as ToastOptions[], }) let timeoutHandle: NodeJS.Timeout | null = null + let startedAt = 0 + let remaining = 0 + let paused = false + + const clear = () => { + if (!timeoutHandle) return + clearTimeout(timeoutHandle) + timeoutHandle = null + } + + const start = (duration: number) => { + clear() + remaining = duration + startedAt = Date.now() + timeoutHandle = setTimeout(() => dismiss(), duration).unref() + } + + const dismiss = () => { + clear() + paused = false + const next = store.queue[0] + setStore("queue", (queue) => queue.slice(1)) + setStore("currentToast", next ?? null) + if (next) start(next.duration) + } const toast = { show(options: ToastInput) { const toastOptions = { ...options, duration: options.duration ?? 5000 } + if (store.currentToast && (paused || store.queue.length > 0)) { + setStore("queue", (queue) => [...queue, toastOptions]) + return + } setStore("currentToast", toastOptions) - if (timeoutHandle) clearTimeout(timeoutHandle) - timeoutHandle = setTimeout(() => { - setStore("currentToast", null) - }, toastOptions.duration).unref() + start(toastOptions.duration) }, error: (err: any) => { if (err instanceof Error) @@ -77,10 +179,31 @@ function init() { message: "An unknown error has occurred", }) }, + pause() { + if (!store.currentToast || paused) return + paused = true + remaining = Math.max(0, remaining - (Date.now() - startedAt)) + clear() + }, + resume() { + if (!store.currentToast || !paused) return + paused = false + start(remaining) + }, + dismiss, + activate() { + const action = store.currentToast?.action + dismiss() + action?.run() + }, get currentToast(): ToastOptions | null { return store.currentToast }, + get pending() { + return store.queue.length + }, } + onCleanup(clear) return toast } diff --git a/packages/tui/test/cli/tui/toast.test.tsx b/packages/tui/test/cli/tui/toast.test.tsx new file mode 100644 index 0000000000..fda5ae5e68 --- /dev/null +++ b/packages/tui/test/cli/tui/toast.test.tsx @@ -0,0 +1,65 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ToastProvider, useToast, type ToastContext } from "../../../src/ui/toast" + +function captureToast(setToast: (toast: ToastContext) => void) { + return function Capture() { + const toast = useToast() + onMount(() => setToast(toast)) + return null + } +} + +test("clicking runs an action and advances a queued toast", async () => { + let toast: ToastContext | undefined + const Capture = captureToast((value) => (toast = value)) + const app = await testRender(() => ( + + + + )) + + try { + await app.waitFor(() => toast !== undefined) + let activated = false + toast!.show({ + message: "Plugin failed", + variant: "error", + action: { label: "Open plugins", run: () => (activated = true) }, + }) + toast!.pause() + toast!.show({ message: "Copied", variant: "success" }) + + expect(toast!.currentToast?.message).toBe("Plugin failed") + expect(toast!.pending).toBe(1) + + toast!.activate() + + expect(activated).toBe(true) + expect(toast!.currentToast?.message).toBe("Copied") + expect(toast!.pending).toBe(0) + } finally { + app.renderer.destroy() + } +}) + +test("clicking a toast without an action dismisses it", async () => { + let toast: ToastContext | undefined + const Capture = captureToast((value) => (toast = value)) + const app = await testRender(() => ( + + + + )) + + try { + await app.waitFor(() => toast !== undefined) + toast!.show({ message: "Copied", variant: "success" }) + toast!.activate() + expect(toast!.currentToast).toBeNull() + } finally { + app.renderer.destroy() + } +})