فهرست منبع

internal which-key plugin, inactive by default (#26337)

Sebastian 4 ماه پیش
والد
کامیت
19da27e1cb
34فایلهای تغییر یافته به همراه908 افزوده شده و 63 حذف شده
  1. 5 1
      packages/opencode/specs/tui-plugins.md
  2. 17 10
      packages/opencode/src/cli/cmd/tui/app.tsx
  3. 2 0
      packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
  4. 8 0
      packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx
  5. 5 5
      packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx
  6. 3 3
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
  7. 10 0
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  8. 8 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  9. 17 0
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  10. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx
  11. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
  12. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
  13. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
  14. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
  15. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
  16. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
  17. 3 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
  18. 4 3
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
  19. 5 2
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
  20. 610 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx
  21. 4 1
      packages/opencode/src/cli/cmd/tui/plugin/internal.ts
  22. 1 1
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  23. 2 2
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  24. 37 4
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  25. 47 11
      packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
  26. 2 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx
  27. 6 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
  28. 4 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx
  29. 2 2
      packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
  30. 16 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  31. 4 0
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  32. 42 0
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  33. 22 0
      packages/opencode/test/config/tui.test.ts
  34. 1 0
      packages/plugin/src/tui.ts

+ 5 - 1
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

+ 17 - 10
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -399,6 +399,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
       {
         name: "command.palette.show",
         title: "Show command palette",
+        category: "System",
         hidden: true,
         run: () => {
           command.show()
@@ -852,6 +853,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     <box
       width={dimensions().width}
       height={dimensions().height}
+      flexDirection="column"
       backgroundColor={theme.background}
       onMouseDown={(evt) => {
         if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@@ -867,17 +869,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         <TimeToFirstDraw />
       </Show>
       <Show when={ready()}>
-        <Switch>
-          <Match when={route.data.type === "home"}>
-            <Home />
-          </Match>
-          <Match when={route.data.type === "session"}>
-            <Session />
-          </Match>
-        </Switch>
+        <box flexGrow={1} minHeight={0} flexDirection="column">
+          <Switch>
+            <Match when={route.data.type === "home"}>
+              <Home />
+            </Match>
+            <Match when={route.data.type === "session"}>
+              <Session />
+            </Match>
+          </Switch>
+          {plugin()}
+        </box>
+        <box flexShrink={0}>
+          <TuiPluginRuntime.Slot name="app_bottom" />
+        </box>
+        <TuiPluginRuntime.Slot name="app" />
       </Show>
-      {plugin()}
-      <TuiPluginRuntime.Slot name="app" />
       <StartupLoading ready={ready} />
     </box>
   )

+ 2 - 0
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

+ 8 - 0
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)

+ 5 - 5
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") },
     ],
   }))
 

+ 3 - 3
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") },
     ],
   }))
 

+ 10 - 0
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) {

+ 8 - 2
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

+ 17 - 0
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",

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 3 - 2
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,
 }

+ 4 - 3
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,
 }

+ 5 - 2
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,
 }

