Skip to content

Commit e2e9926

Browse files
committed
Fix add provider shortcut on composed macOS input
1 parent 475dda2 commit e2e9926

4 files changed

Lines changed: 94 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- `/model` Alt+A opens Add Provider on non-US macOS layouts that emit å/Å
19+
for Option+A without the option modifier, instead of type-to-filter
20+
claiming the glyph.
21+
1622
## [0.3.11] - 2026-08-31
1723

1824
### Changed

src/tui/product-host.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createHarness } from "./harness.js";
1111
import {
1212
acceptOverlaySelection,
1313
closeInsetOverlay,
14+
handleListFilterKey,
1415
moveOverlaySelection,
1516
runOverlayAction,
1617
} from "./shell.js";
@@ -732,6 +733,55 @@ describe("flat type-to-filter model picker", () => {
732733
}
733734
});
734735

736+
test("composed Option+A (å) opens add-provider and is not claimed by type-to-filter", async () => {
737+
// Non-US macOS layouts often emit å/Å for Option+A without meta/option set.
738+
// Type-to-filter used to claim that printable before runOverlayAction ran.
739+
const { harness, host } = await mountPicker({
740+
onConnectProvider: () => {},
741+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }],
742+
});
743+
try {
744+
host.openModels?.();
745+
await harness.renderOnce();
746+
const composed = {
747+
name: "å",
748+
sequence: "å",
749+
ctrl: false,
750+
meta: false,
751+
option: false,
752+
} as KeyEvent;
753+
expect(handleListFilterKey(host.shell, composed)).toBe(false);
754+
expect(runOverlayAction(host.shell, composed)).toBe(true);
755+
expect(host.shell.overlayKind).toBe("add_provider");
756+
} finally {
757+
host.dispose();
758+
harness.destroy();
759+
}
760+
});
761+
762+
test("ordinary letters still type-to-filter when add-provider is wired", async () => {
763+
const { harness, host } = await mountPicker({
764+
onConnectProvider: () => {},
765+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }],
766+
});
767+
try {
768+
host.openModels?.();
769+
await harness.renderOnce();
770+
const letter = {
771+
name: "g",
772+
sequence: "g",
773+
ctrl: false,
774+
meta: false,
775+
option: false,
776+
} as KeyEvent;
777+
expect(handleListFilterKey(host.shell, letter)).toBe(true);
778+
expect(host.shell.overlayKind).toBe("model_picker");
779+
} finally {
780+
host.dispose();
781+
harness.destroy();
782+
}
783+
});
784+
735785
test("Enter on a Custom add-provider row runs the connect flow for custom", async () => {
736786
const connected: string[] = [];
737787
const { harness, host } = await mountPicker({

src/tui/product-host.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
clearTranscript,
4444
closeInsetOverlay,
4545
createAppShell,
46+
isAddProviderShortcutKey,
4647
paintChrome,
4748
setChromeZones,
4849
setHeader,
@@ -585,13 +586,16 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
585586
onSetDefault !== undefined
586587
? {
587588
onAction: (itemId, key) => {
588-
if (key.ctrl || !(key.meta || key.option)) return false;
589-
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
590-
// Alt+A / Alt+F / Alt+D, never bare — type-to-filter claims printable keys.
591-
if (name === "a" && openAddProvider !== undefined) {
589+
if (key.ctrl) return false;
590+
// Alt+A / composed Option+A (å/Å) — never bare ASCII `a`;
591+
// type-to-filter claims ordinary printables.
592+
if (openAddProvider !== undefined && isAddProviderShortcutKey(key)) {
592593
openAddProvider();
593594
return true;
594595
}
596+
if (!(key.meta || key.option)) return false;
597+
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
598+
// Alt+F / Alt+D, never bare — type-to-filter claims printable keys.
595599
if (name === "f" && onFavoriteToggle !== undefined) {
596600
// Empty id is the "(no matches)" filter sentinel — not a model.
597601
if (itemId.length === 0) return false;

src/tui/shell.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3866,6 +3866,26 @@ export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean
38663866
return true;
38673867
}
38683868

3869+
/**
3870+
* Glyphs macOS emits for Option+A on common layouts (US, ABC, Nordic).
3871+
* Non-US input sources often deliver these without meta/option set.
3872+
*/
3873+
const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]);
3874+
3875+
/**
3876+
* True when a key event is the model-picker Alt+A add-provider chord.
3877+
* US layouts send name `"a"` with meta/option; non-US layouts often emit
3878+
* å/Å with neither modifier, so type-to-filter would otherwise claim them.
3879+
*/
3880+
export function isAddProviderShortcutKey(key: KeyEvent): boolean {
3881+
if (key.ctrl) return false;
3882+
const name = typeof key.name === "string" ? key.name : "";
3883+
const seq = typeof key.sequence === "string" ? key.sequence : "";
3884+
if ((key.meta || key.option) && name.toLowerCase() === "a") return true;
3885+
if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) return true;
3886+
return false;
3887+
}
3888+
38693889
/**
38703890
* Keys a type-to-filter list overlay claims while open, so the `>` row
38713891
* narrows as you type. Mirrors the palette filter, but updates the open
@@ -3879,6 +3899,16 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean {
38793899
if (shell.overlayKind === "palette") return false;
38803900
if (key.ctrl || key.meta || key.option) return false;
38813901

3902+
// When Alt+A add-provider is wired, leave Option+A composed glyphs (å/Å)
3903+
// for runOverlayAction — non-US macOS layouts emit them without meta/option.
3904+
if (
3905+
bag?.overlayAddProviderHint === true &&
3906+
shell.overlayKind === "model_picker" &&
3907+
isAddProviderShortcutKey(key)
3908+
) {
3909+
return false;
3910+
}
3911+
38823912
if (key.name === "backspace") {
38833913
if (state.query.length === 0) return true;
38843914
state.query = state.query.slice(0, -1);

0 commit comments

Comments
 (0)