Skip to content

Commit a609270

Browse files
committed
fix(tui): preserve dead-key spacing acute input
1 parent 49510a3 commit a609270

6 files changed

Lines changed: 138 additions & 53 deletions

File tree

‎docs/TUI.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,11 @@ globally claimed Alt+C/M/D/Y chords (`ç`, `µ`, `∂`, `¥`) are normalized bef
659659
key dispatch. Consequently, typing those glyphs directly into the bare prompt
660660
is intentionally unavailable, matching Meta-on behavior. Composed `å`/`Å` is
661661
recognized only by a surface that claims Alt+A; otherwise it passes through and
662-
inserts normally. Paste is a separate event path and is never normalized or
663-
remapped.
662+
inserts normally. There is no composed fallback for Alt+E: with Option-as-Meta
663+
off, Option+E is a dead key, and following it with Space inserts the literal
664+
spacing acute (`´`) without expanding a row. With Option-as-Meta on, the flagged
665+
Alt+E chord still expands. Paste is a separate event path and is never normalized
666+
or remapped.
664667

665668
The recovery environment could not drive GUI terminal settings, so no row below
666669
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
669672
withholds the bare Option+E event from the application; no timeout or synthetic
670673
remapping is appropriate.
671674

672-
| Terminal | Option mode | Alt+C/M/D/Y | Alt+A | Alt+E | Bare prompt | Paste |
673-
| ------------ | ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- |
674-
| 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 |
675-
| 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 |
676-
| 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 |
677-
| 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 |
678-
| 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 |
679-
| 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 |
675+
| Terminal | Option mode | Alt+C/M/D/Y | Alt+A | Alt+E | Bare prompt | Paste |
676+
| ------------ | ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------- |
677+
| 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 |
678+
| 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 |
679+
| 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 |
680+
| 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 |
681+
| 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 |
682+
| 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 |
680683

681684
### Soft steer vs. follow-up
682685

