Skip to content

Commit b9c5718

Browse files
committed
fix(tui): handle composed Option-D in model picker
1 parent 2c38c6f commit b9c5718

3 files changed

Lines changed: 114 additions & 6 deletions

File tree

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,53 @@ describe("flat type-to-filter model picker", () => {
650650
}
651651
});
652652

653+
test("composed Option+D (∂) sets the default instead of filtering the model picker", async () => {
654+
const defaults: string[] = [];
655+
const { harness, host } = await mountPicker({
656+
onSetDefault: (id) => defaults.push(id),
657+
});
658+
try {
659+
host.openModels?.();
660+
await harness.renderOnce();
661+
const composed = {
662+
name: "∂",
663+
sequence: "∂",
664+
ctrl: false,
665+
meta: false,
666+
option: false,
667+
} as KeyEvent;
668+
expect(handleListFilterKey(host.shell, composed)).toBe(false);
669+
expect(runOverlayAction(host.shell, composed)).toBe(true);
670+
expect(defaults).toEqual(["codex/abk-labs:gpt-5.5"]);
671+
expect(host.shell.overlayItems).not.toEqual(["(no matches)"]);
672+
} finally {
673+
host.dispose();
674+
harness.destroy();
675+
}
676+
});
677+
678+
test("composed Option+D (∂) remains filter text when setting a default is unavailable", async () => {
679+
const { harness, host } = await mountPicker();
680+
try {
681+
host.openModels?.();
682+
await harness.renderOnce();
683+
const composed = {
684+
name: "∂",
685+
sequence: "∂",
686+
ctrl: false,
687+
meta: false,
688+
option: false,
689+
} as KeyEvent;
690+
expect(handleListFilterKey(host.shell, composed)).toBe(true);
691+
await harness.renderOnce();
692+
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
693+
expect(runOverlayAction(host.shell, composed)).toBe(false);
694+
} finally {
695+
host.dispose();
696+
harness.destroy();
697+
}
698+
});
699+
653700
test("Alt+D on the no-matches sentinel does not set a default", async () => {
654701
const defaults: string[] = [];
655702
const { harness, host } = await mountPicker({
@@ -663,8 +710,35 @@ describe("flat type-to-filter model picker", () => {
663710
}
664711
await harness.renderOnce();
665712
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
666-
expect(runOverlayAction(host.shell, altD)).toBe(false);
713+
expect(runOverlayAction(host.shell, altD)).toBe(true);
714+
expect(defaults).toEqual([]);
715+
expect(host.shell.overlayKind).toBe("model_picker");
716+
} finally {
717+
host.dispose();
718+
harness.destroy();
719+
}
720+
});
721+
722+
test("composed Option+D (∂) on the no-matches sentinel does not reach the prompt", async () => {
723+
const defaults: string[] = [];
724+
const { harness, host } = await mountPicker({
725+
onSetDefault: (id) => defaults.push(id),
726+
});
727+
try {
728+
host.shell.prompt.value = "draft";
729+
host.openModels?.();
730+
await harness.renderOnce();
731+
for (const ch of "zzzz-no-such-model") {
732+
harness.pressKey(ch);
733+
}
734+
await harness.renderOnce();
735+
expect(host.shell.overlayItems).toEqual(["(no matches)"]);
736+
737+
harness.pressKey("∂");
738+
await harness.renderOnce();
739+
667740
expect(defaults).toEqual([]);
741+
expect(host.shell.prompt.value).toBe("draft");
668742
expect(host.shell.overlayKind).toBe("model_picker");
669743
} finally {
670744
host.dispose();

‎src/tui/product-host.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
import { setOwnedOverlayItems } from "./shell/overlay-host.js";
6262
import {
6363
isAddProviderShortcutKey,
64+
isSetDefaultShortcutKey,
6465
setPaletteCatalog,
6566
} from "./shell/palette.js";
6667
import { surfaceSystemNotice } from "./shell/prompt.js";
@@ -670,18 +671,26 @@ export async function mountProductHost(
670671
openAddProvider({ returnToModels: true });
671672
return true;
672673
}
673-
if (!(key.meta || key.option)) return false;
674+
// Alt+F stays modifier-only; the default shortcut also accepts
675+
// the composed Option+D glyph through its scoped predicate.
674676
const name =
675677
typeof key.name === "string" ? key.name.toLowerCase() : "";
676-
// Alt+F / Alt+D, never bare — type-to-filter claims printable keys.
677-
if (name === "f" && onFavoriteToggle !== undefined) {
678+
if (
679+
!key.ctrl &&
680+
(key.meta || key.option) &&
681+
name === "f" &&
682+
onFavoriteToggle !== undefined
683+
) {
678684
// Empty id is the "(no matches)" filter sentinel — not a model.
679685
if (itemId.length === 0) return false;
680686
onFavoriteToggle(itemId);
681687
return true;
682688
}
683-
if (name === "d" && onSetDefault !== undefined) {
684-
if (itemId.length === 0) return false;
689+
if (
690+
onSetDefault !== undefined &&
691+
isSetDefaultShortcutKey(key)
692+
) {
693+
if (itemId.length === 0) return true;
685694
onSetDefault(itemId);
686695
return true;
687696
}

‎src/tui/shell/palette.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export function handlePaletteFilterKey(
168168
* Glyphs some terminals emit for Option+A without setting meta/option.
169169
*/
170170
const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]);
171+
const OPTION_D_COMPOSED_CHARS = new Set(["∂"]);
171172

172173
/**
173174
* True when a key event is the model-picker Alt+A add-provider chord.
@@ -183,6 +184,20 @@ export function isAddProviderShortcutKey(key: KeyEvent): boolean {
183184
return false;
184185
}
185186

187+
/**
188+
* True when a key event is the model-picker Alt+D set-default chord.
189+
* Terminals may deliver Option+D as ∂ without meta/option.
190+
*/
191+
export function isSetDefaultShortcutKey(key: KeyEvent): boolean {
192+
if (key.ctrl) return false;
193+
const name = typeof key.name === "string" ? key.name : "";
194+
const seq = typeof key.sequence === "string" ? key.sequence : "";
195+
if ((key.meta || key.option) && name.toLowerCase() === "d") return true;
196+
if (OPTION_D_COMPOSED_CHARS.has(name) || OPTION_D_COMPOSED_CHARS.has(seq))
197+
return true;
198+
return false;
199+
}
200+
186201
/**
187202
* Keys a type-to-filter list overlay claims while open, so the `>` row
188203
* narrows as you type. Mirrors the palette filter, but updates the open
@@ -206,6 +221,16 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean {
206221
return false;
207222
}
208223

224+
// setDefaultHint similarly gates the composed Option+D (∂) bypass. Outside
225+
// this model-picker action context, ∂ remains ordinary filter text.
226+
if (
227+
bag?.primaryBindings.setDefaultHint === true &&
228+
shell.overlayKind === "model_picker" &&
229+
isSetDefaultShortcutKey(key)
230+
) {
231+
return false;
232+
}
233+
209234
if (key.name === "backspace") {
210235
if (state.query.length === 0) return true;
211236
state.query = state.query.slice(0, -1);

0 commit comments

Comments
 (0)