@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.17

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 (44) hide show
  1. package/package.json +5 -5
  2. package/src/App.plan-review.test.tsx +1 -1
  3. package/src/App.readonly.test.tsx +3 -3
  4. package/src/App.rendering.test.tsx +16 -16
  5. package/src/App.test.tsx +1 -1
  6. package/src/App.tsx +193 -139
  7. package/src/App.walk.test.tsx +208 -0
  8. package/src/components/AnnotationCard.tsx +4 -4
  9. package/src/components/ComposeBar.tsx +2 -2
  10. package/src/components/ConfirmCard.stories.tsx +15 -0
  11. package/src/components/ConfirmCard.tsx +8 -1
  12. package/src/components/DiffSheet.tsx +8 -8
  13. package/src/components/PlanSheet.stories.tsx +3 -3
  14. package/src/components/PlanSheet.tsx +31 -31
  15. package/src/components/WalkWizard.stories.tsx +89 -0
  16. package/src/components/WalkWizard.tsx +146 -0
  17. package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
  18. package/src/components/stories.test.tsx +1 -1
  19. package/src/components/story-fixtures.ts +1 -1
  20. package/src/components/story.ts +1 -1
  21. package/src/components/syntax-highlight.ts +1 -1
  22. package/src/components/theme-context.tsx +1 -1
  23. package/src/components/truncate-text.ts +6 -0
  24. package/src/config.test.ts +29 -29
  25. package/src/config.ts +13 -12
  26. package/src/editor.ts +1 -1
  27. package/src/herdr.ts +1 -1
  28. package/src/key-bindings.test.ts +18 -1
  29. package/src/key-bindings.ts +23 -2
  30. package/src/keymap.test.ts +138 -101
  31. package/src/keymap.ts +81 -56
  32. package/src/run.ts +3 -3
  33. package/src/serve.test.ts +6 -6
  34. package/src/serve.ts +7 -7
  35. package/src/session-controller.ts +140 -66
  36. package/src/theme.ts +20 -1
  37. package/src/view-diff.test.ts +8 -8
  38. package/src/view-diff.ts +22 -22
  39. package/src/{view.test.ts → view-plan.test.ts} +48 -48
  40. package/src/view-plan.ts +362 -0
  41. package/src/walk.test.ts +110 -0
  42. package/src/walk.ts +65 -0
  43. package/src/components/format.ts +0 -6
  44. package/src/view.ts +0 -341
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The guided walk's focused card wizard: one card per changed file
3
+ * floating over the dimmed diff, with a plain step count in the title - no
4
+ * progress bar. Below the main card a separate gray-bordered block carries
5
+ * the submitting agent's note for the file, when one exists. Past the last
6
+ * file the end card offers the submit action directly with honest counts.
7
+ * The keys live in the keymap's walk overlay; this component only renders.
8
+ */
9
+
10
+ import React from "react";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import type { WalkFile } from "../walk";
14
+ import { viewedCount } from "../walk";
15
+ import { Card } from "./primitives/Card";
16
+ import { Button } from "./primitives/Button";
17
+ import { Toolbar } from "./primitives/Toolbar";
18
+
19
+ export interface WalkWizardProps {
20
+ files: WalkFile[];
21
+ /** The wizard step; index === files.length renders the end card. */
22
+ index: number;
23
+ viewedPaths: ReadonlySet<string>;
24
+ /** The agent's note for the current file; the gray block renders only when set. */
25
+ note?: string;
26
+ /** Terminal width bounds the card width. */
27
+ terminalWidth: number;
28
+ /** End-card submit: leaves the walk and opens the rail confirm. */
29
+ onSubmitRequest: () => void;
30
+ onBack: () => void;
31
+ theme?: Theme;
32
+ }
33
+
34
+ /** The card never crowds the terminal edge nor stretches past reading width. */
35
+ function wizardWidth(terminalWidth: number): number {
36
+ return Math.min(72, Math.max(24, terminalWidth - 8));
37
+ }
38
+
39
+ /** Note rows from a crude wrap estimate, capped so the block stays a block. */
40
+ function noteRows(note: string, innerWidth: number): number {
41
+ return Math.min(3, Math.max(1, Math.ceil(note.length / Math.max(1, innerWidth))));
42
+ }
43
+
44
+ export function WalkWizard({
45
+ files,
46
+ index,
47
+ viewedPaths,
48
+ note,
49
+ terminalWidth,
50
+ onSubmitRequest,
51
+ onBack,
52
+ theme,
53
+ }: WalkWizardProps): React.ReactNode {
54
+ const tokens = useComponentTheme(theme);
55
+ const width = wizardWidth(terminalWidth);
56
+ const viewed = viewedCount(files, viewedPaths);
57
+ const atEnd = index >= files.length;
58
+ const currentFile = files[index];
59
+
60
+ return (
61
+ <box
62
+ style={{
63
+ position: "absolute",
64
+ left: 0,
65
+ top: 0,
66
+ width: "100%",
67
+ height: "100%",
68
+ alignItems: "center",
69
+ justifyContent: "center",
70
+ flexDirection: "column",
71
+ }}
72
+ >
73
+ <box style={{ width, flexDirection: "column" }}>
74
+ {atEnd || !currentFile ? (
75
+ <Card
76
+ title=" walk complete "
77
+ contentRows={3}
78
+ borderColor={tokens.green}
79
+ backgroundColor={tokens.panel}
80
+ theme={theme}
81
+ >
82
+ <text fg={tokens.text}>
83
+ {viewed === files.length
84
+ ? `every file viewed (${viewed}/${files.length})`
85
+ : `${viewed} of ${files.length} files viewed`}
86
+ </text>
87
+ <box style={{ height: 1 }} />
88
+ <Toolbar>
89
+ <Button variant="solid" marginRight={2} onPress={onSubmitRequest} theme={theme}>
90
+ {" Submit review "}
91
+ </Button>
92
+ <Button onPress={onBack} theme={theme}>
93
+ {" [ back "}
94
+ </Button>
95
+ </Toolbar>
96
+ </Card>
97
+ ) : (
98
+ <>
99
+ <Card
100
+ title={` file ${index + 1} of ${files.length} · ${viewed} viewed `}
101
+ contentRows={3 + Math.max(1, currentFile.preview.length)}
102
+ borderColor={tokens.accent}
103
+ backgroundColor={tokens.panel}
104
+ theme={theme}
105
+ >
106
+ <text fg={tokens.text}>{currentFile.path}</text>
107
+ <text>
108
+ <span fg={tokens.green}>{`+${currentFile.added}`}</span>
109
+ <span fg={tokens.textDim}>{" "}</span>
110
+ <span fg={tokens.red}>{`-${currentFile.removed}`}</span>
111
+ <span fg={tokens.textDim}>{viewedPaths.has(currentFile.path) ? " · viewed" : ""}</span>
112
+ </text>
113
+ <box style={{ height: 1 }} />
114
+ {currentFile.preview.length === 0 ? (
115
+ <text fg={tokens.textDim}>(no changed lines)</text>
116
+ ) : (
117
+ currentFile.preview.map((previewLine, previewIndex) => (
118
+ <text
119
+ key={previewIndex}
120
+ style={{ wrapMode: "none" }}
121
+ fg={previewLine.sign === "+" ? tokens.insFg : tokens.delFg}
122
+ >
123
+ {previewLine.sign + previewLine.text}
124
+ </text>
125
+ ))
126
+ )}
127
+ </Card>
128
+ {note !== undefined ? (
129
+ <box style={{ marginTop: 1, flexDirection: "column" }}>
130
+ <Card
131
+ title=" agent note "
132
+ contentRows={noteRows(note, width - 4)}
133
+ borderColor={tokens.border}
134
+ backgroundColor={tokens.panel}
135
+ theme={theme}
136
+ >
137
+ <text fg={tokens.textMuted}>{note}</text>
138
+ </Card>
139
+ </box>
140
+ ) : null}
141
+ </>
142
+ )}
143
+ </box>
144
+ </box>
145
+ );
146
+ }
@@ -1184,6 +1184,136 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
1184
1184
 
