diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index bbfbc4290..b9b622d1f 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -24,8 +24,10 @@ import type { ThemeSetting } from "./theme-detect.js"; import { focusOwner } from "./focus/index.js"; import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js"; import type { Harness } from "./harness"; +import type { AltChordLetter } from "./key-chords"; import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js"; import type { AppShell } from "./shell/internals"; +import { shellInternals } from "./shell/internals"; import { withAppShell } from "./test-helpers"; import { acceptOverlaySelection, @@ -585,6 +587,34 @@ describe("plugins surface admin actions", () => { }); }); + test("hybrid glyph events never reach the pane's bare s/v claims (I-4)", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps({ + credentialValues: { apiKey: "saved-key" }, + }); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, altKey("c"))).toBe(true); + expect(shell.overlayKind).toBe("plugin_credentials"); + // {name:"v", sequence:"√"} is the composed Option glyph arriving with + // no modifier bit — the input shape altChordLetterForKey reads via + // `sequence`. Read through `name` it used to fire save-and-verify. + expect( + runOverlayAction(shell, { ...charKey("√"), name: "v" } as KeyEvent), + ).toBe(false); + expect( + runOverlayAction(shell, { ...charKey("ß"), name: "s" } as KeyEvent), + ).toBe(false); + expect(calls.saveCredentials).toEqual([]); + expect(calls.verify).toEqual([]); + // A true bare key still claims: the guard is sequence-vs-name, not a + // ban on the letters themselves. + expect(runOverlayAction(shell, charKey("v"))).toBe(true); + await Promise.resolve(); + expect(calls.saveCredentials.length).toBe(1); + expect(calls.verify.length).toBe(1); + }); + }); + test("v verifies the currently saved credentials", async () => { await withShell(async (shell) => { const { deps, calls } = pluginActionDeps({ @@ -876,6 +906,212 @@ describe("plugins surface admin actions", () => { }); }); +/** + * CL-8637: on stock macOS terminals Option+ arrives as the composed + * layout glyph with no modifier bit. On the two surfaces that advertise + * Alt+ chords, that glyph must run the advertised action and must + * never leak into the prompt buffer. + */ +describe("composed option glyphs on /plugins and /mcp (CL-8637)", () => { + test("every advertised plugins glyph runs its chord with the prompt left alone", async () => { + const glyphs = ["å", "∑", "ç", "√", "†", "≈"] as const; + for (const glyph of glyphs) { + await withShell(async (shell) => { + const { deps } = pluginActionDeps({ needsTrust: true }); + openCommandSurface(shell, "plugins", deps); + shell.prompt.value = ""; + expect(runOverlayAction(shell, charKey(glyph))).toBe(true); + expect(shell.prompt.value).toBe(""); + }); + } + }); + + test("å add-by-path and ∑ web chooser keep working through the glyph", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("å"))).toBe(true); + typeOverlayText(shell, "/tmp/glyph-plugin"); + acceptOverlaySelection(shell); + await Promise.resolve(); + expect(calls.addPath).toEqual(["/tmp/glyph-plugin"]); + }); + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface( + shell, + "plugins", + patchPlugins(deps, { list: () => [] }), + ); + expect(runOverlayAction(shell, charKey("∑"))).toBe(true); + moveOverlaySelection(shell, 1); + acceptOverlaySelection(shell); + await Promise.resolve(); + expect(calls.setWebProvider).toEqual(["exa"]); + }); + }); + + test("the trust glyph on a trusted plugin is still a no-op (I-7)", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("†"))).toBe(false); + expect(calls.setEnabled).toEqual([]); + }); + }); + + test("mcp glyphs stay text on /plugins: this surface declares no d or r", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("∂"))).toBe(false); + expect(runOverlayAction(shell, charKey("®"))).toBe(false); + expect(calls.setEnabled).toEqual([]); + expect(calls.remove).toEqual([]); + }); + }); + + test("å completes add-name → URL on /mcp with the prompt left alone", async () => { + await withShell(async (shell) => { + const added: { name: string; url: string }[] = []; + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + ]; + openMcp(shell, { + list: () => liveEntries, + openAuthURL: () => undefined, + addServer: async (name, url) => { + added.push({ name, url }); + liveEntries = [...liveEntries, { name, state: "connecting" }]; + return { ok: true, message: `Added ${name}.` }; + }, + }); + + expect(runOverlayAction(shell, charKey("å"))).toBe(true); + expect(shell.prompt.value).toBe(""); + typeOverlayText(shell, "notion"); + acceptOverlaySelection(shell); + typeOverlayText(shell, "https://mcp.notion.test/mcp"); + acceptOverlaySelection(shell); + await flushSurface(); + + expect(added).toEqual([ + { name: "notion", url: "https://mcp.notion.test/mcp" }, + ]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("∂ disables the focused server on /mcp with the prompt left alone", async () => { + await withShell(async (shell) => { + const toggled: { name: string; enabled: boolean }[] = []; + const notes: string[] = []; + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + ]; + openMcp( + shell, + { + list: () => liveEntries, + openAuthURL: () => undefined, + setEnabled: async (name, enabled) => { + toggled.push({ name, enabled }); + liveEntries = [ + { name, state: enabled ? "connecting" : "disabled" }, + ]; + return { ok: true, message: `Disabled ${name}.` }; + }, + }, + (note) => notes.push(note), + ); + expect(shell.prompt.value).toBe(""); + expect(runOverlayAction(shell, charKey("∂"))).toBe(true); + await flushSurface(); + expect(toggled).toEqual([{ name: "linear", enabled: false }]); + expect(shell.prompt.value).toBe(""); + expect(notes).toEqual(["Disabled linear."]); + }); + }); + + test("® on /mcp opens the remove confirm and Enter completes it", async () => { + await withShell(async (shell) => { + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + { name: "notion", state: "connected", toolCount: 3 }, + ]; + openMcp(shell, { + list: () => liveEntries, + openAuthURL: () => undefined, + removeServer: async (name) => { + liveEntries = liveEntries.filter((entry) => entry.name !== name); + return { ok: true, message: `Removed ${name}.` }; + }, + }); + moveOverlaySelection(shell, 1); + expect(runOverlayAction(shell, charKey("®"))).toBe(true); + acceptOverlaySelection(shell); + await flushSurface(); + + expect(liveEntries.map((entry) => entry.name)).toEqual(["linear"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("å is not a chord on /mcp while local settings shadow global (I-3)", async () => { + await withShell(async (shell) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + mcpServersSource: "local", + addServer: async () => ({ ok: true, message: "should not run" }), + }); + expect(runOverlayAction(shell, charKey("å"))).toBe(false); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("ç is not a chord on /mcp: this surface declares no c", async () => { + await withShell(async (shell) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + }); + expect(runOverlayAction(shell, charKey("ç"))).toBe(false); + }); + }); + + test("rendered footers only advertise declared letters, and never Alt+F (I-3)", async () => { + const advertised = (frame: string): string[] => + [...frame.matchAll(/Alt\+([A-Za-z])/g)].map((m) => + (m[1] ?? "").toLowerCase(), + ); + await withWiredShell(async (shell, harness) => { + const { deps } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + await harness.renderOnce(); + const declared = shellInternals(shell)?.primaryBindings.altChords ?? []; + const letters = advertised(harness.captureCharFrame()); + expect(letters.length).toBeGreaterThan(0); + for (const letter of letters) { + expect(declared.includes(letter as AltChordLetter)).toBe(true); + } + }); + await withWiredShell(async (shell, harness) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + }); + await harness.renderOnce(); + const declared = shellInternals(shell)?.primaryBindings.altChords ?? []; + const letters = advertised(harness.captureCharFrame()); + expect(letters.length).toBeGreaterThan(0); + for (const letter of letters) { + expect(declared.includes(letter as AltChordLetter)).toBe(true); + } + }); + }); +}); + describe("hooks surface", () => { test("lists discovered hooks with enabled state and Enter toggles", async () => { await withShell(async (shell) => { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 368a27c54..5d1751d1d 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -843,6 +843,13 @@ function openCredentialsPane( onAction: (_id, key) => { if (state.editing === null) { if (key.ctrl || key.meta || key.option) return false; + // Bare claims fire only for a true bare key: a hybrid + // {name:"v", sequence:"√"} is the composed Option glyph arriving + // with no modifier bit — exactly the input altChordLetterForKey + // reads through `sequence` — and must not reach save/verify via + // the name table. The text sinks below key on `sequence`, which + // is why they need no such guard (I-4). + if (key.sequence !== key.name) return false; if (key.name === "s") { void Promise.resolve( plugins.saveCredentials(entry.id, state.values), @@ -1099,6 +1106,7 @@ export function openPluginsSurface( title: "plugins", frameId: "overlay-plugins", deferIfBusy: true, + altChords: ["a", "w", "c", "v", "t", "x"] as const, ...payload(rows), describe: (id) => { if (id === PLUGIN_LOAD_WARNINGS_ID) { @@ -1140,26 +1148,21 @@ export function openPluginsSurface( deps.notify(`Plugin update failed: ${errorText(err)}`), ); }, - onAction: (id, key) => { - // Alt+, never bare — bare c/v/t/a/w/x would be swallowed by the - // filter list. Alt+C is safe here: this branch returns before - // shell.ts's top-level onKey reaches the global copy-mode chord. - if (key.ctrl || !(key.meta || key.option)) return false; - const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; - // Surface-level chords — advertised in the how-to even when focus is on - // Close, the empty-list row, or load warnings. - if (name === "a") { + onAltChord: (id, letter) => { + // Surface-level chords run even when focus is on Close, the empty + // list, or load warnings. + if (letter === "a") { openAddPathPane(shell, deps, plugins); return true; } - if (name === "w") { + if (letter === "w") { openWebProviderChooser(shell, deps, plugins); return true; } if (id === PLUGIN_LOAD_WARNINGS_ID) return false; const target = byId.get(id); if (target === undefined) return false; - switch (name) { + switch (letter) { case "c": if (target.credentials.length === 0) return false; openCredentialsPane(shell, deps, plugins, target, { @@ -1516,8 +1519,8 @@ export function openMcpSurface( // The flash below reports the outcome; the echo would quote the row's // pre-authorization label back at the operator forever. echoChoice: false, - mcpManageHint: true, - mcpAddHint: canAdd, + // `a` joins only when adding is possible. + altChords: canAdd ? (["a", "d", "r"] as const) : (["d", "r"] as const), ...payload(rows), describe: (id) => { const target = byName.get(id); @@ -1596,17 +1599,14 @@ export function openMcpSurface( }); closeReplaceableOverlay(shell); }, - onAction: (id, key) => { - if (key.ctrl || !(key.meta || key.option)) return false; - const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; - if (name === "a") { - if (!canAdd) return false; + onAltChord: (id, letter) => { + if (letter === "a") { unsubscribe(); openAddMcpNamePane(shell, deps, mcp); return true; } if (isMcpChromeRow(id)) return false; - if (name === "d") { + if (letter === "d") { const setEnabled = mcp.setEnabled; if (setEnabled === undefined) return false; unsubscribe(); @@ -1619,7 +1619,7 @@ export function openMcpSurface( ); return true; } - if (name === "r") { + if (letter === "r") { const target = byName.get(id); if (target?.builtin === true) { deps.notify( diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts new file mode 100644 index 000000000..722d8b92f --- /dev/null +++ b/src/tui/key-chords.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { type KeyEvent } from "@opentui/core"; +import { + OPTION_CHORD_GLYPHS, + type AltChordLetter, + altChordLetterForKey, +} from "./key-chords.js"; + +function key(fields: Partial): KeyEvent { + return { + name: "", + sequence: "", + ctrl: false, + meta: false, + option: false, + ...fields, + } as KeyEvent; +} + +/** Shape B: a bare composed glyph, no modifier bits. */ +function charKey(glyph: string): KeyEvent { + return key({ name: glyph, sequence: glyph }); +} + +// Proven subset (`å/Å`, `∂`, `®`) is pinned end-to-end by CL-8635's +// composed-glyph tests in product-host.test.ts. The remaining rows are the +// SOLUTION_SCOPE.md §3 allowlist for the macOS built-in U.S./ABC Option +// layer (Option+C ç/Ç, Option+T †, Option+V √, Option+W ∑, Option+X ≈). +// Neither `t` nor `w` carries a shifted form: their ⇧Option layers compose +// `ˇ` and `„`, while `‡` and `◊` are the shifted glyphs of Option+**7** and +// Option+**V** — printables that belong to another key (I-4). +const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; +const GLYPH_LETTERS = Object.keys(OPTION_CHORD_GLYPHS) as AltChordLetter[]; + +describe("altChordLetterForKey", () => { + test("shape A: modifier bit plus ASCII letter", () => { + expect(altChordLetterForKey(key({ name: "d", option: true }), PICKER)).toBe( + "d", + ); + expect(altChordLetterForKey(key({ name: "d", meta: true }), PICKER)).toBe( + "d", + ); + }); + + test("shape A: ctrl always wins", () => { + expect( + altChordLetterForKey( + key({ name: "d", ctrl: true, option: true }), + PICKER, + ), + ).toBeNull(); + }); + + test("shape B: composed glyph as name, or riding the sequence only", () => { + expect(altChordLetterForKey(charKey("∂"), PICKER)).toBe("d"); + expect( + altChordLetterForKey(key({ name: "a", sequence: "å" }), PICKER), + ).toBe("a"); + expect(altChordLetterForKey(charKey("Å"), ["a"])).toBe("a"); + }); + + test("an undeclared letter is never a chord", () => { + expect(altChordLetterForKey(charKey("∂"), ["a"])).toBeNull(); + expect(altChordLetterForKey(charKey("∂"), [])).toBeNull(); + expect( + altChordLetterForKey(key({ name: "d", option: true }), []), + ).toBeNull(); + }); + + test("bare ASCII is never a chord", () => { + expect(altChordLetterForKey(charKey("a"), PICKER)).toBeNull(); + expect(altChordLetterForKey(charKey("r"), PICKER)).toBeNull(); + }); + + test("unknown glyphs never fuzzy-match", () => { + // ç is Option+C on macOS US — a real chord letter elsewhere, but not + // declared here, and never folded into "d". + expect(altChordLetterForKey(charKey("ç"), ["d"])).toBeNull(); + // µ is Option+M; no surface declares m. + expect(altChordLetterForKey(charKey("µ"), PICKER)).toBeNull(); + expect(altChordLetterForKey(charKey("é"), PICKER)).toBeNull(); + // ƒ is Option+F — favorites is excluded from the union (§5), so the + // glyph has no chord path on any surface, whatever is declared. + expect(altChordLetterForKey(charKey("ƒ"), GLYPH_LETTERS)).toBeNull(); + // Shifted-layer glyphs belong to other keys on macOS US: „ is + // ⇧Option+W, ◊ is ⇧Option+V, ‰ is ⇧Option+R and ‡ is ⇧Option+**7** — + // none of them is the glyph of its own letter, so none is a chord + // wherever w, v, r or t is declared (I-4). ‡ in particular once + // hijacked the /plugins trust chord: a printable keystroke granted + // trust and enabled plugin code with no confirmation. + expect(altChordLetterForKey(charKey("„"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("◊"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‰"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‡"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‡"), ["t"])).toBeNull(); + }); + + test("ctrl plus a composed glyph is never a chord", () => { + expect( + altChordLetterForKey(key({ ...charKey("®"), ctrl: true }), PICKER), + ).toBeNull(); + }); +}); + +describe("OPTION_CHORD_GLYPHS", () => { + test("never advertises f or the dead-key letters e u i n g", () => { + for (const letter of ["f", "e", "u", "i", "n", "g"]) { + expect(GLYPH_LETTERS.includes(letter as AltChordLetter)).toBe(false); + } + }); + + test("every declared glyph maps back to its letter", () => { + for (const letter of GLYPH_LETTERS) { + for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { + expect(altChordLetterForKey(charKey(glyph), GLYPH_LETTERS)).toBe( + letter, + ); + } + } + }); + + // Provenance lock (I-4): the independent ground truth for the macOS + // built-in U.S./ABC Option layer, letter by letter — plain glyph and, + // where the layout composes one, the ⇧Option glyph. Every table entry + // must be its OWN letter's glyph here; `‡` (⇧Option+7) and `◊` + // (⇧Option+V) failed this and were dropped, and `‰` (⇧Option+R) was + // never declared. Adding a row to the table means adding its verified + // layout pair here first. + const MACOS_US_OPTION_LAYER: Readonly< + Record + > = { + a: ["å", "Å"], + c: ["ç", "Ç"], + d: ["∂", "Î"], + r: ["®", "‰"], + t: ["†", "ˇ"], + v: ["√", "◊"], + w: ["∑", "„"], + x: ["≈", "˛"], + }; + + test("every table entry is its own letter's macOS US/ABC Option glyph", () => { + for (const letter of GLYPH_LETTERS) { + const layoutGlyphs = MACOS_US_OPTION_LAYER[letter]; + expect(layoutGlyphs, `no layout provenance for ${letter}`).toBeDefined(); + for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { + expect( + layoutGlyphs.includes(glyph), + `${glyph} is not an Option-layer glyph of ${letter} on macOS US/ABC`, + ).toBe(true); + } + } + }); +}); diff --git a/src/tui/key-chords.ts b/src/tui/key-chords.ts new file mode 100644 index 000000000..e81b2d264 --- /dev/null +++ b/src/tui/key-chords.ts @@ -0,0 +1,49 @@ +import { type KeyEvent } from "@opentui/core"; + +export type AltChordLetter = "a" | "c" | "d" | "r" | "t" | "v" | "w" | "x"; + +/** + * Allowlist, never a Unicode fold: unknown glyphs stay text (invariant I-4). + * `f` and dead-key letters `e u i n g` are absent: Option+F is favorites + * (modifier only) and those dead keys start a compose sequence. + */ +export const OPTION_CHORD_GLYPHS: Readonly< + Partial> +> = { + a: ["å", "Å"], + c: ["ç", "Ç"], + d: ["∂"], + r: ["®"], + // No shifted form: macOS US composes ⇧Option+T as `ˇ`, and `‡` is the + // shifted glyph of Option+**7**, so claiming either for `t` would turn a + // printable that belongs to another key into the trust chord (I-4). + t: ["†"], + v: ["√"], + // No shifted form: macOS US composes ⇧Option+W as `„`, and `◊` is the + // shifted glyph of Option+**V**, so claiming either for `w` would turn a + // printable that belongs to another key into this chord (CL-8637, I-4). + w: ["∑"], + x: ["≈"], +}; + +const LETTER_BY_GLYPH: ReadonlyMap = new Map( + Object.entries(OPTION_CHORD_GLYPHS).flatMap(([letter, glyphs]) => + (glyphs ?? []).map((glyph) => [glyph, letter as AltChordLetter] as const), + ), +); + +export function altChordLetterForKey( + key: KeyEvent, + declared: readonly AltChordLetter[], +): AltChordLetter | null { + if (key.ctrl) return null; + const name = typeof key.name === "string" ? key.name : ""; + if (key.meta || key.option) { + const letter = name.length === 1 ? name.toLowerCase() : ""; + return declared.find((candidate) => candidate === letter) ?? null; + } + const seq = typeof key.sequence === "string" ? key.sequence : ""; + const glyphLetter = LETTER_BY_GLYPH.get(seq) ?? LETTER_BY_GLYPH.get(name); + if (glyphLetter === undefined) return null; + return declared.find((candidate) => candidate === glyphLetter) ?? null; +} diff --git a/src/tui/mention-popup.test.ts b/src/tui/mention-popup.test.ts index b0fd26b76..3f2f2f5ff 100644 --- a/src/tui/mention-popup.test.ts +++ b/src/tui/mention-popup.test.ts @@ -168,6 +168,15 @@ describe("@ popup narrows as you type", () => { }); }); + test("declared chord glyphs type into the popup like any printable (I-5, CL-8637)", async () => { + await withShell(async (shell) => { + await openAt(shell, "read @"); + // ∂ is a declared chord glyph on other surfaces; here it is text. + expect(await type(shell, printable("∂"))).toBe(true); + expect(shell.prompt.value).toBe("read @∂"); + }); + }); + test("backspace widens the list again", async () => { await withShell(async (shell) => { await openAt(shell, "read @se"); diff --git a/src/tui/overlay-key-routing.test.ts b/src/tui/overlay-key-routing.test.ts index 3fac80f34..c71fafcc8 100644 --- a/src/tui/overlay-key-routing.test.ts +++ b/src/tui/overlay-key-routing.test.ts @@ -7,12 +7,19 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; +import type { KeyEvent } from "@opentui/core"; import type { PaletteCommand } from "./command-catalog"; import { openPermissionsOverlay, openOperatorOverlay } from "./overlays"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; import { isSlashPopupOpen } from "./shell/internals"; -import { openHelpOverlay, openPalette } from "./shell/palette"; +import { openListOverlay } from "./shell/overlay-host"; +import { runOverlayAction } from "./shell/overlay-list"; +import { + handleListFilterKey, + openHelpOverlay, + openPalette, +} from "./shell/palette"; const CATALOG: readonly PaletteCommand[] = [ { id: "model", label: "/model" }, @@ -107,3 +114,95 @@ describe("unclaimed overlay keys reach the prompt", () => { }); }); }); + +/** A composed Option glyph with no modifier bits (shape B, Option+D → ∂). */ +function glyphKey(glyph: string): KeyEvent { + return { + name: glyph, + sequence: glyph, + ctrl: false, + meta: false, + option: false, + } as KeyEvent; +} + +describe("declared altChords drive filter yield and dispatch (CL-8637)", () => { + test("a declared glyph yields the type-to-filter and routes to onAltChord", async () => { + await withShell(async ({ shell }) => { + const chords: string[] = []; + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha", "beta"], + itemIds: ["a", "b"], + typeToFilter: true, + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + + const composed = glyphKey("∂"); + expect(handleListFilterKey(shell, composed)).toBe(false); + expect(runOverlayAction(shell, composed)).toBe(true); + expect(chords).toEqual(["a:d"]); + expect(shell.overlayItems).toEqual(["alpha", "beta"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("the same glyph stays filter text on a surface that did not declare it (I-3)", async () => { + await withShell(async ({ shell, press }) => { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha", "beta"], + itemIds: ["a", "b"], + typeToFilter: true, + }); + + press("∂"); + expect(shell.overlayItems).toEqual(["(no matches)"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("onAltChord returning false leaves the key to the later handlers", async () => { + await withShell(async ({ shell, press }) => { + const chords: string[] = []; + openListOverlay(shell, { + items: ["alpha"], + itemIds: ["a"], + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return false; + }, + }); + + press("∂"); + expect(chords).toEqual(["a:d"]); + // Refused by the chord handler, the glyph behaves like any unclaimed + // printable: it reaches the prompt, exactly as before this change. + expect(shell.prompt.value).toBe("∂"); + }); + }); + + test("no overlay open: composed option glyphs type, never translate (I-6)", async () => { + await withShell(async ({ shell, press }) => { + press("∂"); + press("´"); + expect(shell.prompt.value).toBe("∂´"); + expect(shell.overlayList).toBeNull(); + }); + }); + + test("the `/` popup claims declared chord glyphs as filter text (I-5)", async () => { + await withShell(async ({ shell, press }) => { + press("/"); + expect(isSlashPopupOpen(shell)).toBe(true); + press("∂"); + expect(shell.prompt.value).toBe("/∂"); + expect(isSlashPopupOpen(shell)).toBe(true); + }); + }); +}); diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 0321db15c..b01a27f49 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -115,7 +115,7 @@ describe("overlay host never shares cells with the prompt border", () => { title: "model", items: ITEMS, // Mirror production /model, which always wires add-provider. - addProviderHint: true, + altChords: ["a"] as const, }), size, ); diff --git a/src/tui/overlay-primary-state.test.ts b/src/tui/overlay-primary-state.test.ts index ea7d9c460..4fae7fe25 100644 --- a/src/tui/overlay-primary-state.test.ts +++ b/src/tui/overlay-primary-state.test.ts @@ -2,20 +2,24 @@ import { describe, expect, test } from "bun:test"; import { focusOwner } from "./focus"; import { withTestRenderer } from "./harness"; +import type { KeyEvent } from "@opentui/core"; import { createAppShell } from "./shell/index"; -import type { OverlaySelection } from "./shell/internals"; +import { type OverlaySelection } from "./shell/internals"; +import { overlayAnswerState } from "./shell/chrome"; import { acceptOverlaySelection, closeInsetOverlay, closeReplaceableOverlay, + handleOverlayAnswerKey, openListOverlay, setOwnedOverlayItems, } from "./shell/overlay-host"; import { cycleOverlaySelection, + runOverlayAction, toggleOverlayExpand, } from "./shell/overlay-list"; -import { openPalette } from "./shell/palette"; +import { handlePaletteFilterKey, openPalette } from "./shell/palette"; const catalog = [{ id: "help", label: "/help", keywords: ["help"] }]; @@ -189,3 +193,82 @@ describe("overlay primary bindings across host transitions", () => { }); } }); + +/** A composed Option glyph with no modifier bits (shape B, Option+D → ∂). */ +function glyphKey(glyph: string): KeyEvent { + return { + name: glyph, + sequence: glyph, + ctrl: false, + meta: false, + option: false, + } as KeyEvent; +} + +describe("chord precedence while a higher claimant is active (CL-8637)", () => { + test("a palette stacked over a primary takes the glyph into its filter; chords stay silent", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { run: "idle" }); + const chords: string[] = []; + try { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha"], + itemIds: ["a"], + typeToFilter: true, + altChords: ["a", "d", "r"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + openPalette(shell, { catalog, typeToFilter: true }); + expect(shell.overlayKind).toBe("palette"); + + const composed = glyphKey("∂"); + // keys.ts claims the palette filter (line 416) ahead of any chord + // resolution (line 428); the primary's declarations must not steal + // typing from the stacked palette (invariant I-5). + expect(handlePaletteFilterKey(shell, composed)).toBe(true); + // runOverlayAction itself stays null while a palette is open, even + // though the borrowed primary bindings declare `d`. + expect(runOverlayAction(shell, composed)).toBe(false); + expect(chords).toEqual([]); + } finally { + shell.dispose(); + } + }); + }); + + test("a live answer field takes the glyph as text ahead of chord resolution", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { run: "idle" }); + const chords: string[] = []; + const answers: string[] = []; + try { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha"], + itemIds: ["a"], + onTextAnswer: (text) => { + answers.push(text); + }, + textAnswerActive: true, + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + + const composed = glyphKey("∂"); + // keys.ts routes to the answer field (line 410) before chords (428). + expect(handleOverlayAnswerKey(shell, composed)).toBe(true); + expect(overlayAnswerState(shell)?.text).toBe("∂"); + expect(chords).toEqual([]); + } finally { + shell.dispose(); + } + }); + }); +}); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index b0a6c63c2..074d157a1 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -12,7 +12,7 @@ import { import type { OverlayList, ItemDescription, - OpenListOverlayOpts, + PrimaryOverlayBindings, PrimaryOverlayKind, } from "./shell/internals.js"; import { @@ -23,13 +23,15 @@ import { import { destroySubtree } from "./teardown.js"; import { UI } from "./theme.js"; -export interface OverlayTitlePresentation extends Pick< - OpenListOverlayOpts, - | "addProviderHint" - | "setDefaultHint" - | "removeProviderHint" - | "mcpManageHint" - | "mcpAddHint" +export interface OverlayTitlePresentation extends Partial< + Pick< + PrimaryOverlayBindings, + | "addProviderHint" + | "setDefaultHint" + | "removeProviderHint" + | "mcpManageHint" + | "mcpAddHint" + > > { readonly title: string; readonly kind: PrimaryOverlayKind | null; diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index abbc64ef3..aa348adb9 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -14,6 +14,7 @@ import { reserveOverlayHost, } from "./shell/overlay-host.js"; import type { KeyEvent } from "@opentui/core"; +import { type AltChordLetter } from "./key-chords.js"; export type { OverlaySelection }; export interface OpenPermissionsOpts { @@ -128,6 +129,14 @@ export interface OpenModelPickerOpts { readonly describe?: (itemId: string) => ItemDescription | null; /** Bare-key claim on the focused row (e.g. Alt+F to toggle favorite). */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean; + /** Declares this open's `Alt+` chords (CL-8637). */ + readonly altChords?: readonly AltChordLetter[]; + /** Chord dispatch for a declared letter; no modifier inspection needed. */ + readonly onAltChord?: ( + itemId: string, + letter: AltChordLetter, + key: KeyEvent, + ) => boolean; /** Per-open Esc/dismiss. */ readonly onCancel?: () => void; /** @@ -135,12 +144,6 @@ export interface OpenModelPickerOpts { * as you type. Off by default so other list overlays keep j/k. */ readonly typeToFilter?: boolean; - /** Advertise Alt+A /connect in the footer — only when the caller wired the handler. */ - readonly addProviderHint?: boolean; - /** Advertise Alt+D in the footer — only when the caller wired the handler. */ - readonly setDefaultHint?: boolean; - /** Advertise Alt+R remove in the footer — only when the caller wired the handler. */ - readonly removeProviderHint?: boolean; } export function openModelPickerOverlay( @@ -160,19 +163,14 @@ export function openModelPickerOverlay( ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.describe !== undefined ? { describe: opts.describe } : {}), ...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}), + ...(opts?.altChords !== undefined ? { altChords: opts.altChords } : {}), + ...(opts?.onAltChord !== undefined + ? { onAltChord: opts.onAltChord } + : {}), ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), ...(opts?.typeToFilter !== undefined ? { typeToFilter: opts.typeToFilter } : {}), - ...(opts?.addProviderHint !== undefined - ? { addProviderHint: opts.addProviderHint } - : {}), - ...(opts?.setDefaultHint !== undefined - ? { setDefaultHint: opts.setDefaultHint } - : {}), - ...(opts?.removeProviderHint !== undefined - ? { removeProviderHint: opts.removeProviderHint } - : {}), deferIfBusy: true, }); } finally { diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index f20aa6e61..05d40bc25 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -956,6 +956,50 @@ describe("flat type-to-filter model picker", () => { } }); + test("composed Option+F (ƒ) stays filter text and toggles no favourite (AC-6)", async () => { + const favorites: string[] = []; + const defaults: string[] = []; + const { harness, host } = await mountPicker({ + onFavoriteToggle: (id) => favorites.push(id), + onSetDefault: (id) => defaults.push(id), + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = composedKey("ƒ"); + // `f` is not a declared chord letter, so the glyph is never yielded + // from the filter and never reaches the Alt+F modifier branch. + expect(handleListFilterKey(host.shell, composed)).toBe(true); + await harness.renderOnce(); + expect(host.shell.overlayItems).toEqual(["(no matches)"]); + expect(runOverlayAction(host.shell, composed)).toBe(false); + expect(favorites).toEqual([]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("composed Option+M (µ) stays filter text: no surface declares m", async () => { + const defaults: string[] = []; + const { harness, host } = await mountPicker({ + onSetDefault: (id) => defaults.push(id), + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = composedKey("µ"); + expect(handleListFilterKey(host.shell, composed)).toBe(true); + await harness.renderOnce(); + expect(host.shell.overlayItems).toEqual(["(no matches)"]); + expect(runOverlayAction(host.shell, composed)).toBe(false); + expect(defaults).toEqual([]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + const altR = { name: "r", ctrl: false, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 8bc50cd01..34d2e6b97 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -68,12 +68,8 @@ import { type PaletteOnObserveRequest, } from "./shell/internals.js"; import { setOwnedOverlayItems } from "./shell/overlay-host.js"; -import { - isAddProviderShortcutKey, - isRemoveProviderShortcutKey, - isSetDefaultShortcutKey, - setPaletteCatalog, -} from "./shell/palette.js"; +import { setPaletteCatalog } from "./shell/palette.js"; +import { type AltChordLetter } from "./key-chords.js"; import { surfaceSystemNotice } from "./shell/prompt.js"; import type { DeliverySettle, QueueKind } from "./delivery-queue.js"; import { @@ -830,14 +826,18 @@ export async function mountProductHost( const items = annotateCurrent(currentModels, activeId); const focusIndex = focusId !== undefined ? items.findIndex((m) => m.id === focusId) : -1; + // A letter joins only when its action is wired. + const altChords: AltChordLetter[] = [ + ...(openAddProvider !== undefined ? (["a"] as const) : []), + ...(onSetDefault !== undefined ? (["d"] as const) : []), + ...(removeEnabled ? (["r"] as const) : []), + ]; openModelPickerOverlay(shell, { items: items.map((m) => m.label), itemIds: items.map((m) => m.id), // Flat list: type to narrow rather than drill into a provider pane. typeToFilter: true, - addProviderHint: openAddProvider !== undefined, - setDefaultHint: onSetDefault !== undefined, - removeProviderHint: removeEnabled, + altChords, ...(focusIndex >= 0 ? { activeIndex: focusIndex } : {}), onAccept: (sel) => { disarmRemove(); @@ -865,69 +865,56 @@ export async function mountProductHost( onCancel: () => { disarmRemove(); }, - ...(onFavoriteToggle !== undefined || - openAddProvider !== undefined || - onSetDefault !== undefined || - removeEnabled - ? { - onAction: (itemId, key) => { - if (key.ctrl) return false; - // Alt+A / composed Option+A (å/Å) — never bare `a`; - // type-to-filter claims ordinary printables. - if ( - openAddProvider !== undefined && - isAddProviderShortcutKey(key) - ) { - disarmRemove(); - openAddProvider({ returnToModels: true }); - return true; - } - // Alt+F stays modifier-only; the default shortcut also accepts - // the composed Option+D glyph through its scoped predicate. - const name = - typeof key.name === "string" ? key.name.toLowerCase() : ""; - if ( - !key.ctrl && - (key.meta || key.option) && - name === "f" && - onFavoriteToggle !== undefined - ) { - // Empty id is the "(no matches)" filter sentinel — not a model. - if (itemId.length === 0) return false; - onFavoriteToggle(itemId); - return true; - } - if ( - onSetDefault !== undefined && - isSetDefaultShortcutKey(key) - ) { - if (itemId.length === 0) return true; - onSetDefault(itemId); - return true; - } - // Alt+R remove stays modifier-only (plus the composed ® - // glyph); bare `r` is type-to-filter text and never arrives. - if (removeEnabled && isRemoveProviderShortcutKey(key)) { - // Null arm line: ghost/residual row with no settings entry. - const armLine = describeRemoveProvider?.(itemId) ?? null; - if (armLine === null) return false; - if ( - decideRemoveKey(armedRemove, itemId, true, Date.now()) === - "confirmed" - ) { - disarmRemove(); - onRemoveProvider?.(itemId); - return true; - } - armedRemove = { itemId, armedAt: Date.now() }; - armedRemoveFlash = armLine; - setStatusFlash(shell, armLine, { ttlMs: REMOVE_ARM_MS }); - return true; - } - return false; - }, - } - : {}), + onAltChord: (itemId, letter) => { + if (letter === "a") { + // Never bare `a`: type-to-filter claims ordinary printables. + disarmRemove(); + openAddProvider?.({ returnToModels: true }); + return true; + } + if (letter === "d") { + // Empty id is the "(no matches)" filter sentinel — not a model. + if (itemId.length === 0) return true; + onSetDefault?.(itemId); + return true; + } + // Alt+R remove: two-press arm; bare `r` is type-to-filter text and + // never arrives here. + // Null arm line: ghost/residual row with no settings entry. + const armLine = describeRemoveProvider?.(itemId) ?? null; + if (armLine === null) return false; + if ( + decideRemoveKey(armedRemove, itemId, true, Date.now()) === + "confirmed" + ) { + disarmRemove(); + onRemoveProvider?.(itemId); + return true; + } + armedRemove = { itemId, armedAt: Date.now() }; + armedRemoveFlash = armLine; + setStatusFlash(shell, armLine, { ttlMs: REMOVE_ARM_MS }); + return true; + }, + // Alt+F favorites stays modifier-only: `f` is deliberately outside + // AltChordLetter and unadvertised, so the composed ƒ glyph remains + // filter text (SOLUTION_SCOPE.md §5). + onAction: (itemId, key) => { + if (key.ctrl) return false; + const name = + typeof key.name === "string" ? key.name.toLowerCase() : ""; + if ( + (key.meta || key.option) && + name === "f" && + onFavoriteToggle !== undefined + ) { + // Empty id is the "(no matches)" filter sentinel — not a model. + if (itemId.length === 0) return false; + onFavoriteToggle(itemId); + return true; + } + return false; + }, }); }; } diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index ae8c5a100..a6b389763 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -10,6 +10,7 @@ import { type KeyEvent, } from "@opentui/core"; import { parseAtState, type AtState } from "../components/at-mention/parse.js"; +import { type AltChordLetter } from "../key-chords.js"; import { type ClipboardImageResult, type PendingImageAttachment, @@ -592,7 +593,7 @@ export interface OverlayList { visibleRange(): OverlayListRange; } -interface PrimaryOverlayBindings { +export interface PrimaryOverlayBindings { /** Stable ids aligned with overlayItems. */ itemIds: readonly string[]; /** Plain chosen values aligned with overlayItems. */ @@ -607,6 +608,15 @@ interface PrimaryOverlayBindings { describe: ((itemId: string) => ItemDescription | null) | null; /** Per-open bare-key claim. */ onAction: ((itemId: string, key: KeyEvent) => boolean) | null; + /** Letters this open declares as `Alt+` chords (CL-8637). The + * footer hints, the type-to-filter yield and chord dispatch all read this + * one declaration; a glyph is a chord only where its letter is declared. */ + readonly altChords: readonly AltChordLetter[]; + /** Per-open chord dispatch for a declared letter. `key` is an escape + * hatch only; handlers must not re-inspect modifiers (invariant I-2). */ + onAltChord: + | ((itemId: string, letter: AltChordLetter, key: KeyEvent) => boolean) + | null; /** Per-open bracketed-paste owner. */ onPaste: ((text: string) => void) | null; /** Per-open dismiss hook for promise-backed overlays. */ @@ -636,6 +646,8 @@ export const EMPTY_PRIMARY_BINDINGS: Readonly = { onCycle: null, describe: null, onAction: null, + altChords: [], + onAltChord: null, onPaste: null, onCancel: null, onDispose: null, @@ -811,6 +823,17 @@ export interface OpenListOverlayOpts { /** Per-open bare-key claim, checked before list navigation; returning false * leaves the key to j/k and arrows. */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean; + /** Declares this open's `Alt+` chords (CL-8637). One declaration + * drives the footer hints, the type-to-filter yield and `onAltChord` + * dispatch; a glyph is a chord only on the surface declaring its letter. */ + readonly altChords?: readonly AltChordLetter[]; + /** Per-open chord dispatch when a declared letter fires. `key` is an escape + * hatch; handlers must not re-inspect modifiers (invariant I-2). */ + readonly onAltChord?: ( + itemId: string, + letter: AltChordLetter, + key: KeyEvent, + ) => boolean; /** Per-open bracketed-paste target for synthetic text panes. */ readonly onPaste?: (text: string) => void; /** Per-open free-text answer. When set, the operator can Tab into a field; @@ -826,20 +849,6 @@ export interface OpenListOverlayOpts { /** Claim printable keys for a `>` filter row so the list narrows; * opt-in per open, else j/k navigate. */ readonly typeToFilter?: boolean; - /** Advertise Alt+A and /connect in the footer and yield Option+A (å/Å) from - * type-to-filter. */ - readonly addProviderHint?: boolean; - /** Advertise the Alt+D set-default hint in the footer. */ - readonly setDefaultHint?: boolean; - /** Advertise the Alt+R remove-provider hint and yield Option+R (®) from - * type-to-filter. */ - readonly removeProviderHint?: boolean; - /** Advertise Alt+D disable / Alt+R remove in the `/mcp` footer; confirm - * overlays leave it unset (DEFAULT_OVERLAY_HINTS). */ - readonly mcpManageHint?: boolean; - /** Advertise Alt+A add in the `/mcp` footer; false while local settings - * shadow global MCP. */ - readonly mcpAddHint?: boolean; /** When the host shows a non-palette overlay, stash this open in the one * deferred slot and print a system line. Off by default: a busy open is a * silent no-op. */ diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 78e69f70e..064922882 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -269,6 +269,7 @@ export function openListOverlay( bag.overlayGeneration += 1; // A stacked palette borrows the primary bindings until restoration. if (!isPalette || !bag.priorOverlay) { + const chords = opts?.altChords ?? []; bag.primaryBindings = { itemIds: opts?.itemIds ? [...opts.itemIds] : [], itemValues: opts?.itemValues ? [...opts.itemValues] : [], @@ -277,15 +278,20 @@ export function openListOverlay( onCycle: opts?.onCycle ?? null, describe: opts?.describe ?? null, onAction: opts?.onAction ?? null, + altChords: chords, + onAltChord: opts?.onAltChord ?? null, onPaste: opts?.onPaste ?? null, onCancel: opts?.onCancel ?? null, onDispose: opts?.onDispose ?? null, isGate: opts?.isGate === true, - addProviderHint: opts?.addProviderHint ?? false, - setDefaultHint: opts?.setDefaultHint ?? false, - removeProviderHint: opts?.removeProviderHint ?? false, - mcpManageHint: opts?.mcpManageHint ?? false, - mcpAddHint: opts?.mcpAddHint ?? false, + // Advertising is declaration (invariant I-1): the footer flags are + // the output of this open's altChords, never caller-passed knobs. + addProviderHint: kind === "model_picker" && chords.includes("a"), + setDefaultHint: kind === "model_picker" && chords.includes("d"), + removeProviderHint: kind === "model_picker" && chords.includes("r"), + mcpManageHint: + kind === "mcp" && (chords.includes("d") || chords.includes("r")), + mcpAddHint: kind === "mcp" && chords.includes("a"), }; bag.overlayEchoChoice = opts?.echoChoice ?? true; // Capture the full unfiltered set so typing can re-narrow in place. diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index aaacc8960..005be390a 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -10,6 +10,7 @@ import { type SelectOption, } from "@opentui/core"; import { UI } from "../theme.js"; +import { altChordLetterForKey } from "../key-chords.js"; import { overlayRowsPerItem, overlayChromeRows, @@ -379,15 +380,24 @@ export function cycleOverlaySelection( } /** - * Run the open overlay's bare-key claim, for overlays that opted in via - * `onAction`. No-op without one, so the key falls through unclaimed. + * Run the open overlay's chord or bare-key claim. Stays silent while a + * palette is stacked: typing there belongs to the palette filter, not the + * borrowed primary bindings. No-op without a handler. */ export function runOverlayAction(shell: AppShell, key: KeyEvent): boolean { const list = shell.overlayList; if (!list) return false; - const onAction = shellInternals(shell)?.primaryBindings.onAction; - if (!onAction) return false; - return onAction(activeOverlayItemId(shell, list), key); + const bindings = shellInternals(shell)?.primaryBindings; + if (!bindings) return false; + const itemId = activeOverlayItemId(shell, list); + const letter = + shell.overlayKind === "palette" + ? null + : altChordLetterForKey(key, bindings.altChords); + if (letter !== null) { + return bindings.onAltChord?.(itemId, letter, key) ?? false; + } + return bindings.onAction?.(itemId, key) ?? false; } /** Page overlay selection (PgUp/PgDn). */ diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index e5ee386ef..27a36d053 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -13,6 +13,7 @@ import { type PaletteCommand, } from "../command-catalog.js"; import { helpItems } from "../keybindings.js"; +import { altChordLetterForKey } from "../key-chords.js"; import { filterMentionSuggestions, splitMentionToken, @@ -162,56 +163,6 @@ export function handlePaletteFilterKey( return true; } -/** - * Glyphs some terminals emit for Option+A without setting meta/option. - */ -const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); -const OPTION_D_COMPOSED_CHARS = new Set(["∂"]); -const OPTION_R_COMPOSED_CHARS = new Set(["®"]); - -/** - * True when a key event is the model-picker Alt+A add-provider chord. - * Terminals may deliver Option+A as å/Å without meta/option. - */ -export function isAddProviderShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "a") return true; - if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - -/** - * True when a key event is the model-picker Alt+D set-default chord. - * Terminals may deliver Option+D as ∂ without meta/option. - */ -export function isSetDefaultShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "d") return true; - if (OPTION_D_COMPOSED_CHARS.has(name) || OPTION_D_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - -/** - * True when a key event is the model-picker Alt+R remove-provider chord. - * Terminals may deliver Option+R as ® without meta/option. Bare `r` stays - * false: type-to-filter owns printables. - */ -export function isRemoveProviderShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "r") return true; - if (OPTION_R_COMPOSED_CHARS.has(name) || OPTION_R_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - /** * Keys a type-to-filter list overlay claims while open. Mirrors the palette * filter but updates the open list in place: a busy openListOverlay is a @@ -224,31 +175,9 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; - // Armed provider hints send the composed Option chords (å/Å, ∂, ®) to - // runOverlayAction, not type-to-filter. - if ( - bag?.primaryBindings.addProviderHint === true && - shell.overlayKind === "model_picker" && - isAddProviderShortcutKey(key) - ) { + // Undeclared glyphs stay filter text: on other layouts the same Option press is ordinary typing. + if (altChordLetterForKey(key, bag?.primaryBindings.altChords ?? []) !== null) return false; - } - - if ( - bag?.primaryBindings.setDefaultHint === true && - shell.overlayKind === "model_picker" && - isSetDefaultShortcutKey(key) - ) { - return false; - } - - if ( - bag?.primaryBindings.removeProviderHint === true && - shell.overlayKind === "model_picker" && - isRemoveProviderShortcutKey(key) - ) { - return false; - } if (key.name === "backspace") { if (state.query.length === 0) return true;