Skip to content

Commit 0384976

Browse files
committed
Name /connect on the model picker hint and pin Esc paths
The footer still advertised only Alt+A, which is a dead chord on layouts whose Option+A is not å/Å. Tests now cover closed-prompt å, other composed glyphs, sequence-only å, typed /connect Esc, and Alt+A Esc through the key path.
1 parent 469a29f commit 0384976

5 files changed

Lines changed: 130 additions & 19 deletions

File tree

docs/TUI.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,14 +410,19 @@ not-yet-configured provider kind, filtered out once that kind had any
410410
connected account. That filtering made a second OAuth account (a second
411411
Codex or xAI login) unreachable — OAuth accounts are per-profile, so
412412
kind-level "already connected" filtering hid the connect path the moment the
413-
first profile existed. **Alt+A** now opens `add_provider`
413+
first profile existed. **Alt+A** (US-style Option+A, including composed å/Å)
414+
and **/connect** now open `add_provider`
414415
(`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind`
415416
listing every first-class provider kind from `providerChoices()` — OAuth,
416417
API-key, and Custom alike — each annotated with its live connected-account
417-
count and none of them filtered out. Custom uses the full manual form (name,
418+
count and none of them filtered out. `/connect` is the layout-proof path:
419+
layouts whose Option+A is not å/Å still type a printable glyph, so Alt+A
420+
is a dead chord there. Custom uses the full manual form (name,
418421
base URL, key, model); first-class kinds keep their auth-only or browser
419-
login paths. Esc returns to the model list through the same
420-
`openModels()` entry point the picker itself uses. Picking a row runs the
422+
login paths. Esc after Alt+A from `/model` returns to the model list through
423+
the same `openModels()` entry point the picker itself uses. Esc after
424+
`/connect` from a closed prompt dismisses the selector without reopening
425+
`/model`. Picking a row runs the
421426
existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth
422427
and API-key) both ask for an instance/account name before auth so multiple
423428
instances coexist as `kind/slug` catalog rows, and reusing a name confirms

src/tui/overlay-paint.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,14 @@ describe("overlay host never shares cells with the prompt border", () => {
100100
kind: "model_picker",
101101
title: "model",
102102
items: ITEMS,
103-
// Mirror production /model, which always wires Alt+A.
103+
// Mirror production /model, which always wires add-provider.
104104
addProviderHint: true,
105105
}),
106106
size,
107107
);
108108

109109
const expected = [
110-
" model · Esc cancel · Enter choose · Alt+A add provider",
110+
" model · Esc cancel · Enter choose · Alt+A /connect add provider",
111111
` > ${ITEMS[0]}`,
112112
...ITEMS.slice(1).map((i) => ` ${i}`),
113113
];

