From 9a376f11621cd1e0780b86771902ee5e3d9aa7c6 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 21 Sep 2026 21:09:08 +0530 Subject: [PATCH 1/5] fix(tui): `/skills` opens the Altimate skills browser, and its actions work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/skills` was owned twice: the core prompt's `prompt.skills` (upstream's plain selector — Enter inserts `/`, no actions) and the Altimate plugin's `altimate.skill.list` both registered that slash name, so the autocomplete listed two `/skills` rows and Enter took the first. Users were in the plain selector, where ctrl+a is the input's line-home, and the only TUI route to "Publish to workspace" was unreachable (#1328). - The core command keeps its palette entry and loses the slash name; the Altimate browser is `/skills`. - Enter in the browser now USES the skill — inserts `/ ` into the prompt, as the docs said and the core selector did. The action picker is `ctrl+a` and an "Actions" footer button. - Actions, New and Install are `DialogSelect` actions with in-dialog bindings (`ctrl+a` / `ctrl+e` / `ctrl+i`) rather than a plugin-level keymap layer the open dialog outranks; they render as Tab-reachable footer buttons, so none of them depends on a chord. New is `ctrl+e` because `ctrl+n` is every dialog's "next". The dialog actions carry their own command names so the footer labels show the chords that work there. - The plugin `DialogSelect` API gains `actions` and `bindings`, forwarded by the adapter. - `DialogSelect` computed its action list eagerly, before `selected` was declared; a function-valued `disabled` (which the browser needs to keep the synthetic Install row out of the picker) threw "Cannot access 'selected' before initialization". Evaluated lazily now. Verified under vhs on a local build: the footer reads "Actions ctrl+a · New ctrl+e · Install ctrl+i"; Down then ctrl+a opens "Actions: dbt-pr-review"; Enter on a row leaves "/dbt-pr-review " in the prompt without submitting. Six tests mount a real DialogSelect in the provider stack and press the keys (including with the global layer registered and with a function-valued `disabled`). Closes #1328 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- docs/docs/configure/skills.md | 9 +- .../src/plugin/tui/altimate/skill-ops.tsx | 48 ++++- packages/plugin/src/tui.ts | 18 ++ packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/plugin/adapters.tsx | 26 ++- packages/tui/src/ui/dialog-select.tsx | 10 +- .../test/ui/dialog-select-actions.test.tsx | 194 ++++++++++++++++++ 7 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 packages/tui/test/ui/dialog-select-actions.test.tsx diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 9978116285..30c168b968 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -201,7 +201,7 @@ altimate-code skill publish my-tool # upload every file in the skill dir ### TUI -Open the skill browser with `ctrl+i` when no other dialog is open, or type `/skills` in the prompt: +Open the skill browser by typing `/skills` in the prompt (or `k`): ![Skill Browser](../assets/images/skills/tui-skill-browser.png) @@ -209,13 +209,14 @@ Open the skill browser with `ctrl+i` when no other dialog is open, or type `/ski | Key | Action | |-----|--------| -| `ctrl+i` | Open skill browser (when no dialog is open) / Install skill (when inside browser) | | Enter | Use — inserts `/` into the prompt | | `ctrl+a` | Actions — show, edit, test, remove, or publish the selected skill to the linked workspace (the publish row appears only with `ALTIMATE_WORKSPACE=1`) | -| `ctrl+n` | New — scaffold a new skill + CLI tool | +| `ctrl+e` | New — scaffold a new skill + CLI tool (`ctrl+n` moves down the list, as in every dialog) | +| `ctrl+i` | Install a skill from a GitHub repo, URL, or local path | +| Tab / Shift+Tab | Move between the **Actions · New · Install** buttons in the footer, then Enter — the same three without a chord | | Esc | Back — returns to previous screen | -**Create skill** (`ctrl+n`): +**Create skill** (`ctrl+e`, or the **New** footer button): ![Create Skill Dialog](../assets/images/skills/tui-skill-create.png) diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 3421e0e88b..98ba891c77 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -781,9 +781,55 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string | } // altimate_change end props.onCurrent(item.value) - // Selecting a skill opens its action picker (the pre-merge default action was the picker). + // altimate_change start — Enter USES the skill: it inserts `/ ` into the + // prompt, as the docs say and as the core selector this dialog now replaces did + // (#1328). The action picker moved to ctrl+a and the "Actions" footer button. + const ref = api.prompt.active() + if (ref) { + ref.set({ ...ref.current, input: `/${item.value} `, parts: [] }) + api.ui.dialog.clear() + ref.focus() + return + } + // No prompt to write into (no session mounted): fall back to the picker. openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api)) + // altimate_change end }} + // altimate_change start — the picker, create and install as DIALOG actions (#1328). + // The plugin's global keymap layer registers ctrl+a / ctrl+n / ctrl+i too, but while + // this dialog is open its own layer outranks that one: ctrl+a went to the filter + // input's line-home and ctrl+n to `dialog.select.next`. Declared here they are bound + // inside the dialog (the model dialog binds ctrl+a the same way) and rendered as + // footer buttons reachable with Tab, so the picker no longer depends on a chord at + // all. ctrl+n stays the dialog's own "next"; New is ctrl+e in here. + actions={[ + { + command: "altimate.skill.list.actions", + title: "Actions", + disabled: (option) => option === undefined || option.value === INSTALL_ACTION_VALUE, + onTrigger: (item) => { + if (item.value === INSTALL_ACTION_VALUE) return + props.onCurrent(item.value) + openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api)) + }, + }, + { + command: "altimate.skill.list.create", + title: "New", + onTrigger: () => showCreate(api, filter().trim() || undefined), + }, + { + command: "altimate.skill.list.install", + title: "Install", + onTrigger: () => showInstall(api, filter().trim() || undefined), + }, + ]} + bindings={[ + { key: "ctrl+a", cmd: "altimate.skill.list.actions" }, + { key: "ctrl+e", cmd: "altimate.skill.list.create" }, + { key: "ctrl+i", cmd: "altimate.skill.list.install" }, + ]} + // altimate_change end /> ) } diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 70c15b8f46..a4a45951d4 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -183,9 +183,27 @@ export type TuiDialogSelectProps = { // altimate_change start — a fixed-option dialog can hide the filter box entirely renderFilter?: boolean // altimate_change end + // altimate_change start — dialog-level actions: footer buttons (Tab-reachable) with + // keybinds that are live INSIDE the dialog, where the dialog's own layer outranks a + // plugin's global keymap layer. Mirrors the host DialogSelect `actions`/`bindings`. + actions?: TuiDialogSelectAction[] + bindings?: { key: string; cmd: string }[] + // altimate_change end current?: Value } +// altimate_change start +export type TuiDialogSelectAction = { + /** Command name the `bindings` entries refer to. */ + command: string + title: string + side?: "left" | "right" + hidden?: boolean + disabled?: boolean | ((option: TuiDialogSelectOption | undefined) => boolean) + onTrigger: (option: TuiDialogSelectOption) => void +} +// altimate_change end + export type TuiPromptInfo = { input: string mode?: "normal" | "shell" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7c8c5594bf..58bae4f710 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -593,7 +593,12 @@ export function Prompt(props: PromptProps) { title: "Skills", name: "prompt.skills", category: "Prompt", - slashName: "skills", + // altimate_change start — `/skills` belongs to the Altimate skills browser + // (`altimate.skill.list`: browse, actions, create, install). This command kept the + // same slash name, so autocomplete listed two `/skills` rows and Enter took this + // one — the plain selector with no actions — which is why ctrl+a never opened the + // picker (#1328). The command stays in the palette without a slash name. + // altimate_change end run: () => { dialog.replace(() => ( }, - DialogSelect(props) { + // altimate_change start — generic over the option value so the dialog-level + // actions below can be typed against it + DialogSelect(props: TuiDialogSelectProps) { + // altimate_change end return ( ({ + command: action.command, + title: action.title, + side: action.side, + hidden: action.hidden, + disabled: + typeof action.disabled === "function" + ? (option: SelectOption | undefined) => + (action.disabled as (o: TuiDialogSelectOption | undefined) => boolean)( + option ? pickOption(option) : undefined, + ) + : action.disabled, + onTrigger: (option: SelectOption) => action.onTrigger(pickOption(option)), + }))} + bindings={props.bindings} + // altimate_change end current={props.current} /> ) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 99299f4e22..a97c8315e6 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -135,11 +135,15 @@ export function DialogSelect(props: DialogSelectProps) { .filter((item) => item.label), ...(props.footerHints ?? []), ]) - const actionItems = createMemo(() => + // altimate_change start — evaluated lazily rather than as an eager memo: `isActionDisabled` + // reads `selected()`, which is declared further down, so a function-valued `disabled` + // (the Skills browser's, #1328) threw "Cannot access 'selected' before initialization" + // during setup. Every existing caller passed a boolean, which never touched `selected`. + const actionItems = () => visibleActions() .filter(isActionItem) - .filter((item) => !isActionDisabled(item)), - ) + .filter((item) => !isActionDisabled(item)) + // altimate_change end createEffect(() => { const index = focusedAction() diff --git a/packages/tui/test/ui/dialog-select-actions.test.tsx b/packages/tui/test/ui/dialog-select-actions.test.tsx new file mode 100644 index 0000000000..f9b485e9b0 --- /dev/null +++ b/packages/tui/test/ui/dialog-select-actions.test.tsx @@ -0,0 +1,194 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change — dialog-level actions with in-dialog keybinds (#1328). +// +// The Skills browser's "Actions" picker was reachable only through a plugin-registered +// global keymap layer, which the open dialog's own layer (and its focused filter input) +// outranked, so ctrl+a never opened it. Declared as DialogSelect `actions` with `bindings` +// the chord is handled inside the dialog. This mounts a real DialogSelect in the provider +// stack and presses the key. +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onCleanup } from "solid-js" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { TestTuiContexts } from "../fixture/tui-environment" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +async function mount(opts: { bindings?: { key: string; cmd: string }[]; globalLayer?: boolean } = {}) { + const [ + { DialogProvider, useDialog }, + { DialogSelect }, + { ThemeProvider }, + { TuiConfigProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + { KVProvider }, + { ArgsProvider }, + { ToastProvider }, + ] = await Promise.all([ + import("../../src/ui/dialog"), + import("../../src/ui/dialog-select"), + import("../../src/context/theme"), + import("../../src/config"), + import("../../src/keymap"), + import("../../src/context/kv"), + import("../../src/context/args"), + import("../../src/ui/toast"), + ]) + const triggered: string[] = [] + + function Opener() { + const dialog = useDialog() + dialog.replace(() => ( + o === undefined || o.value === "__install__", + onTrigger: (o) => triggered.push(`actions:${o.value}`), + }, + { command: "altimate.skill.create", title: "New", onTrigger: () => triggered.push("create") }, + ]} + bindings={ + opts.bindings ?? [ + { key: "ctrl+a", cmd: "altimate.skill.actions" }, + { key: "ctrl+e", cmd: "altimate.skill.create" }, + ] + } + /> + )) + return + } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1000 }) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + // The plugin's global layer, as skill-ops.tsx registers it at plugin init: the same + // command name, bound to the same chord, active everywhere. + if (opts.globalLayer) { + const offGlobal = keymap.registerLayer({ + commands: [ + { + name: "altimate.skill.actions", + title: "Skill actions", + run() { + triggered.push("global") + }, + }, + ], + bindings: [{ key: "ctrl+a", cmd: "altimate.skill.actions" }], + }) + onCleanup(offGlobal) + } + return ( + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => ) + await Bun.sleep(50) + return { app, triggered } +} + +test("ctrl+a inside an open DialogSelect triggers the declared action for the highlighted row", async () => { + const { app, triggered } = await mount() + try { + app.mockInput.pressKey("a", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["actions:alpha"]) + } finally { + app.renderer.destroy() + } +}) + +test("the action follows the highlight: Down then ctrl+a names the second row", async () => { + const { app, triggered } = await mount() + try { + app.mockInput.pressKey("ARROW_DOWN") + await Bun.sleep(20) + app.mockInput.pressKey("a", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["actions:beta"]) + } finally { + app.renderer.destroy() + } +}) + +test("a second action with its own chord fires independently", async () => { + const { app, triggered } = await mount() + try { + app.mockInput.pressKey("e", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["create"]) + } finally { + app.renderer.destroy() + } +}) + +test("without a binding the chord does nothing — the test proves the binding is what carries it", async () => { + const { app, triggered } = await mount({ bindings: [] }) + try { + app.mockInput.pressKey("a", { ctrl: true }) + await Bun.sleep(150) + expect(triggered).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + +test("REPRO: with the plugin's global layer registering the same command name, the dialog action is what fires", async () => { + const { app, triggered } = await mount({ globalLayer: true }) + try { + app.mockInput.pressKey("a", { ctrl: true }) + await Bun.sleep(200) + expect(triggered).toEqual(["actions:alpha"]) + } finally { + app.renderer.destroy() + } +}) + +test("the actions render as footer buttons with their chords, so the picker is discoverable without one", async () => { + const { app } = await mount({ globalLayer: true }) + try { + await app.renderOnce() + const frame = app.captureCharFrame() + expect(frame).toContain("Actions") + expect(frame).toContain("New") + expect(frame).toMatch(/ctrl\+a|\^a/i) + } finally { + app.renderer.destroy() + } +}) From 726a3c7fe3f20b0cc68fe66f93e3ebd0eccd4779 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 21 Sep 2026 22:07:09 +0530 Subject: [PATCH 2/5] =?UTF-8?q?fix(tui):=20bind=20Install=20to=20ctrl+g=20?= =?UTF-8?q?=E2=80=94=20ctrl+i=20is=20Tab=20on=20the=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most terminals send ctrl+i as byte 0x09, which the TUI receives as Tab: the footer's own key. The documented chord could move footer focus instead of opening Install. ctrl+g is free everywhere in the TUI; the plugin's global binding and the docs table follow. Test: Tab runs nothing, ctrl+g runs Install. (bot review on #1342) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- docs/docs/configure/skills.md | 4 ++-- .../src/plugin/tui/altimate/skill-ops.tsx | 9 ++++++--- .../test/ui/dialog-select-actions.test.tsx | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index 30c168b968..a6d3e8df84 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -212,7 +212,7 @@ Open the skill browser by typing `/skills` in the prompt (or `k`): | Enter | Use — inserts `/` into the prompt | | `ctrl+a` | Actions — show, edit, test, remove, or publish the selected skill to the linked workspace (the publish row appears only with `ALTIMATE_WORKSPACE=1`) | | `ctrl+e` | New — scaffold a new skill + CLI tool (`ctrl+n` moves down the list, as in every dialog) | -| `ctrl+i` | Install a skill from a GitHub repo, URL, or local path | +| `ctrl+g` | Install a skill from a GitHub repo, URL, or local path (`ctrl+i` is Tab in most terminals, so it cannot be the chord) | | Tab / Shift+Tab | Move between the **Actions · New · Install** buttons in the footer, then Enter — the same three without a chord | | Esc | Back — returns to previous screen | @@ -220,7 +220,7 @@ Open the skill browser by typing `/skills` in the prompt (or `k`): ![Create Skill Dialog](../assets/images/skills/tui-skill-create.png) -**Install skill** (`ctrl+i` inside browser): +**Install skill** (`ctrl+g`, or the **Install** footer button): ![Install Skill Dialog](../assets/images/skills/tui-skill-install.png) diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 98ba891c77..1a6bc1a1d3 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -801,7 +801,9 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string | // input's line-home and ctrl+n to `dialog.select.next`. Declared here they are bound // inside the dialog (the model dialog binds ctrl+a the same way) and rendered as // footer buttons reachable with Tab, so the picker no longer depends on a chord at - // all. ctrl+n stays the dialog's own "next"; New is ctrl+e in here. + // all. ctrl+n stays the dialog's own "next"; New is ctrl+e in here. Install is + // ctrl+g, not ctrl+i: most terminals send ctrl+i as byte 0x09, which is Tab — the + // footer's own key (bot review). actions={[ { command: "altimate.skill.list.actions", @@ -827,7 +829,7 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string | bindings={[ { key: "ctrl+a", cmd: "altimate.skill.list.actions" }, { key: "ctrl+e", cmd: "altimate.skill.list.create" }, - { key: "ctrl+i", cmd: "altimate.skill.list.install" }, + { key: "ctrl+g", cmd: "altimate.skill.list.install" }, ]} // altimate_change end /> @@ -930,11 +932,12 @@ const tui: TuiPlugin = async (api) => { // ctrl+a -> actions · ctrl+n -> create · ctrl+i -> install. // altimate_change start — restore a default key to OPEN the skills list (pre-merge skill_list // was ctrl+i, which now collides with tab/agent-cycle; use a collision-free k instead). + // Install is ctrl+g for the same reason: ctrl+i is Tab on the wire for most terminals. bindings: [ { key: "k", cmd: "altimate.skill.list" }, { key: "ctrl+a", cmd: "altimate.skill.actions" }, { key: "ctrl+n", cmd: "altimate.skill.create" }, - { key: "ctrl+i", cmd: "altimate.skill.install" }, + { key: "ctrl+g", cmd: "altimate.skill.install" }, ], // altimate_change end }) diff --git a/packages/tui/test/ui/dialog-select-actions.test.tsx b/packages/tui/test/ui/dialog-select-actions.test.tsx index f9b485e9b0..a4c5454247 100644 --- a/packages/tui/test/ui/dialog-select-actions.test.tsx +++ b/packages/tui/test/ui/dialog-select-actions.test.tsx @@ -62,11 +62,13 @@ async function mount(opts: { bindings?: { key: string; cmd: string }[]; globalLa onTrigger: (o) => triggered.push(`actions:${o.value}`), }, { command: "altimate.skill.create", title: "New", onTrigger: () => triggered.push("create") }, + { command: "altimate.skill.install", title: "Install", onTrigger: () => triggered.push("install") }, ]} bindings={ opts.bindings ?? [ { key: "ctrl+a", cmd: "altimate.skill.actions" }, { key: "ctrl+e", cmd: "altimate.skill.create" }, + { key: "ctrl+g", cmd: "altimate.skill.install" }, ] } /> @@ -192,3 +194,20 @@ test("the actions render as footer buttons with their chords, so the picker is d app.renderer.destroy() } }) + +// Install is ctrl+g because ctrl+i is Tab on the wire for most terminals (byte 0x09), and +// Tab is the footer's own key. A Tab press must move footer focus, not run Install; the +// chord that runs it must be one no terminal folds into Tab. (bot review on #1342) +test("Tab walks the footer and does not run Install; ctrl+g does", async () => { + const { app, triggered } = await mount() + try { + app.mockInput.pressKey("TAB") + await Bun.sleep(150) + expect(triggered).toEqual([]) + app.mockInput.pressKey("g", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["install"]) + } finally { + app.renderer.destroy() + } +}) From 995bb3b16f0d4f1b2e80e685f8142a0d2f87d792 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 21 Sep 2026 22:48:05 +0530 Subject: [PATCH 3/5] fix(tui): standalone New/Install, route every Skills entry to the browser, no global ctrl+g MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of #1342 (gpt-5.6-sol). - `DialogSelect` refused to run any action without a highlighted row, so with a filter that matched nothing — the create-from-filter flow — ctrl+e and the New/Install footer buttons did nothing. Actions can be `standalone` (discriminated union, so every existing row-bound caller types unchanged); the plugin API and adapter carry it, and New / Install set it - The core `prompt.skills` command was still a palette row and the target of a configured `prompt_skills` keybind, both opening the plain selector. It now dispatches `altimate.skill.list` when that is registered and is hidden from the palette then, so no route lands on the selector while the browser exists - The global ctrl+g binding collided with the session route's `messages_first`; Install keeps its dialog-local chord only - `docs/configure/tools/custom.md` still said ctrl+i - Tests: standalone fires with nothing matching while the row-bound action does not; the same through the plugin API adapter (a dropped forward fails it — checked by mutation); Tab then Enter activates the focused footer button Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- docs/docs/configure/tools/custom.md | 2 +- .../src/plugin/tui/altimate/skill-ops.tsx | 11 +- packages/plugin/src/tui.ts | 7 +- packages/tui/src/component/prompt/index.tsx | 12 +- packages/tui/src/plugin/adapters.tsx | 8 +- packages/tui/src/ui/dialog-select.tsx | 39 ++++-- .../test/ui/dialog-select-actions.test.tsx | 117 +++++++++++++----- 7 files changed, 148 insertions(+), 48 deletions(-) diff --git a/docs/docs/configure/tools/custom.md b/docs/docs/configure/tools/custom.md index c7b70ea276..c960986bef 100644 --- a/docs/docs/configure/tools/custom.md +++ b/docs/docs/configure/tools/custom.md @@ -73,7 +73,7 @@ altimate-code skill install https://github.com/owner/repo/tree/main/skills/my-sk altimate-code skill remove my-skill ``` -Or use the TUI: type `/skills`, then `ctrl+i` to install or `ctrl+a` → Remove to delete. +Or use the TUI: type `/skills`, then `ctrl+g` (or the **Install** footer button) to install, or `ctrl+a` → Remove to delete. ### Output Conventions diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 1a6bc1a1d3..856c113060 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -810,19 +810,23 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string | title: "Actions", disabled: (option) => option === undefined || option.value === INSTALL_ACTION_VALUE, onTrigger: (item) => { - if (item.value === INSTALL_ACTION_VALUE) return + if (!item || item.value === INSTALL_ACTION_VALUE) return props.onCurrent(item.value) openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api)) }, }, + // New and Install need no highlighted row: typing a name that matches no + // installed skill and pressing ctrl+e is the create-from-filter flow. { command: "altimate.skill.list.create", title: "New", + standalone: true, onTrigger: () => showCreate(api, filter().trim() || undefined), }, { command: "altimate.skill.list.install", title: "Install", + standalone: true, onTrigger: () => showInstall(api, filter().trim() || undefined), }, ]} @@ -932,12 +936,13 @@ const tui: TuiPlugin = async (api) => { // ctrl+a -> actions · ctrl+n -> create · ctrl+i -> install. // altimate_change start — restore a default key to OPEN the skills list (pre-merge skill_list // was ctrl+i, which now collides with tab/agent-cycle; use a collision-free k instead). - // Install is ctrl+g for the same reason: ctrl+i is Tab on the wire for most terminals. + // Install has no global chord: ctrl+i is Tab on the wire for most terminals, and + // ctrl+g is the session route's "first message". Inside the browser it is ctrl+g + // (a dialog-local binding, see DialogSkillList); from anywhere else, the palette. bindings: [ { key: "k", cmd: "altimate.skill.list" }, { key: "ctrl+a", cmd: "altimate.skill.actions" }, { key: "ctrl+n", cmd: "altimate.skill.create" }, - { key: "ctrl+g", cmd: "altimate.skill.install" }, ], // altimate_change end }) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index a4a45951d4..917be70b12 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -200,7 +200,12 @@ export type TuiDialogSelectAction = { side?: "left" | "right" hidden?: boolean disabled?: boolean | ((option: TuiDialogSelectOption | undefined) => boolean) - onTrigger: (option: TuiDialogSelectOption) => void + /** Called with the highlighted option — or with `undefined` when `standalone` is set + * and no row is highlighted (empty list, nothing matches the filter). */ + onTrigger: (option: TuiDialogSelectOption | undefined) => void + /** The action needs no highlighted row (create, install): it fires even when the + * list is empty or the filter matches nothing. */ + standalone?: boolean } // altimate_change end diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 58bae4f710..64e99b58cf 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -597,9 +597,17 @@ export function Prompt(props: PromptProps) { // (`altimate.skill.list`: browse, actions, create, install). This command kept the // same slash name, so autocomplete listed two `/skills` rows and Enter took this // one — the plain selector with no actions — which is why ctrl+a never opened the - // picker (#1328). The command stays in the palette without a slash name. - // altimate_change end + // picker (#1328). It has no slash name now, and its other two entry points — the + // palette row and a configured `prompt_skills` keybind — hand over to the browser + // when it is registered, so no route lands on the plain selector while a better + // one exists. Hidden from the palette then, too: two "Skills" rows invite the + // wrong one. + get hidden() { + return keymap.getCommands({ visibility: "registered", filter: { name: "altimate.skill.list" } }).length > 0 + }, run: () => { + if (keymap.dispatchCommand("altimate.skill.list").ok) return + // altimate_change end dialog.replace(() => ( { diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 2d43811169..19a94cc4a5 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -266,7 +266,13 @@ export function createTuiApiAdapters(input: Input): Omit) => action.onTrigger(pickOption(option)), + standalone: true as const, + onTrigger: (option: SelectOption | undefined) => { + // The plugin API's shape is the row-bound one unless `standalone`; the + // core gate is applied here so a plugin action without a row is not called. + if (!option && !action.standalone) return + action.onTrigger(option ? pickOption(option) : undefined) + }, }))} bindings={props.bindings} // altimate_change end diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index a97c8315e6..17f11ca1c6 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -20,6 +20,19 @@ import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../config" import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" +// altimate_change start — see `actions` +type DialogSelectActionBase = { + command: string + title: string + side?: "left" | "right" + hidden?: boolean + disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) +} +export type DialogSelectAction = + | (DialogSelectActionBase & { standalone?: false; onTrigger: (option: DialogSelectOption) => void }) + | (DialogSelectActionBase & { standalone: true; onTrigger: (option: DialogSelectOption | undefined) => void }) +// altimate_change end + export interface DialogSelectProps { title: string titleView?: JSX.Element @@ -35,14 +48,12 @@ export interface DialogSelectProps { skipFilter?: boolean renderFilter?: boolean locked?: boolean - actions?: { - command: string - title: string - side?: "left" | "right" - hidden?: boolean - disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) - onTrigger: (option: DialogSelectOption) => void - }[] + // altimate_change start — a `standalone` action needs no highlighted row (create, + // install): it fires with `undefined` when the list is empty or nothing matches the + // filter. The default keeps the row-bound contract every existing caller relies on, + // as a discriminated union so those callers' `onTrigger` still types as row-bound. + actions?: DialogSelectAction[] + // altimate_change end footerHints?: { title: string label: string @@ -375,8 +386,10 @@ export function DialogSelect(props: DialogSelectProps) { if (isActionDisabled(item)) return setStore("input", "keyboard") const option = selected() - if (!option) return - item.onTrigger(option) + // altimate_change start — see `standalone` + if (item.standalone) item.onTrigger(option) + else if (option) item.onTrigger(option) + // altimate_change end }, })), ], @@ -438,8 +451,10 @@ export function DialogSelect(props: DialogSelectProps) { if (!item || !isActionItem(item) || isActionDisabled(item)) return setStore("input", "keyboard") const option = selected() - if (!option) return - item.onTrigger(option) + // altimate_change start — see `standalone` + if (item.standalone) item.onTrigger(option) + else if (option) item.onTrigger(option) + // altimate_change end } function isActionItem(item: VisibleAction): item is Action & { label: string } { diff --git a/packages/tui/test/ui/dialog-select-actions.test.tsx b/packages/tui/test/ui/dialog-select-actions.test.tsx index a4c5454247..7c5513ceab 100644 --- a/packages/tui/test/ui/dialog-select-actions.test.tsx +++ b/packages/tui/test/ui/dialog-select-actions.test.tsx @@ -21,10 +21,13 @@ async function wait(fn: () => boolean, timeout = 2000) { } } -async function mount(opts: { bindings?: { key: string; cmd: string }[]; globalLayer?: boolean } = {}) { +async function mount( + opts: { bindings?: { key: string; cmd: string }[]; globalLayer?: boolean; via?: "core" | "adapter" } = {}, +) { const [ { DialogProvider, useDialog }, - { DialogSelect }, + { DialogSelect: CoreDialogSelect }, + { createTuiApiAdapters }, { ThemeProvider }, { TuiConfigProvider }, { OpencodeKeymapProvider, registerOpencodeKeymap }, @@ -34,6 +37,7 @@ async function mount(opts: { bindings?: { key: string; cmd: string }[]; globalLa ] = await Promise.all([ import("../../src/ui/dialog"), import("../../src/ui/dialog-select"), + import("../../src/plugin/adapters"), import("../../src/context/theme"), import("../../src/config"), import("../../src/keymap"), @@ -43,34 +47,50 @@ async function mount(opts: { bindings?: { key: string; cmd: string }[]; globalLa ]) const triggered: string[] = [] + // The plugin-API shape, exactly as skill-ops.tsx declares it: a function-valued + // `disabled` (the synthetic Install row must not open the picker), and New / Install + // `standalone` so they fire with no highlighted row. + const actions = [ + { + command: "altimate.skill.actions", + title: "Actions", + disabled: (o: { value: string } | undefined) => o === undefined || o.value === "__install__", + onTrigger: (o: { value: string } | undefined) => triggered.push(`actions:${o?.value}`), + }, + { command: "altimate.skill.create", title: "New", standalone: true, onTrigger: () => triggered.push("create") }, + { command: "altimate.skill.install", title: "Install", standalone: true, onTrigger: () => triggered.push("install") }, + ] + const bindings = opts.bindings ?? [ + { key: "ctrl+a", cmd: "altimate.skill.actions" }, + { key: "ctrl+e", cmd: "altimate.skill.create" }, + { key: "ctrl+g", cmd: "altimate.skill.install" }, + ] + const options = [ + { title: "alpha", value: "alpha" }, + { title: "beta", value: "beta" }, + ] + function Opener() { const dialog = useDialog() + if (opts.via === "adapter") { + // Through the plugin API adapter — the seam skill-ops.tsx really goes through — + // so a dropped `actions`/`bindings`/`standalone` forward fails here. The adapter's + // DialogSelect reads only its props, so the rest of the input is not needed. + const api = createTuiApiAdapters({ + version: "0", + tuiConfig: { keybinds: { gather: () => [], get: () => [] } }, + keymap: { registerLayer: () => () => {} }, + dialog, + } as never) + dialog.replace(() => ) + return + } dialog.replace(() => ( - o === undefined || o.value === "__install__", - onTrigger: (o) => triggered.push(`actions:${o.value}`), - }, - { command: "altimate.skill.create", title: "New", onTrigger: () => triggered.push("create") }, - { command: "altimate.skill.install", title: "Install", onTrigger: () => triggered.push("install") }, - ]} - bindings={ - opts.bindings ?? [ - { key: "ctrl+a", cmd: "altimate.skill.actions" }, - { key: "ctrl+e", cmd: "altimate.skill.create" }, - { key: "ctrl+g", cmd: "altimate.skill.install" }, - ] - } + options={options} + actions={actions.map((a) => ({ ...a, standalone: a.standalone === true }))} + bindings={bindings} /> )) return @@ -198,15 +218,56 @@ test("the actions render as footer buttons with their chords, so the picker is d // Install is ctrl+g because ctrl+i is Tab on the wire for most terminals (byte 0x09), and // Tab is the footer's own key. A Tab press must move footer focus, not run Install; the // chord that runs it must be one no terminal folds into Tab. (bot review on #1342) -test("Tab walks the footer and does not run Install; ctrl+g does", async () => { +test("Tab walks the footer (Enter then activates the focused button) and does not run Install; ctrl+g does", async () => { const { app, triggered } = await mount() try { app.mockInput.pressKey("TAB") await Bun.sleep(150) expect(triggered).toEqual([]) + // Tab moved focus to the first footer button (Actions); Enter activates it. + app.mockInput.pressKey("RETURN") + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["actions:alpha"]) app.mockInput.pressKey("g", { ctrl: true }) + await wait(() => triggered.length > 1) + expect(triggered).toEqual(["actions:alpha", "install"]) + } finally { + app.renderer.destroy() + } +}) + +// codex on #1342: New and Install need no highlighted row. Typing a name that matches no +// installed skill and pressing ctrl+e is the create-from-filter flow, and it did nothing. +test("with nothing matching the filter, ctrl+e still creates and ctrl+a (row-bound) does nothing", async () => { + const { app, triggered } = await mount() + try { + for (const ch of "zzz") app.mockInput.pressKey(ch) + await Bun.sleep(50) + app.mockInput.pressKey("a", { ctrl: true }) + await Bun.sleep(100) + expect(triggered).toEqual([]) + app.mockInput.pressKey("e", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["create"]) + } finally { + app.renderer.destroy() + } +}) + +test("through the plugin API adapter: chords fire, standalone survives the mapping, row-bound gate holds", async () => { + const { app, triggered } = await mount({ via: "adapter" }) + try { + app.mockInput.pressKey("a", { ctrl: true }) await wait(() => triggered.length > 0) - expect(triggered).toEqual(["install"]) + expect(triggered).toEqual(["actions:alpha"]) + for (const ch of "zzz") app.mockInput.pressKey(ch) + await Bun.sleep(50) + app.mockInput.pressKey("a", { ctrl: true }) + await Bun.sleep(100) + expect(triggered).toEqual(["actions:alpha"]) + app.mockInput.pressKey("g", { ctrl: true }) + await wait(() => triggered.length > 1) + expect(triggered).toEqual(["actions:alpha", "install"]) } finally { app.renderer.destroy() } From e705c90815421f956b294b0ff6173d28f07f4c26 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 21 Sep 2026 23:29:00 +0530 Subject: [PATCH 4/5] test(tui): production command names in the dialog test; exercise the adapter gate The dialog actions are named `altimate.skill.list.*` as skill-ops declares them, and the adapter test carries a row-bound action with no `disabled` function, so the adapter's own gate is what stops it with no row (a dropped gate fails the test). (bot review) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- .../tui/test/ui/dialog-select-actions.test.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/tui/test/ui/dialog-select-actions.test.tsx b/packages/tui/test/ui/dialog-select-actions.test.tsx index 7c5513ceab..29419814a0 100644 --- a/packages/tui/test/ui/dialog-select-actions.test.tsx +++ b/packages/tui/test/ui/dialog-select-actions.test.tsx @@ -52,18 +52,22 @@ async function mount( // `standalone` so they fire with no highlighted row. const actions = [ { - command: "altimate.skill.actions", + command: "altimate.skill.list.actions", title: "Actions", disabled: (o: { value: string } | undefined) => o === undefined || o.value === "__install__", onTrigger: (o: { value: string } | undefined) => triggered.push(`actions:${o?.value}`), }, - { command: "altimate.skill.create", title: "New", standalone: true, onTrigger: () => triggered.push("create") }, - { command: "altimate.skill.install", title: "Install", standalone: true, onTrigger: () => triggered.push("install") }, + { command: "altimate.skill.list.create", title: "New", standalone: true, onTrigger: () => triggered.push("create") }, + { command: "altimate.skill.list.install", title: "Install", standalone: true, onTrigger: () => triggered.push("install") }, + // Row-bound with NO `disabled` function: the shape the adapter's own gate exists + // for (the core cannot refuse it before `onTrigger`). (bot review) + { command: "altimate.skill.list.plain", title: "Plain", onTrigger: (o: { value: string } | undefined) => triggered.push(`plain:${o?.value}`) }, ] const bindings = opts.bindings ?? [ - { key: "ctrl+a", cmd: "altimate.skill.actions" }, - { key: "ctrl+e", cmd: "altimate.skill.create" }, - { key: "ctrl+g", cmd: "altimate.skill.install" }, + { key: "ctrl+a", cmd: "altimate.skill.list.actions" }, + { key: "ctrl+e", cmd: "altimate.skill.list.create" }, + { key: "ctrl+g", cmd: "altimate.skill.list.install" }, + { key: "ctrl+p", cmd: "altimate.skill.list.plain" }, ] const options = [ { title: "alpha", value: "alpha" }, @@ -262,7 +266,7 @@ test("through the plugin API adapter: chords fire, standalone survives the mappi expect(triggered).toEqual(["actions:alpha"]) for (const ch of "zzz") app.mockInput.pressKey(ch) await Bun.sleep(50) - app.mockInput.pressKey("a", { ctrl: true }) + app.mockInput.pressKey("p", { ctrl: true }) // row-bound, no `disabled`: the adapter gate alone stops it (ctrl+p is only the palette outside a dialog) await Bun.sleep(100) expect(triggered).toEqual(["actions:alpha"]) app.mockInput.pressKey("g", { ctrl: true }) From b443ef56818d86987e536222d3f7194039560aa4 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 21 Sep 2026 23:29:45 +0530 Subject: [PATCH 5/5] test(tui): the adapter-gate case must use a chord the dialog receives ctrl+p is the palette outside a dialog and never reached the action, so the previous no-row assertion was vacuous. ctrl+o instead, with a positive control that the action fires with a row; disabling the adapter gate now fails the test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- .../tui/test/ui/dialog-select-actions.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/tui/test/ui/dialog-select-actions.test.tsx b/packages/tui/test/ui/dialog-select-actions.test.tsx index 29419814a0..d5c0587748 100644 --- a/packages/tui/test/ui/dialog-select-actions.test.tsx +++ b/packages/tui/test/ui/dialog-select-actions.test.tsx @@ -67,7 +67,7 @@ async function mount( { key: "ctrl+a", cmd: "altimate.skill.list.actions" }, { key: "ctrl+e", cmd: "altimate.skill.list.create" }, { key: "ctrl+g", cmd: "altimate.skill.list.install" }, - { key: "ctrl+p", cmd: "altimate.skill.list.plain" }, + { key: "ctrl+o", cmd: "altimate.skill.list.plain" }, ] const options = [ { title: "alpha", value: "alpha" }, @@ -266,7 +266,7 @@ test("through the plugin API adapter: chords fire, standalone survives the mappi expect(triggered).toEqual(["actions:alpha"]) for (const ch of "zzz") app.mockInput.pressKey(ch) await Bun.sleep(50) - app.mockInput.pressKey("p", { ctrl: true }) // row-bound, no `disabled`: the adapter gate alone stops it (ctrl+p is only the palette outside a dialog) + app.mockInput.pressKey("o", { ctrl: true }) // row-bound, no `disabled`: the adapter gate alone stops it await Bun.sleep(100) expect(triggered).toEqual(["actions:alpha"]) app.mockInput.pressKey("g", { ctrl: true }) @@ -276,3 +276,14 @@ test("through the plugin API adapter: chords fire, standalone survives the mappi app.renderer.destroy() } }) + +test("the plain row-bound action does fire with a row (so the no-row assertion above is not vacuous)", async () => { + const { app, triggered } = await mount({ via: "adapter" }) + try { + app.mockInput.pressKey("o", { ctrl: true }) + await wait(() => triggered.length > 0) + expect(triggered).toEqual(["plain:alpha"]) + } finally { + app.renderer.destroy() + } +})