From 49510a3a1656096e245a319c027758dbc6c4c07f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:26:12 -0700 Subject: [PATCH 1/2] fix(tui): normalize composed macOS Option chords --- docs/TUI.md | 27 ++++ src/tui/shell/keys.ts | 5 + src/tui/shell/palette.ts | 59 +++++--- tests/unit/tui/option-key-composed.test.ts | 166 +++++++++++++++++++++ 4 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 tests/unit/tui/option-key-composed.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index f4dab17a3..0301b6ae0 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -651,6 +651,33 @@ Enter and Shift+Enter, so on those Shift+Enter silently does nothing — driven live, this is exactly what happens, not a hypothetical. Ctrl+Enter/Ctrl+J are the chord to point an operator at when Shift+Enter doesn't respond. +### macOS Option-key audit + +OpenTUI must see the same chord regardless of whether a macOS terminal sends an +ESC-prefixed Meta chord or an unmodified composed glyph. The composed forms for +globally claimed Alt+C/M/D/Y chords (`ç`, `µ`, `∂`, `¥`) are normalized before +key dispatch. Consequently, typing those glyphs directly into the bare prompt +is intentionally unavailable, matching Meta-on behavior. Composed `å`/`Å` is +recognized only by a surface that claims Alt+A; otherwise it passes through and +inserts normally. Paste is a separate event path and is never normalized or +remapped. + +The recovery environment could not drive GUI terminal settings, so no row below +claims an observation that was not made. `UNVERIFIED` means the implementation +and automated parser tests cover the expected event shape but the named GUI +combination still needs a manual run. `UNFIXABLE` means macOS dead-key handling +withholds the bare Option+E event from the application; no timeout or synthetic +remapping is appropriate. + +| Terminal | Option mode | Alt+C/M/D/Y | Alt+A | Alt+E | Bare prompt | Paste | +| ------------ | ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- | +| Terminal.app | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| Terminal.app | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | +| iTerm2 | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| iTerm2 | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | +| Ghostty | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| Ghostty | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | + ### Soft steer vs. follow-up Two mid-run gestures, two delivery times (CL-6290): diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index d711305bb..12556187d 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -55,6 +55,7 @@ import { handlePaletteFilterKey, handleSlashPopupKey, MOTION_KEYS, + normalizeOptionKey, openAtMentionSuggestions, openSlashCommands, setPromptText, @@ -278,6 +279,10 @@ export function createShellKeyHandlers( }; const onKey = (key: KeyEvent): void => { + // Composed Option glyphs (ç for Alt+C, ∂ for Alt+D, …) arrive with no + // modifier flags; fold globally claimed chords before dispatch so every + // handler sees one form. Unmapped and contextual glyphs pass through. + normalizeOptionKey(key); if (opts.isDisposed()) return; if (shellInternals(shell)?.inputSuspended === true) return; diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index bec2fa648..d0152baad 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -167,37 +167,60 @@ export function handlePaletteFilterKey( } /** - * Glyphs some terminals emit for Option+A without setting meta/option. + * Composed glyphs a macOS terminal emits for Option+letter when Option is not + * Meta (raw/legacy input): the byte stream carries the printable glyph with no + * modifier flags, so OpenTUI reports it bare ({name: "∂", meta: false, + * option: false}) instead of the flagged chord. Kitty-protocol input already + * arrives flagged ({name: "d", meta: true, option: true}) and never hits this + * map. */ +const OPTION_COMPOSED_BASE: ReadonlyMap = new Map([ + ["∂", "d"], + ["´", "e"], + ["ç", "c"], + ["µ", "m"], + ["¥", "y"], +]); const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); -const OPTION_D_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. + * Fold a composed Option glyph into the flagged chord it shadows, in place. + * Unknown keys pass through untouched — including Ctrl chords and + * already-flagged events, whose names are base ASCII and never in the map. + * Sequence and raw retain the terminal's original bytes. */ +export function normalizeOptionKey(key: KeyEvent): KeyEvent { + if (key.ctrl) return key; + const name = typeof key.name === "string" ? key.name.normalize("NFC") : ""; + const seq = + typeof key.sequence === "string" ? key.sequence.normalize("NFC") : ""; + const base = OPTION_COMPOSED_BASE.get(name) ?? OPTION_COMPOSED_BASE.get(seq); + if (base === undefined) return key; + key.name = base; + key.option = true; + return key; +} + +/** True when a key event is the model-picker Alt+A add-provider chord. */ 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; + const name = typeof key.name === "string" ? key.name.normalize("NFC") : ""; + const seq = + typeof key.sequence === "string" ? key.sequence.normalize("NFC") : ""; if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) return true; - return false; + return (key.meta || key.option) && name.toLowerCase() === "a"; } -/** - * True when a key event is the model-picker Alt+D set-default chord. - * Terminals may deliver Option+D as ∂ without meta/option. - */ +/** True when a key event is the model-picker Alt+D set-default chord. */ 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; + const name = typeof key.name === "string" ? key.name.normalize("NFC") : ""; + const seq = + typeof key.sequence === "string" ? key.sequence.normalize("NFC") : ""; + if (OPTION_COMPOSED_BASE.get(name) === "d") return true; + if (OPTION_COMPOSED_BASE.get(seq) === "d") return true; + return (key.meta || key.option) && name.toLowerCase() === "d"; } /** diff --git a/tests/unit/tui/option-key-composed.test.ts b/tests/unit/tui/option-key-composed.test.ts new file mode 100644 index 000000000..63f80ec6a --- /dev/null +++ b/tests/unit/tui/option-key-composed.test.ts @@ -0,0 +1,166 @@ +import { test, expect } from "bun:test"; +import { parseKeypress, type KeyEvent } from "@opentui/core"; +import { + isAddProviderShortcutKey, + isSetDefaultShortcutKey, + normalizeOptionKey, +} from "../../../src/tui/shell/palette.js"; + +function ev(overrides: Partial = {}): KeyEvent { + return { + name: "", + ctrl: false, + meta: false, + shift: false, + option: false, + sequence: "", + number: false, + raw: "", + eventType: "press", + source: "raw", + ...overrides, + } as KeyEvent; +} + +/** ESC-prefixed delivery: Option-as-Meta terminals, already flagged. */ +function flagged(name: string): KeyEvent { + return ev({ name, sequence: `\x1b${name}`, meta: true }); +} + +/** Bare-glyph delivery: Option-not-Meta terminals, no flags (per parseKeypress). */ +function composed(glyph: string): KeyEvent { + return ev({ name: glyph, sequence: glyph, raw: glyph }); +} + +/** + * Same chord for every dispatcher in keys.ts, which all gate on + * (key.meta || key.option) && !key.ctrl plus the base name. + */ +function expectSameChord(actual: KeyEvent, expected: KeyEvent): void { + expect(actual.name).toBe(expected.name); + expect(actual.ctrl).toBe(expected.ctrl); + expect(actual.meta || actual.option).toBe(expected.meta || expected.option); +} + +const CHORD_ROWS: { + glyph: string; + base: string; + flaggedName: string; +}[] = [ + { glyph: "∂", base: "d", flaggedName: "d" }, + { glyph: "¥", base: "y", flaggedName: "y" }, + { glyph: "´", base: "e", flaggedName: "e" }, + { glyph: "ç", base: "c", flaggedName: "c" }, + { glyph: "µ", base: "m", flaggedName: "m" }, +]; + +for (const { glyph, base, flaggedName } of CHORD_ROWS) { + test(`composed ${glyph} folds to the flagged Alt+${flaggedName.toUpperCase()} chord`, () => { + const folded = normalizeOptionKey(composed(glyph)); + expect(folded.name).toBe(base); + expect(folded.option).toBe(true); + expect(folded.ctrl).toBe(false); + expect(folded.sequence).toBe(glyph); + expectSameChord(folded, flagged(flaggedName)); + }); +} + +test("folded Alt+D/Alt+Y carry the kill-ring gate flags (keys.ts Alt+D/Alt+Y)", () => { + for (const glyph of ["∂", "¥"]) { + const folded = normalizeOptionKey(composed(glyph)); + expect(folded.meta || folded.option).toBe(true); + expect(folded.ctrl).toBe(false); + } +}); + +test("folded Alt+E uses the exact expand name (keys.ts Alt+E matches EXPAND_KEY)", () => { + const folded = normalizeOptionKey(composed("´")); + expect(folded.name).toBe("e"); +}); + +test("palette Alt+A fires identically for å/Å and flagged Alt+A", () => { + for (const glyph of ["å", "Å"]) { + expect(isAddProviderShortcutKey(composed(glyph))).toBe(true); + } + expect(isAddProviderShortcutKey(flagged("a"))).toBe(true); + expect(isAddProviderShortcutKey(ev({ name: "a", sequence: "a" }))).toBe( + false, + ); + expect( + isAddProviderShortcutKey(ev({ name: "å", sequence: "å", ctrl: true })), + ).toBe(false); +}); + +test("palette Alt+D fires identically for ∂ and flagged Alt+D", () => { + expect(isSetDefaultShortcutKey(composed("∂"))).toBe(true); + expect(isSetDefaultShortcutKey(flagged("d"))).toBe(true); + expect(isSetDefaultShortcutKey(ev({ name: "d", sequence: "d" }))).toBe(false); +}); + +test("folded chords are not printable inserts (printable-insert guard)", () => { + for (const { glyph } of CHORD_ROWS) { + const folded = normalizeOptionKey(composed(glyph)); + expect(folded.option).toBe(true); + } +}); + +test("NFD Alt+A is recognized contextually and passes through globally", () => { + const nfd = "Å"; + expect(nfd.normalize("NFC")).toBe("Å"); + const key = composed(nfd); + expect(isAddProviderShortcutKey(key)).toBe(true); + expect(normalizeOptionKey(key)).toBe(key); + expect(key.name).toBe(nfd); + expect(key.sequence).toBe(nfd); + expect(key.option).toBe(false); + + const parsed = parseKeypress(Buffer.from(nfd, "utf8")); + if (parsed === null) throw new Error("parseKeypress returned null for NFD"); + expect(isAddProviderShortcutKey(parsed as KeyEvent)).toBe(true); +}); + +test("unclaimed å passes through unchanged and remains insertable", () => { + for (const glyph of ["å", "Å"]) { + const key = composed(glyph); + expect(normalizeOptionKey(key)).toBe(key); + expect(key.name).toBe(glyph); + expect(key.sequence).toBe(glyph); + expect(key.meta).toBe(false); + expect(key.option).toBe(false); + } +}); + +test("real parser output folds end to end: ∂ ≡ flagged Alt+D", () => { + const parsed = parseKeypress(Buffer.from("∂", "utf8")); + if (parsed === null) throw new Error("parseKeypress returned null for ∂"); + expect(parsed.meta).toBe(false); + expect(parsed.option).toBe(false); + const folded = normalizeOptionKey(parsed as KeyEvent); + expectSameChord(folded, flagged("d")); +}); + +test("unmapped glyphs pass through untouched, still insertable", () => { + for (const glyph of ["é", "ñ", "ü", "—"]) { + const key = composed(glyph); + expect(normalizeOptionKey(key)).toBe(key); + expect(key.name).toBe(glyph); + expect(key.meta).toBe(false); + expect(key.option).toBe(false); + expect(key.sequence).toBe(glyph); + } +}); + +test("flagged and plain ASCII keys pass through untouched", () => { + const kitty = ev({ name: "d", sequence: "d", meta: true, option: true }); + expect(normalizeOptionKey(kitty)).toBe(kitty); + expect(kitty.name).toBe("d"); + const plain = ev({ name: "c", sequence: "c" }); + expect(normalizeOptionKey(plain)).toBe(plain); +}); + +test("Ctrl chords are never remapped", () => { + const key = ev({ name: "∂", sequence: "∂", ctrl: true }); + expect(normalizeOptionKey(key)).toBe(key); + expect(key.name).toBe("∂"); + expect(key.option).toBe(false); +}); From a609270e384ddedc1ab56615661c6207759a13fb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:46:14 -0700 Subject: [PATCH 2/2] fix(tui): preserve dead-key spacing acute input --- docs/TUI.md | 23 +++-- src/tui/product-host.test.ts | 28 ++---- src/tui/prompt-features.test.ts | 6 ++ src/tui/shell/palette.ts | 15 --- src/tui/transcript-layout.test.ts | 9 +- tests/unit/tui/option-key-composed.test.ts | 110 +++++++++++++++++++-- 6 files changed, 138 insertions(+), 53 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 0301b6ae0..74a157fe1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -659,8 +659,11 @@ globally claimed Alt+C/M/D/Y chords (`ç`, `µ`, `∂`, `¥`) are normalized bef key dispatch. Consequently, typing those glyphs directly into the bare prompt is intentionally unavailable, matching Meta-on behavior. Composed `å`/`Å` is recognized only by a surface that claims Alt+A; otherwise it passes through and -inserts normally. Paste is a separate event path and is never normalized or -remapped. +inserts normally. There is no composed fallback for Alt+E: with Option-as-Meta +off, Option+E is a dead key, and following it with Space inserts the literal +spacing acute (`´`) without expanding a row. With Option-as-Meta on, the flagged +Alt+E chord still expands. Paste is a separate event path and is never normalized +or remapped. The recovery environment could not drive GUI terminal settings, so no row below claims an observation that was not made. `UNVERIFIED` means the implementation @@ -669,14 +672,14 @@ combination still needs a manual run. `UNFIXABLE` means macOS dead-key handling withholds the bare Option+E event from the application; no timeout or synthetic remapping is appropriate. -| Terminal | Option mode | Alt+C/M/D/Y | Alt+A | Alt+E | Bare prompt | Paste | -| ------------ | ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- | -| Terminal.app | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | -| Terminal.app | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | -| iTerm2 | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | -| iTerm2 | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | -| Ghostty | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — Option+E is a dead key and emits no immediate chord | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | -| Ghostty | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | +| Terminal | Option mode | Alt+C/M/D/Y | Alt+A | Alt+E | Bare prompt | Paste | +| ------------ | ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- | +| Terminal.app | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — dead key; then Space inserts literal `´`, never expands | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| Terminal.app | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | +| iTerm2 | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — dead key; then Space inserts literal `´`, never expands | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| iTerm2 | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | +| Ghostty | Meta off | **UNVERIFIED** — composed `ç`/`µ`/`∂`/`¥` normalize to the flagged chords | **UNVERIFIED** — `å`/`Å` acts as Alt+A only where claimed | **UNFIXABLE** — dead key; then Space inserts literal `´`, never expands | **UNVERIFIED** — `ç`/`µ`/`∂`/`¥` do not insert; unclaimed `å` does | **UNVERIFIED** — pasted glyphs remain literal | +| Ghostty | Meta on | **UNVERIFIED** — expected ESC-prefixed flagged chords | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — expected ESC-prefixed flagged chord | **UNVERIFIED** — chord bytes do not insert | **UNVERIFIED** — pasted glyphs remain literal | ### Soft steer vs. follow-up diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index bc28a665a..f4138ab7f 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -664,15 +664,8 @@ describe("flat type-to-filter model picker", () => { try { host.openModels?.(); await harness.renderOnce(); - const composed = { - name: "∂", - sequence: "∂", - ctrl: false, - meta: false, - option: false, - } as KeyEvent; - expect(handleListFilterKey(host.shell, composed)).toBe(false); - expect(runOverlayAction(host.shell, composed)).toBe(true); + harness.pressKey("∂"); + await harness.renderOnce(); expect(defaults).toEqual([modelOptionId("codex/abk-labs", "gpt-5.5")]); expect(host.shell.overlayItems).not.toEqual(["(no matches)"]); } finally { @@ -681,22 +674,17 @@ describe("flat type-to-filter model picker", () => { } }); - test("composed Option+D (∂) remains filter text when setting a default is unavailable", async () => { + test("composed Option+D (∂) is globally claimed when setting a default is unavailable", async () => { const { harness, host } = await mountPicker(); try { + host.shell.prompt.value = "draft"; host.openModels?.(); await harness.renderOnce(); - const composed = { - name: "∂", - sequence: "∂", - ctrl: false, - meta: false, - option: false, - } as KeyEvent; - expect(handleListFilterKey(host.shell, composed)).toBe(true); + const items = host.shell.overlayItems; + harness.pressKey("∂"); await harness.renderOnce(); - expect(host.shell.overlayItems).toEqual(["(no matches)"]); - expect(runOverlayAction(host.shell, composed)).toBe(false); + expect(host.shell.overlayItems).toEqual(items); + expect(host.shell.prompt.value).toBe("draft"); } finally { host.dispose(); harness.destroy(); diff --git a/src/tui/prompt-features.test.ts b/src/tui/prompt-features.test.ts index 87ba2089a..f40f69ba4 100644 --- a/src/tui/prompt-features.test.ts +++ b/src/tui/prompt-features.test.ts @@ -367,6 +367,12 @@ describe("text paste", () => { "hello world", ); + pasteCase( + "composed Option glyphs pasted together remain literal", + async (h) => await h.mockInput.pasteBracketedText("çµ∂¥"), + "çµ∂¥", + ); + pasteCase( "multi-line paste keeps its newlines and does not submit", async (h) => diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index d0152baad..dc2ed629b 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -176,7 +176,6 @@ export function handlePaletteFilterKey( */ const OPTION_COMPOSED_BASE: ReadonlyMap = new Map([ ["∂", "d"], - ["´", "e"], ["ç", "c"], ["µ", "m"], ["¥", "y"], @@ -216,10 +215,6 @@ export function isAddProviderShortcutKey(key: KeyEvent): boolean { export function isSetDefaultShortcutKey(key: KeyEvent): boolean { if (key.ctrl) return false; const name = typeof key.name === "string" ? key.name.normalize("NFC") : ""; - const seq = - typeof key.sequence === "string" ? key.sequence.normalize("NFC") : ""; - if (OPTION_COMPOSED_BASE.get(name) === "d") return true; - if (OPTION_COMPOSED_BASE.get(seq) === "d") return true; return (key.meta || key.option) && name.toLowerCase() === "d"; } @@ -246,16 +241,6 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { return false; } - // setDefaultHint similarly gates the composed Option+D (∂) bypass. Outside - // this model-picker action context, ∂ remains ordinary filter text. - if ( - bag?.primaryBindings.setDefaultHint === true && - shell.overlayKind === "model_picker" && - isSetDefaultShortcutKey(key) - ) { - return false; - } - if (key.name === "backspace") { if (state.query.length === 0) return true; state.query = state.query.slice(0, -1); diff --git a/src/tui/transcript-layout.test.ts b/src/tui/transcript-layout.test.ts index 7e70b410a..b5f954818 100644 --- a/src/tui/transcript-layout.test.ts +++ b/src/tui/transcript-layout.test.ts @@ -249,7 +249,7 @@ describe("transcript turn layout", () => { ); }); - test("Alt+E expands the newest collapsed row; a bare e always just types", async () => { + test("Alt+E expands the newest collapsed row; bare e and ´ only type", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -273,6 +273,13 @@ describe("transcript turn layout", () => { expect(h.captureCharFrame()).not.toContain("no emojis"); expect(shell.prompt.value).toBe("e"); + // Option+E then Space emits a literal spacing acute with no modifier. + // It remains text even while an expandable row is available. + h.pressKey("´"); + await h.renderOnce(); + expect(h.captureCharFrame()).not.toContain("no emojis"); + expect(shell.prompt.value).toBe("e´"); + // Alt+E expands regardless of which widget nominally has focus — // the prompt still holds focus here, and it still fires. h.pressKey("e", { meta: true }); diff --git a/tests/unit/tui/option-key-composed.test.ts b/tests/unit/tui/option-key-composed.test.ts index 63f80ec6a..087d2e9ef 100644 --- a/tests/unit/tui/option-key-composed.test.ts +++ b/tests/unit/tui/option-key-composed.test.ts @@ -1,5 +1,9 @@ import { test, expect } from "bun:test"; import { parseKeypress, type KeyEvent } from "@opentui/core"; +import { withTestRenderer, type Harness } from "../../../src/tui/harness.js"; +import { appendStreamRow } from "../../../src/tui/shell/chrome.js"; +import { createAppShell } from "../../../src/tui/shell/index.js"; +import type { AppShell } from "../../../src/tui/shell/internals.js"; import { isAddProviderShortcutKey, isSetDefaultShortcutKey, @@ -49,7 +53,6 @@ const CHORD_ROWS: { }[] = [ { glyph: "∂", base: "d", flaggedName: "d" }, { glyph: "¥", base: "y", flaggedName: "y" }, - { glyph: "´", base: "e", flaggedName: "e" }, { glyph: "ç", base: "c", flaggedName: "c" }, { glyph: "µ", base: "m", flaggedName: "m" }, ]; @@ -73,10 +76,95 @@ test("folded Alt+D/Alt+Y carry the kill-ring gate flags (keys.ts Alt+D/Alt+Y)", } }); -test("folded Alt+E uses the exact expand name (keys.ts Alt+E matches EXPAND_KEY)", () => { - const folded = normalizeOptionKey(composed("´")); - expect(folded.name).toBe("e"); -}); +type OptionKeyVariant = { + readonly label: string; + readonly press: (harness: Harness) => void; +}; + +function optionKeyVariants(glyph: string, base: string): OptionKeyVariant[] { + return [ + { label: "composed", press: (harness) => harness.pressKey(glyph) }, + { + label: "flagged", + press: (harness) => harness.pressKey(base, { meta: true }), + }, + ]; +} + +async function withWiredShell( + run: (shell: AppShell, harness: Harness) => Promise | void, +): Promise { + await withTestRenderer( + async (harness) => { + const shell = createAppShell(harness.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + shell.prompt.focus(); + await run(shell, harness); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); +} + +for (const variant of optionKeyVariants("ç", "c")) { + test(`${variant.label} Alt+C opens copy mode through the global dispatcher`, async () => { + await withWiredShell((shell, harness) => { + appendStreamRow(shell, { role: "assistant", text: "copy this" }); + variant.press(harness); + expect(shell.overlayKind).toBe("copy"); + }); + }); +} + +for (const variant of optionKeyVariants("µ", "m")) { + test(`${variant.label} Alt+M toggles mouse capture through the global dispatcher`, async () => { + await withWiredShell((shell, harness) => { + let captured = false; + shell.mouseCapture = { + get: () => captured, + set: (enabled) => { + captured = enabled; + }, + }; + variant.press(harness); + expect(captured).toBe(true); + }); + }); +} + +for (const variant of optionKeyVariants("∂", "d")) { + test(`${variant.label} Alt+D deletes the next word through the global dispatcher`, async () => { + await withWiredShell((shell, harness) => { + shell.prompt.value = "foo bar"; + shell.prompt.cursorOffset = 0; + variant.press(harness); + expect(shell.prompt.value).toBe("bar"); + }); + }); +} + +for (const variant of optionKeyVariants("¥", "y")) { + test(`${variant.label} Alt+Y rotates the yank through the global dispatcher`, async () => { + await withWiredShell((shell, harness) => { + shell.prompt.value = "older"; + shell.prompt.cursorOffset = 0; + harness.pressKey("k", { ctrl: true }); + harness.pressKey("b", { ctrl: true }); + shell.prompt.value = "newer"; + shell.prompt.cursorOffset = 0; + harness.pressKey("k", { ctrl: true }); + harness.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("newer"); + variant.press(harness); + expect(shell.prompt.value).toBe("older"); + }); + }); +} test("palette Alt+A fires identically for å/Å and flagged Alt+A", () => { for (const glyph of ["å", "Å"]) { @@ -91,9 +179,10 @@ test("palette Alt+A fires identically for å/Å and flagged Alt+A", () => { ).toBe(false); }); -test("palette Alt+D fires identically for ∂ and flagged Alt+D", () => { - expect(isSetDefaultShortcutKey(composed("∂"))).toBe(true); +test("palette Alt+D accepts normalized and flagged chords", () => { + expect(isSetDefaultShortcutKey(normalizeOptionKey(composed("∂")))).toBe(true); expect(isSetDefaultShortcutKey(flagged("d"))).toBe(true); + expect(isSetDefaultShortcutKey(composed("∂"))).toBe(false); expect(isSetDefaultShortcutKey(ev({ name: "d", sequence: "d" }))).toBe(false); }); @@ -130,6 +219,13 @@ test("unclaimed å passes through unchanged and remains insertable", () => { } }); +test("literal å inserts outside the picker through the global dispatcher", async () => { + await withWiredShell((shell, harness) => { + harness.pressKey("å"); + expect(shell.prompt.value).toBe("å"); + }); +}); + test("real parser output folds end to end: ∂ ≡ flagged Alt+D", () => { const parsed = parseKeypress(Buffer.from("∂", "utf8")); if (parsed === null) throw new Error("parseKeypress returned null for ∂");