src/tui/overlays.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export interface OpenModelPickerOpts {
175175
* as you type. Off by default so other list overlays keep j/k.
176176
*/
177177
readonly typeToFilter?: boolean;
178-
/** Advertise Alt+A in the footer — only when the caller wired the handler. */
178+
/** Advertise Alt+A /connect in the footer — only when the caller wired the handler. */
179179
readonly addProviderHint?: boolean;
180180
/** Advertise Alt+D in the footer — only when the caller wired the handler. */
181181
readonly setDefaultHint?: boolean;

src/tui/product-host.test.ts

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { AGENTS_PANEL_LINGER_MS } from "./chrome-state.js";
1010
import { createHarness } from "./harness.js";
1111
import {
1212
acceptOverlaySelection,
13-
closeInsetOverlay,
1413
handleListFilterKey,
1514
moveOverlaySelection,
1615
runOverlayAction,
@@ -690,7 +689,7 @@ describe("flat type-to-filter model picker", () => {
690689

691690
const altA = { name: "a", ctrl: false, meta: false, option: true } as KeyEvent;
692691

693-
test("the model picker footer advertises Alt+A", async () => {
692+
test("the model picker footer advertises Alt+A and /connect", async () => {
694693
const { harness, host } = await mountPicker({
695694
// The hint requires the full wiring — choices AND the connect handler —
696695
// because that is exactly when the key actually works.
@@ -700,7 +699,9 @@ describe("flat type-to-filter model picker", () => {
700699
try {
701700
host.openModels?.();
702701
await harness.renderOnce();
703-
expect(harness.captureCharFrame()).toContain("Alt+A");
702+
const frame = harness.captureCharFrame();
703+
expect(frame).toContain("Alt+A");
704+
expect(frame).toContain("/connect");
704705
} finally {
705706
host.dispose();
706707
harness.destroy();
@@ -799,6 +800,74 @@ describe("flat type-to-filter model picker", () => {
799800
}
800801
});
801802

803+
test("closed-prompt å stays in the prompt and does not open add-provider", async () => {
804+
const { harness, host } = await mountPicker({
805+
onConnectProvider: () => {},
806+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }],
807+
});
808+
try {
809+
expect(host.shell.overlayKind).toBeNull();
810+
harness.pressKey("å");
811+
await harness.renderOnce();
812+
expect(host.shell.overlayKind).toBeNull();
813+
expect(host.shell.prompt.value).toContain("å");
814+
} finally {
815+
host.dispose();
816+
harness.destroy();
817+
}
818+
});
819+
820+
test("other composed glyphs still type-to-filter in the model picker", async () => {
821+
const { harness, host } = await mountPicker({
822+
onConnectProvider: () => {},
823+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }],
824+
});
825+
try {
826+
host.openModels?.();
827+
await harness.renderOnce();
828+
for (const glyph of ["ø", "ä", "æ"] as const) {
829+
const composed = {
830+
name: glyph,
831+
sequence: glyph,
832+
ctrl: false,
833+
meta: false,
834+
option: false,
835+
} as KeyEvent;
836+
expect(handleListFilterKey(host.shell, composed)).toBe(true);
837+
expect(host.shell.overlayKind).toBe("model_picker");
838+
}
839+
} finally {
840+
host.dispose();
841+
harness.destroy();
842+
}
843+
});
844+
845+
test("sequence-only å with name a opens add-provider from the model picker", async () => {
846+
// Terminals can report Option+A as sequence å while name stays ASCII a
847+
// and option/meta stay false (#482).
848+
const { harness, host } = await mountPicker({
849+
onConnectProvider: () => {},
850+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }],
851+
});
852+
try {
853+
host.openModels?.();
854+
await harness.renderOnce();
855+
const sequenceOnly = {
856+
name: "a",
857+
sequence: "å",
858+
ctrl: false,
859+
meta: false,
860+
option: false,
861+
} as KeyEvent;
862+
expect(handleListFilterKey(host.shell, sequenceOnly)).toBe(false);
863+
expect(runOverlayAction(host.shell, sequenceOnly)).toBe(true);
864+
expect(host.shell.overlayKind).toBe("add_provider");
865+
} finally {
866+
host.dispose();
867+
harness.destroy();
868+
}
869+
});
870+
802871
test("composed å still type-to-filters when add-provider is not wired", async () => {
803872
const { harness, host } = await mountPicker();
804873
try {
@@ -901,7 +970,8 @@ describe("flat type-to-filter model picker", () => {
901970
runOverlayAction(host.shell, altA);
902971
await harness.renderOnce();
903972
expect(host.shell.overlayKind).toBe("add_provider");
904-
closeInsetOverlay(host.shell);
973+
harness.pressKey("Escape");
974+
await new Promise((r) => setTimeout(r, 60));
905975
await harness.renderOnce();
906976
expect(host.shell.overlayKind).toBe("model_picker");
907977
expect(host.shell.overlayItems).toEqual(modelItems);
@@ -921,7 +991,8 @@ describe("flat type-to-filter model picker", () => {
921991
host.openAddProvider?.();
922992
await harness.renderOnce();
923993
expect(host.shell.overlayKind).toBe("add_provider");
924-
closeInsetOverlay(host.shell);
994+
harness.pressKey("Escape");
995+
await new Promise((r) => setTimeout(r, 60));
925996
await harness.renderOnce();
926997
expect(host.shell.overlayKind).not.toBe("model_picker");
927998
expect(host.shell.overlayKind).toBeNull();
@@ -931,6 +1002,41 @@ describe("flat type-to-filter model picker", () => {
9311002
}
9321003
});
9331004

1005+
test("typed /connect then Enter opens add-provider and Esc leaves overlay null", async () => {
1006+
const queued: { open?: () => void } = {};
1007+
const { harness, host } = await mountPicker({
1008+
onConnectProvider: () => {},
1009+
addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 1 }],
1010+
commands: [
1011+
{
1012+
id: "connect",
1013+
label: "/connect",
1014+
description: "Add a provider account",
1015+
keywords: ["connect", "Add a provider account", "slash", "command"],
1016+
},
1017+
],
1018+
onCommand: (name) => {
1019+
if (name === "connect") queued.open?.();
1020+
},
1021+
});
1022+
queued.open = () => host.openAddProvider?.();
1023+
try {
1024+
expect(host.shell.overlayKind).toBeNull();
1025+
for (const ch of "/connect") harness.pressKey(ch);
1026+
await harness.renderOnce();
1027+
harness.pressKey("Enter");
1028+
await harness.renderOnce();
1029+
expect(host.shell.overlayKind).toBe("add_provider");
1030+
harness.pressKey("Escape");
1031+
await new Promise((r) => setTimeout(r, 60));
1032+
await harness.renderOnce();
1033+
expect(host.shell.overlayKind).toBeNull();
1034+
} finally {
1035+
host.dispose();
1036+
harness.destroy();
1037+
}
1038+
});
1039+
9341040
test("Enter on an add-provider row runs the connect flow for that provider", async () => {
9351041
const connected: string[] = [];
9361042
const { harness, host } = await mountPicker({

src/tui/shell.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,8 +1332,8 @@ const DEFAULT_OVERLAY_HINTS = ["Esc cancel · Enter choose", "Esc · Enter"] as
13321332
*/
13331333
/** Model picker only: same three-tier fallback shape as DEFAULT_OVERLAY_HINTS. */
13341334
const MODEL_PICKER_HINTS = [
1335-
"Esc cancel · Enter choose · Alt+A add provider",
1336-
"Esc · Enter · Alt+A add",
1335+
"Esc cancel · Enter choose · Alt+A /connect add provider",
1336+
"Esc · Enter · Alt+A /connect",
13371337
"Esc · Enter",
13381338
] as const;
13391339

@@ -1355,8 +1355,8 @@ function overlayHints(shell: AppShell): readonly string[] {
13551355
const setDefault = bag?.overlaySetDefaultHint === true;
13561356
if (addProvider && setDefault) {
13571357
return [
1358-
"Esc cancel · Enter choose · Alt+A add provider · Alt+D set default",
1359-
"Esc · Enter · Alt+A add · Alt+D default",
1358+
"Esc cancel · Enter choose · Alt+A /connect add provider · Alt+D set default",
1359+
"Esc · Enter · Alt+A /connect · Alt+D default",
13601360
"Esc · Enter · Alt+A · Alt+D",
13611361
"Esc · Enter",
13621362
];
@@ -3569,9 +3569,9 @@ export interface OpenListOverlayOpts {
35693569
*/
35703570
readonly typeToFilter?: boolean;
35713571
/**
3572-
* Advertise Alt+A in the footer and yield composed Option+A (å/Å) from
3573-
* type-to-filter. Set only when the caller actually wired an Alt+A handler
3574-
* via `onAction`, so the hint can never name a key that is a dead end.
3572+
* Advertise Alt+A and /connect in the footer and yield composed Option+A
3573+
* (å/Å) from type-to-filter. Set only when the caller actually wired an
3574+
* add-provider handler via `onAction`, so the hint never names a dead chord.
35753575
*/
35763576
readonly addProviderHint?: boolean;
35773577
/**

0 commit comments

Comments
 (0)