diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 34cb863536..511a6d0c5c 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -19,7 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" -import type { KeyEvent, Renderable } from "@opentui/core" +import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" interface LocationCollection { @@ -115,6 +115,118 @@ export interface Page { export type Slot = (props: Record) => JSX.Element +export type ToastVariant = "info" | "success" | "warning" | "error" + +export interface ToastOptions { + readonly title?: string + readonly message: string + readonly variant?: ToastVariant + readonly duration?: number +} + +export interface Toast { + show(options: ToastOptions): void +} + +export type AttentionWhen = "always" | "focused" | "blurred" +export type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done" + +export type AttentionNotification = + | boolean + | { + readonly when?: AttentionWhen + } + +export type AttentionSound = + | boolean + | { + readonly name?: AttentionSoundName + readonly volume?: number + readonly when?: AttentionWhen + } + +export interface AttentionNotifyOptions { + readonly title?: string + readonly message: string + readonly notification?: AttentionNotification + readonly sound?: AttentionSound +} + +export type AttentionNotifySkipReason = + | "attention_disabled" + | "empty_message" + | "blurred" + | "focused" + | "focus_unknown" + | "renderer_destroyed" + +export interface AttentionNotifyResult { + readonly ok: boolean + readonly notification: boolean + readonly sound: boolean + readonly skipped?: AttentionNotifySkipReason +} + +export interface Attention { + notify(options: AttentionNotifyOptions): Promise +} + +export type DialogSize = "medium" | "large" | "xlarge" + +export interface DialogOptions { + readonly size?: DialogSize + readonly centered?: boolean +} + +export interface DialogAlertOptions { + readonly title: string + readonly message: string +} + +export interface DialogConfirmOptions { + readonly title: string + readonly message: string + readonly label?: { + readonly confirm?: string + readonly cancel?: string + } +} + +export interface DialogPromptOptions { + readonly title: string + readonly description?: string + readonly placeholder?: string + readonly value?: string +} + +export interface DialogSelectOption { + readonly title: string + readonly value: Value + readonly description?: string + readonly category?: string + readonly disabled?: boolean +} + +export interface DialogSelectOptions { + readonly title: string + readonly placeholder?: string + readonly options: readonly DialogSelectOption[] + readonly current?: Value +} + +export interface Dialog { + /** Shows a dialog and returns a function that closes it. */ + show(render: () => JSX.Element, onClose?: () => void): () => void + /** Sets the presentation options for this plugin's active dialog. */ + set(options: DialogOptions): void + /** Closes this plugin's active dialog. */ + clear(): void + alert(options: DialogAlertOptions): Promise + confirm(options: DialogConfirmOptions): Promise + prompt(options: DialogPromptOptions): Promise + select(options: DialogSelectOptions): Promise +} + export interface KeymapCommand { /** Stable command and config keybind identifier. Omit for an inline command. */ readonly id?: string @@ -158,13 +270,32 @@ export interface KeymapLayer { readonly bindings?: readonly string[] } +export interface KeymapPending { + readonly key: string + readonly token?: string +} + +export interface KeymapActive { + readonly key: string + readonly title?: string + readonly description?: string + readonly group?: string + readonly continues: boolean +} + export interface Keymap { /** Creates a reactive keymap layer owned by the calling component. */ layer(input: () => KeymapLayer): void /** Dispatches a reachable command by ID. */ dispatch(id: string, input?: string): void - /** Returns the formatted shortcut for a registered command. */ - shortcut(id: string): string | undefined + /** Returns every formatted shortcut for a registered command. */ + shortcuts(id: string): readonly string[] + /** Returns the currently reachable commands. Reactive when read in a Solid computation. */ + commands(): readonly KeymapCommand[] + /** Returns the pending key sequence. Reactive when read in a Solid computation. */ + pending(): readonly KeymapPending[] + /** Returns bindings reachable from the pending key sequence. Reactive when read in a Solid computation. */ + active(): readonly KeymapActive[] /** Controls mutually exclusive OpenCode input modes. */ readonly mode: { /** Returns the active mode. */ @@ -175,6 +306,8 @@ export interface Keymap { } export interface UI { + readonly dialog: Dialog + readonly toast: Toast readonly router: { register(page: Page): () => void navigate(destination: Destination): void @@ -186,8 +319,11 @@ export interface UI { export interface Context { readonly options: Readonly> readonly location: LocationRef | undefined + readonly renderer: CliRenderer readonly client: OpenCodeClient readonly data: Data + readonly attention: Attention + readonly theme: any readonly keymap: Keymap readonly ui: UI } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 91b286e798..77e58224ba 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -88,6 +88,7 @@ import { DialogVariant } from "./component/dialog-variant" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" +import { AttentionProvider } from "./context/attention" registerOpencodeSpinner() @@ -346,18 +347,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - + + + + + diff --git a/packages/tui/src/context/attention.tsx b/packages/tui/src/context/attention.tsx new file mode 100644 index 0000000000..525d620517 --- /dev/null +++ b/packages/tui/src/context/attention.tsx @@ -0,0 +1,24 @@ +import type { Attention } from "@opencode-ai/plugin/tui/context" +import { useRenderer } from "@opentui/solid" +import { createContext, onCleanup, useContext, type ParentProps } from "solid-js" +import { createTuiAttention } from "../attention" +import { useConfig } from "../config" + +const AttentionContext = createContext() + +export function AttentionProvider(props: ParentProps) { + const config = useConfig() + const attention = createTuiAttention({ + renderer: useRenderer(), + config: config.data, + update: config.update, + }) + onCleanup(() => attention.dispose()) + return {props.children} +} + +export function useAttention() { + const attention = useContext(AttentionContext) + if (!attention) throw new Error("AttentionProvider is missing") + return attention +} diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index e29c6fa2f3..a7a28efa12 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -1,4 +1,4 @@ -import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context" +import type { KeymapActive, KeymapCommand, KeymapLayer, KeymapPending } from "@opencode-ai/plugin/tui/context" import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core" import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap" import { @@ -255,13 +255,19 @@ function useShortcuts() { const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) return new Map( - commands.map((id) => [ - id, - { - first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)), - all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)), - }, - ]), + commands.map((id) => { + const current = bindings.get(id) ?? [] + return [ + id, + { + first: formatKeySequence(current[0]?.sequence, formatOptions(value.config)), + all: formatCommandBindings(current, formatOptions(value.config)), + list: current + .map((binding) => formatKeySequence(binding.sequence, formatOptions(value.config))) + .filter((shortcut): shortcut is string => shortcut !== undefined), + }, + ] + }), ) }) return { @@ -271,6 +277,9 @@ function useShortcuts() { all(id: string) { return shortcuts().get(id)?.all }, + list(id: string) { + return shortcuts().get(id)?.list ?? [] + }, } } @@ -328,6 +337,41 @@ function useActiveKeys() { return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) } +function useState() { + const value = useValue() + const commands = useCommands() + const pending = usePendingSequence() + const active = useActiveKeys() + return { + commands, + pending: (): readonly KeymapPending[] => + pending().map((item) => ({ + key: formatKeySequence([item], formatOptions(value.config)) ?? "", + ...(item.tokenName ? { token: item.tokenName } : {}), + })), + active: (): readonly KeymapActive[] => + active().map((item) => ({ + key: + formatKeySequence( + [{ stroke: item.stroke, display: item.display, tokenName: item.tokenName }], + formatOptions(value.config), + ) ?? "", + ...(typeof item.commandAttrs?.title === "string" ? { title: item.commandAttrs.title } : {}), + ...(typeof item.bindingAttrs?.desc === "string" + ? { description: item.bindingAttrs.desc } + : typeof item.commandAttrs?.desc === "string" + ? { description: item.commandAttrs.desc } + : {}), + ...(typeof item.commandAttrs?.category === "string" + ? { group: item.commandAttrs.category } + : typeof item.bindingAttrs?.group === "string" + ? { group: item.bindingAttrs.group } + : {}), + continues: item.continues, + })), + } +} + function useValue() { const value = useContext(Context) if (!value) throw new Error("Keymap.Provider is missing") @@ -344,6 +388,7 @@ export const Keymap = { useCommands, usePendingSequence, useActiveKeys, + useState, } as const function createMode(keymap: OpenTuiKeymap) { diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index e3c1783f58..0a5af4cd54 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,6 +1,5 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/v1/tui" import type { PluginRuntime } from "../plugin/runtime" -import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" @@ -11,7 +10,7 @@ export type BuiltinTuiPlugin = Omit & { } export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [Notifications, PluginManager, WhichKey] + return [PluginManager, WhichKey] } export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 60623ef61b..cbd360fcd1 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -2,13 +2,11 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useTuiApp, useTuiPaths } from "../../context/runtime" -import { useTheme } from "../../context/theme" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" import { stringWidth } from "../../util/string-width" function Directory(props: { context: Plugin.Context; maxWidth: number }) { - const { themeV2 } = useTheme() const paths = useTuiPaths() const directory = createMemo(() => props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, @@ -16,13 +14,12 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) { return ( - {(value) => } + {(value) => } ) } function Mcp(props: { context: Plugin.Context }) { - const { themeV2 } = useTheme() const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? []) const failed = createMemo(() => list().some((item) => item.status.status === "failed")) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) @@ -30,25 +27,31 @@ function Mcp(props: { context: Plugin.Context }) { return ( - + - + - 0 ? themeV2.text.feedback.success.default : themeV2.text.subdued }}>⊙ + 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued, + }} + > + ⊙{" "} + {count()} MCP - /status + /status ) } function View(props: { context: Plugin.Context }) { - const { themeV2 } = useTheme() const app = useTuiApp() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { @@ -76,7 +79,7 @@ function View(props: { context: Plugin.Context }) { - {app.version} + {app.version} ) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index d7dbbd957e..d72421676c 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -83,7 +83,7 @@ function diffSourceLabel(mode: DiffMode) { function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const config = useConfig() - const dialog = useDialog() + const dialog = props.context.ui.dialog const themeState = useTheme() const themeV2 = themeState.themeV2 const params = () => { @@ -141,7 +141,7 @@ function DiffViewer(props: { context: Plugin.Context }) { const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) const focusRunner = (input: Record void>) => () => input[focus()]() - const shortcut = (id: string) => () => props.context.keymap.shortcut(id) + const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const switchFocusShortcut = shortcut("diff.switch_focus") const nextHunkShortcut = shortcut("diff.next_hunk") const previousHunkShortcut = shortcut("diff.previous_hunk") @@ -703,7 +703,7 @@ function DiffViewer(props: { context: Plugin.Context }) { }) const openSwitchDiffDialog = () => { - dialog.replace(() => ( + dialog.show(() => ( ({ ...option, - onSelect(dialog) { + onSelect() { dialog.clear() props.context.ui.router.navigate({ type: "plugin", @@ -729,8 +729,8 @@ function DiffViewer(props: { context: Plugin.Context }) { } const openHelpDialog = () => { - dialog.replace(() => ) - dialog.setSize("large") + dialog.show(() => ) + dialog.set({ size: "large" }) } props.context.keymap.layer(() => ({ @@ -952,7 +952,7 @@ function DiffViewer(props: { context: Plugin.Context }) { function DiffViewerHelpDialog(props: { context: Plugin.Context }) { const { themeV2 } = useTheme().contextual("elevated") - const shortcut = (id: string) => () => props.context.keymap.shortcut(id) + const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] const rows = [ { shortcut: () => "q", diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 6592b843b9..fd5d35b9b1 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,21 +1,19 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context" import type { OpenCodeEvent } from "@opencode-ai/client" -import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import type { BuiltinTuiPlugin } from "../builtins" - -const id = "internal:notifications" type SessionError = Extract["data"]["error"] function notify( - api: TuiPluginApi, + context: Plugin.Context, sessionID: string | undefined, message: string, - sound: TuiAttentionSoundName, + sound: AttentionSoundName, title?: string, ) { - const session = sessionID ? api.state.session.get(sessionID) : undefined + const session = sessionID ? context.data.session.get(sessionID) : undefined const isSubagent = session?.parentID !== undefined - void api.attention.notify({ + void context.attention.notify({ title: title ?? session?.title, message, notification: isSubagent ? false : { when: "blurred" }, @@ -32,101 +30,74 @@ function sessionErrorMessage(error: SessionError) { return "Session error" } -const tui: TuiPlugin = async (api) => { - const errored = new Set() - const terminal = new Set() - const forms = new Set() - const questions = new Set() - const permissions = new Set() +export default Plugin.define({ + id: "opencode.notifications", + setup(context) { + const errored = new Set() + const terminal = new Set() + const forms = new Set() + const questions = new Set() + const permissions = new Set() - api.event.on("form.created", (event) => { - if (forms.has(event.data.form.id)) return - forms.add(event.data.form.id) - notify( - api, - event.data.form.sessionID, - "Input needs response", - "question", - event.data.form.title, - ) - }) - - api.event.on("form.replied", (event) => { - forms.delete(event.data.id) - }) - - api.event.on("form.cancelled", (event) => { - forms.delete(event.data.id) - }) - - api.event.on("question.asked", (event) => { - if (questions.has(event.data.id)) return - questions.add(event.data.id) - notify(api, event.data.sessionID, "Question needs input", "question") - }) - - api.event.on("question.replied", (event) => { - questions.delete(event.data.requestID) - }) - - api.event.on("question.rejected", (event) => { - questions.delete(event.data.requestID) - }) - - api.event.on("permission.asked", (event) => { - if (permissions.has(event.data.id)) return - permissions.add(event.data.id) - notify(api, event.data.sessionID, "Permission needs input", "permission") - }) - - api.event.on("permission.replied", (event) => { - permissions.delete(event.data.requestID) - }) - - const started = (sessionID: string) => { - errored.delete(sessionID) - terminal.delete(sessionID) - } - - const ended = (sessionID: string) => { - if (terminal.has(sessionID)) return - terminal.add(sessionID) - if (errored.has(sessionID)) { + const started = (sessionID: string) => { errored.delete(sessionID) - return + terminal.delete(sessionID) + } + const ended = (sessionID: string) => { + if (terminal.has(sessionID)) return + terminal.add(sessionID) + if (errored.has(sessionID)) { + errored.delete(sessionID) + return + } + const session = context.data.session.get(sessionID) + notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - const session = api.state.session.get(sessionID) - notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") - } + const dispose = [ + context.data.on("form.created", (event) => { + if (forms.has(event.data.form.id)) return + forms.add(event.data.form.id) + notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title) + }), + context.data.on("form.replied", (event) => forms.delete(event.data.id)), + context.data.on("form.cancelled", (event) => forms.delete(event.data.id)), + context.data.on("question.asked", (event) => { + if (questions.has(event.data.id)) return + questions.add(event.data.id) + notify(context, event.data.sessionID, "Question needs input", "question") + }), + context.data.on("question.replied", (event) => questions.delete(event.data.requestID)), + context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)), + context.data.on("permission.asked", (event) => { + if (permissions.has(event.data.id)) return + permissions.add(event.data.id) + notify(context, event.data.sessionID, "Permission needs input", "permission") + }), + context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)), + context.data.on("session.execution.started", (event) => started(event.data.sessionID)), + context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)), + context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)), + context.data.on("session.execution.failed", (event) => { + const sessionID = event.data.sessionID + if (errored.has(sessionID)) { + ended(sessionID) + return + } + errored.add(sessionID) + notify(context, sessionID, event.data.error.message, "error") + ended(sessionID) + }), + context.data.on("session.error", (event) => { + const sessionID = event.data.sessionID + if (!sessionID) return + if (context.data.session.status(sessionID) !== "running") return + if (errored.has(sessionID)) return + errored.add(sessionID) + notify(context, sessionID, sessionErrorMessage(event.data.error), "error") + }), + ] - api.event.on("session.execution.started", (event) => started(event.data.sessionID)) - api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID)) - api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID)) - api.event.on("session.execution.failed", (event) => { - const sessionID = event.data.sessionID - if (errored.has(sessionID)) { - ended(sessionID) - return - } - errored.add(sessionID) - notify(api, sessionID, event.data.error.message, "error") - ended(sessionID) - }) - - api.event.on("session.error", (event) => { - const sessionID = event.data.sessionID - if (!sessionID) return - if (api.state.session.status(sessionID)?.type !== "busy") return - if (errored.has(sessionID)) return - errored.add(sessionID) - notify(api, sessionID, sessionErrorMessage(event.data.error), "error") - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin + return () => dispose.reverse().forEach((cleanup) => cleanup()) + }, +}) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 4e9040ce6e..5c2cb7cbc3 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarMcp from "../feature-plugins/sidebar/mcp" import DiffViewer from "../feature-plugins/system/diff-viewer" +import Notifications from "../feature-plugins/system/notifications" import Scrap from "../feature-plugins/system/scrap" export const builtins = [ @@ -12,6 +13,7 @@ export const builtins = [ SidebarMcp, SidebarLsp, SidebarFooter, + Notifications, Scrap, DiffViewer, ] diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a42434b5d6..21da40657c 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -13,8 +13,9 @@ import { import path from "path" import { stat } from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" -import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context" +import type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context" import { createStore, produce, reconcile as reconcileStore } from "solid-js/store" +import { useRenderer } from "@opentui/solid" import { useConfig } from "../config" import { useClient } from "../context/client" import { useData } from "../context/data" @@ -22,6 +23,14 @@ import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiLifecycle } from "../context/runtime" import { useLocation } from "../context/location" +import { useTheme } from "../context/theme" +import { DialogAlert } from "../ui/dialog-alert" +import { DialogConfirm } from "../ui/dialog-confirm" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect } from "../ui/dialog-select" +import { useDialog } from "../ui/dialog" +import { useToast } from "../ui/toast" +import { useAttention } from "../context/attention" import { builtins } from "./builtins" export interface PackageResolver { @@ -57,14 +66,20 @@ type Registration = { const PluginContext = createContext() export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { + const renderer = useRenderer() const client = useClient() const data = useData() const route = useRoute() const config = useConfig() const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() + const keymapState = Keymap.useState() const lifecycle = useTuiLifecycle() const location = useLocation() + const theme = useTheme() + const dialog = useDialog() + const toast = useToast() + const attention = useAttention() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -82,20 +97,144 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> setStore("registrations", id, "cleanups", []) }) const owned: Dispose[] = [] + let activeDialog: symbol | undefined + const dialogApi: Dialog = { + show(render, onClose) { + const token = Symbol() + let closed = false + activeDialog = token + dialog.replace(render, () => { + if (closed) return + closed = true + if (activeDialog === token) activeDialog = undefined + onClose?.() + }) + return () => { + if (closed || activeDialog !== token) return + dialog.clear() + } + }, + set(options) { + if (!activeDialog) return + dialog.setSize(options.size ?? "medium") + dialog.setCentered(options.centered ?? false) + }, + clear() { + if (!activeDialog) return + dialog.clear() + }, + alert(options) { + return new Promise((resolve) => { + let settled = false + const done = () => { + if (settled) return + settled = true + resolve() + } + dialogApi.show(() => , done) + }) + }, + confirm(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: boolean | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + done(true)} + onCancel={() => done(false)} + /> + ), + () => done(undefined), + ) + }) + }, + prompt(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: string | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + {options.description} : undefined} + placeholder={options.placeholder} + value={options.value} + onConfirm={(value) => { + done(value) + dialogApi.clear() + }} + /> + ), + () => done(undefined), + ) + }) + }, + select(options) { + return new Promise((resolve) => { + let settled = false + const done = (result: (typeof options.options)[number]["value"] | undefined) => { + if (settled) return + settled = true + resolve(result) + } + dialogApi.show( + () => ( + ({ ...option }))} + current={options.current} + onSelect={(option) => { + done(option.value) + dialogApi.clear() + }} + /> + ), + () => done(undefined), + ) + }) + }, + } + const toastApi: Toast = { + show(options) { + toast.show({ ...options, variant: options.variant ?? "info" }) + }, + } + owned.push(async () => dialogApi.clear()) const context: Context = { options: item.options ?? {}, get location() { return location.current }, + renderer, client: client.api, data, + attention, + theme: theme.themeV2, keymap: { layer: Keymap.createLayer, dispatch: keymap.dispatch, - shortcut: shortcuts.get, + shortcuts: shortcuts.list, + commands: keymapState.commands, + pending: keymapState.pending, + active: keymapState.active, mode: keymap.mode, }, ui: { + dialog: dialogApi, + toast: toastApi, router: { register(page) { if (store.registrations[item.plugin.id]?.routes[page.name]) diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 6abbc98dd6..1541c4c992 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -11,7 +11,10 @@ export type DialogConfirmProps = { message: string onConfirm?: () => void onCancel?: () => void - label?: string + label?: { + confirm?: string + cancel?: string + } } export type DialogConfirmResult = boolean | undefined @@ -81,7 +84,7 @@ export function DialogConfirm(props: DialogConfirmProps) { }} > - {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)} + {Locale.titlecase(props.label?.[key] ?? key)} )} @@ -91,7 +94,7 @@ export function DialogConfirm(props: DialogConfirmProps) { ) } -DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => { +DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => { return new Promise((resolve) => { dialog.replace( () => ( diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index f717496def..6574874c8a 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -1,27 +1,17 @@ import { describe, expect, test } from "bun:test" import Notifications from "../../../../src/feature-plugins/system/notifications" import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client" -import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/v1/tui" -import { createTuiPluginApi } from "../../../fixture/tui-plugin" +import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context" -type Session = NonNullable> +type Session = { id: string; title: string; parentID?: string } async function setup() { - const notifications: TuiAttentionNotifyInput[] = [] + const notifications: AttentionNotifyOptions[] = [] const handlers = new Map void)[]>() - const session = ( - id: string, - title: string, - parentID?: string, - ): Session => ({ + const session = (id: string, title: string, parentID?: string): Session => ({ id, title, - slug: id, - projectID: "project", - directory: "/workspace", ...(parentID && { parentID }), - version: "0.0.0-test", - time: { created: 0, updated: 0 }, }) const sessions: Record = { session: session("session", "Demo session"), @@ -30,41 +20,35 @@ async function setup() { timeout: session("timeout", "Timeout session"), } - await Notifications.tui( - createTuiPluginApi({ - attention: { - async notify(input) { - notifications.push(input) - return { ok: true, notification: true, sound: true } - }, + await Notifications.setup({ + attention: { + async notify(input: AttentionNotifyOptions) { + notifications.push(input) + return { ok: true, notification: true, sound: true } }, - event: { - on: ( - type: Type, - handler: (event: Extract) => void, - ) => { - const list = handlers.get(type) ?? [] - const wrapped = handler as (event: OpenCodeEvent) => void - list.push(wrapped) - handlers.set(type, list) - return () => { - handlers.set( - type, - (handlers.get(type) ?? []).filter((item) => item !== wrapped), - ) - } - }, + }, + data: { + on: ( + type: Type, + handler: (event: Extract) => void, + ) => { + const list = handlers.get(type) ?? [] + const wrapped = handler as (event: OpenCodeEvent) => void + list.push(wrapped) + handlers.set(type, list) + return () => { + handlers.set( + type, + (handlers.get(type) ?? []).filter((item) => item !== wrapped), + ) + } }, - state: { - session: { - get: (sessionID: string) => sessions[sessionID], - status: () => ({ type: "busy" }), - }, + session: { + get: (sessionID: string) => sessions[sessionID], + status: () => "running" as const, }, - }), - undefined, - {} as never, - ) + }, + } as unknown as Context) return { notifications, @@ -139,31 +123,31 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent { } } -const questionNotification: TuiAttentionNotifyInput = { +const questionNotification: AttentionNotifyOptions = { title: "Demo session", message: "Question needs input", notification: { when: "blurred" }, sound: { name: "question", when: "always" }, } -const formNotification: TuiAttentionNotifyInput = { +const formNotification: AttentionNotifyOptions = { title: "Input requested", message: "Input needs response", notification: { when: "blurred" }, sound: { name: "question", when: "always" }, } -const titledFormNotification: TuiAttentionNotifyInput = { +const titledFormNotification: AttentionNotifyOptions = { ...formNotification, title: "Confirm deployment", } -const globalFormNotification: TuiAttentionNotifyInput = { +const globalFormNotification: AttentionNotifyOptions = { ...formNotification, title: "demo-mcp is requesting input", } -const permissionNotification: TuiAttentionNotifyInput = { +const permissionNotification: AttentionNotifyOptions = { title: "Demo session", message: "Permission needs input", notification: { when: "blurred" }, diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index c54df1f229..524e1c2b84 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -171,19 +171,25 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, dispatch() {}, - shortcut: () => undefined, + shortcuts: () => [], mode: { current: () => "base", push: () => () => {} }, }, ui: { + dialog: { + show: () => () => {}, + set() {}, + clear() {}, + }, router: { register(page: Page) { if (page.name === "diff") renderDiff = page.render - return () => {} + return () => {} }, navigate(destination: Destination) { - current = destination.type === "plugin" && !("id" in destination) - ? { ...destination, id: "diff-viewer" } - : destination + current = + destination.type === "plugin" && !("id" in destination) + ? { ...destination, id: "diff-viewer" } + : destination }, current: () => current, }, diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 3885914ebc..e6f2c3fc5c 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -76,6 +76,32 @@ test("formats navigation keys as arrows", async () => { } }) +test("returns every formatted command shortcut", async () => { + let read = () => [] as readonly string[] + + function Harness() { + const shortcuts = Keymap.useShortcuts() + Keymap.createLayer(() => ({ + commands: [{ id: "demo.command", bind: "x,y", run() {} }], + })) + read = () => shortcuts.list("demo.command") + return + } + + const app = await testRender(() => ( + + + + + + )) + try { + expect(read()).toEqual(["x", "y"]) + } finally { + app.renderer.destroy() + } +}) + test("global commands stay reachable when the mode changes", async () => { const calls: string[] = [] let exercise = () => {}