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

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The guided walk's focused card wizard (#83): 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
  `;
package/src/config.ts CHANGED
@@ -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> {
@@ -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
  });
@@ -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",
@@ -20,6 +20,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
20
20
  annotationCount: 1,
21
21
  hasFocusedAnnotation: true,
22
22
  cursorAnnotatable: true,
23
+ walkAtEnd: false,
23
24
  ...patch,
24
25
  };
25
26
  }
@@ -228,6 +229,42 @@ describe("diff mode", () => {
228
229
  });
229
230
  });
230
231
 
232
+ describe("guided walk", () => {
233
+ test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
234
+ expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ t: "walkStart" }]);
235
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
236
+ { t: "status", msg: "review submitted - read-only" },
237
+ ]);
238
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
239
+ { t: "status", msg: "observer - read-only" },
240
+ ]);
241
+ expect(reduceKey(state(), key("w"))).toEqual([{ t: "status", msg: "the guided walk is a diff-review mode" }]);
242
+ });
243
+
244
+ const walking = state({ view: "diff", overlay: "walk" });
245
+ const table: [string, Intent[]][] = [
246
+ ["]", [{ t: "walkForward" }]],
247
+ ["[", [{ t: "walkBack" }]],
248
+ ["escape", [{ t: "walkLeave" }]],
249
+ ["q", [{ t: "exit" }]],
250
+ // mid-walk return does nothing: submit lives on the end card only
251
+ ["return", []],
252
+ ["j", []],
253
+ ["c", []],
254
+ ];
255
+ for (const [name, expected] of table) {
256
+ test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
257
+ expect(reduceKey(walking, key(name))).toEqual(expected);
258
+ });
259
+ }
260
+
261
+ test("the end card's return leaves the walk and opens the submit confirm", () => {
262
+ const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
263
+ expect(reduceKey(atEnd, key("return"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
264
+ expect(reduceKey(atEnd, key("enter"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
265
+ });
266
+ });
267
+
231
268
  describe("duplicated-branch collapse", () => {
232
269
  test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
233
270
  for (const patch of [
package/src/keymap.ts CHANGED
@@ -23,6 +23,10 @@ export type Intent =
23
23
  | { t: "editCard" }
24
24
  | { t: "nextAnn" }
25
25
  | { t: "prevAnn" }
26
+ | { t: "walkStart" }
27
+ | { t: "walkForward" }
28
+ | { t: "walkBack" }
29
+ | { t: "walkLeave" }
26
30
  | { t: "removeAnnotation" }
27
31
  | { t: "deselect" }
28
32
  | { t: "closeOverlay" }
@@ -43,10 +47,12 @@ export interface KeyState {
43
47
  keys: Record<string, string[]>;
44
48
  readOnly: boolean;
45
49
  /** Layer that owns keys before the grammar runs. */
46
- overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
50
+ overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
47
51
  view: "inbox" | "plan" | "diff";
48
52
  /** Plan-only span selection sub-mode. */
49
53
  spanMode: boolean;
54
+ /** The walk cursor sits on the end card - return offers the submit action. */
55
+ walkAtEnd: boolean;
50
56
  resolved: boolean;
51
57
  hasInboxItems: boolean;
52
58
  annotationCount: number;
@@ -56,7 +62,7 @@ export interface KeyState {
56
62
  }
57
63
 
58
64
  /** Verbs that write session state; an observer never reaches their handlers. */
59
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
65
+ const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
60
66
 
61
67
  const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
62
68
 
@@ -90,6 +96,19 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
90
96
  if (name === "escape") return [{ t: "dismissCompletion" }];
91
97
  return [];
92
98
  }
99
+ // the walk wizard owns its keys while active: ] advances (marking the
100
+ // current file viewed), [ steps back, escape leaves keeping progress, and
101
+ // return on the end card hands over to the submit confirm
102
+ if (state.overlay === "walk") {
103
+ if (name === "]") return [{ t: "walkForward" }];
104
+ if (name === "[") return [{ t: "walkBack" }];
105
+ if (name === "escape") return [{ t: "walkLeave" }];
106
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
107
+ return [{ t: "walkLeave" }, { t: "openSubmit" }];
108
+ }
109
+ if (name === "q") return [{ t: "exit" }];
110
+ return [];
111
+ }
93
112
  const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
94
113
  if (action === "quit") return [{ t: "exit" }];
95
114
  // the ONE read-only rule: any mutating attempt answers instead of acting
@@ -114,6 +133,11 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
114
133
  function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
115
134
  const nav = navIntent(action);
116
135
  if (nav) return nav;
136
+ if (action === "walk") {
137
+ // marking viewed writes the session record, so a resolved review answers
138
+ if (state.resolved) return status("review submitted - read-only");
139
+ return [{ t: "walkStart" }];
140
+ }
117
141
  if (action === "comment") {
118
142
  if (state.resolved) return status("review submitted - read-only");
119
143
  if (!state.cursorAnnotatable) return status("move to a code line to comment");
@@ -139,6 +163,7 @@ function spanGrammar(name: string): Intent[] {
139
163
  function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
140
164
  const nav = navIntent(action);
141
165
  if (nav) return nav;
166
+ if (action === "walk") return status("the guided walk is a diff-review mode");
142
167
  if (name === "escape") return [{ t: "deselect" }];
143
168
  if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
144
169
  if (action === "comment" || action === "suggest") {
@@ -24,6 +24,7 @@ import { Registry, type Exporter } from "@cueloop/extension-api";
24
24
  import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
25
25
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
26
26
  import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
27
+ import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
27
28
  import { editInEditor } from "./editor";
28
29
  import { focusHerdrPane, returnPaneFor } from "./herdr";
29
30
  import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
@@ -53,6 +54,12 @@ export interface ControllerSnapshot {
53
54
  * hand-off - the reconciliation banner count. 0 = no banner.
54
55
  */
55
56
  editOrphanCount: number;
57
+ /**
58
+ * The guided walk's cursor (diff sessions): which wizard step is on
59
+ * screen. index === file count is the end card. null = not walking; the
60
+ * viewed set itself rides the session record, so leaving loses nothing.
61
+ */
62
+ walk: { index: number } | null;
56
63
  }
57
64
 
58
65
  export interface ReviewControllerOptions {
@@ -79,6 +86,8 @@ export interface ReviewController {
79
86
  /** Derived projections, cached per session identity. */
80
87
  display(): DisplayBlock[];
81
88
  rows(): DiffRow[];
89
+ /** The walk's step list, derived from the diff rows. */
90
+ files(): WalkFile[];
82
91
  working(): string;
83
92
  /** Open a session from the inbox. */
84
93
  open(id: string): void;
@@ -95,6 +104,13 @@ export interface ReviewController {
95
104
  updateAnnotation(id: string, body: string): void;
96
105
  removeAnnotation(id: string): void;
97
106
  setWorkingCopy(content: string | undefined): void;
107
+ /** Enter the guided walk at the first unviewed file (diff sessions). */
108
+ walkStart(): void;
109
+ /** Mark the current file viewed (persists with the session) and advance. */
110
+ walkForward(): void;
111
+ walkBack(): void;
112
+ /** Leave the walk; the viewed set stays on the session record. */
113
+ walkLeave(): void;
98
114
  /** Resolve the review, run the export, start the completion hand-back. */
99
115
  submit(verdict: VerdictKind, summary: string): void;
100
116
  /** Close the review and, inside herdr, bounce focus back to the agent. */
@@ -119,6 +135,7 @@ class Controller implements ReviewController {
119
135
  error: null,
120
136
  completion: { phase: "idle" },
121
137
  editOrphanCount: 0,
138
+ walk: null,
122
139
  };
123
140
  private listeners = new Set<() => void>();
124
141
  private autoClose: AutoClose = "off";
@@ -128,7 +145,11 @@ class Controller implements ReviewController {
128
145
  private countdown: TimerHandle | undefined;
129
146
  /** Projections keyed by session identity so renders reuse one computation. */
130
147
  private derivedFor: ReviewSession | null = null;
131
- private derived: { display: DisplayBlock[]; rows: DiffRow[] } = { display: [], rows: [] };
148
+ private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
149
+ display: [],
150
+ rows: [],
151
+ files: [],
152
+ };
132
153
 
133
154
  constructor(private readonly opts: ReviewControllerOptions) {
134
155
  this.readOnly = opts.readOnly ?? false;
@@ -201,9 +222,11 @@ class Controller implements ReviewController {
201
222
  const s = this.snap.session;
202
223
  if (this.derivedFor === s) return;
203
224
  this.derivedFor = s;
225
+ const rows = s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [];
204
226
  this.derived = {
205
227
  display: s ? buildDisplay(s.artifact.content, s.workingCopy) : [],
206
- rows: s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [],
228
+ rows,
229
+ files: walkFiles(rows),
207
230
  };
208
231
  }
209
232
 
@@ -217,6 +240,11 @@ class Controller implements ReviewController {
217
240
  return this.derived.rows;
218
241
  }
219
242
 
243
+ files(): WalkFile[] {
244
+ this.ensureDerived();
245
+ return this.derived.files;
246
+ }
247
+
220
248
  working(): string {
221
249
  const s = this.snap.session;
222
250
  return s ? (s.workingCopy ?? s.artifact.content) : "";
@@ -250,6 +278,7 @@ class Controller implements ReviewController {
250
278
 
251
279
  // ── verbs ───────────────────────────────────
252
280
  open(id: string): void {
281
+ this.locallyViewed.clear();
253
282
  const cached = this.snap.inbox?.find((s) => s.id === id);
254
283
  if (cached) this.update({ session: cached });
255
284
  else void this.refreshSession(id);
@@ -364,9 +393,54 @@ class Controller implements ReviewController {
364
393
  this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
365
394
  }
366
395
 
396
+ // ── the guided walk ─────────────────────────
397
+ // Marks sent but possibly not yet reflected in the session snapshot; the
398
+ // union keeps the walk title truthful and rapid advances from losing marks.
399
+ private locallyViewed = new Set<string>();
400
+
401
+ private viewedSet(): Set<string> {
402
+ return new Set([...(this.snap.session?.viewedPaths ?? []), ...this.locallyViewed]);
403
+ }
404
+
405
+ walkStart(): void {
406
+ const session = this.snap.session;
407
+ if (!session || session.artifact.type !== "diff") return;
408
+ const files = this.files();
409
+ if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
410
+ // resume at the first unviewed file; a finished walk reopens on the end card
411
+ this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
412
+ }
413
+
414
+ walkForward(): void {
415
+ const walk = this.snap.walk;
416
+ const session = this.snap.session;
417
+ if (!walk || !session) return;
418
+ const files = this.files();
419
+ const current = files[walk.index];
420
+ if (!current) return; // already on the end card
421
+ // advancing IS the viewed mark: the step is complete once you move past
422
+ // it; the daemon verb merges, so only the new path travels
423
+ if (!this.viewedSet().has(current.path)) {
424
+ this.locallyViewed.add(current.path);
425
+ this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
426
+ }
427
+ this.update({ walk: { index: walk.index + 1 } });
428
+ }
429
+
430
+ walkBack(): void {
431
+ const walk = this.snap.walk;
432
+ if (!walk) return;
433
+ this.update({ walk: { index: Math.max(0, walk.index - 1) } });
434
+ }
435
+
436
+ walkLeave(): void {
437
+ if (this.snap.walk) this.update({ walk: null });
438
+ }
439
+
367
440
  submit(verdict: VerdictKind, summary: string): void {
368
441
  const session = this.snap.session;
369
442
  if (!session) return;
443
+ this.walkLeave();
370
444
  this.client!.sessionResolve(session.id, verdict, summary)
371
445
  .then((resolved) => {
372
446
  // The completion overlay heading already states the verdict, so the