magus-cli 7.3.1 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Blind tests — contract C18 (requirement R2.8.6), with C2 / C13 for the
3
+ * SettingsParseError class.
4
+ *
5
+ * Written from requirements.md and architecture.md only.
6
+ *
7
+ * R2.8.6: registering the agent-model hook refuses a user settings file whose
8
+ * `hooks` is present but not a JSON object, or whose `hooks.PreToolUse` is
9
+ * present but not an array — with a `SettingsParseError`-class error naming
10
+ * the file — and leaves it byte-identical. It never replaces them with empty
11
+ * ones (the user's own hooks live there). Well-shaped `hooks` keep every
12
+ * existing entry and gain the magus one.
13
+ *
14
+ * C18: `registerAgentModelHook({ configDir })` writes `<configDir>/settings.json`
15
+ * and resolves "registered" | "already". It adds
16
+ * `{ matcher: "Agent", hooks: [{ type: "command", command: AGENT_MODEL_HOOK_COMMAND, … }] }`
17
+ * under `hooks.PreToolUse`. AGENT_MODEL_HOOK_COMMAND is "magus hook agent-model".
18
+ *
19
+ * Isolation: the function's default target is user scope (under HOME), and a
20
+ * black-box test cannot rule out a fallback to it despite `configDir`. So every
21
+ * call runs in a child `bun` with a temp HOME and a temp cwd (architecture.md
22
+ * C2 correction; see ./child-run.ts), and one control checks that nothing
23
+ * appears under that HOME. The child reports booleans, class names and the
24
+ * contract's own return value — never an error message or a value read from a
25
+ * file. The settings files read back here are this test's own temp testdata.
26
+ */
27
+ import { afterEach, describe, expect, test } from "bun:test";
28
+ import {
29
+ existsSync,
30
+ mkdtempSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ rmSync,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { type ChildResult, runInChild } from "./child-run.js";
39
+
40
+ const SERVICES = path.resolve(import.meta.dir, "../../services");
41
+ const HOOK_REGISTRATION = path.join(SERVICES, "hook-registration.ts");
42
+ const SETTINGS_FILE = path.join(SERVICES, "settings-file.ts");
43
+ const CLAUDE_SETTINGS = path.join(SERVICES, "claude-settings.ts");
44
+ const T = 30_000; // per-test timeout: each child bun spawn gets up to 25s
45
+
46
+ const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
47
+
48
+ const dirs: string[] = [];
49
+ afterEach(() => {
50
+ for (const dir of dirs.splice(0))
51
+ rmSync(dir, { recursive: true, force: true });
52
+ });
53
+
54
+ function tempDir(tag: string): string {
55
+ // realpath: on macOS tmpdir is /var/… → /private/var/…; one spelling only.
56
+ const dir = realpathSync(
57
+ mkdtempSync(path.join(os.tmpdir(), `magus-blind-c18-${tag}-`)),
58
+ );
59
+ dirs.push(dir);
60
+ return dir;
61
+ }
62
+
63
+ interface Case {
64
+ home: string;
65
+ cwd: string;
66
+ configDir: string;
67
+ /** <configDir>/settings.json — the only file the call may touch. */
68
+ file: string;
69
+ }
70
+
71
+ /** A fresh temp HOME, cwd and configDir; `content` null means no settings.json. */
72
+ function newCase(content: string | null): Case {
73
+ const home = tempDir("home");
74
+ const cwd = tempDir("cwd");
75
+ const configDir = tempDir("cfg");
76
+ const file = path.join(configDir, "settings.json");
77
+ if (content !== null) writeFileSync(file, content);
78
+ return { home, cwd, configDir, file };
79
+ }
80
+
81
+ // ── the child call ──────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Child body: `await mod.registerAgentModelHook({ configDir: job.configDir })`.
85
+ * Success → `{ ok: true, result }`, where `result` is reported only when it is
86
+ * one of the contract's two strings. Failure → whether the error is a
87
+ * SettingsParseError (by name, by a constructor named SettingsParseError on
88
+ * its prototype chain — i.e. a subclass — or by instanceof an exported
89
+ * class), and whether its message names the settings file. The message itself
90
+ * is never reported.
91
+ */
92
+ const REGISTER_BODY = `
93
+ const fs = await import("node:fs");
94
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
95
+ const classes = [];
96
+ for (const m of job.errorModules || []) {
97
+ try {
98
+ const x = await import(m);
99
+ if (typeof x.SettingsParseError === "function") classes.push(x.SettingsParseError);
100
+ } catch {}
101
+ }
102
+ const k = mod.AGENT_MODEL_HOOK_COMMAND;
103
+ const commandConst = typeof k === "string" && k.length < 200 ? k : "<" + typeof k + ">";
104
+ if (typeof mod.registerAgentModelHook !== "function") {
105
+ await out({ stage: "missing-export", commandConst });
106
+ return;
107
+ }
108
+ try {
109
+ const r = await mod.registerAgentModelHook({ configDir: job.configDir });
110
+ await out({
111
+ stage: "done",
112
+ ok: true,
113
+ result: r === "registered" || r === "already" ? r : "<" + typeof r + ">",
114
+ commandConst,
115
+ });
116
+ } catch (e) {
117
+ const isErr = e instanceof Error;
118
+ const chain = [];
119
+ for (let p = e !== null && typeof e === "object" ? Object.getPrototypeOf(e) : null; p; p = Object.getPrototypeOf(p)) {
120
+ const c = Object.prototype.hasOwnProperty.call(p, "constructor") ? p.constructor : null;
121
+ if (c && typeof c.name === "string") chain.push(c.name);
122
+ }
123
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
124
+ const want = real(job.file);
125
+ await out({
126
+ stage: "done",
127
+ ok: false,
128
+ isError: isErr,
129
+ errorName: isErr ? String(e.name) : typeof e,
130
+ nameIsSettingsParseError: isErr && e.name === "SettingsParseError",
131
+ chainHasSettingsParseError: chain.includes("SettingsParseError"),
132
+ instanceOfSettingsParseError: classes.some((C) => e instanceof C),
133
+ messageNamesFile: msg.includes(job.file) || (!!want && msg.includes(want)),
134
+ commandConst,
135
+ });
136
+ }
137
+ `;
138
+
139
+ function register(c: Case): ChildResult {
140
+ return runInChild({
141
+ home: c.home,
142
+ cwd: c.cwd,
143
+ module: HOOK_REGISTRATION,
144
+ job: {
145
+ configDir: c.configDir,
146
+ file: c.file,
147
+ errorModules: [SETTINGS_FILE, CLAUDE_SETTINGS, HOOK_REGISTRATION],
148
+ },
149
+ body: REGISTER_BODY,
150
+ });
151
+ }
152
+
153
+ /** The refusal R2.8.6 asks for, reduced to what the tests assert on. */
154
+ function refusal(r: ChildResult) {
155
+ return {
156
+ stage: r.stage,
157
+ ok: r.ok,
158
+ settingsParseError:
159
+ r.nameIsSettingsParseError === true ||
160
+ r.chainHasSettingsParseError === true ||
161
+ r.instanceOfSettingsParseError === true,
162
+ messageNamesFile: r.messageNamesFile,
163
+ // Diagnostic only (e.g. a raw TypeError, R2.8.4). Not asserted.
164
+ errorName: r.errorName,
165
+ result: r.result,
166
+ };
167
+ }
168
+
169
+ // ── wrong shapes: refused ───────────────────────────────────────────────────
170
+
171
+ const USER_BASH_BLOCK = {
172
+ matcher: "Bash",
173
+ hooks: [{ type: "command", command: "my-guard.sh" }],
174
+ };
175
+
176
+ /** R2.8.6: `hooks` not an object, or `hooks.PreToolUse` not an array. */
177
+ const WRONG_SHAPES: Record<string, string> = {
178
+ 'hooks is an array {"hooks": []}': '{"hooks": []}\n',
179
+ 'hooks is a string {"hooks": "x"}': '{"hooks": "x"}\n',
180
+ 'hooks is a number {"hooks": 5}': '{"hooks": 5}\n',
181
+ "hooks.PreToolUse is one block object, not an array": `${JSON.stringify({
182
+ hooks: { PreToolUse: USER_BASH_BLOCK },
183
+ })}\n`,
184
+ 'hooks.PreToolUse is a string {"hooks": {"PreToolUse": "x"}}':
185
+ '{"hooks": {"PreToolUse": "x"}}\n',
186
+ };
187
+
188
+ describe("C18 — registerAgentModelHook refuses a wrong-shape hooks in <configDir>/settings.json", () => {
189
+ for (const [shape, content] of Object.entries(WRONG_SHAPES)) {
190
+ test(
191
+ `R2.8.6 ${shape}: rejects with a SettingsParseError-class error and never resolves`,
192
+ () => {
193
+ const c = newCase(content);
194
+ expect(refusal(register(c))).toMatchObject({
195
+ stage: "done",
196
+ ok: false,
197
+ settingsParseError: true,
198
+ });
199
+ },
200
+ T,
201
+ );
202
+
203
+ test(
204
+ `R2.8.6 ${shape}: the error message names <configDir>/settings.json`,
205
+ () => {
206
+ const c = newCase(content);
207
+ expect(refusal(register(c))).toMatchObject({
208
+ stage: "done",
209
+ ok: false,
210
+ messageNamesFile: true,
211
+ });
212
+ },
213
+ T,
214
+ );
215
+
216
+ test(
217
+ `R2.8.6 ${shape}: settings.json stays byte-identical`,
218
+ () => {
219
+ const c = newCase(content);
220
+ const before = readFileSync(c.file);
221
+ const r = register(c);
222
+ expect(r.stage).toBe("done");
223
+ expect(readFileSync(c.file).equals(before)).toBe(true);
224
+ },
225
+ T,
226
+ );
227
+ }
228
+ });
229
+
230
+ // ── controls: absent or well-shaped hooks gain the magus Agent block ────────
231
+
232
+ const CONTROLS: Record<string, string | null> = {
233
+ "no settings.json": null,
234
+ "{}": "{}\n",
235
+ '{"hooks": {}}': '{"hooks": {}}\n',
236
+ "a user Bash hook in PreToolUse and other: 1": `${JSON.stringify({
237
+ hooks: { PreToolUse: [USER_BASH_BLOCK] },
238
+ other: 1,
239
+ })}\n`,
240
+ };
241
+
242
+ function readBack(c: Case): Record<string, unknown> {
243
+ // This test's own temp testdata — safe to read.
244
+ return JSON.parse(readFileSync(c.file, "utf8")) as Record<string, unknown>;
245
+ }
246
+
247
+ function preToolUse(settings: Record<string, unknown>): unknown[] | null {
248
+ const hooks = settings.hooks as Record<string, unknown> | undefined;
249
+ const pre = hooks?.PreToolUse;
250
+ return Array.isArray(pre) ? pre : null;
251
+ }
252
+
253
+ /** Every hook entry of type "command" running the magus command in an Agent block. */
254
+ function magusAgentHooks(settings: Record<string, unknown>): unknown[] {
255
+ const found: unknown[] = [];
256
+ for (const block of preToolUse(settings) ?? []) {
257
+ const b = block as { matcher?: unknown; hooks?: unknown };
258
+ if (b?.matcher !== "Agent" || !Array.isArray(b.hooks)) continue;
259
+ for (const h of b.hooks as { type?: unknown; command?: unknown }[]) {
260
+ if (h?.type === "command" && h.command === AGENT_MODEL_HOOK_COMMAND)
261
+ found.push(h);
262
+ }
263
+ }
264
+ return found;
265
+ }
266
+
267
+ describe("C18 — registerAgentModelHook controls (absent or well-shaped hooks)", () => {
268
+ test(
269
+ `R2.8.6 AGENT_MODEL_HOOK_COMMAND is "${AGENT_MODEL_HOOK_COMMAND}"`,
270
+ () => {
271
+ const c = newCase("{}\n");
272
+ expect(register(c).commandConst).toBe(AGENT_MODEL_HOOK_COMMAND);
273
+ },
274
+ T,
275
+ );
276
+
277
+ for (const [label, content] of Object.entries(CONTROLS)) {
278
+ test(
279
+ `R2.8.6 control (${label}): resolves "registered"`,
280
+ () => {
281
+ const c = newCase(content);
282
+ expect(refusal(register(c))).toMatchObject({
283
+ stage: "done",
284
+ ok: true,
285
+ result: "registered",
286
+ });
287
+ },
288
+ T,
289
+ );
290
+
291
+ test(
292
+ `R2.8.6 control (${label}): hooks.PreToolUse gains one Agent block running "${AGENT_MODEL_HOOK_COMMAND}"`,
293
+ () => {
294
+ const c = newCase(content);
295
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
296
+ const after = readBack(c);
297
+ expect(Array.isArray(preToolUse(after))).toBe(true);
298
+ expect(magusAgentHooks(after).length).toBe(1);
299
+ },
300
+ T,
301
+ );
302
+
303
+ test(
304
+ `R2.8.6 control (${label}): a second call resolves "already" and leaves settings.json byte-identical`,
305
+ () => {
306
+ const c = newCase(content);
307
+ expect(register(c)).toMatchObject({
308
+ stage: "done",
309
+ ok: true,
310
+ result: "registered",
311
+ });
312
+ const afterFirst = readFileSync(c.file);
313
+ // A fresh process: "already" must come from the file, not memory.
314
+ expect(refusal(register(c))).toMatchObject({
315
+ stage: "done",
316
+ ok: true,
317
+ result: "already",
318
+ });
319
+ expect(readFileSync(c.file).equals(afterFirst)).toBe(true);
320
+ },
321
+ 2 * T,
322
+ );
323
+ }
324
+
325
+ test(
326
+ "R2.8.6 control (a user Bash hook in PreToolUse and other: 1): the user's my-guard.sh block and other: 1 are kept",
327
+ () => {
328
+ const c = newCase(
329
+ CONTROLS["a user Bash hook in PreToolUse and other: 1"] as string,
330
+ );
331
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
332
+ const after = readBack(c);
333
+ expect(after.other).toBe(1);
334
+ const pre = preToolUse(after) ?? [];
335
+ const bash = pre.filter(
336
+ (b) => (b as { matcher?: unknown })?.matcher === "Bash",
337
+ );
338
+ expect(bash).toEqual([USER_BASH_BLOCK]);
339
+ expect(magusAgentHooks(after).length).toBe(1);
340
+ },
341
+ T,
342
+ );
343
+
344
+ test(
345
+ "R2.8.6 control (no settings.json): configDir is the only target — nothing appears under HOME/.claude",
346
+ () => {
347
+ const c = newCase(null);
348
+ expect(register(c)).toMatchObject({ stage: "done", ok: true });
349
+ expect(existsSync(c.file)).toBe(true);
350
+ expect(existsSync(path.join(c.home, ".claude"))).toBe(false);
351
+ },
352
+ T,
353
+ );
354
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Blind tests — contract C19 (requirement R4.9).
3
+ *
4
+ * Written from requirements.md and architecture.md only. A terminal delivers a
5
+ * paste as ONE event, not as keys, and C19 routes that event through the same
6
+ * layer order as a keypress. The layers here are stubs that record every
7
+ * handlePaste() call and the exact text each one received, so "a lower layer
8
+ * never sees the text" and "the text is passed through unchanged" are observed
9
+ * directly rather than inferred.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ const load = () => import("../../ui/input/key-dispatch.js");
14
+
15
+ type LayerId = "system" | "modal" | "input" | "screen" | "scroll" | "global";
16
+
17
+ const ORDER: LayerId[] = [
18
+ "system",
19
+ "modal",
20
+ "input",
21
+ "screen",
22
+ "scroll",
23
+ "global",
24
+ ];
25
+
26
+ interface Recorder {
27
+ /** Layer ids in the order their handlePaste() was called. */
28
+ calls: LayerId[];
29
+ /** Every (layer, text) pair a handler actually received. */
30
+ seen: { id: LayerId; text: string }[];
31
+ layers: {
32
+ id: LayerId;
33
+ active: boolean;
34
+ handlePaste(text: string): boolean;
35
+ }[];
36
+ }
37
+
38
+ /** Build one paste layer per id. `consumes[id]` decides; absent means "never consumes". */
39
+ function stack(
40
+ consumes: Partial<Record<LayerId, (text: string) => boolean>>,
41
+ active: Partial<Record<LayerId, boolean>> = {},
42
+ order: LayerId[] = ORDER,
43
+ ): Recorder {
44
+ const calls: LayerId[] = [];
45
+ const seen: { id: LayerId; text: string }[] = [];
46
+ const layers = order.map((id) => ({
47
+ id,
48
+ active: active[id] ?? true,
49
+ handlePaste(text: string): boolean {
50
+ calls.push(id);
51
+ seen.push({ id, text });
52
+ return consumes[id]?.(text) ?? false;
53
+ },
54
+ }));
55
+ return { calls, seen, layers };
56
+ }
57
+
58
+ const all = () => true;
59
+ const TEXT = "pasted search term";
60
+
61
+ describe("C19 — dispatchPaste: layer order", () => {
62
+ test("R4.9 a paste is offered to layers in LAYER_ORDER regardless of array order", async () => {
63
+ const { dispatchPaste, LAYER_ORDER } = await load();
64
+ expect([...LAYER_ORDER]).toEqual(ORDER);
65
+
66
+ const shuffled: LayerId[] = [
67
+ "global",
68
+ "screen",
69
+ "system",
70
+ "scroll",
71
+ "input",
72
+ "modal",
73
+ ];
74
+ const r = stack({}, {}, shuffled);
75
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
76
+ expect(r.calls).toEqual(ORDER);
77
+ });
78
+
79
+ test("R4.9 with every layer consuming in reversed array order, the topmost layer wins alone", async () => {
80
+ const { dispatchPaste } = await load();
81
+ const r = stack(
82
+ {
83
+ system: all,
84
+ modal: all,
85
+ input: all,
86
+ screen: all,
87
+ scroll: all,
88
+ global: all,
89
+ },
90
+ {},
91
+ [...ORDER].reverse(),
92
+ );
93
+ expect(dispatchPaste(r.layers, TEXT)).toBe("system");
94
+ expect(r.calls).toEqual(["system"]);
95
+ });
96
+
97
+ for (const [i, consumer] of ORDER.entries()) {
98
+ test(`R4.9 when ${consumer} consumes, dispatchPaste returns "${consumer}" and no lower layer sees the text`, async () => {
99
+ const { dispatchPaste } = await load();
100
+ // Every layer from the consumer down would also consume — only order decides.
101
+ const consumes: Partial<Record<LayerId, (text: string) => boolean>> = {};
102
+ for (const id of ORDER.slice(i)) consumes[id] = all;
103
+ const r = stack(consumes);
104
+ expect(dispatchPaste(r.layers, TEXT)).toBe(consumer);
105
+ expect(r.calls).toEqual(ORDER.slice(0, i + 1));
106
+ expect(r.seen.map((s) => s.id)).toEqual(ORDER.slice(0, i + 1));
107
+ });
108
+ }
109
+
110
+ test("R4.9 inactive layers are skipped: their handlePaste() is never called", async () => {
111
+ const { dispatchPaste } = await load();
112
+ const r = stack(
113
+ { modal: all, input: all, global: all },
114
+ { modal: false, input: false },
115
+ );
116
+ expect(dispatchPaste(r.layers, TEXT)).toBe("global");
117
+ expect(r.calls).toEqual(["system", "screen", "scroll", "global"]);
118
+ });
119
+
120
+ test("R4.9 an inactive consumer does not stop the paste: the next active consumer wins", async () => {
121
+ const { dispatchPaste } = await load();
122
+ const r = stack({ input: all, screen: all }, { input: false });
123
+ expect(dispatchPaste(r.layers, TEXT)).toBe("screen");
124
+ expect(r.calls).toEqual(["system", "modal", "screen"]);
125
+ });
126
+
127
+ test("R4.9 dispatchPaste returns null when no layer consumes, calling each active layer once", async () => {
128
+ const { dispatchPaste } = await load();
129
+ const r = stack({});
130
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
131
+ expect(r.calls).toEqual(ORDER);
132
+ });
133
+
134
+ test("R4.9 missing layers are fine: a stack of only screen and global", async () => {
135
+ const { dispatchPaste } = await load();
136
+ const r = stack({ global: all }, {}, ["global", "screen"]);
137
+ expect(dispatchPaste(r.layers, TEXT)).toBe("global");
138
+ expect(r.calls).toEqual(["screen", "global"]);
139
+ });
140
+
141
+ test("R4.9 an empty layer list consumes nothing", async () => {
142
+ const { dispatchPaste } = await load();
143
+ expect(dispatchPaste([], TEXT)).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe("C19 — dispatchPaste: a focused search box takes the paste", () => {
148
+ test("R4.9 a focused input consumes the paste and the screen below never sees the text", async () => {
149
+ const { dispatchPaste } = await load();
150
+ const typed: string[] = [];
151
+ const r = stack({
152
+ input: (text) => {
153
+ typed.push(text);
154
+ return true;
155
+ },
156
+ screen: all,
157
+ global: all,
158
+ });
159
+ expect(dispatchPaste(r.layers, TEXT)).toBe("input");
160
+ expect(typed).toEqual([TEXT]);
161
+ expect(r.calls).toEqual(["system", "modal", "input"]);
162
+ expect(r.seen.some((s) => s.id === "screen")).toBe(false);
163
+ expect(r.seen.some((s) => s.id === "global")).toBe(false);
164
+ });
165
+
166
+ test("R4.9 with no active input layer, the paste reaches the lower layers in order", async () => {
167
+ const { dispatchPaste } = await load();
168
+ const r = stack({ input: all }, { input: false });
169
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
170
+ expect(r.calls).toEqual(["system", "modal", "screen", "scroll", "global"]);
171
+ expect(r.seen.every((s) => s.text === TEXT)).toBe(true);
172
+ });
173
+
174
+ test("R4.9 with no input layer in the stack at all, nothing consumes the paste", async () => {
175
+ const { dispatchPaste } = await load();
176
+ const r = stack({}, {}, ["system", "modal", "screen", "scroll", "global"]);
177
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
178
+ expect(r.calls).toEqual(["system", "modal", "screen", "scroll", "global"]);
179
+ });
180
+
181
+ test("R4.9 a modal above the input takes the paste first", async () => {
182
+ const { dispatchPaste } = await load();
183
+ const r = stack({ modal: all, input: all });
184
+ expect(dispatchPaste(r.layers, TEXT)).toBe("modal");
185
+ expect(r.calls).toEqual(["system", "modal"]);
186
+ });
187
+ });
188
+
189
+ describe("C19 — dispatchPaste: empty text", () => {
190
+ test("R4.9 empty text consumes nothing and returns null", async () => {
191
+ const { dispatchPaste } = await load();
192
+ const r = stack({
193
+ system: all,
194
+ modal: all,
195
+ input: all,
196
+ screen: all,
197
+ scroll: all,
198
+ global: all,
199
+ });
200
+ expect(dispatchPaste(r.layers, "")).toBeNull();
201
+ });
202
+
203
+ test("R4.9 empty text calls no handler, not even a focused input", async () => {
204
+ const { dispatchPaste } = await load();
205
+ const r = stack({ input: all });
206
+ dispatchPaste(r.layers, "");
207
+ expect(r.calls).toEqual([]);
208
+ expect(r.seen).toEqual([]);
209
+ });
210
+ });
211
+
212
+ describe("C19 — dispatchPaste: the text passes through unchanged", () => {
213
+ const PAYLOADS: [string, string][] = [
214
+ ["a plain term", "node_modules"],
215
+ ["leading and trailing spaces", " spaced term "],
216
+ ["a single newline", "first line\nsecond line"],
217
+ ["a trailing newline", "term\n"],
218
+ ["CRLF line endings", "one\r\ntwo\r\n"],
219
+ ["several lines", "a\nb\nc\nd"],
220
+ ["a tab", "left\tright"],
221
+ ["unicode accents", "café naïve"],
222
+ ["CJK", "日本語のテキスト"],
223
+ ["emoji", "🚀 ship it 🎉"],
224
+ ["combining marks", "égalité"],
225
+ ["a path with spaces", "/tmp/some dir/file.json"],
226
+ ["JSON-ish punctuation", '{"key": "value", "n": 1}'],
227
+ ["a single space", " "],
228
+ ["a lone newline", "\n"],
229
+ ];
230
+
231
+ for (const [label, text] of PAYLOADS) {
232
+ test(`R4.9 ${label} reaches the consuming layer byte-for-byte`, async () => {
233
+ const { dispatchPaste } = await load();
234
+ const got: string[] = [];
235
+ const r = stack({
236
+ input: (t) => {
237
+ got.push(t);
238
+ return true;
239
+ },
240
+ });
241
+ expect(dispatchPaste(r.layers, text)).toBe("input");
242
+ expect(got).toEqual([text]);
243
+ });
244
+
245
+ test(`R4.9 ${label} reaches every layer it is offered to unchanged`, async () => {
246
+ const { dispatchPaste } = await load();
247
+ const r = stack({});
248
+ expect(dispatchPaste(r.layers, text)).toBeNull();
249
+ expect(r.seen).toEqual(ORDER.map((id) => ({ id, text })));
250
+ });
251
+ }
252
+ });