+ 610 - 0
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<Renderable, KeyEvent>) {
+  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<Renderable, KeyEvent>) {
+  if (active.continues) return "System"
+  return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
+}
+
+function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): 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<string, Entry[]>()
+  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 (
+    <box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
+      <text fg={look().muted} wrapMode="none">
+        Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
+      </text>
+    </box>
+  )
+}
+
+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<string | undefined>()
+  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<Item[]>(() => {
+    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<HeaderItem[]>(() => [
+    ...(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 (
+    <Show when={visible()}>
+      <box
+        position={props.layout === "overlay" ? "absolute" : "relative"}
+        zIndex={3500}
+        left={left}
+        bottom={props.layout === "overlay" ? 0 : undefined}
+        width={dimensions().width}
+        height={panelHeight()}
+        backgroundColor={look().panel}
+        paddingLeft={1}
+        paddingRight={1}
+        paddingTop={1}
+        flexShrink={0}
+        flexDirection="column"
+      >
+        <Show when={headerVisible()}>
+          <box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
+            <For each={headerItems()}>
+              {(item) => (
+                <Show
+                  when={item.type === "tab" ? item.group : undefined}
+                  fallback={
+                    <box flexShrink={0}>
+                      <text wrapMode="none">
+                        <span style={{ fg: upActive() ? look().text : look().muted }}>↑</span>
+                        <span style={{ fg: look().muted }}> </span>
+                        <span style={{ fg: downActive() ? look().text : look().muted }}>↓</span>
+                      </text>
+                    </box>
+                  }
+                >
+                  {(group) => {
+                    const selected = createMemo(() => currentGroup()?.label === group().label)
+                    return (
+                      <box
+                        paddingLeft={1}
+                        paddingRight={1}
+                        flexShrink={0}
+                        backgroundColor={selected() ? look().tab : undefined}
+                        onMouseDown={() => {
+                          setActiveGroup(group().label)
+                          setOffset(0)
+                        }}
+                      >
+                        <text
+                          fg={selected() ? look().tabText : look().muted}
+                          attributes={selected() ? TextAttributes.BOLD : undefined}
+                          wrapMode="none"
+                        >
+                          {group().label}
+                        </text>
+                      </box>
+                    )
+                  }}
+                </Show>
+              )}
+            </For>
+          </box>
+        </Show>
+        <Show when={tabsVisible()}>
+          <box height={TAB_CONTENT_GAP} flexShrink={0} />
+        </Show>
+        <box height={rows()} flexShrink={0} flexDirection="column">
+          <Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
+            <For each={rowIndexes()}>
+              {(row) => (
+                <box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
+                  <For each={shown()}>
+                    {(column) => {
+                      const item = createMemo(() => column[row])
+                      const entry = createMemo(() => {
+                        const value = item()
+                        if (value?.type !== "entry") return undefined
+                        return value
+                      })
+                      return (
+                        <box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
+                          <Show when={item()}>
+                            {(value) => (
+                              <Show
+                                when={entry()}
+                                fallback={
+                                  <text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
+                                    {value().label}
+                                  </text>
+                                }
+                              >
+                                {(binding) => (
+                                  <>
+                                    <box flexGrow={1} minWidth={0}>
+                                      <text
+                                        fg={binding().continues ? look().accent : look().muted}
+                                        wrapMode="none"
+                                        truncate
+                                      >
+                                        {binding().label}
+                                      </text>
+                                    </box>
+                                    <box flexShrink={0}>
+                                      <text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
+                                        {binding().key}
+                                      </text>
+                                    </box>
+                                  </>
+                                )}
+                              </Show>
+                            )}
+                          </Show>
+                        </box>
+                      )
+                    }}
+                  </For>
+                </box>
+              )}
+            </For>
+          </Show>
+        </box>
+        <Show when={footerVisible()}>
+          <box height={FOOTER_MARGIN} flexShrink={0} />
+          <box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
+            <box>
+              <text fg={look().text} wrapMode="none">
+                toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
+              </text>
+            </box>
+            <box>
+              <text fg={look().text} wrapMode="none">
+                {nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
+              </text>
+            </box>
+          </box>
+        </Show>
+      </box>
+    </Show>
+  )
+}
+
+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 <HomeHint api={api} />
+      },
+      app() {
+        return (
+          <Show when={mode() === "overlay"}>
+            <WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
+          </Show>
+        )
+      },
+      app_bottom() {
+        return (
+          <Show when={mode() === "dock"}>
+            <WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
+          </Show>
+        )
+      },
+    },
+  })
+}
+
+const plugin: InternalTuiPlugin = {
+  id: "tui-which-key",
+  enabled: false,
+  tui,
+}
+
+export default plugin

+ 4 - 1
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<TuiPluginModule, "id"> & {
   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] : []),
 ]

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

+ 2 - 2
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1039,8 +1039,8 @@ export function Session() {
         tui: tuiConfig,
       }}
     >
-      <box flexDirection="row">
-        <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" flexGrow={1} minHeight={0}>
+        <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
           <Show when={session()}>
             <scrollbox
               ref={(r) => (scroll = r)}

+ 37 - 4
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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(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"]) : []),
     ],

+ 47 - 11
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,
             ]),
       ],

+ 2 - 0
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()

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

+ 4 - 0
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)

+ 2 - 2
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() },
     ],
   }))
 

+ 16 - 0
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -237,6 +237,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
       commands: [
         {
           name: "dialog.select.prev",
+          title: "Previous item",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             move(-1)
@@ -244,6 +246,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           name: "dialog.select.next",
+          title: "Next item",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             move(1)
@@ -251,6 +255,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           name: "dialog.select.page_up",
+          title: "Page up",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             move(-10)
@@ -258,6 +264,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           name: "dialog.select.page_down",
+          title: "Page down",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             move(10)
@@ -265,6 +273,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           name: "dialog.select.home",
+          title: "First item",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             moveTo(0)
@@ -272,6 +282,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           name: "dialog.select.end",
+          title: "Last item",
+          category: "Dialog",
           run() {
             setStore("input", "keyboard")
             moveTo(flat().length - 1)
@@ -279,10 +291,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         },
         {
           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()

+ 4 - 0
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()

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

+ 22 - 0
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("<leader>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",

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