From 19da27e1cb7ddc0e6a75cca8edafb260f471d5be Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 May 2026 13:55:49 +0200 Subject: [PATCH] internal which-key plugin, inactive by default (#26337) --- packages/opencode/specs/tui-plugins.md | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 27 +- .../cli/cmd/tui/component/dialog-provider.tsx | 2 + .../cmd/tui/component/dialog-retry-action.tsx | 8 + .../dialog-session-delete-failed.tsx | 10 +- .../dialog-workspace-unavailable.tsx | 6 +- .../cmd/tui/component/prompt/autocomplete.tsx | 10 + .../cli/cmd/tui/component/prompt/index.tsx | 10 +- .../src/cli/cmd/tui/config/tui-schema.ts | 17 + .../cmd/tui/feature-plugins/home/footer.tsx | 5 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 5 +- .../tui/feature-plugins/sidebar/context.tsx | 5 +- .../cmd/tui/feature-plugins/sidebar/files.tsx | 5 +- .../tui/feature-plugins/sidebar/footer.tsx | 5 +- .../cmd/tui/feature-plugins/sidebar/lsp.tsx | 5 +- .../cmd/tui/feature-plugins/sidebar/mcp.tsx | 5 +- .../cmd/tui/feature-plugins/sidebar/todo.tsx | 5 +- .../tui/feature-plugins/system/plugins.tsx | 7 +- .../tui/feature-plugins/system/session-v2.tsx | 7 +- .../tui/feature-plugins/system/which-key.tsx | 610 ++++++++++++++++++ .../src/cli/cmd/tui/plugin/internal.ts | 5 +- .../src/cli/cmd/tui/plugin/runtime.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../cli/cmd/tui/routes/session/permission.tsx | 41 +- .../cli/cmd/tui/routes/session/question.tsx | 58 +- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 2 + .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 6 + .../cli/cmd/tui/ui/dialog-export-options.tsx | 4 + .../src/cli/cmd/tui/ui/dialog-help.tsx | 4 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 16 + .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 4 + .../test/cli/tui/plugin-toggle.test.ts | 42 ++ packages/opencode/test/config/tui.test.ts | 22 + packages/plugin/src/tui.ts | 1 + 34 files changed, 908 insertions(+), 63 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 1a337a60c8..73927dbf83 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -36,6 +36,7 @@ Example: - `plugin_enabled` is keyed by plugin id, not by plugin spec. - For file plugins, that id must come from the plugin module's exported `id`. For npm plugins, it is the exported `id` or the package name if `id` is omitted. - Plugins are enabled by default. `plugin_enabled` is only for explicit overrides, usually to disable a plugin with `false`. +- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. @@ -227,6 +228,7 @@ Top-level API groups exposed to `tui(api, options, meta)`: - To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. - Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. +- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options. ### Keys @@ -314,6 +316,7 @@ Theme install behavior: Current host slot names: - `app` +- `app_bottom` - `home_logo` - `home_prompt` with props `{ workspace_id?, ref? }` - `home_prompt_right` with props `{ workspace_id? }` @@ -332,7 +335,8 @@ Slot notes: - `api.slots.register(plugin)` does not return an unregister function. - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. - Plugin-provided `id` is not allowed. -- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `app_bottom`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode. +- `app_bottom` is rendered in normal layout flow below the active route, while `app` is rendered afterward for global app-level UI. - Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`. ### Plugin control and lifecycle diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a8cc7946a9..275a494578 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -399,6 +399,7 @@ function App(props: { onSnapshot?: () => Promise }) { { name: "command.palette.show", title: "Show command palette", + category: "System", hidden: true, run: () => { command.show() @@ -852,6 +853,7 @@ function App(props: { onSnapshot?: () => Promise }) { { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return @@ -867,17 +869,22 @@ function App(props: { onSnapshot?: () => Promise }) { - - - - - - - - + + + + + + + + + + {plugin()} + + + + + - {plugin()} - ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index a03ac7cac2..2caa67b559 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -243,6 +243,8 @@ function AutoMethod(props: AutoMethodProps) { bindings: [ { key: "c", + desc: "Copy provider code", + group: "Dialog", cmd: () => { const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index cbc8f0ef08..6e4db2a21a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -48,18 +48,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) { bindings: [ { key: "left", + desc: "Previous retry option", + group: "Dialog", cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "right", + desc: "Next retry option", + group: "Dialog", cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "tab", + desc: "Next retry option", + group: "Dialog", cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "return", + desc: "Confirm retry option", + group: "Dialog", cmd: () => { if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx index cdd50019e4..f3617a5347 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -42,11 +42,11 @@ export function DialogSessionDeleteFailed(props: { useBindings(() => ({ bindings: [ - { key: "return", cmd: () => void confirm() }, - { key: "left", cmd: () => setStore("active", "delete") }, - { key: "up", cmd: () => setStore("active", "delete") }, - { key: "right", cmd: () => setStore("active", "restore") }, - { key: "down", cmd: () => setStore("active", "restore") }, + { key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() }, + { key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, + { key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, + { key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, + { key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, ], })) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 0da7394bc4..3181bd8590 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -25,9 +25,9 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | useBindings(() => ({ bindings: [ - { key: "return", cmd: () => void confirm() }, - { key: "left", cmd: () => setStore("active", "cancel") }, - { key: "right", cmd: () => setStore("active", "restore") }, + { key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() }, + { key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") }, + { key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") }, ], })) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7a2548704d..57c890f5a2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -528,6 +528,8 @@ export function Autocomplete(props: { commands: [ { name: "prompt.autocomplete.prev", + title: "Previous autocomplete item", + category: "Autocomplete", run() { setStore("input", "keyboard") move(-1) @@ -535,6 +537,8 @@ export function Autocomplete(props: { }, { name: "prompt.autocomplete.next", + title: "Next autocomplete item", + category: "Autocomplete", run() { setStore("input", "keyboard") move(1) @@ -542,18 +546,24 @@ export function Autocomplete(props: { }, { name: "prompt.autocomplete.hide", + title: "Hide autocomplete", + category: "Autocomplete", run() { hide() }, }, { name: "prompt.autocomplete.select", + title: "Select autocomplete item", + category: "Autocomplete", run() { select() }, }, { name: "prompt.autocomplete.complete", + title: "Complete autocomplete item", + category: "Autocomplete", run() { const selected = options()[store.selected] if (selected?.isDirectory) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index e165f75ac0..cafb1ba373 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -892,6 +892,8 @@ export function Prompt(props: PromptProps) { bindings: [ { key: "!", + desc: "Shell mode", + group: "Prompt", cmd: () => { setStore("placeholder", randomIndex(shell().length)) setStore("mode", "shell") @@ -905,7 +907,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", - bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }], + bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], } }) @@ -916,7 +918,7 @@ export function Prompt(props: PromptProps) { cursorVersion() return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 })(), - bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }], + bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], } }) @@ -936,6 +938,8 @@ export function Prompt(props: PromptProps) { commands: [ { name: "prompt.history.previous", + title: "Previous prompt history", + category: "Prompt", run() { if (input.cursorOffset !== 0) { input.cursorOffset = 0 @@ -972,6 +976,8 @@ export function Prompt(props: PromptProps) { commands: [ { name: "prompt.history.next", + title: "Next prompt history", + category: "Prompt", run() { if (input.cursorOffset !== input.plainText.length) { input.cursorOffset = input.plainText.length diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 74e1b696f8..8e142dc101 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -88,6 +88,20 @@ const GlobalKeymapSection = { "terminal.title.toggle": keymapBinding("none"), } +const WhichKeyKeymapSection = { + "tui-which-key.toggle": keymapBinding("ctrl+alt+k"), + "tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"), + "tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"), + "tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["), + "tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"), + "tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"), + "tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"), + "tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"), + "tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"), + "tui-which-key.home": keymapBinding("ctrl+alt+home"), + "tui-which-key.end": keymapBinding("ctrl+alt+end"), +} + const SessionKeymapSection = { "session.share": keymapBinding("none"), "session.rename": keymapBinding("ctrl+r"), @@ -231,6 +245,7 @@ const HomeTipsKeymapSection = { const KeymapSectionsShape = { global: keymapSection(GlobalKeymapSection), + which_key: keymapSection(WhichKeyKeymapSection), session: keymapSection(SessionKeymapSection), prompt: keymapSection(PromptKeymapSection), autocomplete: keymapSection(AutocompleteKeymapSection), @@ -246,6 +261,7 @@ const KeymapSectionsShape = { const KeymapSectionsInputShape = { global: keymapSectionInput(GlobalKeymapSection).optional(), + which_key: keymapSectionInput(WhichKeyKeymapSection).optional(), session: keymapSectionInput(SessionKeymapSection).optional(), prompt: keymapSectionInput(PromptKeymapSection).optional(), autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), @@ -271,6 +287,7 @@ export type KeymapInfo = { export const KeymapSectionGroups = { global: "Global", + which_key: "System", session: "Session", prompt: "Prompt", autocomplete: "Autocomplete", diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx index 7f2ef55e9b..ea9c966bc9 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, Match, Show, Switch } from "solid-js" import { Global } from "@opencode-ai/core/global" @@ -85,7 +86,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index a9542fc127..beb92578fa 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" import { useBindings } from "../../keymap" @@ -50,7 +51,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index 9ffe779791..b3cf2beb44 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -1,5 +1,6 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo } from "solid-js" const id = "internal:sidebar-context" @@ -55,7 +56,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx index c865c5eb49..8951bdcab0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, For, Show, createSignal } from "solid-js" const id = "internal:sidebar-files" @@ -54,7 +55,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx index bb51d4f426..1bdffa4865 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, Show } from "solid-js" import { Global } from "@opencode-ai/core/global" @@ -85,7 +86,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index cb4050fdb8..5127ca0e4b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, For, Show, createSignal } from "solid-js" const id = "internal:sidebar-lsp" @@ -58,7 +59,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx index 391bf27b90..3fc6683103 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js" const id = "internal:sidebar-mcp" @@ -88,7 +89,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx index eed0cb703d..ccd4013b70 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo, For, Show, createSignal } from "solid-js" import { TodoItem } from "../../component/todo-item" @@ -40,7 +41,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index 6551185002..34666ff88c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { useTerminalDimensions } from "@opentui/solid" import { fileURLToPath } from "url" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" @@ -40,7 +41,7 @@ function Install(props: { api: TuiPluginApi }) { useBindings(() => ({ enabled: !busy(), - bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }], + bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }], })) return ( @@ -261,7 +262,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8fca0de0c8..8b741ccb49 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -1,4 +1,5 @@ -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" import { useSyncV2 } from "@tui/context/sync-v2" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" @@ -59,6 +60,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { bindings: [ { key: "escape", + desc: "Back to session", + group: "Session", cmd() { props.api.route.navigate("session", { sessionID: props.sessionID }) }, @@ -1144,7 +1147,7 @@ const tui: TuiPlugin = async (api) => { }) } -const plugin: TuiPluginModule & { id: string } = { +const plugin: InternalTuiPlugin = { id, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx new file mode 100644 index 0000000000..5eea0d87bb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx @@ -0,0 +1,610 @@ +/** @jsxImportSource @opentui/solid */ +import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { useBindings, useKeymapSelector } from "../../keymap" +import type { ActiveKey } from "@opentui/keymap" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" + +const command = { + toggle: "tui-which-key.toggle", + toggleLayout: "tui-which-key.layout.toggle", + togglePending: "tui-which-key.pending.toggle", + groupPrevious: "tui-which-key.group.previous", + groupNext: "tui-which-key.group.next", + scrollUp: "tui-which-key.scroll.up", + scrollDown: "tui-which-key.scroll.down", + pageUp: "tui-which-key.page.up", + pageDown: "tui-which-key.page.down", + home: "tui-which-key.home", + end: "tui-which-key.end", +} as const + +const LAYER_PRIORITY = 900 +const KV_LAYOUT = "which_key_layout" +const KV_PENDING_PREVIEW = "which_key_pending_preview" +const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const +const scrollCommands = [ + command.scrollUp, + command.scrollDown, + command.pageUp, + command.pageDown, + command.home, + command.end, +] as const +const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const +const COLUMN_GAP = 4 +const TAB_GAP = 3 +const MIN_TAB_GAP = 1 +const TAB_CONTENT_GAP = 1 +const MIN_COLUMN_WIDTH = 28 +const MAX_COLUMN_WIDTH = 44 +const PANEL_HEIGHT_RATIO = 0.3 +const MIN_PANEL_HEIGHT = 8 +const MAX_PANEL_HEIGHT = 16 +const PANEL_TOP_PADDING = 1 +const FOOTER_HEIGHT = 1 +const FOOTER_MARGIN = 1 +const UNKNOWN = "Unknown" + +type Layout = "dock" | "overlay" + +type Color = RGBA | string + +type Skin = { + panel: Color + text: Color + muted: Color + subtle: Color + key: Color + accent: Color + tab: Color + tabText: Color +} + +type Entry = { + type: "entry" + key: string + label: string + group: string + continues: boolean +} + +type Group = { + label: string + entries: Entry[] +} + +type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" } + +type GroupHeader = { + type: "group" + label: string +} + +type Item = Entry | GroupHeader + +function text(value: unknown) { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +function ink(api: TuiPluginApi, name: string, fallback: string): Color { + const value = Reflect.get(api.theme.current, name) + if (typeof value === "string") return value + if (value instanceof RGBA) return value + return fallback +} + +function skin(api: TuiPluginApi): Skin { + return { + panel: ink(api, "backgroundMenu", "#1c1c1c"), + text: ink(api, "text", "#f0f0f0"), + muted: ink(api, "textMuted", "#a5a5a5"), + subtle: ink(api, "borderSubtle", "#6f6f6f"), + key: ink(api, "warning", "#ffd75f"), + accent: ink(api, "primary", "#5f87ff"), + tab: ink(api, "primary", "#5f87ff"), + tabText: ink(api, "selectedListItemText", "#ffffff"), + } +} + +function activeKeyLabel(active: ActiveKey) { + const group = text(active.bindingAttrs?.group) + if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN + return ( + text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN + ) +} + +function activeKeyGroup(active: ActiveKey) { + if (active.continues) return "System" + return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN +} + +function activeKeyEntry(api: TuiPluginApi, active: ActiveKey): Entry { + const key = api.keys.formatSequence([ + { + stroke: active.stroke, + display: active.display, + tokenName: active.tokenName, + }, + ]) + const label = activeKeyLabel(active) + return { + type: "entry", + key, + label: active.continues ? `+${label}` : label, + group: activeKeyGroup(active), + continues: active.continues, + } +} + +function grouped(entries: Entry[]): Group[] { + const map = new Map() + for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry]) + return [...map] + .map(([label, entries]) => ({ + label, + entries: entries.toSorted( + (a, b) => + Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key), + ), + })) + .toSorted((a, b) => a.label.localeCompare(b.label)) +} + +function commandShortcut(api: TuiPluginApi, name: string) { + return useKeymapSelector((keymap) => + api.keys.formatSequence( + keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence, + ), + ) +} + +function layout(value: unknown): Layout { + if (value === "overlay") return "overlay" + return "dock" +} + +function HomeHint(props: { api: TuiPluginApi }) { + const trigger = commandShortcut(props.api, command.toggle) + const look = createMemo(() => skin(props.api)) + + return ( + + + Show keyboard shortcuts with {trigger() || command.toggle} + + + ) +} + +function WhichKeyPanel(props: { + api: TuiPluginApi + layout: Layout + mode: () => Layout + pendingPreview: () => boolean + pinned: () => boolean +}) { + const dimensions = useTerminalDimensions() + const [offset, setOffset] = createSignal(0) + const [activeGroup, setActiveGroup] = createSignal() + const pending = useKeymapSelector((keymap) => keymap.getPendingSequence()) + const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) + const pendingActive = createMemo(() => pending().length > 0 && active().length > 0) + const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive()) + const visible = createMemo(() => props.pinned() || pendingAutoVisible()) + const pendingMode = createMemo(() => visible() && pendingActive()) + const left = 0 + const width = createMemo(() => Math.max(1, dimensions().width)) + const panelHeight = createMemo(() => + Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))), + ) + const contentWidth = createMemo(() => Math.max(1, width() - 2)) + const columns = createMemo(() => + Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)), + ) + const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item))) + const groups = createMemo(() => grouped(entries())) + const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0) + const headerVisible = createMemo(() => tabsVisible() || pendingMode()) + const footerVisible = createMemo(() => !pendingMode()) + const rows = createMemo(() => + Math.max( + 1, + panelHeight() - + PANEL_TOP_PADDING - + (headerVisible() ? 1 : 0) - + (tabsVisible() ? TAB_CONTENT_GAP : 0) - + (footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0), + ), + ) + const pageSize = createMemo(() => rows() * columns()) + const currentGroup = createMemo(() => { + const group = activeGroup() + return groups().find((item) => item.label === group) ?? groups()[0] + }) + const activeEntries = createMemo(() => currentGroup()?.entries ?? []) + const items = createMemo(() => { + if (!pendingMode()) return activeEntries() + return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries]) + }) + const maxOffset = createMemo(() => Math.max(0, items().length - pageSize())) + const shown = createMemo(() => { + const columnsItems: Item[][] = [] + let index = offset() + for (let column = 0; column < columns() && index < items().length; column++) { + const list: Item[] = [] + while (list.length < rows() && index < items().length) { + list.push(items()[index]!) + index += 1 + } + columnsItems.push(list) + } + return columnsItems + }) + const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index)) + const trigger = commandShortcut(props.api, command.toggle) + const modeTrigger = commandShortcut(props.api, command.toggleLayout) + const upActive = createMemo(() => offset() > 0) + const downActive = createMemo(() => offset() < maxOffset()) + const scrollable = createMemo(() => maxOffset() > 0) + const headerItems = createMemo(() => [ + ...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []), + ...(scrollable() ? [{ type: "scroll" as const }] : []), + ]) + const tabGap = createMemo(() => { + const itemCount = headerItems().length + if (itemCount <= 1) return 0 + const itemWidth = headerItems().reduce( + (sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3), + 0, + ) + return Math.max( + MIN_TAB_GAP, + Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))), + ) + }) + const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock")) + const look = createMemo(() => skin(props.api)) + const columnWidth = createMemo(() => + Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))), + ) + const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value)) + const scroll = (delta: number) => setOffset((value) => clamp(value + delta)) + const moveGroup = (delta: number) => { + if (pendingMode()) return + const list = groups() + if (!list.length) return + const index = Math.max( + 0, + list.findIndex((item) => item.label === currentGroup()?.label), + ) + setActiveGroup(list[(index + delta + list.length) % list.length]!.label) + setOffset(0) + } + + useBindings(() => ({ + priority: 1000, + enabled: visible(), + commands: [ + { + name: command.groupPrevious, + title: "Previous key binding group", + desc: "Show the previous which-key group", + category: "System", + run() { + moveGroup(-1) + }, + }, + { + name: command.groupNext, + title: "Next key binding group", + desc: "Show the next which-key group", + category: "System", + run() { + moveGroup(1) + }, + }, + { + name: command.scrollUp, + title: "Scroll key bindings up", + desc: "Scroll the which-key panel up", + category: "System", + run() { + scroll(-columns()) + }, + }, + { + name: command.scrollDown, + title: "Scroll key bindings down", + desc: "Scroll the which-key panel down", + category: "System", + run() { + scroll(columns()) + }, + }, + { + name: command.pageUp, + title: "Page key bindings up", + desc: "Page the which-key panel up", + category: "System", + run() { + scroll(-pageSize()) + }, + }, + { + name: command.pageDown, + title: "Page key bindings down", + desc: "Page the which-key panel down", + category: "System", + run() { + scroll(pageSize()) + }, + }, + { + name: command.home, + title: "First key binding", + desc: "Jump to the first which-key binding", + category: "System", + run() { + setOffset(0) + }, + }, + { + name: command.end, + title: "Last key binding", + desc: "Jump to the last which-key binding", + category: "System", + run() { + setOffset(maxOffset()) + }, + }, + ], + bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands), + })) + + createEffect(() => { + if (pendingMode()) return + const group = currentGroup() + if (group?.label === activeGroup()) return + setActiveGroup(group?.label) + }) + + createEffect(() => { + if (pendingMode()) return + activeGroup() + setOffset(0) + }) + + createEffect(() => { + if (!visible()) setOffset(0) + }) + + createEffect(() => { + pending() + setOffset(0) + }) + + createEffect(() => { + setOffset((value) => clamp(value)) + }) + + return ( + + + + + + {(item) => ( + + + + + + + + } + > + {(group) => { + const selected = createMemo(() => currentGroup()?.label === group().label) + return ( + { + setActiveGroup(group().label) + setOffset(0) + }} + > + + {group().label} + + + ) + }} + + )} + + + + + + + + 0} fallback={No reachable bindings}> + + {(row) => ( + + + {(column) => { + const item = createMemo(() => column[row]) + const entry = createMemo(() => { + const value = item() + if (value?.type !== "entry") return undefined + return value + }) + return ( + + + {(value) => ( + + {value().label} + + } + > + {(binding) => ( + <> + + + {binding().label} + + + + + {binding().key} + + + + )} + + )} + + + ) + }} + + + )} + + + + + + + + + toggle {trigger() || command.toggle} + + + + + {nextMode()} {modeTrigger() || command.toggleLayout} + + + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + const [pinned, setPinned] = createSignal(false) + const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock"))) + const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false)) + + api.keymap.registerLayer({ + priority: LAYER_PRIORITY, + commands: [ + { + name: command.toggle, + title: "Show key bindings", + desc: "Toggle which-key overlay", + category: "System", + run() { + setPinned((value) => !value) + }, + }, + { + name: command.toggleLayout, + title: "Toggle key bindings layout", + desc: "Switch which-key between dock and overlay mode", + category: "System", + run() { + setMode((value) => { + const next = value === "dock" ? "overlay" : "dock" + api.kv.set(KV_LAYOUT, next) + return next + }) + }, + }, + { + name: command.togglePending, + title: "Toggle pending key preview", + desc: "Automatically show which-key for pending key sequences in overlay mode", + category: "System", + run() { + setPendingPreview((value) => { + api.kv.set(KV_PENDING_PREVIEW, !value) + return !value + }) + }, + }, + ], + bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands), + }) + + api.slots.register({ + order: 200, + slots: { + home_bottom() { + return + }, + app() { + return ( + + + + ) + }, + app_bottom() { + return ( + + + + ) + }, + }, + }) +} + +const plugin: InternalTuiPlugin = { + id: "tui-which-key", + enabled: false, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 2b0d859192..664b5c1ac1 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -8,12 +8,14 @@ import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" import SessionV2Debug from "../feature-plugins/system/session-v2" +import WhichKey from "../feature-plugins/system/which-key" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import { Flag } from "@opencode-ai/core/flag/flag" -export type InternalTuiPlugin = TuiPluginModule & { +export type InternalTuiPlugin = Omit & { id: string tui: TuiPlugin + enabled?: boolean } export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ @@ -26,5 +28,6 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + WhichKey, ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index a43f62deec..91ccaaaa01 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1049,7 +1049,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved }) { meta, themes: {}, plugin: entry.module.tui, - enabled: true, + enabled: item.enabled ?? true, }) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index af70f83711..f214540c96 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1039,8 +1039,8 @@ export function Session() { tui: tuiConfig, }} > - - + + (scroll = r)} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index fd4c96d124..036b56dbd5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -472,15 +472,22 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( commands: [ { name: "permission.reject.cancel", + title: "Cancel permission rejection", + category: "Permission", run() { props.onCancel() }, }, ], bindings: [ - { key: "escape", cmd: () => props.onCancel() }, + { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, ...keymapConfig.pick("permission", ["permission.reject.cancel"]), - { key: "return", cmd: () => props.onConfirm(input.plainText) }, + { + key: "return", + desc: "Confirm permission rejection", + group: "Permission", + cmd: () => props.onConfirm(input.plainText), + }, ], })) @@ -562,6 +569,8 @@ function Prompt>(props: { commands: [ { name: "permission.prompt.escape", + title: "Reject permission", + category: "Permission", run() { if (!props.escapeKey) return props.onSelect(props.escapeKey) @@ -569,6 +578,8 @@ function Prompt>(props: { }, { name: "permission.prompt.fullscreen", + title: "Toggle permission fullscreen", + category: "Permission", run() { if (!props.fullscreen) return setStore("expanded", (v) => !v) @@ -578,6 +589,8 @@ function Prompt>(props: { bindings: [ { key: "left", + desc: "Previous permission option", + group: "Permission", cmd: () => { const idx = keys.indexOf(store.selected) const next = keys[(idx - 1 + keys.length) % keys.length] @@ -586,6 +599,8 @@ function Prompt>(props: { }, { key: "h", + desc: "Previous permission option", + group: "Permission", cmd: () => { const idx = keys.indexOf(store.selected) const next = keys[(idx - 1 + keys.length) % keys.length] @@ -594,6 +609,8 @@ function Prompt>(props: { }, { key: "right", + desc: "Next permission option", + group: "Permission", cmd: () => { const idx = keys.indexOf(store.selected) const next = keys[(idx + 1) % keys.length] @@ -602,14 +619,30 @@ function Prompt>(props: { }, { key: "l", + desc: "Next permission option", + group: "Permission", cmd: () => { const idx = keys.indexOf(store.selected) const next = keys[(idx + 1) % keys.length] setStore("selected", next) }, }, - { key: "return", cmd: () => props.onSelect(store.selected) }, - ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []), + { + key: "return", + desc: "Select permission option", + group: "Permission", + cmd: () => props.onSelect(store.selected), + }, + ...(props.escapeKey + ? [ + { + key: "escape", + desc: "Reject permission", + group: "Permission", + cmd: () => props.onSelect(props.escapeKey!), + }, + ] + : []), ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), ], diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 811db7e82f..e37b51e0a4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -129,6 +129,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { commands: [ { name: "question.edit.clear", + title: "Clear answer edit", + category: "Question", run() { const text = textarea?.plainText ?? "" if (!text) { @@ -142,6 +144,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { bindings: [ { key: "escape", + desc: "Cancel answer edit", + group: "Question", cmd: () => { setStore("editing", false) }, @@ -149,6 +153,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ...keymapConfig.pick("question", ["question.edit.clear"]), { key: "return", + desc: "Submit answer edit", + group: "Question", cmd: () => { const text = textarea?.plainText?.trim() ?? "" const prev = store.custom[store.tab] @@ -203,38 +209,68 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { commands: [ { name: "question.reject", + title: "Reject question", + category: "Question", run() { reject() }, }, ], bindings: [ - { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, - { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, - { key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) }, - { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "left", + desc: "Previous question", + group: "Question", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { + key: "h", + desc: "Previous question", + group: "Question", + cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), + }, + { key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) }, { key: "tab", + desc: "Next question", + group: "Question", cmd: ({ event }: { event: { shift: boolean } }) => { selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs()) }, }, ...(confirm() - ? [{ key: "return", cmd: () => submit() }, { key: "escape", cmd: () => reject() }, ...sections.question] + ? [ + { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, + { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, + ...sections.question, + ] : [ ...Array.from({ length: max }, (_, index) => ({ key: String(index + 1), + desc: `Select answer ${index + 1}`, + group: "Question", cmd: () => { moveTo(index) selectOption() }, })), - { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) }, - { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) }, - { key: "down", cmd: () => moveTo((store.selected + 1) % total) }, - { key: "j", cmd: () => moveTo((store.selected + 1) % total) }, - { key: "return", cmd: () => selectOption() }, - { key: "escape", cmd: () => reject() }, + { + key: "up", + desc: "Previous answer", + group: "Question", + cmd: () => moveTo((store.selected - 1 + total) % total), + }, + { + key: "k", + desc: "Previous answer", + group: "Question", + cmd: () => moveTo((store.selected - 1 + total) % total), + }, + { key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, + { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, ...sections.question, ]), ], diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 965c80f362..9fe15de6b7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -17,6 +17,8 @@ export function DialogAlert(props: DialogAlertProps) { bindings: [ { key: "return", + desc: "Confirm alert", + group: "Dialog", cmd: () => { props.onConfirm?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 0a1ce0b344..823ba13a89 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -27,6 +27,8 @@ export function DialogConfirm(props: DialogConfirmProps) { bindings: [ { key: "return", + desc: "Confirm dialog selection", + group: "Dialog", cmd: () => { if (store.active === "confirm") props.onConfirm?.() if (store.active === "cancel") props.onCancel?.() @@ -35,12 +37,16 @@ export function DialogConfirm(props: DialogConfirmProps) { }, { key: "left", + desc: "Previous dialog option", + group: "Dialog", cmd: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, }, { key: "right", + desc: "Next dialog option", + group: "Dialog", cmd: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 35d9dec4b0..cab853ff2a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -37,6 +37,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { bindings: [ { key: "tab", + desc: "Next export option", + group: "Dialog", cmd: () => { const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ "filename", @@ -58,6 +60,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { bindings: [ { key: "space", + desc: "Toggle export option", + group: "Dialog", cmd: () => { if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index b6a394d2de..c79fa04570 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -10,8 +10,8 @@ export function DialogHelp() { useBindings(() => ({ bindings: [ - { key: "return", cmd: () => dialog.clear() }, - { key: "escape", cmd: () => dialog.clear() }, + { key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, + { key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, ], })) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index cbf5d2dbfc..afa9d50571 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -237,6 +237,8 @@ export function DialogSelect(props: DialogSelectProps) { commands: [ { name: "dialog.select.prev", + title: "Previous item", + category: "Dialog", run() { setStore("input", "keyboard") move(-1) @@ -244,6 +246,8 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.next", + title: "Next item", + category: "Dialog", run() { setStore("input", "keyboard") move(1) @@ -251,6 +255,8 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.page_up", + title: "Page up", + category: "Dialog", run() { setStore("input", "keyboard") move(-10) @@ -258,6 +264,8 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.page_down", + title: "Page down", + category: "Dialog", run() { setStore("input", "keyboard") move(10) @@ -265,6 +273,8 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.home", + title: "First item", + category: "Dialog", run() { setStore("input", "keyboard") moveTo(0) @@ -272,6 +282,8 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.end", + title: "Last item", + category: "Dialog", run() { setStore("input", "keyboard") moveTo(flat().length - 1) @@ -279,10 +291,14 @@ export function DialogSelect(props: DialogSelectProps) { }, { name: "dialog.select.submit", + title: "Select item", + category: "Dialog", run: submit, }, ...enabledActions.map((item) => ({ name: item.command, + title: item.title, + category: "Dialog", run() { setStore("input", "keyboard") const option = selected() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 0dff8b5433..3c74ded5fd 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -97,6 +97,8 @@ function init() { bindings: [ { key: "escape", + desc: "Close dialog", + group: "Dialog", cmd: () => { if (renderer.getSelection()) { renderer.clearSelection() @@ -109,6 +111,8 @@ function init() { }, { key: "ctrl+c", + desc: "Close dialog", + group: "Dialog", cmd: () => { if (renderer.getSelection()) { renderer.clearSelection() diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 4dde1add4d..0f3f663c02 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -156,3 +156,45 @@ test("kv plugin_enabled overrides tui config on startup", async () => { delete process.env.OPENCODE_PLUGIN_META_FILE } }) + +test("loads disabled-by-default internal plugin inactive and activates on demand", async () => { + await using tmp = await tmpdir() + const config = createTuiResolvedConfig() + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const api = createTuiPluginApi() + + try { + await TuiPluginRuntime.init({ api, config }) + + expect(TuiPluginRuntime.list().find((item) => item.id === "internal:plugin-manager")).toMatchObject({ + enabled: true, + active: true, + }) + expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ + id: "tui-which-key", + source: "internal", + spec: "tui-which-key", + target: "tui-which-key", + enabled: false, + active: false, + }) + + await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true) + expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ + id: "tui-which-key", + source: "internal", + spec: "tui-which-key", + target: "tui-which-key", + enabled: true, + active: true, + }) + expect(api.kv.get("plugin_enabled", {})).toEqual({ + "tui-which-key": true, + }) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + wait.mockRestore() + } +}) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5adff22422..f94642abd5 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -412,6 +412,7 @@ test("resolves semantic keymap sections", async () => { keymap: { sections: { global: { "command.palette.show": "alt+p" }, + which_key: { "tui-which-key.toggle": "alt+k" }, prompt: { "prompt.editor": "ctrl+e" }, autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, dialog_actions: { "dialog.action.toggle": "ctrl+t" }, @@ -427,6 +428,23 @@ test("resolves semantic keymap sections", async () => { const config = await getTuiConfig(tmp.path) expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") + expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe( + "alt+k", + ) + expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe( + "ctrl+alt+shift+k", + ) + expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe( + "ctrl+alt+shift+p", + ) + expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe( + "ctrl+alt+right,ctrl+alt+]", + ) + expect( + (config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as + | { group?: unknown } + | undefined)?.group, + ).toBe("System") expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( "ctrl+j", @@ -467,6 +485,7 @@ test("legacy keybinds transform into semantic keymap sections", async () => { const config = await getTuiConfig(tmp.path) expect(Object.keys(config.keymap.sections)).toEqual([ "global", + "which_key", "session", "prompt", "autocomplete", @@ -480,6 +499,9 @@ test("legacy keybinds transform into semantic keymap sections", async () => { "home_tips", ]) expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") + expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe( + "ctrl+alt+k", + ) expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( "ctrl+j", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 86175c3891..b42bfdaf1f 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -329,6 +329,7 @@ export type TuiSidebarFileItem = { export type TuiHostSlotMap = { app: {} + app_bottom: {} home_logo: {} home_prompt: { workspace_id?: string