Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,36 @@ 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. 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
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** — 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

Two mid-run gestures, two delivery times (CL-6290):
Expand Down
28 changes: 8 additions & 20 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/tui/prompt-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
5 changes: 5 additions & 0 deletions src/tui/shell/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
handlePaletteFilterKey,
handleSlashPopupKey,
MOTION_KEYS,
normalizeOptionKey,
openAtMentionSuggestions,
openSlashCommands,
setPromptText,
Expand Down Expand Up @@ -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;

Expand Down
64 changes: 36 additions & 28 deletions src/tui/shell/palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,37 +167,55 @@ 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<string, string> = new Map([
["∂", "d"],
["ç", "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") : "";
return (key.meta || key.option) && name.toLowerCase() === "d";
}

/**
Expand All @@ -223,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);
Expand Down
9 changes: 8 additions & 1 deletion src/tui/transcript-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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 });
Expand Down
Loading
Loading