Skip to content

Commit 99f67eb

Browse files
Merge pull request #1518 from corbitsdev/cl-8637-option-chords
fix(tui): fire composed option chords on every advertised surface
2 parents 79a5940 + f694ede commit 99f67eb

16 files changed

Lines changed: 833 additions & 218 deletions

‎src/tui/command-surfaces.test.ts‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import type { ThemeSetting } from "./theme-detect.js";
2424
import { focusOwner } from "./focus/index.js";
2525
import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js";
2626
import type { Harness } from "./harness";
27+
import type { AltChordLetter } from "./key-chords";
2728
import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js";
2829
import type { AppShell } from "./shell/internals";
30+
import { shellInternals } from "./shell/internals";
2931
import { withAppShell } from "./test-helpers";
3032
import {
3133
acceptOverlaySelection,
@@ -585,6 +587,34 @@ describe("plugins surface admin actions", () => {
585587
});
586588
});
587589

590+
test("hybrid glyph events never reach the pane's bare s/v claims (I-4)", async () => {
591+
await withShell(async (shell) => {
592+
const { deps, calls } = pluginActionDeps({
593+
credentialValues: { apiKey: "saved-key" },
594+
});
595+
openCommandSurface(shell, "plugins", deps);
596+
expect(runOverlayAction(shell, altKey("c"))).toBe(true);
597+
expect(shell.overlayKind).toBe("plugin_credentials");
598+
// {name:"v", sequence:"√"} is the composed Option glyph arriving with
599+
// no modifier bit — the input shape altChordLetterForKey reads via
600+
// `sequence`. Read through `name` it used to fire save-and-verify.
601+
expect(
602+
runOverlayAction(shell, { ...charKey("√"), name: "v" } as KeyEvent),
603+
).toBe(false);
604+
expect(
605+
runOverlayAction(shell, { ...charKey("ß"), name: "s" } as KeyEvent),
606+
).toBe(false);
607+
expect(calls.saveCredentials).toEqual([]);
608+
expect(calls.verify).toEqual([]);
609+
// A true bare key still claims: the guard is sequence-vs-name, not a
610+
// ban on the letters themselves.
611+
expect(runOverlayAction(shell, charKey("v"))).toBe(true);
612+
await Promise.resolve();
613+
expect(calls.saveCredentials.length).toBe(1);
614+
expect(calls.verify.length).toBe(1);
615+
});
616+
});
617+
588618
test("v verifies the currently saved credentials", async () => {
589619
await withShell(async (shell) => {
590620
const { deps, calls } = pluginActionDeps({
@@ -876,6 +906,212 @@ describe("plugins surface admin actions", () => {
876906
});
877907
});
878908

909+
/**
910+
* CL-8637: on stock macOS terminals Option+<letter> arrives as the composed
911+
* layout glyph with no modifier bit. On the two surfaces that advertise
912+
* Alt+<letter> chords, that glyph must run the advertised action and must
913+
* never leak into the prompt buffer.
914+
*/
915+
describe("composed option glyphs on /plugins and /mcp (CL-8637)", () => {
916+
test("every advertised plugins glyph runs its chord with the prompt left alone", async () => {
917+
const glyphs = ["å", "∑", "ç", "√", "†", "≈"] as const;
918+
for (const glyph of glyphs) {
919+
await withShell(async (shell) => {
920+
const { deps } = pluginActionDeps({ needsTrust: true });
921+
openCommandSurface(shell, "plugins", deps);
922+
shell.prompt.value = "";
923+
expect(runOverlayAction(shell, charKey(glyph))).toBe(true);
924+
expect(shell.prompt.value).toBe("");
925+
});
926+
}
927+
});
928+
929+
test("å add-by-path and ∑ web chooser keep working through the glyph", async () => {
930+
await withShell(async (shell) => {
931+
const { deps, calls } = pluginActionDeps();
932+
openCommandSurface(shell, "plugins", deps);
933+
expect(runOverlayAction(shell, charKey("å"))).toBe(true);
934+
typeOverlayText(shell, "/tmp/glyph-plugin");
935+
acceptOverlaySelection(shell);
936+
await Promise.resolve();
937+
expect(calls.addPath).toEqual(["/tmp/glyph-plugin"]);
938+
});
939+
await withShell(async (shell) => {
940+
const { deps, calls } = pluginActionDeps();
941+
openCommandSurface(
942+
shell,
943+
"plugins",
944+
patchPlugins(deps, { list: () => [] }),
945+
);
946+
expect(runOverlayAction(shell, charKey("∑"))).toBe(true);
947+
moveOverlaySelection(shell, 1);
948+
acceptOverlaySelection(shell);
949+
await Promise.resolve();
950+
expect(calls.setWebProvider).toEqual(["exa"]);
951+
});
952+
});
953+
954+
test("the trust glyph on a trusted plugin is still a no-op (I-7)", async () => {
955+
await withShell(async (shell) => {
956+
const { deps, calls } = pluginActionDeps();
957+
openCommandSurface(shell, "plugins", deps);
958+
expect(runOverlayAction(shell, charKey("†"))).toBe(false);
959+
expect(calls.setEnabled).toEqual([]);
960+
});
961+
});
962+
963+
test("mcp glyphs stay text on /plugins: this surface declares no d or r", async () => {
964+
await withShell(async (shell) => {
965+
const { deps, calls } = pluginActionDeps();
966+
openCommandSurface(shell, "plugins", deps);
967+
expect(runOverlayAction(shell, charKey("∂"))).toBe(false);
968+
expect(runOverlayAction(shell, charKey("®"))).toBe(false);
969+
expect(calls.setEnabled).toEqual([]);
970+
expect(calls.remove).toEqual([]);
971+
});
972+
});
973+
974+
test("å completes add-name → URL on /mcp with the prompt left alone", async () => {
975+
await withShell(async (shell) => {
976+
const added: { name: string; url: string }[] = [];
977+
let liveEntries: readonly McpEntry[] = [
978+
{ name: "linear", state: "connected", toolCount: 12 },
979+
];
980+
openMcp(shell, {
981+
list: () => liveEntries,
982+
openAuthURL: () => undefined,
983+
addServer: async (name, url) => {
984+
added.push({ name, url });
985+
liveEntries = [...liveEntries, { name, state: "connecting" }];
986+
return { ok: true, message: `Added ${name}.` };
987+
},
988+
});
989+
990+
expect(runOverlayAction(shell, charKey("å"))).toBe(true);
991+
expect(shell.prompt.value).toBe("");
992+
typeOverlayText(shell, "notion");
993+
acceptOverlaySelection(shell);
994+
typeOverlayText(shell, "https://mcp.notion.test/mcp");
995+
acceptOverlaySelection(shell);
996+
await flushSurface();
997+
998+
expect(added).toEqual([
999+
{ name: "notion", url: "https://mcp.notion.test/mcp" },
1000+
]);
1001+
expect(shell.prompt.value).toBe("");
1002+
});
1003+
});
1004+
1005+
test("∂ disables the focused server on /mcp with the prompt left alone", async () => {
1006+
await withShell(async (shell) => {
1007+
const toggled: { name: string; enabled: boolean }[] = [];
1008+
const notes: string[] = [];
1009+
let liveEntries: readonly McpEntry[] = [
1010+
{ name: "linear", state: "connected", toolCount: 12 },
1011+
];
1012+
openMcp(
1013+
shell,
1014+
{
1015+
list: () => liveEntries,
1016+
openAuthURL: () => undefined,
1017+
setEnabled: async (name, enabled) => {
1018+
toggled.push({ name, enabled });
1019+
liveEntries = [
1020+
{ name, state: enabled ? "connecting" : "disabled" },
1021+
];
1022+
return { ok: true, message: `Disabled ${name}.` };
1023+
},
1024+
},
1025+
(note) => notes.push(note),
1026+
);
1027+
expect(shell.prompt.value).toBe("");
1028+
expect(runOverlayAction(shell, charKey("∂"))).toBe(true);
1029+
await flushSurface();
1030+
expect(toggled).toEqual([{ name: "linear", enabled: false }]);
1031+
expect(shell.prompt.value).toBe("");
1032+
expect(notes).toEqual(["Disabled linear."]);
1033+
});
1034+
});
1035+
1036+
test("® on /mcp opens the remove confirm and Enter completes it", async () => {
1037+
await withShell(async (shell) => {
1038+
let liveEntries: readonly McpEntry[] = [
1039+
{ name: "linear", state: "connected", toolCount: 12 },
1040+
{ name: "notion", state: "connected", toolCount: 3 },
1041+
];
1042+
openMcp(shell, {
1043+
list: () => liveEntries,
1044+
openAuthURL: () => undefined,
1045+
removeServer: async (name) => {
1046+
liveEntries = liveEntries.filter((entry) => entry.name !== name);
1047+
return { ok: true, message: `Removed ${name}.` };
1048+
},
1049+
});
1050+
moveOverlaySelection(shell, 1);
1051+
expect(runOverlayAction(shell, charKey("®"))).toBe(true);
1052+
acceptOverlaySelection(shell);
1053+
await flushSurface();
1054+
1055+
expect(liveEntries.map((entry) => entry.name)).toEqual(["linear"]);
1056+
expect(shell.prompt.value).toBe("");
1057+
});
1058+
});
1059+
1060+
test("å is not a chord on /mcp while local settings shadow global (I-3)", async () => {
1061+
await withShell(async (shell) => {
1062+
openMcp(shell, {
1063+
list: () => [{ name: "linear", state: "connected", toolCount: 1 }],
1064+
openAuthURL: () => undefined,
1065+
mcpServersSource: "local",
1066+
addServer: async () => ({ ok: true, message: "should not run" }),
1067+
});
1068+
expect(runOverlayAction(shell, charKey("å"))).toBe(false);
1069+
expect(shell.prompt.value).toBe("");
1070+
});
1071+
});
1072+
1073+
test("ç is not a chord on /mcp: this surface declares no c", async () => {
1074+
await withShell(async (shell) => {
1075+
openMcp(shell, {
1076+
list: () => [{ name: "linear", state: "connected", toolCount: 1 }],
1077+
openAuthURL: () => undefined,
1078+
});
1079+
expect(runOverlayAction(shell, charKey("ç"))).toBe(false);
1080+
});
1081+
});
1082+
1083+
test("rendered footers only advertise declared letters, and never Alt+F (I-3)", async () => {
1084+
const advertised = (frame: string): string[] =>
1085+
[...frame.matchAll(/Alt\+([A-Za-z])/g)].map((m) =>
1086+
(m[1] ?? "").toLowerCase(),
1087+
);
1088+
await withWiredShell(async (shell, harness) => {
1089+
const { deps } = pluginActionDeps();
1090+
openCommandSurface(shell, "plugins", deps);
1091+
await harness.renderOnce();
1092+
const declared = shellInternals(shell)?.primaryBindings.altChords ?? [];
1093+
const letters = advertised(harness.captureCharFrame());
1094+
expect(letters.length).toBeGreaterThan(0);
1095+
for (const letter of letters) {
1096+
expect(declared.includes(letter as AltChordLetter)).toBe(true);
1097+
}
1098+
});
1099+
await withWiredShell(async (shell, harness) => {
1100+
openMcp(shell, {
1101+
list: () => [{ name: "linear", state: "connected", toolCount: 1 }],
1102+
openAuthURL: () => undefined,
1103+
});
1104+
await harness.renderOnce();
1105+
const declared = shellInternals(shell)?.primaryBindings.altChords ?? [];
1106+
const letters = advertised(harness.captureCharFrame());
1107+
expect(letters.length).toBeGreaterThan(0);
1108+
for (const letter of letters) {
1109+
expect(declared.includes(letter as AltChordLetter)).toBe(true);
1110+
}
1111+
});
1112+
});
1113+
});
1114+
8791115
describe("hooks surface", () => {
8801116
test("lists discovered hooks with enabled state and Enter toggles", async () => {
8811117
await withShell(async (shell) => {

‎src/tui/command-surfaces.ts‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,13 @@ function openCredentialsPane(
843843
onAction: (_id, key) => {
844844
if (state.editing === null) {
845845
if (key.ctrl || key.meta || key.option) return false;
846+
// Bare claims fire only for a true bare key: a hybrid
847+
// {name:"v", sequence:"√"} is the composed Option glyph arriving
848+
// with no modifier bit — exactly the input altChordLetterForKey
849+
// reads through `sequence` — and must not reach save/verify via
850+
// the name table. The text sinks below key on `sequence`, which
851+
// is why they need no such guard (I-4).
852+
if (key.sequence !== key.name) return false;
846853
if (key.name === "s") {
847854
void Promise.resolve(
848855
plugins.saveCredentials(entry.id, state.values),
@@ -1099,6 +1106,7 @@ export function openPluginsSurface(
10991106
title: "plugins",
11001107
frameId: "overlay-plugins",
11011108
deferIfBusy: true,
1109+
altChords: ["a", "w", "c", "v", "t", "x"] as const,
11021110
...payload(rows),
11031111
describe: (id) => {
11041112
if (id === PLUGIN_LOAD_WARNINGS_ID) {
@@ -1140,26 +1148,21 @@ export function openPluginsSurface(
11401148
deps.notify(`Plugin update failed: ${errorText(err)}`),
11411149
);
11421150
},
1143-
onAction: (id, key) => {
1144-
// Alt+<key>, never bare — bare c/v/t/a/w/x would be swallowed by the
1145-
// filter list. Alt+C is safe here: this branch returns before
1146-
// shell.ts's top-level onKey reaches the global copy-mode chord.
1147-
if (key.ctrl || !(key.meta || key.option)) return false;
1148-
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
1149-
// Surface-level chords — advertised in the how-to even when focus is on
1150-
// Close, the empty-list row, or load warnings.
1151-
if (name === "a") {
1151+
onAltChord: (id, letter) => {
1152+
// Surface-level chords run even when focus is on Close, the empty
1153+
// list, or load warnings.
1154+
if (letter === "a") {
11521155
openAddPathPane(shell, deps, plugins);
11531156
return true;
11541157
}
1155-
if (name === "w") {
1158+
if (letter === "w") {
11561159
openWebProviderChooser(shell, deps, plugins);
11571160
return true;
11581161
}
11591162
if (id === PLUGIN_LOAD_WARNINGS_ID) return false;
11601163
const target = byId.get(id);
11611164
if (target === undefined) return false;
1162-
switch (name) {
1165+
switch (letter) {
11631166
case "c":
11641167
if (target.credentials.length === 0) return false;
11651168
openCredentialsPane(shell, deps, plugins, target, {
@@ -1516,8 +1519,8 @@ export function openMcpSurface(
15161519
// The flash below reports the outcome; the echo would quote the row's
15171520
// pre-authorization label back at the operator forever.
15181521
echoChoice: false,
1519-
mcpManageHint: true,
1520-
mcpAddHint: canAdd,
1522+
// `a` joins only when adding is possible.
1523+
altChords: canAdd ? (["a", "d", "r"] as const) : (["d", "r"] as const),
15211524
...payload(rows),
15221525
describe: (id) => {
15231526
const target = byName.get(id);
@@ -1596,17 +1599,14 @@ export function openMcpSurface(
15961599
});
15971600
closeReplaceableOverlay(shell);
15981601
},
1599-
onAction: (id, key) => {
1600-
if (key.ctrl || !(key.meta || key.option)) return false;
1601-
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
1602-
if (name === "a") {
1603-
if (!canAdd) return false;
1602+
onAltChord: (id, letter) => {
1603+
if (letter === "a") {
16041604
unsubscribe();
16051605
openAddMcpNamePane(shell, deps, mcp);
16061606
return true;
16071607
}
16081608
if (isMcpChromeRow(id)) return false;
1609-
if (name === "d") {
1609+
if (letter === "d") {
16101610
const setEnabled = mcp.setEnabled;
16111611
if (setEnabled === undefined) return false;
16121612
unsubscribe();
@@ -1619,7 +1619,7 @@ export function openMcpSurface(
16191619
);
16201620
return true;
16211621
}
1622-
if (name === "r") {
1622+
if (letter === "r") {
16231623
const target = byName.get(id);
16241624
if (target?.builtin === true) {
16251625
deps.notify(

0 commit comments

Comments
 (0)