magus-cli 7.3.0 → 7.4.0

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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  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__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Teardown tests for `installShutdown`. Every one fires on a SPECIFIC regression in the
3
+ * quit path, because that path is the one an app cannot recover from: a shutdown that
4
+ * throws half-way leaves the terminal in raw mode inside the alternate screen, and the
5
+ * reader's next prompt needs `reset` to come back.
6
+ *
7
+ * THE EXIT-PATH TESTS RUN IN SUBPROCESSES AND SEND REAL SIGNALS. They have to. This file
8
+ * has been believed correct and been wrong three times, and each time the belief rested
9
+ * on mocks: a stubbed `process.exit` proves the function was reached, not that the process
10
+ * ended; a hand-called handler proves nothing about WHO the kernel delivers a signal to
11
+ * when two owners registered for it. So the child sends itself a real signal, records
12
+ * each step to a file with `appendFileSync` (synchronous, so `process.exit` cannot
13
+ * truncate it, and immune to the renderer's stdout capture), and the parent asserts the
14
+ * exact order and the exact exit code.
15
+ *
16
+ * Every child arms a REF'D 4s guard timer that exits 99. That is the negative control: a
17
+ * teardown that never reaches `process.exit` reports 99 instead of drifting out with a
18
+ * misleading 0, so a green result here cannot be vacuous.
19
+ *
20
+ * The in-process stubs are `Pick<CliRenderer, "destroy">` / `Pick<Root, "unmount">`, which
21
+ * the signature admits directly — no `as unknown as` cast, and a cast is exactly what
22
+ * stops anyone writing these tests. */
23
+ import { afterAll, describe, expect, test } from "bun:test";
24
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
25
+ import { constants, tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { OWNED_SIGNALS, installShutdown } from "../ui/runtime/shutdown.js";
28
+
29
+ const noop = (): void => {};
30
+ const boom = (): never => {
31
+ throw new Error("boom");
32
+ };
33
+
34
+ const SHUTDOWN = Bun.fileURLToPath(
35
+ new URL("../ui/runtime/shutdown.ts", import.meta.url),
36
+ );
37
+ /** `bun -e` resolves bare specifiers from cwd, and the real-renderer children import
38
+ * `@opentui/core` — so every child runs with the package root (the package boundary) as
39
+ * its cwd. */
40
+ const PACKAGE_ROOT = Bun.fileURLToPath(new URL("../../", import.meta.url));
41
+ const WORK = mkdtempSync(join(tmpdir(), "opentui-shutdown-"));
42
+ afterAll(() => {
43
+ rmSync(WORK, { recursive: true, force: true });
44
+ });
45
+
46
+ /** 128 + the OS's own signal number. Restated here from the policy, NOT read back from
47
+ * the implementation, and resolved through `node:os` because the numbers are
48
+ * platform-specific (SIGBUS is 10 on darwin, 7 on Linux). */
49
+ const expectedCode = (sig: NodeJS.Signals): number => {
50
+ const signum = (
51
+ constants.signals as unknown as Record<string, number | undefined>
52
+ )[sig];
53
+ if (typeof signum !== "number")
54
+ throw new Error(`${sig} has no number on ${process.platform}`);
55
+ return 128 + signum;
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Subprocess harness
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** How a teardown step fails. `plain` is an ordinary Error; `hostile` and `nasty` are the
63
+ * throwables whose FORMATTING throws — the defect that skipped `destroy()` entirely. */
64
+ type Fault = "none" | "plain" | "hostile" | "nasty";
65
+
66
+ const THROWABLES = `
67
+ const plainBoom = () => { throw new Error("boom") }
68
+ // The reported repro: an object whose toString() throws. String(err) reaches
69
+ // Symbol.toPrimitive and the formatter itself throws.
70
+ const hostileBoom = () => {
71
+ const o = {}
72
+ Object.defineProperty(o, "toString", { value: () => { throw new Error("toString boom") } })
73
+ Object.defineProperty(o, Symbol.toPrimitive, { value: () => { throw new Error("toPrimitive boom") } })
74
+ throw o
75
+ }
76
+ // The other half of the same defect: an Error whose OWN stack/message getters throw, so
77
+ // the \`err instanceof Error\` branch is the one that explodes.
78
+ const nastyBoom = () => {
79
+ const e = new Error("nasty")
80
+ Object.defineProperty(e, "stack", { get() { throw new Error("stack getter boom") }, configurable: true })
81
+ Object.defineProperty(e, "message", { get() { throw new Error("message getter boom") }, configurable: true })
82
+ throw e
83
+ }
84
+ `;
85
+
86
+ const throwFor = (f: Fault): string =>
87
+ f === "none"
88
+ ? ""
89
+ : f === "plain"
90
+ ? "plainBoom()"
91
+ : f === "hostile"
92
+ ? "hostileBoom()"
93
+ : "nastyBoom()";
94
+
95
+ interface ChildOpts {
96
+ /** How the child ends: a real signal it sends itself, the returned quit function, that
97
+ * function called concurrently, or an uncaught throw. */
98
+ readonly via: NodeJS.Signals | "quit" | "quit-twice" | "crash";
99
+ readonly unmount?: Fault;
100
+ readonly destroy?: Fault;
101
+ /** `undefined` = two-method stubs. Otherwise a REAL `createCliRenderer` with those
102
+ * `exitSignals`, to measure who actually owns the signal. */
103
+ readonly rendererExitSignals?: NodeJS.Signals[];
104
+ }
105
+
106
+ interface ChildResult {
107
+ readonly code: number | null;
108
+ readonly signal: string | null;
109
+ /** Teardown steps in the order the child actually performed them. */
110
+ readonly log: string[];
111
+ readonly stderr: string;
112
+ }
113
+
114
+ async function runChild(opts: ChildOpts): Promise<ChildResult> {
115
+ const logPath = join(WORK, `${crypto.randomUUID()}.log`);
116
+ const trigger =
117
+ opts.via === "quit"
118
+ ? 'await quit(); rec("resolved"); clearTimeout(guard)'
119
+ : opts.via === "quit-twice"
120
+ ? 'await Promise.all([quit(), quit(), quit()]); await quit(); rec("resolved"); clearTimeout(guard)'
121
+ : opts.via === "crash"
122
+ ? 'setTimeout(() => { throw new Error("app boom") }, 1)'
123
+ : `process.kill(process.pid, ${JSON.stringify(opts.via)})`;
124
+
125
+ const renderer =
126
+ opts.rendererExitSignals === undefined
127
+ ? `const renderer = { destroy: () => { rec("destroy"); ${throwFor(opts.destroy ?? "none")} } }`
128
+ : // An own `destroy` property SHADOWS the prototype method, so the renderer's own
129
+ // internal `this.destroy()` — the one its exitHandler calls — is recorded too. That
130
+ // is how a second owner becomes visible as a second "destroy" line.
131
+ `import { createCliRenderer } from "@opentui/core"
132
+ const renderer = await createCliRenderer({
133
+ exitSignals: ${JSON.stringify(opts.rendererExitSignals)},
134
+ exitOnCtrlC: false,
135
+ screenMode: "main-screen",
136
+ })
137
+ const realDestroy = renderer.destroy.bind(renderer)
138
+ renderer.destroy = () => { rec("destroy"); realDestroy() }`;
139
+
140
+ const src = [
141
+ 'import { appendFileSync } from "node:fs"',
142
+ `import { installShutdown } from ${JSON.stringify(SHUTDOWN)}`,
143
+ `const rec = (s) => appendFileSync(${JSON.stringify(logPath)}, s + "\\n")`,
144
+ THROWABLES,
145
+ renderer,
146
+ `const root = { unmount: () => { rec("unmount"); ${throwFor(opts.unmount ?? "none")} } }`,
147
+ 'const quit = installShutdown(renderer, root, [() => rec("disposer")])',
148
+ // REF'D on purpose: it is the only thing keeping the loop alive on the quit path, so
149
+ // a teardown that hangs reports 99 rather than looking like a clean exit.
150
+ 'const guard = setTimeout(() => { rec("GUARD-FIRED"); process.exit(99) }, 4000)',
151
+ trigger,
152
+ ].join("\n");
153
+
154
+ const p = Bun.spawn(["bun", "-e", src], {
155
+ cwd: PACKAGE_ROOT,
156
+ stdin: "ignore",
157
+ stdout: "ignore",
158
+ stderr: "pipe",
159
+ });
160
+ const [, stderr] = await Promise.all([
161
+ p.exited,
162
+ new Response(p.stderr).text(),
163
+ ]);
164
+ let log: string[] = [];
165
+ try {
166
+ log = readFileSync(logPath, "utf8").split("\n").filter(Boolean);
167
+ } catch {
168
+ /* child died before recording */
169
+ }
170
+ return { code: p.exitCode, signal: p.signalCode, log, stderr };
171
+ }
172
+
173
+ /** The contract, in one place: stop the app's own work, take the tree down, then restore
174
+ * the terminal. */
175
+ const ORDER = ["disposer", "unmount", "destroy"];
176
+
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("OWNED_SIGNALS", () => {
180
+ // It used to be "OpenTUI's default set MINUS SIGINT", because it was handed to
181
+ // `createCliRenderer` — which is what made the renderer a second teardown owner. The
182
+ // renderer is now given `exitSignals: []`, so this module registers the complete set.
183
+ test("is the complete default set, SIGINT included, because nothing else registers any", () => {
184
+ expect(OWNED_SIGNALS).toEqual([
185
+ "SIGINT",
186
+ "SIGTERM",
187
+ "SIGQUIT",
188
+ "SIGABRT",
189
+ "SIGHUP",
190
+ "SIGBREAK",
191
+ "SIGPIPE",
192
+ "SIGBUS",
193
+ ]);
194
+ expect(OWNED_SIGNALS).toContain("SIGINT");
195
+ expect(OWNED_SIGNALS).toContain("SIGBUS");
196
+ });
197
+
198
+ test("every signal it claims is one it can actually register and remove", () => {
199
+ const before = OWNED_SIGNALS.map((s) => process.listenerCount(s));
200
+ const shutdown = installShutdown({ destroy: noop }, { unmount: noop });
201
+ // SIGBREAK is Windows-only: `process.on` accepts it on darwin, it just never fires.
202
+ expect(OWNED_SIGNALS.map((s) => process.listenerCount(s))).toEqual(
203
+ before.map((n) => n + 1),
204
+ );
205
+ void shutdown();
206
+ expect(OWNED_SIGNALS.map((s) => process.listenerCount(s))).toEqual(before);
207
+ });
208
+ });
209
+
210
+ describe("exit-code policy — 128 + signum, never 0", () => {
211
+ test("a signal never maps to a success code, and no two collide", () => {
212
+ const codes = OWNED_SIGNALS.filter((s) => s !== "SIGBREAK").map(
213
+ expectedCode,
214
+ );
215
+ for (const c of codes) expect(c).toBeGreaterThan(128);
216
+ expect(new Set(codes).size).toBe(codes.length);
217
+ });
218
+
219
+ test("the numbers a shell would report", () => {
220
+ if (process.platform !== "darwin") return; // signal numbers are platform-specific
221
+ expect({
222
+ SIGINT: expectedCode("SIGINT"),
223
+ SIGQUIT: expectedCode("SIGQUIT"),
224
+ SIGABRT: expectedCode("SIGABRT"),
225
+ SIGHUP: expectedCode("SIGHUP"),
226
+ SIGPIPE: expectedCode("SIGPIPE"),
227
+ SIGBUS: expectedCode("SIGBUS"),
228
+ SIGTERM: expectedCode("SIGTERM"),
229
+ }).toEqual({
230
+ SIGINT: 130,
231
+ SIGQUIT: 131,
232
+ SIGABRT: 134,
233
+ SIGHUP: 129,
234
+ SIGPIPE: 141,
235
+ SIGBUS: 138,
236
+ SIGTERM: 143,
237
+ });
238
+ });
239
+ });
240
+
241
+ describe("installShutdown — teardown always completes", () => {
242
+ // THE DEFECT: `root.unmount()` and `renderer.destroy()` ran unguarded, so a throw from
243
+ // the first skipped the second. Restoring the terminal is the step that must not be
244
+ // skipped.
245
+ test("a throwing unmount still reaches renderer.destroy(), and does not reject", async () => {
246
+ let destroyed = false;
247
+ const shutdown = installShutdown(
248
+ {
249
+ destroy: () => {
250
+ destroyed = true;
251
+ },
252
+ },
253
+ { unmount: boom },
254
+ );
255
+ await shutdown();
256
+ expect(destroyed).toBe(true);
257
+ });
258
+
259
+ test("a throwing destroy does not reject either — the app can still await the end", async () => {
260
+ let unmounted = false;
261
+ const shutdown = installShutdown(
262
+ { destroy: boom },
263
+ {
264
+ unmount: () => {
265
+ unmounted = true;
266
+ },
267
+ },
268
+ );
269
+ await shutdown();
270
+ expect(unmounted).toBe(true);
271
+ });
272
+
273
+ // THE FORMATTER DEFECT, in process. `String(err)` inside the catch reached a throwing
274
+ // `Symbol.toPrimitive`, so the catch block itself threw and took the rest of teardown
275
+ // with it.
276
+ test("an unmount that throws an unprintable object still reaches destroy()", async () => {
277
+ const hostile: Record<PropertyKey, unknown> = {};
278
+ Object.defineProperty(hostile, "toString", {
279
+ value: () => {
280
+ throw new Error("toString boom");
281
+ },
282
+ });
283
+ Object.defineProperty(hostile, Symbol.toPrimitive, {
284
+ value: () => {
285
+ throw new Error("toPrimitive boom");
286
+ },
287
+ });
288
+ let destroyed = false;
289
+ const shutdown = installShutdown(
290
+ {
291
+ destroy: () => {
292
+ destroyed = true;
293
+ },
294
+ },
295
+ {
296
+ unmount: () => {
297
+ throw hostile;
298
+ },
299
+ },
300
+ );
301
+ await shutdown();
302
+ expect(destroyed).toBe(true);
303
+ });
304
+
305
+ test("an Error whose own stack getter throws does not stop teardown either", async () => {
306
+ const nasty = new Error("nasty");
307
+ Object.defineProperty(nasty, "stack", {
308
+ get() {
309
+ throw new Error("stack getter boom");
310
+ },
311
+ configurable: true,
312
+ });
313
+ Object.defineProperty(nasty, "message", {
314
+ get() {
315
+ throw new Error("message getter boom");
316
+ },
317
+ configurable: true,
318
+ });
319
+ let destroyed = false;
320
+ const shutdown = installShutdown(
321
+ {
322
+ destroy: () => {
323
+ destroyed = true;
324
+ },
325
+ },
326
+ {
327
+ unmount: () => {
328
+ throw nasty;
329
+ },
330
+ },
331
+ );
332
+ await shutdown();
333
+ expect(destroyed).toBe(true);
334
+ });
335
+
336
+ // DISPOSERS. `installShutdown(renderer, root)` took no extra teardown, while this file
337
+ // forbids `process.exit()` everywhere else — so an app-owned `setInterval` had nowhere
338
+ // to be cleared and kept the loop alive past the shutdown that was meant to end it.
339
+ test("disposers run, once each, BEFORE the tree comes down", async () => {
340
+ const seen: string[] = [];
341
+ const shutdown = installShutdown(
342
+ { destroy: () => seen.push("destroy") },
343
+ { unmount: () => seen.push("unmount") },
344
+ [() => seen.push("disposer-a"), () => seen.push("disposer-b")],
345
+ );
346
+ await shutdown();
347
+ expect(seen).toEqual(["disposer-a", "disposer-b", "unmount", "destroy"]);
348
+ });
349
+
350
+ test("a throwing disposer stops neither the unmount nor the destroy", async () => {
351
+ const seen: string[] = [];
352
+ const shutdown = installShutdown(
353
+ { destroy: () => seen.push("destroy") },
354
+ { unmount: () => seen.push("unmount") },
355
+ [boom, () => seen.push("disposer")],
356
+ );
357
+ await shutdown();
358
+ expect(seen).toEqual(["disposer", "unmount", "destroy"]);
359
+ });
360
+
361
+ // The three steps are guarded INDEPENDENTLY and at the same level, so not even a
362
+ // pathological disposers collection can skip the terminal restore.
363
+ test("a disposers collection that throws on iteration still leaves the terminal restored", async () => {
364
+ const exploding = new Proxy([] as Array<() => void>, {
365
+ get() {
366
+ throw new Error("array boom");
367
+ },
368
+ }) as ReadonlyArray<() => void>;
369
+ const seen: string[] = [];
370
+ const shutdown = installShutdown(
371
+ { destroy: () => seen.push("destroy") },
372
+ { unmount: () => seen.push("unmount") },
373
+ exploding,
374
+ );
375
+ await shutdown();
376
+ expect(seen).toEqual(["unmount", "destroy"]);
377
+ });
378
+
379
+ test("an app-owned interval is actually cleared, so nothing survives teardown", async () => {
380
+ let ticks = 0;
381
+ const t = setInterval(() => {
382
+ ticks++;
383
+ }, 1);
384
+ const shutdown = installShutdown({ destroy: noop }, { unmount: noop }, [
385
+ () => clearInterval(t),
386
+ ]);
387
+ await shutdown();
388
+ const after = ticks;
389
+ await new Promise((r) => setTimeout(r, 20));
390
+ expect(ticks).toBe(after);
391
+ });
392
+
393
+ test("teardown is idempotent however many times it is called", async () => {
394
+ let n = 0;
395
+ const shutdown = installShutdown(
396
+ {
397
+ destroy: () => {
398
+ n += 1;
399
+ },
400
+ },
401
+ { unmount: noop },
402
+ [
403
+ () => {
404
+ n += 10;
405
+ },
406
+ ],
407
+ );
408
+ await Promise.all([shutdown(), shutdown(), shutdown()]);
409
+ await shutdown();
410
+ expect(n).toBe(11);
411
+ });
412
+
413
+ // Registration is not the same as listing: SIGBUS has to be a real listener, and every
414
+ // listener has to come off again or a second renderer in the same process (a server
415
+ // taking the stream again) inherits the first one's handlers.
416
+ test("it registers SIGBUS, and removes every listener it added", async () => {
417
+ const count = () => ({
418
+ SIGINT: process.listenerCount("SIGINT"),
419
+ SIGBUS: process.listenerCount("SIGBUS"),
420
+ SIGTERM: process.listenerCount("SIGTERM"),
421
+ uncaught: process.listenerCount("uncaughtException"),
422
+ rejection: process.listenerCount("unhandledRejection"),
423
+ });
424
+ const before = count();
425
+ const shutdown = installShutdown({ destroy: noop }, { unmount: noop });
426
+ const during = count();
427
+ expect(during.SIGBUS).toBe(before.SIGBUS + 1);
428
+ expect(during.SIGINT).toBe(before.SIGINT + 1);
429
+ expect(during.uncaught).toBe(before.uncaught + 1);
430
+ await shutdown();
431
+ expect(count()).toEqual(before);
432
+ });
433
+ });
434
+
435
+ /**
436
+ * REAL SIGNALS, REAL PROCESSES. Everything below spawns a child, sends it an actual
437
+ * signal, and reads back the order it tore down in and the code it died with.
438
+ */
439
+ describe("installShutdown — real signals, real processes", () => {
440
+ // THE MASKING DEFECT: every signal used to exit 0. A SIGABRT or SIGBUS — a fatal
441
+ // termination — reported success to whatever supervises the app.
442
+ test.each([
443
+ "SIGTERM",
444
+ "SIGINT",
445
+ "SIGQUIT",
446
+ "SIGABRT",
447
+ "SIGHUP",
448
+ "SIGPIPE",
449
+ "SIGBUS",
450
+ ] as NodeJS.Signals[])(
451
+ "%s tears down in order and exits 128+signum",
452
+ async (sig) => {
453
+ const r = await runChild({ via: sig });
454
+ expect(r.log).toEqual(ORDER);
455
+ expect(r.code).toBe(expectedCode(sig));
456
+ },
457
+ 30_000,
458
+ );
459
+
460
+ test("a normal quit tears down in the same order and the process ends 0", async () => {
461
+ // No `process.exit` on this path: the returned function tears down and hands control
462
+ // back, and the program ends by letting the loop drain. `resolved` proves the promise
463
+ // settled — an unguarded step used to reject it instead.
464
+ const r = await runChild({ via: "quit" });
465
+ expect(r.log).toEqual([...ORDER, "resolved"]);
466
+ expect(r.code).toBe(0);
467
+ }, 30_000);
468
+
469
+ test("a fatal signal is distinguishable from a clean quit and from every other signal", async () => {
470
+ const [abrt, bus, term, quit] = await Promise.all([
471
+ runChild({ via: "SIGABRT" }),
472
+ runChild({ via: "SIGBUS" }),
473
+ runChild({ via: "SIGTERM" }),
474
+ runChild({ via: "quit" }),
475
+ ]);
476
+ const codes = [abrt.code, bus.code, term.code, quit.code];
477
+ expect(new Set(codes).size).toBe(4);
478
+ expect(quit.code).toBe(0);
479
+ for (const c of [abrt.code, bus.code, term.code]) expect(c).not.toBe(0);
480
+ }, 60_000);
481
+
482
+ test("concurrent calls run every step exactly once and still end the process", async () => {
483
+ const r = await runChild({ via: "quit-twice" });
484
+ expect(r.log).toEqual([...ORDER, "resolved"]);
485
+ expect(r.code).toBe(0);
486
+ }, 30_000);
487
+ });
488
+
489
+ describe("installShutdown — a throwing step never strands the process", () => {
490
+ // 99 is the guard timer, i.e. "never exited at all". These four are the exact shapes
491
+ // that produced it.
492
+ test("a throwing unmount still reaches destroy() and still exits", async () => {
493
+ const r = await runChild({ via: "SIGTERM", unmount: "plain" });
494
+ expect(r.log).toEqual(ORDER);
495
+ expect(r.code).toBe(expectedCode("SIGTERM"));
496
+ }, 30_000);
497
+
498
+ // THE CASE THAT HUNG. `unmount` throws an object whose `toString()` also throws, so the
499
+ // catch block's own `String(err)` threw: `renderer.destroy()` never ran, the exit never
500
+ // ran, and `done` was already latched so no retry could clean up. Measured exit: 99.
501
+ test("an unmount throwing an unprintable object still reaches destroy() and still exits", async () => {
502
+ const r = await runChild({ via: "SIGTERM", unmount: "hostile" });
503
+ expect(r.log).toEqual(ORDER);
504
+ expect(r.code).toBe(expectedCode("SIGTERM"));
505
+ expect(r.code).not.toBe(99); // 99 is the guard: the process hung
506
+ }, 30_000);
507
+
508
+ test("an unmount throwing an Error with a throwing stack getter behaves the same", async () => {
509
+ const r = await runChild({ via: "SIGTERM", unmount: "nasty" });
510
+ expect(r.log).toEqual(ORDER);
511
+ expect(r.code).toBe(expectedCode("SIGTERM"));
512
+ }, 30_000);
513
+
514
+ test("a throwing destroy still exits — the terminal restore was attempted", async () => {
515
+ const r = await runChild({ via: "SIGTERM", destroy: "plain" });
516
+ expect(r.log).toEqual(ORDER);
517
+ expect(r.code).toBe(expectedCode("SIGTERM"));
518
+ }, 30_000);
519
+
520
+ test("every step throwing unprintably still exits, and the failures are reported after destroy()", async () => {
521
+ const r = await runChild({
522
+ via: "SIGTERM",
523
+ unmount: "hostile",
524
+ destroy: "nasty",
525
+ });
526
+ expect(r.log).toEqual(ORDER);
527
+ expect(r.code).toBe(expectedCode("SIGTERM"));
528
+ expect(r.stderr).toContain("shutdown: root.unmount");
529
+ expect(r.stderr).toContain("shutdown: renderer.destroy");
530
+ expect(r.stderr).toContain("<unprintable throwable>"); // formatted, not thrown
531
+ }, 30_000);
532
+
533
+ test("a throwing unmount does not strand the normal-quit path either", async () => {
534
+ const r = await runChild({
535
+ via: "quit",
536
+ unmount: "hostile",
537
+ destroy: "plain",
538
+ });
539
+ expect(r.log).toEqual([...ORDER, "resolved"]);
540
+ expect(r.code).toBe(0);
541
+ }, 30_000);
542
+
543
+ test("the crash path tears down first, then reports, then exits 1", async () => {
544
+ const r = await runChild({ via: "crash", unmount: "hostile" });
545
+ expect(r.log).toEqual(ORDER);
546
+ expect(r.code).toBe(1);
547
+ expect(r.stderr).toContain("app boom");
548
+ }, 30_000);
549
+ });
550
+
551
+ /**
552
+ * THE SECOND OWNER. These two run a REAL `createCliRenderer` from the pinned version and
553
+ * send it a REAL SIGTERM. Nothing here is stubbed, because the whole question is which
554
+ * handler the process calls first — and that is decided by registration order inside
555
+ * OpenTUI, which no mock reproduces.
556
+ */
557
+ describe("installShutdown — the renderer must not be a second teardown owner", () => {
558
+ test("with exitSignals: [] there is exactly ONE destroy(), in the documented order", async () => {
559
+ const r = await runChild({ via: "SIGTERM", rendererExitSignals: [] });
560
+ expect(r.log).toEqual(ORDER);
561
+ expect(r.log.filter((l) => l === "destroy")).toHaveLength(1);
562
+ expect(r.code).toBe(expectedCode("SIGTERM"));
563
+ }, 60_000);
564
+
565
+ // THE REGRESSION WITNESS, and the negative control for the test above: hand the renderer
566
+ // the same signal and it wins the race, because it was constructed first. The tree comes
567
+ // down AFTER the terminal was restored, and destroy() runs twice. This is what the
568
+ // bootstrap used to do, and it is why the bootstrap must now pass `[]`.
569
+ test("handing the renderer the same signal reproduces the double-destroy in the wrong order", async () => {
570
+ const r = await runChild({
571
+ via: "SIGTERM",
572
+ rendererExitSignals: ["SIGTERM"],
573
+ });
574
+ expect(r.log).toEqual(["destroy", ...ORDER]);
575
+ expect(r.log.filter((l) => l === "destroy")).toHaveLength(2);
576
+ expect(r.code).toBe(expectedCode("SIGTERM"));
577
+ }, 60_000);
578
+ });
@@ -1080,9 +1080,9 @@ describe("the Community section", () => {
1080
1080
  });
1081
1081
 
1082
1082
  test("wears the same tone as Anthropic captures, never the danger tone", () => {
1083
- // Both sections are "text captured from somewhere else onto this disk".
1084
- // Red is the only unused tone and it means danger here — a normal, working
1085
- // section must not wear it.
1083
+ // Both sections are "text captured from somewhere else onto this disk",
1084
+ // a fact about the source, so both wear `info`. `danger` means broken —
1085
+ // a normal, working section must not wear it.
1086
1086
  const items = buildStyleBrowserItems({
1087
1087
  presets: [],
1088
1088
  imports: [],
@@ -1092,7 +1092,7 @@ describe("the Community section", () => {
1092
1092
  registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
1093
1093
  });
1094
1094
  const category = items.find((i) => i.kind === "category");
1095
- expect(category?.kind === "category" && category.tone).toBe("yellow");
1095
+ expect(category?.kind === "category" && category.tone).toBe("info");
1096
1096
  expect(category?.kind === "category" && category.badge).toBe("from GitHub");
1097
1097
  });
1098
1098
  });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm, symlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { parseKeypress } from "@opentui/core";
5
6
  import fs from "fs-extra";
6
7
  import {
7
8
  applyStyles,
@@ -9,6 +10,8 @@ import {
9
10
  communityCacheDirOrNull,
10
11
  loadStyles,
11
12
  } from "../services/styles-manager.js";
13
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
14
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
12
15
  import { appReducer, initialState } from "../ui/state/reducer.js";
13
16
  import type { AppAction, AppState } from "../ui/state/types.js";
14
17
 
@@ -428,28 +431,39 @@ describe("a project that committed a community style", () => {
428
431
 
429
432
  // ─── Keybindings ──────────────────────────────────────────────────────────────
430
433
 
434
+ /** A real parsed keypress, in the shape the keymap matches. */
435
+ function press(raw: string): KeyPress {
436
+ const k = parseKeypress(raw);
437
+ return {
438
+ name: k?.name ?? "",
439
+ sequence: k?.sequence ?? "",
440
+ ctrl: k?.ctrl === true,
441
+ meta: k?.meta === true,
442
+ shift: k?.shift === true,
443
+ };
444
+ }
445
+
431
446
  describe("StylesScreen keybindings", () => {
432
- test("Shift+U is tested BEFORE plain u, or the plain branch swallows it", async () => {
447
+ test("Shift+U and plain u reach different actions — neither swallows the other", () => {
433
448
  // OpenTUI reports Shift+U as `{name: "u", shift: true}`, so a lowercase
434
- // branch placed first matches it too. That exact shape shipped once in
435
- // PluginsScreen and made "update" install at user scope instead.
436
- const src = await fs.readFile(
437
- join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
438
- "utf8",
449
+ // branch placed first once matched it too. That exact shape shipped in
450
+ // PluginsScreen and made "update" install at user scope instead. Keys are
451
+ // matched through the keymap now, shift flag included.
452
+ expect(matchBinding(SCREEN_BINDINGS.styles, press("U"))?.id).toBe(
453
+ "check-all",
439
454
  );
440
- const shifted = src.indexOf('event.name === "u" && event.shift');
441
- const plain = src.indexOf('event.name === "u") {');
442
- expect(shifted).toBeGreaterThan(-1);
443
- expect(plain).toBeGreaterThan(-1);
444
- expect(shifted).toBeLessThan(plain);
455
+ expect(matchBinding(SCREEN_BINDINGS.styles, press("u"))?.id).toBe("check");
445
456
  });
446
457
 
447
458
  test("f is bound, and it is what the sync status tells the user to press", async () => {
459
+ expect(matchBinding(SCREEN_BINDINGS.styles, press("f"))?.id).toBe("fetch");
448
460
  const src = await fs.readFile(
449
461
  join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
450
462
  "utf8",
451
463
  );
452
- expect(src).toContain('event.name === "f"');
464
+ const fetch = src.indexOf("fetch: () =>");
465
+ expect(fetch).toBeGreaterThan(-1);
466
+ expect(src.slice(fetch, fetch + 120)).toContain("handleFetch()");
453
467
  // The falls-through-to-the-declaration branch. Without it, "press f" is a
454
468
  // lie whenever the cursor is not parked on a Community row.
455
469
  expect(src).toContain("status.fetchable");