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,114 @@
1
+ /**
2
+ * Run a contract call in a child `bun` process whose HOME is a temp dir.
3
+ *
4
+ * Why a child: under Bun, `os.homedir()` ignores a `process.env.HOME` change
5
+ * made at runtime, but honours HOME set when the process starts
6
+ * (architecture.md C2, correction of 2026-09-18). Anything that could resolve
7
+ * `~/.claude` therefore runs here, never in the test process.
8
+ *
9
+ * Safety, in order:
10
+ * 1. the child's env pins HOME, CLAUDE_CONFIG_DIR and XDG_CONFIG_HOME inside
11
+ * the temp home, and its cwd is a temp dir;
12
+ * 2. before importing anything under test, the child checks that
13
+ * `os.homedir()` IS the temp home, and stops if not ("guard" stage);
14
+ * 3. the child reports only a JSON summary chosen by the test (booleans,
15
+ * names, URLs) on a marker line. Values read from files and error
16
+ * messages are never printed, and the child's raw stdout/stderr is never
17
+ * surfaced — so no test output can echo the contents of a user file.
18
+ */
19
+ import path from "node:path";
20
+
21
+ const MARK = "__BLIND_RESULT__";
22
+
23
+ export type ChildResult = Record<string, unknown> & { stage: string };
24
+
25
+ export interface ChildRun {
26
+ /** Temp HOME for the child. */
27
+ home: string;
28
+ /** Working directory for the child — always a temp dir. */
29
+ cwd: string;
30
+ /** Absolute path of the module under test, or undefined for none. */
31
+ module?: string;
32
+ /** Passed to the child as `job` (JSON). */
33
+ job?: Record<string, unknown>;
34
+ /** JS run BEFORE the module is imported (e.g. install a fetch mock). */
35
+ setup?: string;
36
+ /** JS run after import. Has `job`, `mod`, `out(summary)` in scope. */
37
+ body: string;
38
+ timeoutMs?: number;
39
+ }
40
+
41
+ function childScript(setup: string, body: string): string {
42
+ return `
43
+ const MARK = ${JSON.stringify(MARK)};
44
+ const out = async (o) => { await Bun.write(Bun.stdout, "\\n" + MARK + JSON.stringify(o) + "\\n"); };
45
+ (async () => {
46
+ try {
47
+ const os = await import("node:os");
48
+ const job = JSON.parse(process.env.BLIND_JOB || "{}");
49
+ if (os.homedir() !== job.__home || process.env.HOME !== job.__home) {
50
+ await out({ stage: "guard", homeRedirected: false });
51
+ return;
52
+ }
53
+ ${setup}
54
+ let mod = null;
55
+ if (job.__module) {
56
+ try {
57
+ mod = await import(job.__module);
58
+ } catch (e) {
59
+ await out({ stage: "import", error: String((e && e.message) || e).split("\\n")[0] });
60
+ return;
61
+ }
62
+ }
63
+ ${body}
64
+ } catch (e) {
65
+ await out({ stage: "harness", name: (e && e.name) || typeof e });
66
+ }
67
+ })().then(() => process.exit(0));
68
+ `;
69
+ }
70
+
71
+ export function runInChild(run: ChildRun): ChildResult {
72
+ const job = {
73
+ ...(run.job ?? {}),
74
+ __home: run.home,
75
+ __module: run.module ?? null,
76
+ };
77
+ const proc = Bun.spawnSync(
78
+ [
79
+ process.execPath,
80
+ "--no-env-file",
81
+ "-e",
82
+ childScript(run.setup ?? "", run.body),
83
+ ],
84
+ {
85
+ cwd: run.cwd,
86
+ env: {
87
+ ...process.env,
88
+ HOME: run.home,
89
+ CLAUDE_CONFIG_DIR: path.join(run.home, ".claude"),
90
+ XDG_CONFIG_HOME: path.join(run.home, ".config"),
91
+ BLIND_JOB: JSON.stringify(job),
92
+ },
93
+ stdout: "pipe",
94
+ stderr: "pipe",
95
+ timeout: run.timeoutMs ?? 25_000,
96
+ },
97
+ );
98
+ const line = proc.stdout
99
+ .toString()
100
+ .split("\n")
101
+ .reverse()
102
+ .find((l) => l.startsWith(MARK));
103
+ if (!line) {
104
+ // Withhold the child's output: it could echo a file's contents. A
105
+ // module-resolution line names paths only, so that one is safe to show.
106
+ const moduleLine = proc.stderr
107
+ .toString()
108
+ .match(/Cannot find module[^\n]*/)?.[0];
109
+ throw new Error(
110
+ `child bun produced no result (exit ${proc.exitCode}${moduleLine ? `; ${moduleLine}` : ""}); its raw output is withheld`,
111
+ );
112
+ }
113
+ return JSON.parse(line.slice(MARK.length)) as ChildResult;
114
+ }
@@ -240,7 +240,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
240
240
  });
