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.
- package/package.json +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +107 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +173 -101
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +129 -129
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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 ? <
|
|
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(
|
|
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
|
-
|
|
3
|
+
COMPONENT_CHIP,
|
|
4
4
|
CONTRAST_REFERENCE_KEYS,
|
|
5
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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/
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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.
|
|
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
|
|
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("
|
|
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
|
|
272
|
-
expect(
|
|
273
|
-
expect(
|
|
274
|
-
expect(
|
|
275
|
-
expect(
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
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 {
|
|
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
|
|
7
|
+
* The scope bar is three positional squares: user, project, local.
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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("
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
theme.
|
|
92
|
-
|
|
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("
|
|
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.
|
|
74
|
+
theme.selection.dim,
|
|
161
75
|
theme.selection.fg,
|
|
162
76
|
]);
|
|
163
77
|
});
|