Selaa lähdekoodia

feat(tui): add interactive toast actions (#42407)

Kit Langton 3 päivää sitten
vanhempi
sitoutus
95be07463b

+ 2 - 0
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") },
         })
     }
   })

+ 1 - 0
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))
   }

+ 153 - 30
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<ToastOptions, "duration"> & { 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 (
-    <Show when={toast.currentToast}>
-      {(current) => (
-        <box
-          position="absolute"
-          justifyContent="center"
-          alignItems="flex-start"
-          top={1}
-          right={2}
-          maxWidth={Math.min(60, dimensions().width - 6)}
-          paddingLeft={2}
-          paddingRight={2}
-          paddingTop={1}
-          paddingBottom={1}
-          backgroundColor={theme.background.default}
-          borderColor={theme.text.feedback[current().variant].default}
-          border={["left", "right"]}
-          customBorderChars={SplitBorder.customBorderChars}
+    <box
+      position="absolute"
+      top={1}
+      right={2}
+      maxWidth={Math.min(60, dimensions().width - 6)}
+      justifyContent="center"
+      alignItems="flex-start"
+      borderColor={theme.text.feedback[props.toast.variant].default}
+      border={["left", "right"]}
+      customBorderChars={SplitBorder.customBorderChars}
+      onMouseOver={() => hover(true)}
+      onMouseOut={() => hover(false)}
+      onMouseUp={props.onActivate}
+    >
+      <box
+        width="100%"
+        paddingLeft={2}
+        paddingRight={2}
+        paddingTop={1}
+        paddingBottom={1}
+        backgroundColor={hovered() ? tint(theme.background.default, theme.text.default, 0.04) : theme.background.default}
+      >
+        <Show
+          when={props.toast.title}
+          fallback={
+            <box flexDirection="row" width="100%">
+              <text fg={theme.text.default} wrapMode="word" flexGrow={1}>
+                {props.toast.message}
+              </text>
+              <Show when={props.toast.action || hovered()}>
+                <text
+                  flexShrink={0}
+                  marginLeft={2}
+                  wrapMode="none"
+                  attributes={hovered() ? TextAttributes.BOLD : undefined}
+                  fg={hovered() ? theme.text.action.primary.default : theme.text.subdued}
+                >
+                  {hovered() && props.toast.action ? "› " : ""}
+                  {props.toast.action?.label ?? "x"}
+                </text>
+              </Show>
+            </box>
+          }
         >
-          <Show when={current().title}>
-            <text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text.default}>
-              {current().title}
+          <box flexDirection="row" width="100%" marginBottom={1}>
+            <text attributes={TextAttributes.BOLD} fg={theme.text.default}>
+              {props.toast.title}
             </text>
-          </Show>
+            <box flexGrow={1} />
+            <Show when={props.toast.action || hovered()}>
+              <text
+                flexShrink={0}
+                marginLeft={2}
+                wrapMode="none"
+                attributes={hovered() ? TextAttributes.BOLD : undefined}
+                fg={hovered() ? theme.text.action.primary.default : theme.text.subdued}
+              >
+                {hovered() && props.toast.action ? "› " : ""}
+                {props.toast.action?.label ?? "x"}
+              </text>
+            </Show>
+          </box>
           <text fg={theme.text.default} wrapMode="word" width="100%">
-            {current().message}
+            {props.toast.message}
+          </text>
+        </Show>
+        <Show when={props.pending}>
+          <text fg={theme.text.subdued} marginTop={1}>
+            +{props.pending} more
           </text>
-        </box>
+        </Show>
+      </box>
+    </box>
+  )
+}
+
+export function Toast() {
+  const toast = useToast()
+
+  return (
+    <Show when={toast.currentToast}>
+      {(current) => (
+        <ToastSurface
+          toast={current()}
+          pending={toast.pending}
+          onHover={(hovered) => (hovered ? toast.pause() : toast.resume())}
+          onActivate={toast.activate}
+        />
       )}
     </Show>
   )
@@ -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
 }
 

+ 65 - 0
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(() => (
+    <ToastProvider>
+      <Capture />
+    </ToastProvider>
+  ))
+
+  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(() => (
+    <ToastProvider>
+      <Capture />
+    </ToastProvider>
+  ))
+
+  try {
+    await app.waitFor(() => toast !== undefined)
+    toast!.show({ message: "Copied", variant: "success" })
+    toast!.activate()
+    expect(toast!.currentToast).toBeNull()
+  } finally {
+    app.renderer.destroy()
+  }
+})