1185
1185
 
1186
1186
 
1187
+
1188
+ "
1189
+ `;
1190
+
1191
+ exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and carries its colors: ConfirmCard/WithViewedSummary 1`] = `
1192
+ "╭─ submit review ─────────────────────╮
1193
+ │ 1 annotations · 0 blocking │
1194
+ │ 2/3 files viewed │
1195
+ │ │
1196
+ │ Comment [Approve] Changes │
1197
+ │ │
1198
+ │ summary for the agent (optional) │
1199
+ │ │
1200
+ │ Submit Cancel │
1201
+ ╰─────────────────────────────────────╯
1202
+
1203
+
1204
+
1205
+
1206
+ "
1207
+ `;
1208
+
1209
+ exports[`stories catalog WalkWizard/EndCard renders, snapshots, and carries its colors: WalkWizard/EndCard 1`] = `
1210
+ "
1211
+
1212
+
1213
+
1214
+
1215
+
1216
+
1217
+
1218
+
1219
+
1220
+ ╭─ walk complete ──────────────────────────────────────────────────────╮
1221
+ │ every file viewed (3/3) │
1222
+ │ │
1223
+ │ Submit review [ back │
1224
+ ╰──────────────────────────────────────────────────────────────────────╯
1225
+
1226
+
1227
+
1228
+
1229
+
1230
+
1231
+
1232
+
1233
+
1234
+ "
1235
+ `;
1236
+
1237
+ exports[`stories catalog WalkWizard/EndCardPartialPass renders, snapshots, and carries its colors: WalkWizard/EndCardPartialPass 1`] = `
1238
+ "
1239
+
1240
+
1241
+
1242
+
1243
+
1244
+
1245
+
1246
+
1247
+
1248
+ ╭─ walk complete ──────────────────────────────────────────────────────╮
1249
+ │ 1 of 3 files viewed │
1250
+ │ │
1251
+ │ Submit review [ back │
1252
+ ╰──────────────────────────────────────────────────────────────────────╯
1253
+
1254
+
1255
+
1256
+
1257
+
1258
+
1259
+
1260
+
1261
+
1262
+ "
1263
+ `;
1264
+
1265
+ exports[`stories catalog WalkWizard/FirstFile renders, snapshots, and carries its colors: WalkWizard/FirstFile 1`] = `
1266
+ "
1267
+
1268
+
1269
+
1270
+
1271
+
1272
+
1273
+
1274
+ ╭─ file 1 of 3 · 0 viewed ─────────────────────────────────────────────╮
1275
+ │ packages/daemon/src/store.ts │
1276
+ │ +12 -3 │
1277
+ │ │
1278
+ │ +const viewed = new Set<string>(); │
1279
+ │ +record.viewedPaths = [...viewed]; │
1280
+ │ -// viewed tracking lives in the client only │
1281
+ ╰──────────────────────────────────────────────────────────────────────╯
1282
+
1283
+
1284
+
1285
+
1286
+
1287
+
1288
+
1289
+
1290
+ "
1291
+ `;
1292
+
1293
+ exports[`stories catalog WalkWizard/WithAgentNote renders, snapshots, and carries its colors: WalkWizard/WithAgentNote 1`] = `
1294
+ "
1295
+
1296
+
1297
+
1298
+
1299
+
1300
+
1301
+ ╭─ file 2 of 3 · 1 viewed ─────────────────────────────────────────────╮
1302
+ │ packages/client/src/walk.ts │
1303
+ │ +48 -0 │
1304
+ │ │
1305
+ │ +export function firstUnviewedIndex(files, viewed) { │
1306
+ ╰──────────────────────────────────────────────────────────────────────╯
1307
+
1308
+ ╭─ agent note ─────────────────────────────────────────────────────────╮
1309
+ │ New module: pure helpers that pick the next unviewed file. No IO, │
1310
+ │ unit tested. │
1311
+ ╰──────────────────────────────────────────────────────────────────────╯
1312
+
1313
+
1314
+
1315
+
1316
+
1187
1317
 
1188
1318
  "
1189
1319
  `;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The catalog IS the regression suite (#86): every exported story renders in
2
+ * The catalog IS the regression suite: every exported story renders in
3
3
  * a virtual terminal, its char frame snapshots, and its declared colors must
4
4
  * appear among the styled spans. A component file without a colocated
5
5
  * stories file fails the harness, so the safety net cannot silently thin.
@@ -1,7 +1,7 @@
1
1
  /** Shared fixture data for the stories catalog: one plan session, one diff. */
2
2
 
3
3
  import type { Annotation, ReviewSession } from "@cueloop/schema";
4
- import { buildDisplay, marksByDisplay } from "../view";
4
+ import { buildDisplay, marksByDisplay } from "../view-plan";
5
5
  import { diffRows } from "../view-diff";
6
6
 
7
7
  export const FIXTURE_PLAN = `# Migration Plan
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The story contract (#86). Every component file in components/ ships a
2
+ * The story contract. Every component file in components/ ships a
3
3
  * colocated *.stories.tsx that exports `meta` plus named Story objects.
4
4
  * The catalog IS the regression suite: stories.test.tsx walks every story,
5
5
  * snapshots its char frame, and asserts declared colors against the styled
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Code highlighting tokens (#86): the theme's named tokens projected onto
2
+ * Code highlighting tokens: the theme's named tokens projected onto
3
3
  * tree-sitter capture names for the native code renderable. One SyntaxStyle
4
4
  * per theme object, cached, so every code block on screen shares a handle.
5
5
  * Unknown languages simply render unstyled - highlighting is an enhancement,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Provider theming for the component system (#86). The context default is the
2
+ * Provider theming for the component system. The context default is the
3
3
  * built-in dark theme, so embedding a component (or rendering a story) needs
4
4
  * no provider at all. One ThemeProvider swap switches the live theme; every
5
5
  * component also accepts an optional `theme` prop that overrides the context
@@ -0,0 +1,6 @@
1
+ /** Truncate text to one line of at most maxLength characters, ellipsis-terminated. */
2
+
3
+ export function truncateToSingleLine(value: string, maxLength: number): string {
4
+ const oneLine = value.replace(/\n/g, " ");
5
+ return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
6
+ }
@@ -7,9 +7,9 @@ import { DARK } from "./theme";
7
7
 
8
8
  describe("loadConfig", () => {
9
9
  test("defaults when no file exists", () => {
10
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
- expect(c.keys["comment"]).toEqual(["c"]);
12
- expect(c.theme.accent).toBe(DARK.accent);
10
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
+ expect(config.keys["comment"]).toEqual(["c"]);
12
+ expect(config.theme.accent).toBe(DARK.accent);
13
13
  });
14
14
 
15
15
  test("user config rebinds actions and overrides theme tokens", () => {
@@ -20,12 +20,12 @@ describe("loadConfig", () => {
20
20
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
21
  );
22
22
  try {
23
- const c = loadConfig({ userConfigPath: path });
24
- expect(c.keys["comment"]).toEqual(["a"]);
25
- expect(c.keys["submit"]).toEqual(["return", "S"]);
26
- expect(c.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
- expect(c.theme.accent).toBe("#ff0000");
28
- expect(c.theme.green).toBe(DARK.green);
23
+ const config = loadConfig({ userConfigPath: path });
24
+ expect(config.keys["comment"]).toEqual(["a"]);
25
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
26
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
+ expect(config.theme.accent).toBe("#ff0000");
28
+ expect(config.theme.green).toBe(DARK.green);
29
29
  } finally {
30
30
  rmSync(dir, { recursive: true, force: true });
31
31
  }
@@ -39,8 +39,8 @@ describe("loadConfig", () => {
39
39
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
40
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
41
41
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
42
+ const config = loadConfig({ userConfigPath: user, repoRoot });
43
+ expect(config.keys["comment"]).toEqual(["z"]);
44
44
  } finally {
45
45
  rmSync(dir, { recursive: true, force: true });
46
46
  }
@@ -51,8 +51,8 @@ describe("loadConfig", () => {
51
51
  const path = join(dir, "config.toml");
52
52
  writeFileSync(path, "not [valid toml");
53
53
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
54
+ const config = loadConfig({ userConfigPath: path });
55
+ expect(config.keys["comment"]).toEqual(["c"]);
56
56
  } finally {
57
57
  rmSync(dir, { recursive: true, force: true });
58
58
  }
@@ -61,12 +61,12 @@ describe("loadConfig", () => {
61
61
 
62
62
  describe("integrations.obsidian config", () => {
63
63
  test("defaults when the section is absent", () => {
64
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
- expect(c.integrations.obsidian.vault).toBeUndefined();
66
- expect(c.integrations.obsidian.folder).toBe("cueloop");
67
- expect(c.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
- expect(c.integrations.obsidian.separator).toBe("space");
69
- expect(c.integrations.obsidian.exportOn).toBe("manual");
64
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
+ expect(config.integrations.obsidian.vault).toBeUndefined();
66
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
67
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
+ expect(config.integrations.obsidian.separator).toBe("space");
69
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
70
  });
71
71
 
72
72
  test("user config sets the section; invalid enum values are ignored", () => {
@@ -77,12 +77,12 @@ describe("integrations.obsidian config", () => {
77
77
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
78
78
  );
79
79
  try {
80
- const c = loadConfig({ userConfigPath: path });
81
- expect(c.integrations.obsidian.vault).toBe("/notes/vault");
82
- expect(c.integrations.obsidian.folder).toBe("plans");
83
- expect(c.integrations.obsidian.exportOn).toBe("approve");
84
- expect(c.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
- expect(c.keys["comment"]).toEqual(["c"]); // other sections untouched
80
+ const config = loadConfig({ userConfigPath: path });
81
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
82
+ expect(config.integrations.obsidian.folder).toBe("plans");
83
+ expect(config.integrations.obsidian.exportOn).toBe("approve");
84
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
86
  } finally {
87
87
  rmSync(dir, { recursive: true, force: true });
88
88
  }
@@ -96,10 +96,10 @@ describe("integrations.obsidian config", () => {
96
96
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
97
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
98
98
  try {
99
- const c = loadConfig({ userConfigPath: user, repoRoot });
100
- expect(c.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
- expect(c.integrations.obsidian.exportOn).toBe("resolve");
102
- expect(c.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
99
+ const config = loadConfig({ userConfigPath: user, repoRoot });
100
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
+ expect(config.integrations.obsidian.exportOn).toBe("resolve");
102
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
103
  } finally {
104
104
  rmSync(dir, { recursive: true, force: true });
105
105
  }
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Layered TOML config (#20): built-in defaults → user config → trusted repo
2
+ * Layered TOML config: built-in defaults → user config → trusted repo
3
3
  * config → env. Sections: [keys] action = "combo" (every action rebindable),
4
4
  * [theme] per-token overrides, [integrations.obsidian] notes-vault export.
5
5
  */
@@ -46,6 +46,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
46
46
  delete_annotation: ["backspace"],
47
47
  submit: ["return", "enter"],
48
48
  quit: ["q"],
49
+ walk: ["w"],
49
50
  };
50
51
 
51
52
  function parseToml(text: string): Record<string, unknown> {
@@ -82,19 +83,19 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
82
83
  const integrations = raw["integrations"] as Record<string, unknown> | undefined;
83
84
  const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
84
85
  if (obsidian) {
85
- const o = out.integrations.obsidian;
86
- if (typeof obsidian["vault"] === "string") o.vault = obsidian["vault"];
87
- if (typeof obsidian["folder"] === "string") o.folder = obsidian["folder"];
88
- if (typeof obsidian["filenameFormat"] === "string") o.filenameFormat = obsidian["filenameFormat"];
89
- const sep = obsidian["separator"];
90
- if (sep === "space" || sep === "dash" || sep === "underscore") o.separator = sep;
91
- const on = obsidian["exportOn"];
92
- if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
86
+ const obsidianConfig = out.integrations.obsidian;
87
+ if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
88
+ if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
89
+ if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
90
+ const separator = obsidian["separator"];
91
+ if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
92
+ const exportOn = obsidian["exportOn"];
93
+ if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
93
94
  }
94
95
  return out;
95
96
  }
96
97
 
97
- export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
98
+ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
98
99
  let config: CueloopConfig = {
99
100
  keys: { ...DEFAULT_KEYS },
100
101
  theme: { ...DARK },
@@ -102,10 +103,10 @@ export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string }
102
103
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
103
104
  };
104
105
  const userPath =
105
- opts.userConfigPath ??
106
+ options.userConfigPath ??
106
107
  process.env.CUELOOP_CONFIG ??
107
108
  join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
108
- for (const path of [userPath, opts.repoRoot ? join(opts.repoRoot, ".cueloop", "config.toml") : undefined]) {
109
+ for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
109
110
  if (!path || !existsSync(path)) continue;
110
111
  try {
111
112
  config = layer(config, parseToml(readFileSync(path, "utf8")));
package/src/editor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * File-level edit mode = the $EDITOR hand-off (#22, #11): write the working
2
+ * File-level edit mode = the $EDITOR hand-off: write the working
3
3
  * copy to a temp file, run the user's editor on it, read the result back.
4
4
  * Tests point CUELOOP_EDITOR at a non-interactive command.
5
5
  */
package/src/herdr.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * First-class herdr return (#23): a review opened beside an agent should hand
2
+ * First-class herdr return: a review opened beside an agent should hand
3
3
  * focus back to the agent's pane when it closes, not leave the reviewer on a
4
4
  * dead tab. Detection is the ambient env contract; the return target comes
5
5
  * from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
@@ -8,7 +8,7 @@ import { describe, expect, test } from "bun:test";
8
8
  import { DEFAULT_KEYS } from "./config";
9
9
  import { KeyBindings } from "./key-bindings";
10
10
 
11
- function bindings(context: { overlay?: "none" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
11
+ function bindings(context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
12
12
  const resolver = new KeyBindings(DEFAULT_KEYS);
13
13
  resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
14
14
  return resolver;
@@ -62,6 +62,17 @@ describe("mode layers own their keys", () => {
62
62
  expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
63
63
  expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
64
64
  });
65
+
66
+ test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
67
+ const resolver = bindings({ overlay: "walk" });
68
+ expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
69
+ expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
70
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
71
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("walk_submit");
72
+ expect(resolver.resolveAction({ name: "q", shift: false })).toBe("quit");
73
+ expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
74
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
75
+ });
65
76
  });
66
77
 
67
78
  describe("getActiveKeys-generated status hints", () => {
@@ -89,6 +100,12 @@ describe("getActiveKeys-generated status hints", () => {
89
100
  expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
90
101
  });
91
102
 
103
+ test("walk hint", () => {
104
+ expect(bindings({ overlay: "walk" }).statusHint("walk")).toBe(
105
+ "walk · ] next (marks viewed) · [ back · esc leave walk · q quit",
106
+ );
107
+ });
108
+
92
109
  test("read-only hint", () => {
93
110
  expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
94
111
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Binding resolution over @opentui/keymap (#86): the loaded config keymap
2
+ * Binding resolution over @opentui/keymap: the loaded config keymap
3
3
  * registers as layers (grammar, span, compose, submit, completion) on one
4
4
  * Keymap instance, key events resolve to action names through its dispatch,
5
5
  * and the status-line hints generate from getActiveKeys() - a rebound key
@@ -24,7 +24,7 @@ export interface KeyLayerContext {
24
24
  }
25
25
 
26
26
  /** Hint templates keyed by the same view states the status line shows. */
27
- export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "read-only";
27
+ export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
28
28
 
29
29
  type HintEntry =
30
30
  | { text: string }
@@ -70,6 +70,13 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
70
70
  { commands: ["submit_verdict"], label: "submit" },
71
71
  { commands: ["cancel_submit"], label: "cancel" },
72
72
  ],
73
+ walk: [
74
+ { text: "walk" },
75
+ { commands: ["walk_next"], label: "next (marks viewed)" },
76
+ { commands: ["walk_prev"], label: "back" },
77
+ { commands: ["walk_leave"], label: "leave walk" },
78
+ { commands: ["quit"], label: "quit" },
79
+ ],
73
80
  "read-only": [
74
81
  { text: "observer - read-only" },
75
82
  { commands: ["down", "up"], label: "move" },
@@ -243,6 +250,20 @@ export class KeyBindings {
243
250
  bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
244
251
  commands: SPAN_COMMANDS.map(([, command]) => record(command)),
245
252
  });
253
+ // the walk wizard: prev/next stepping, leave, and submit from the end card
254
+ this.keymap.registerLayer({
255
+ priority: 20,
256
+ when: () => this.context.overlay === "walk",
257
+ bindings: [
258
+ { key: "]", cmd: "walk_next" },
259
+ { key: "[", cmd: "walk_prev" },
260
+ { key: "escape", cmd: "walk_leave" },
261
+ { key: "return", cmd: "walk_submit" },
262
+ { key: "enter", cmd: "walk_submit" },
263
+ { key: "q", cmd: "quit" },
264
+ ],
265
+ commands: [record("walk_next"), record("walk_prev"), record("walk_leave"), record("walk_submit"), record("quit")],
266
+ });
246
267
  this.keymap.registerLayer({
247
268
  priority: 20,
248
269
  when: () => this.context.overlay === "compose",