‎src/tui/product-host.test.ts‎

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -664,15 +664,8 @@ describe("flat type-to-filter model picker", () => {
664664
try {
665665
host.openModels?.();
666666
await harness.renderOnce();
667-
const composed = {
668-
name: "∂",
669-
sequence: "∂",
670-
ctrl: false,
671-
meta: false,
672-
option: false,
673-
} as KeyEvent;
674-
expect(handleListFilterKey(host.shell, composed)).toBe(false);
675-
expect(runOverlayAction(host.shell, composed)).toBe(true);
667+
harness.pressKey("∂");
668+
await harness.renderOnce();
676669
expect(defaults).toEqual([modelOptionId("codex/abk-labs", "gpt-5.5")]);
677670
expect(host.shell.overlayItems).not.toEqual(["(no matches)"]);
678671
} finally {
@@ -681,22 +674,17 @@ describe("flat type-to-filter model picker", () => {
681674
}
682675
});
683676

684-
test("composed Option+D (∂) remains filter text when setting a default is unavailable", async () => {
677+
test("composed Option+D (∂) is globally claimed when setting a default is unavailable", async () => {
685678
const { harness, host } = await mountPicker();
686679
try {
680+
host.shell.prompt.value = "draft";
687681
host.openModels?.();
688682
await harness.renderOnce();
689-
const composed = {
690-
name: "∂",
691-
sequence: "∂",
692-
ctrl: false,
693-
meta: false,
694-
option: false,
695-
} as KeyEvent;
696-
expect(handleListFilterKey(host.shell, composed)).toBe(true);
683+
const items = host.shell.overlayItems;
684+
harness.pressKey("∂");
697685
await harness.renderOnce();
698-
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
699-
expect(runOverlayAction(host.shell, composed)).toBe(false);
686+
expect(host.shell.overlayItems).toEqual(items);
687+
expect(host.shell.prompt.value).toBe("draft");
700688
} finally {
701689
host.dispose();
702690
harness.destroy();

‎src/tui/prompt-features.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,12 @@ describe("text paste", () => {
367367
"hello world",
368368
);
369369

370+
pasteCase(
371+
"composed Option glyphs pasted together remain literal",
372+
async (h) => await h.mockInput.pasteBracketedText("çµ∂¥"),
373+
"çµ∂¥",
374+
);
375+
370376
pasteCase(
371377
"multi-line paste keeps its newlines and does not submit",
372378
async (h) =>

‎src/tui/shell/palette.ts‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ export function handlePaletteFilterKey(
176176
*/
177177
const OPTION_COMPOSED_BASE: ReadonlyMap<string, string> = new Map([
178178
["∂", "d"],
179-
["´", "e"],
180179
["ç", "c"],
181180
["µ", "m"],
182181
["¥", "y"],
@@ -216,10 +215,6 @@ export function isAddProviderShortcutKey(key: KeyEvent): boolean {
216215
export function isSetDefaultShortcutKey(key: KeyEvent): boolean {
217216
if (key.ctrl) return false;
218217
const name = typeof key.name === "string" ? key.name.normalize("NFC") : "";
219-
const seq =
220-
typeof key.sequence === "string" ? key.sequence.normalize("NFC") : "";
221-
if (OPTION_COMPOSED_BASE.get(name) === "d") return true;
222-
if (OPTION_COMPOSED_BASE.get(seq) === "d") return true;
223218
return (key.meta || key.option) && name.toLowerCase() === "d";
224219
}
225220

@@ -246,16 +241,6 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean {
246241
return false;
247242
}
248243

249-
// setDefaultHint similarly gates the composed Option+D (∂) bypass. Outside
250-
// this model-picker action context, ∂ remains ordinary filter text.
251-
if (
252-
bag?.primaryBindings.setDefaultHint === true &&
253-
shell.overlayKind === "model_picker" &&
254-
isSetDefaultShortcutKey(key)
255-
) {
256-
return false;
257-
}
258-
259244
if (key.name === "backspace") {
260245
if (state.query.length === 0) return true;
261246
state.query = state.query.slice(0, -1);

‎src/tui/transcript-layout.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ describe("transcript turn layout", () => {
249249
);
250250
});
251251

252-
test("Alt+E expands the newest collapsed row; a bare e always just types", async () => {
252+
test("Alt+E expands the newest collapsed row; bare e and ´ only type", async () => {
253253
await withTestRenderer(
254254
async (h) => {
255255
const shell = createAppShell(h.renderer, {
@@ -273,6 +273,13 @@ describe("transcript turn layout", () => {
273273
expect(h.captureCharFrame()).not.toContain("no emojis");
274274
expect(shell.prompt.value).toBe("e");
275275

276+
// Option+E then Space emits a literal spacing acute with no modifier.
277+
// It remains text even while an expandable row is available.
278+
h.pressKey("´");
279+
await h.renderOnce();
280+
expect(h.captureCharFrame()).not.toContain("no emojis");
281+
expect(shell.prompt.value).toBe("e´");
282+
276283
// Alt+E expands regardless of which widget nominally has focus —
277284
// the prompt still holds focus here, and it still fires.
278285
h.pressKey("e", { meta: true });

‎tests/unit/tui/option-key-composed.test.ts‎

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { test, expect } from "bun:test";
22
import { parseKeypress, type KeyEvent } from "@opentui/core";
3+
import { withTestRenderer, type Harness } from "../../../src/tui/harness.js";
4+
import { appendStreamRow } from "../../../src/tui/shell/chrome.js";
5+
import { createAppShell } from "../../../src/tui/shell/index.js";
6+
import type { AppShell } from "../../../src/tui/shell/internals.js";
37
import {
48
isAddProviderShortcutKey,
59
isSetDefaultShortcutKey,
@@ -49,7 +53,6 @@ const CHORD_ROWS: {
4953
}[] = [
5054
{ glyph: "∂", base: "d", flaggedName: "d" },
5155
{ glyph: "¥", base: "y", flaggedName: "y" },
52-
{ glyph: "´", base: "e", flaggedName: "e" },
5356
{ glyph: "ç", base: "c", flaggedName: "c" },
5457
{ glyph: "µ", base: "m", flaggedName: "m" },
5558
];
@@ -73,10 +76,95 @@ test("folded Alt+D/Alt+Y carry the kill-ring gate flags (keys.ts Alt+D/Alt+Y)",
7376
}
7477
});
7578

76-
test("folded Alt+E uses the exact expand name (keys.ts Alt+E matches EXPAND_KEY)", () => {
77-
const folded = normalizeOptionKey(composed("´"));
78-
expect(folded.name).toBe("e");
79-
});
79+
type OptionKeyVariant = {
80+
readonly label: string;
81+
readonly press: (harness: Harness) => void;
82+
};
83+
84+
function optionKeyVariants(glyph: string, base: string): OptionKeyVariant[] {
85+
return [
86+
{ label: "composed", press: (harness) => harness.pressKey(glyph) },
87+
{
88+
label: "flagged",
89+
press: (harness) => harness.pressKey(base, { meta: true }),
90+
},
91+
];
92+
}
93+
94+
async function withWiredShell(
95+
run: (shell: AppShell, harness: Harness) => Promise<void> | void,
96+
): Promise<void> {
97+
await withTestRenderer(
98+
async (harness) => {
99+
const shell = createAppShell(harness.renderer, {
100+
terminal: { columns: 80, rows: 24 },
101+
wireKeys: true,
102+
});
103+
try {
104+
shell.prompt.focus();
105+
await run(shell, harness);
106+
} finally {
107+
shell.dispose();
108+
}
109+
},
110+
{ width: 80, height: 24 },
111+
);
112+
}
113+
114+
for (const variant of optionKeyVariants("ç", "c")) {
115+
test(`${variant.label} Alt+C opens copy mode through the global dispatcher`, async () => {
116+
await withWiredShell((shell, harness) => {
117+
appendStreamRow(shell, { role: "assistant", text: "copy this" });
118+
variant.press(harness);
119+
expect(shell.overlayKind).toBe("copy");
120+
});
121+
});
122+
}
123+
124+
for (const variant of optionKeyVariants("µ", "m")) {
125+
test(`${variant.label} Alt+M toggles mouse capture through the global dispatcher`, async () => {
126+
await withWiredShell((shell, harness) => {
127+
let captured = false;
128+
shell.mouseCapture = {
129+
get: () => captured,
130+
set: (enabled) => {
131+
captured = enabled;
132+
},
133+
};
134+
variant.press(harness);
135+
expect(captured).toBe(true);
136+
});
137+
});
138+
}
139+
140+
for (const variant of optionKeyVariants("∂", "d")) {
141+
test(`${variant.label} Alt+D deletes the next word through the global dispatcher`, async () => {
142+
await withWiredShell((shell, harness) => {
143+
shell.prompt.value = "foo bar";
144+
shell.prompt.cursorOffset = 0;
145+
variant.press(harness);
146+
expect(shell.prompt.value).toBe("bar");
147+
});
148+
});
149+
}
150+
151+
for (const variant of optionKeyVariants("¥", "y")) {
152+
test(`${variant.label} Alt+Y rotates the yank through the global dispatcher`, async () => {
153+
await withWiredShell((shell, harness) => {
154+
shell.prompt.value = "older";
155+
shell.prompt.cursorOffset = 0;
156+
harness.pressKey("k", { ctrl: true });
157+
harness.pressKey("b", { ctrl: true });
158+
shell.prompt.value = "newer";
159+
shell.prompt.cursorOffset = 0;
160+
harness.pressKey("k", { ctrl: true });
161+
harness.pressKey("y", { ctrl: true });
162+
expect(shell.prompt.value).toBe("newer");
163+
variant.press(harness);
164+
expect(shell.prompt.value).toBe("older");
165+
});
166+
});
167+
}
80168

81169
test("palette Alt+A fires identically for å/Å and flagged Alt+A", () => {
82170
for (const glyph of ["å", "Å"]) {
@@ -91,9 +179,10 @@ test("palette Alt+A fires identically for å/Å and flagged Alt+A", () => {
91179
).toBe(false);
92180
});
93181

94-
test("palette Alt+D fires identically for ∂ and flagged Alt+D", () => {
95-
expect(isSetDefaultShortcutKey(composed("∂"))).toBe(true);
182+
test("palette Alt+D accepts normalized and flagged chords", () => {
183+
expect(isSetDefaultShortcutKey(normalizeOptionKey(composed("∂")))).toBe(true);
96184
expect(isSetDefaultShortcutKey(flagged("d"))).toBe(true);
185+
expect(isSetDefaultShortcutKey(composed("∂"))).toBe(false);
97186
expect(isSetDefaultShortcutKey(ev({ name: "d", sequence: "d" }))).toBe(false);
98187
});
99188

@@ -130,6 +219,13 @@ test("unclaimed å passes through unchanged and remains insertable", () => {
130219
}
131220
});
132221

222+
test("literal å inserts outside the picker through the global dispatcher", async () => {
223+
await withWiredShell((shell, harness) => {
224+
harness.pressKey("å");
225+
expect(shell.prompt.value).toBe("å");
226+
});
227+
});
228+
133229
test("real parser output folds end to end: ∂ ≡ flagged Alt+D", () => {
134230
const parsed = parseKeypress(Buffer.from("∂", "utf8"));
135231
if (parsed === null) throw new Error("parseKeypress returned null for ∂");

0 commit comments

Comments
 (0)