241
241
 
242
242
  describe("cliToolStatusText — the row's words", () => {
243
- test("unknown renders as ? <note>, the note verbatim", () => {
243
+ test("unknown renders as ? <short label>; the full note stays on the status", () => {
244
244
  const tool = catalogue("crush");
245
245
  const spec = specForCatalogTool(tool, null, {
246
246
  primary: "npm",
@@ -250,7 +250,10 @@ describe("cliToolStatusText — the row's words", () => {
250
250
  const row = buildToolRow(tool, spec, f);
251
251
  const text = cliToolStatusText(row);
252
252
  expect(text.tone).toBe("warning");
253
- expect(text.suffix).toBe(` ? ${classifyTool(spec, f).note}`);
253
+ expect(text.suffix).toBe(" ? not managed by npm");
254
+ // The note (with its path) is for the detail pane's "Why", not the row.
255
+ expect(row.status?.note).toBe(classifyTool(spec, f).note);
256
+ expect(text.suffix).not.toContain("/");
254
257
  });
255
258
 
256
259
  test("upgrade names the target once, with one v", () => {
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
2
  import {
3
- COMPONENT_BADGE,
3
+ COMPONENT_CHIP,
4
4
  CONTRAST_REFERENCE_KEYS,
5
- componentBadge,
5
+ componentChip,
6
6
  resetThemeMode,
7
7
  setThemeMode,
8
8
  } from "../ui/theme-mode.js";
@@ -30,51 +30,44 @@ function contrast(a: string, b: string): number {
30
30
  return (hi + 0.05) / (lo + 0.05);
31
31
  }
32
32
 
33
- const KINDS = ["skill", "command", "agent", "mcp"] as const;
34
-
35
- describe("componentBadge", () => {
33
+ /**
34
+ * The chip behind a plugin component's type glyph. It used to be a wash in a
35
+ * different hue per kind; the glyph already says the kind, so it is now one
36
+ * neutral chip for all of them (see `COMPONENT_CHIP`).
37
+ */
38
+ describe("componentChip", () => {
36
39
  it("serves the dark pair before any resolution", () => {
37
40
  // There is no unknown: dark is the stated default, so a chip is always a
38
41
  // pair, never null, and the caller has no bare-glyph branch to keep.
39
- for (const kind of KINDS) {
40
- expect(componentBadge(kind)).toEqual(COMPONENT_BADGE[kind].dark);
41
- }
42
+ expect(componentChip()).toEqual(COMPONENT_CHIP.dark);
42
43
  });
43
44
 
44
45
  it("serves the light pair on a light terminal", () => {
45
46
  setThemeMode("light");
46
-
47
- expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.light);
48
- expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.light);
47
+ expect(componentChip()).toEqual(COMPONENT_CHIP.light);
49
48
  });
50
49
 
51
50
  it("serves the dark pair on a dark terminal", () => {
52
51
  setThemeMode("dark");
53
-
54
- expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.dark);
55
- expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.dark);
52
+ expect(componentChip()).toEqual(COMPONENT_CHIP.dark);
56
53
  });
57
54
 
58
- it("keeps every glyph legible on its own chip", () => {
55
+ it("keeps the glyph legible on its own chip", () => {
59
56
  // 3:1 is the floor the rest of the palette is measured to. A chip whose
60
57
  // glyph disappears into its wash is worse than no chip at all.
61
- for (const kind of KINDS) {
62
- for (const mode of ["light", "dark"] as const) {
63
- const { bg, fg } = COMPONENT_BADGE[kind][mode];
64
- expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
65
- }
58
+ for (const mode of ["light", "dark"] as const) {
59
+ const { bg, fg } = COMPONENT_CHIP[mode];
60
+ expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
66
61
  }
67
62
  });
68
63
 
69
- it("keeps every chip quiet against the page it is drawn on", () => {
64
+ it("keeps the chip quiet against the page it is drawn on", () => {
70
65
  // The wash must read as a tint of the page, not as a block on it. Above
71
66
  // ~1.6 it stops being a chip and starts being a highlight bar.
72
- for (const kind of KINDS) {
73
- for (const mode of ["light", "dark"] as const) {
74
- const { bg } = COMPONENT_BADGE[kind][mode];
75
- const page = CONTRAST_REFERENCE_KEYS[mode];
76
- expect(contrast(bg, page)).toBeLessThan(1.6);
77
- }
67
+ for (const mode of ["light", "dark"] as const) {
68
+ const { bg } = COMPONENT_CHIP[mode];
69
+ const page = CONTRAST_REFERENCE_KEYS[mode];
70
+ expect(contrast(bg, page)).toBeLessThan(1.6);
78
71
  }
79
72
  });
80
73
 
@@ -86,13 +79,4 @@ describe("componentBadge", () => {
86
79
  expect(CONTRAST_REFERENCE_KEYS.light).toBe(CONTRAST_REFERENCE.light);
87
80
  expect(CONTRAST_REFERENCE_KEYS.dark).toBe(CONTRAST_REFERENCE.dark);
88
81
  });
89
-
90
- it("gives each kind a distinguishable hue", () => {
91
- // Single-character colour carries no readable hue *shift*, only a readable
92
- // hue *difference* — the same reason the scope squares are far apart.
93
- for (const mode of ["light", "dark"] as const) {
94
- const fills = KINDS.map((k) => COMPONENT_BADGE[k][mode].bg);
95
- expect(new Set(fills).size).toBe(KINDS.length);
96
- }
97
- });
98
82
  });
@@ -7,7 +7,7 @@
7
7
  * does not exist.
8
8
  */
9
9
  import { describe, expect, test } from "bun:test";
10
- import { joinKeys } from "../ui/components/layout/FooterHints.js";
10
+ import { joinKeys } from "../ui/keymap.js";
11
11
 
12
12
  describe("joinKeys", () => {
13
13
  test("single-character keys butt together, the way an arrow pair is written", () => {
@@ -32,20 +32,6 @@ describe("migrateToV2", () => {
32
32
  expect(v2.profiles.mine!.plugins).toEqual({ "dev@magus": true });
33
33
  });
34
34
 
35
- test("null/garbage input degrades to an empty manifest", () => {
36
- expect(migrateToV2(null)).toEqual(emptyManifest());
37
- expect(migrateToV2(42)).toEqual(emptyManifest());
38
- expect(migrateToV2({})).toEqual(emptyManifest());
39
- });
40
-
41
- test("non-object profile entries are dropped", () => {
42
- const v2 = migrateToV2({
43
- version: 2,
44
- profiles: { good: { name: "G" }, bad: 5 },
45
- });
46
- expect(Object.keys(v2.profiles)).toEqual(["good"]);
47
- });
48
-
49
35
  test("strictVersions is carried through as a boolean", () => {
50
36
  expect(
51
37
  migrateToV2({ version: 2, strictVersions: true, profiles: {} })
@@ -226,9 +212,9 @@ describe("mergeManifestSettings", () => {
226
212
  );
227
213
  });
228
214
 
229
- // readManifest degrades a corrupt file to an empty manifest. Reusing it here
230
- // would record nothing and report success, leaving the caller's live setting
231
- // to be reverted by the next install with no way to learn why.
215
+ // A merge that quietly treated a corrupt file as empty would record nothing and
216
+ // report success, leaving the caller's live setting to be reverted by the next
217
+ // install with no way to learn why.
232
218
  test("throws on an unreadable manifest instead of silently doing nothing", async () => {
233
219
  await fs.outputFile(join(dir, ".claude", "profiles.json"), "{ not json");
234
220
 
@@ -278,4 +264,26 @@ describe("readManifest / writeManifest", () => {
278
264
  expect(m.version).toBe(2);
279
265
  expect(m.profiles.legacy!.name).toBe("L");
280
266
  });
267
+
268
+ // These inputs used to degrade to an empty manifest (garbage) or lose the
269
+ // bad entry (non-object profile). "Empty" is the state that offers to save a
270
+ // first profile over the file, so both are now refusals.
271
+ test("null/garbage input is refused, not read as an empty manifest", async () => {
272
+ const file = join(dir, ".claude", "profiles.json");
273
+ for (const garbage of ["null", "42"]) {
274
+ await fs.outputFile(file, garbage);
275
+ await expect(readManifest(dir)).rejects.toThrow(file);
276
+ }
277
+ await fs.outputFile(file, "{}");
278
+ expect(await readManifest(dir)).toEqual(emptyManifest());
279
+ });
280
+
281
+ test("a non-object profile entry is refused, not dropped", async () => {
282
+ const file = join(dir, ".claude", "profiles.json");
283
+ await fs.outputJson(file, {
284
+ version: 2,
285
+ profiles: { good: { name: "G" }, bad: 5 },
286
+ });
287
+ await expect(readManifest(dir)).rejects.toThrow(file);
288
+ });
281
289
  });
@@ -52,8 +52,8 @@ function toneFor(marketplace: Marketplace): string | undefined {
52
52
 
53
53
  describe("marketplace category badge", () => {
54
54
  test("the byline uses a low-key tone, not an attention-grabbing one", () => {
55
- // It is a signature, not a status. Green reads as "action succeeded" and
56
- // competes with the plugin rows for attention.
55
+ // It is a signature, not a status. The success role reads as "action
56
+ // succeeded" and competes with the plugin rows for attention.
57
57
  expect(
58
58
  toneFor(
59
59
  mp({
@@ -63,7 +63,7 @@ describe("marketplace category badge", () => {
63
63
  ),
64
64
  ).toBe("byline");
65
65
 
66
- // A third-party marketplace still uses the green "Added" status tone.
66
+ // A third-party marketplace still uses the "Added" status tone, by role.
67
67
  expect(
68
68
  toneFor(
69
69
  mp({
@@ -71,7 +71,7 @@ describe("marketplace category badge", () => {
71
71
  source: { source: "github", repo: "obra/superpowers-marketplace" },
72
72
  }),
73
73
  ),
74
- ).toBe("green");
74
+ ).toBe("success");
75
75
  });
76
76
 
77
77
  test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { join } from "node:path";
3
+ import { parseKeypress } from "@opentui/core";
3
4
  import fs from "fs-extra";
4
5
  import { DEFAULT_PRESET, findPreset } from "../data/models-presets.js";
5
6
  import type { ModelsStatus } from "../services/models-core.js";
7
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
8
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
6
9
  import { appReducer, initialState } from "../ui/state/reducer.js";
7
10
  import type { AppAction, AppState, ModelsSnapshot } from "../ui/state/types.js";
8
11
  import { asyncValue } from "../ui/state/types.js";
@@ -11,6 +14,18 @@ function run(actions: AppAction[], from: AppState = initialState): AppState {
11
14
  return actions.reduce(appReducer, from);
12
15
  }
13
16
 
17
+ /** A real parsed keypress, in the shape the keymap matches. */
18
+ function press(raw: string): KeyPress {
19
+ const k = parseKeypress(raw);
20
+ return {
21
+ name: k?.name ?? "",
22
+ sequence: k?.sequence ?? "",
23
+ ctrl: k?.ctrl === true,
24
+ meta: k?.meta === true,
25
+ shift: k?.shift === true,
26
+ };
27
+ }
28
+
14
29
  function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
15
30
  return { state: "off", preset: null, drift: [], warnings: [], ...over };
16
31
  }
@@ -268,23 +283,31 @@ const SCREEN = join(import.meta.dir, "..", "ui", "screens", "ModelsScreen.tsx");
268
283
 
269
284
  describe("ModelsScreen keybindings", () => {
270
285
  test("a and Enter both apply, and c clears", async () => {
271
- const src = await fs.readFile(SCREEN, "utf8");
272
- expect(src).toContain('event.name === "a"');
273
- expect(src).toContain('event.name === "c"');
274
- expect(src).toContain('event.name === "r"');
275
- expect(src).toContain('event.name === "/"');
286
+ const models = SCREEN_BINDINGS.models;
287
+ expect(matchBinding(models, press("a"))?.id).toBe("apply");
288
+ expect(matchBinding(models, press("c"))?.id).toBe("off");
289
+ expect(matchBinding(models, press("r"))?.id).toBe("reload");
290
+ expect(matchBinding(models, press("/"))?.id).toBe("search");
276
291
  // Enter is the same action as `a`. There is nothing to "open" here, so
277
292
  // binding the most obvious key to nothing would be the wrong lesson.
278
- const enter = src.indexOf('event.name === "return"');
279
- expect(enter).toBeGreaterThan(-1);
280
- expect(src.slice(enter, enter + 400)).toContain("handleApply()");
293
+ expect(matchBinding(models, press("\r"))?.id).toBe("apply");
294
+ const src = await fs.readFile(SCREEN, "utf8");
295
+ const apply = src.indexOf("apply: () =>");
296
+ expect(apply).toBeGreaterThan(-1);
297
+ expect(src.slice(apply, apply + 120)).toContain("handleApply()");
281
298
  });
282
299
 
283
- test("digits are treated as navigation, so a message survives a tab round trip", async () => {
284
- // Every digit now navigates, 0 included. Clearing the status line on the
285
- // way OUT of the tab loses the message the state was changed to preserve.
286
- const src = await fs.readFile(SCREEN, "utf8");
287
- expect(src).toContain("/^[0-9]$/.test(event.name");
300
+ test("digits and Tab never reach the screen, so a message survives a tab round trip", () => {
301
+ // The status line is cleared only by this screen's own bindings. Every
302
+ // digit (0 included) and Tab navigate, and they belong to the global key
303
+ // layer: no Models binding may claim one, or the message would be wiped
304
+ // on the way OUT of the tab — the loss the state was moved to prevent.
305
+ for (const digit of "1234567890") {
306
+ expect(
307
+ matchBinding(SCREEN_BINDINGS.models, press(digit)),
308
+ ).toBeUndefined();
309
+ }
310
+ expect(matchBinding(SCREEN_BINDINGS.models, press("\t"))).toBeUndefined();
288
311
  });
289
312
 
290
313
  test("no screen still treats 0 as a non-navigation key", async () => {
@@ -1,25 +1,16 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
  import type React from "react";
3
3
  import { ScopeSquares } from "../ui/components/primitives/ScopeSquares.js";
4
- import {
5
- SCOPE_OFF_FILL,
6
- resetThemeMode,
7
- setThemeMode,
8
- } from "../ui/theme-mode.js";
9
- import { CONTRAST_REFERENCE, theme } from "../ui/theme.js";
4
+ import { theme } from "../ui/theme.js";
10
5
 
11
6
  /**
12
- * The scope bar is three filled segments; only the fill says lit or unlit.
7
+ * The scope bar is three positional squares: user, project, local.
13
8
  *
14
- * Its unlit fill was `theme.colors.muted` (#6B7280) — the same mid-dark grey as
15
- * de-emphasised text. On a light page that is a *dark* block, so an unlit segment
16
- * carried the same visual weight as a lit one and the bar read as three similar
17
- * blobs: "non installed state is dark as well - confusing".
18
- *
19
- * An unlit segment has to recede toward the page, which is exactly what a fixed
20
- * colour cannot do — a pale fill that sits quietly on cream is a bright block on
21
- * near-black. So the fill follows the resolved mode, and these tests pin both
22
- * that behaviour and the dark default that applies before resolution.
9
+ * It used to be three FILLED blocks told apart only by fill colour — a scope
10
+ * hue when lit, a page-relative grey when not — so without colour (a
11
+ * monochrome terminal, a colour-blind reader, a theme drawing two hues alike)
12
+ * every state was the same shape. Now position names the scope and SHAPE says
13
+ * the state: `■` installed, `□` not. Colour only repeats what the shape says.
23
14
  */
24
15
 
25
16
  interface SpanLike {
@@ -35,129 +26,52 @@ const glyphs = (el: React.ReactElement) =>
35
26
  segments(el).map((c) => c.props.children);
36
27
  const fills = (el: React.ReactElement) => segments(el).map((c) => c.props.fg);
37
28
 
38
- function relativeLuminance(hex: string): number {
39
- const n = hex.replace("#", "");
40
- const ch = [0, 2, 4].map((i) => Number.parseInt(n.slice(i, i + 2), 16) / 255);
41
- const lin = ch.map((c) =>
42
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
43
- );
44
- return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
45
- }
46
-
47
- afterEach(() => resetThemeMode());
48
-
49
- describe("every segment is a filled block", () => {
50
- test("lit and unlit alike use the filled glyph", () => {
51
- setThemeMode("light");
29
+ describe("shape says the state", () => {
30
+ test("installed is filled, not installed is hollow, by position", () => {
52
31
  const el = ScopeSquares({ user: true, project: false, local: true });
53
- expect(glyphs(el)).toEqual(["■", "■", "■"]);
32
+ expect(glyphs(el)).toEqual(["■", "□", "■"]);
54
33
  });
55
34
 
56
35
  test("local is omitted when the caller does not pass it", () => {
57
- setThemeMode("light");
58
36
  expect(glyphs(ScopeSquares({ user: true, project: false }))).toEqual([
59
37
  "■",
60
- "■",
61
- ]);
62
- });
63
- });
64
-
65
- describe("fill distinguishes lit from unlit", () => {
66
- test("each lit segment keeps its own scope hue", () => {
67
- setThemeMode("light");
68
- const el = ScopeSquares({ user: true, project: true, local: true });
69
- expect(fills(el)).toEqual([
70
- theme.scopes.user,
71
- theme.scopes.project,
72
- theme.scopes.local,
38
+ "□",
73
39
  ]);
74
40
  });
75
41
 
76
- test("unlit segments take the page-relative off fill", () => {
77
- setThemeMode("light");
78
- const el = ScopeSquares({ user: false, project: false, local: false });
79
- expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.light]));
80
-
81
- setThemeMode("dark");
82
- const dark = ScopeSquares({ user: false, project: false, local: false });
83
- expect(new Set(fills(dark))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
42
+ test("the two states never share a glyph", () => {
43
+ const on = glyphs(ScopeSquares({ user: true, project: true, local: true }));
44
+ const off = glyphs(
45
+ ScopeSquares({ user: false, project: false, local: false }),
46
+ );
47
+ expect(new Set(on)).toEqual(new Set(["■"]));
48
+ expect(new Set(off)).toEqual(new Set(["□"]));
84
49
  });
50
+ });
85
51
 
86
- test("a mixed bar shows lit and unlit fills side by side", () => {
87
- setThemeMode("light");
52
+ describe("colour repeats the shape", () => {
53
+ test("a filled square wears success, a hollow one muted", () => {
88
54
  const el = ScopeSquares({ user: false, project: true, local: false });
89
55
  expect(fills(el)).toEqual([
90
- SCOPE_OFF_FILL.light,
91
- theme.scopes.project,
92
- SCOPE_OFF_FILL.light,
56
+ theme.colors.muted,
57
+ theme.colors.success,
58
+ theme.colors.muted,
93
59
  ]);
94
60
  });
95
-
96
- test("before resolution the fill is the dark pair", () => {
97
- resetThemeMode();
98
- const el = ScopeSquares({ user: false, project: false, local: false });
99
- expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
100
- });
101
- });
102
-
103
- describe("the unlit fill recedes toward the page it is on", () => {
104
- /**
105
- * How far a fill sits from the page, relative to the NEAREST lit segment.
106
- *
107
- * The naive property — "unlit is closer to the page than a lit colour" — is
108
- * too weak, and measurably so: the old #6B7280 satisfied it on a light page
109
- * (0.766 vs 0.774) while still looking wrong. What made the bar read as three
110
- * similar blobs was that unlit sat **99% as far** from the page as a lit
111
- * segment: essentially the same visual weight. Ratio, not order, is the
112
- * property that captures the defect.
113
- */
114
- const pageDistanceRatio = (fill: string, page: string) => {
115
- const d = (hex: string) =>
116
- Math.abs(relativeLuminance(hex) - relativeLuminance(page));
117
- const nearestLit = Math.min(...Object.values(theme.scopes).map(d));
118
- return d(fill) / nearestLit;
119
- };
120
-
121
- /** Unlit must be at most half as far from the page as the nearest lit segment. */
122
- const MAX_RATIO = 0.5;
123
-
124
- test("light page: unlit recedes (measured 0.32)", () => {
125
- expect(
126
- pageDistanceRatio(SCOPE_OFF_FILL.light, CONTRAST_REFERENCE.light),
127
- ).toBeLessThan(MAX_RATIO);
128
- });
129
-
130
- test("dark page: unlit recedes (measured 0.22)", () => {
131
- expect(
132
- pageDistanceRatio(SCOPE_OFF_FILL.dark, CONTRAST_REFERENCE.dark),
133
- ).toBeLessThan(MAX_RATIO);
134
- });
135
-
136
- test("the old mid-grey fails the property on BOTH pages", () => {
137
- // Pins why the value changed, and guards against anyone reinstating it:
138
- // 0.99 on cream and 1.10 on near-black — an unlit segment as heavy as a lit
139
- // one, or heavier.
140
- expect(
141
- pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.light),
142
- ).toBeGreaterThan(MAX_RATIO);
143
- expect(
144
- pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.dark),
145
- ).toBeGreaterThan(MAX_RATIO);
146
- });
147
61
  });
148
62
 
149
63
  describe("selected rows", () => {
150
- test("unlit segments recede into the selection block, lit take its ink", () => {
151
- setThemeMode("light");
64
+ test("filled squares take the selection ink, hollow ones its dim ink", () => {
152
65
  const el = ScopeSquares({
153
66
  user: true,
154
67
  project: false,
155
68
  local: true,
156
69
  selected: true,
157
70
  });
71
+ expect(glyphs(el)).toEqual(["■", "□", "■"]);
158
72
  expect(fills(el)).toEqual([
159
73
  theme.selection.fg,
160
- theme.selection.bg,
74
+ theme.selection.dim,
161
75
  theme.selection.fg,
162
76
  ]);
163
77
  });