@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
package/src/App.tsx CHANGED
@@ -1,24 +1,21 @@
1
1
  /**
2
- * The cueloop TUI (#22, #71, #86): state wiring, keymap dispatch, and layout
3
- * composition - nothing else. Rendering lives in components/ (PlanSheet,
4
- * DiffSheet, ReviewRail, ...), daemon IO and the mutation verbs live in
5
- * session-controller.ts, the keyboard grammar in keymap.ts with binding
6
- * resolution and status hints from key-bindings.ts, and theming flows
7
- * through the ThemeProvider - config themes swap the provider value.
8
- *
9
- * The plan review grammar: selection is the entry primitive (mouse drag or
10
- * keyboard span on one native renderer selection), compose happens inline
11
- * under the anchor, annotation text lives in the rail while the document
12
- * keeps only the kind-colored highlight, and the rail edits what the
13
- * document selects - one selected id drives both sides.
2
+ * The cueloop TUI: state wiring, keymap dispatch, and layout composition -
3
+ * nothing else. Rendering lives in components/, daemon IO and the mutation
4
+ * verbs in session-controller.ts, the keyboard grammar in keymap.ts with
5
+ * binding resolution and status hints from key-bindings.ts, and theming in
6
+ * the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
7
+ * span on one native renderer selection); annotation text lives in the rail
8
+ * while the document keeps only the highlight, and one selected id drives
9
+ * both sides.
14
10
  */
15
11
 
16
12
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
18
14
  import type { Clock } from "@opentui/core";
19
- import { type ReviewSession, type VerdictKind } from "@cueloop/schema";
20
- import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
21
- import { DARK } from "./theme";
15
+ import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
16
+ import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view-plan";
17
+ import { noteForFile, viewedCount } from "./walk";
18
+ import { DARK, dimmedTheme } from "./theme";
22
19
  import { DEFAULT_KEYS, loadConfig } from "./config";
23
20
  import { returnPaneFor } from "./herdr";
24
21
  import { createReviewController } from "./session-controller";
@@ -34,6 +31,7 @@ import { VERDICTS } from "./components/ConfirmCard";
34
31
  import { CompletionOverlay } from "./components/CompletionOverlay";
35
32
  import { InboxList } from "./components/InboxList";
36
33
  import { ComposeBar } from "./components/ComposeBar";
34
+ import { WalkWizard } from "./components/WalkWizard";
37
35
 
38
36
  export interface AppProps {
39
37
  home?: string;
@@ -49,14 +47,19 @@ export interface AppProps {
49
47
  }
50
48
 
51
49
  type Mode =
52
- | { m: "normal" }
53
- | { m: "span"; span: SpanState }
54
- | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
55
- | { m: "railEdit"; id: string; text: string }
56
- | { m: "submit"; verdict: VerdictKind; summary: string };
50
+ | { type: "normal" }
51
+ | { type: "span"; span: SpanState }
52
+ | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
53
+ | { type: "railEdit"; id: string; text: string }
54
+ | { type: "submit"; verdict: VerdictKind; summary: string };
55
+
56
+ /** Reviewer-authored annotations only: agent notes never count as feedback. */
57
+ function reviewerAnnotations(session: ReviewSession) {
58
+ return session.annotations.filter((annotation) => !isAgentNote(annotation));
59
+ }
57
60
 
58
61
  function defaultVerdict(session: ReviewSession): VerdictKind {
59
- return session.annotations.length || session.workingCopy !== undefined ? "request_changes" : "approve";
62
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
60
63
  }
61
64
 
62
65
  export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
@@ -69,7 +72,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
69
72
  controller.connect();
70
73
  return () => controller.close();
71
74
  }, [controller]);
72
- const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
75
+ const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
73
76
  controller.subscribe,
74
77
  controller.getSnapshot,
75
78
  controller.getSnapshot,
@@ -80,11 +83,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
80
83
  // ── view state ──────────────────────────────
81
84
  const [cursor, setCursor] = useState(0);
82
85
  const [inboxCursor, setInboxCursor] = useState(0);
83
- const [mode, setMode] = useState<Mode>({ m: "normal" });
84
- const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
86
+ const [mode, setMode] = useState<Mode>({ type: "normal" });
87
+ const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
85
88
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
86
89
  // ~2s focus pulse on the document highlight when a rail card is activated
87
- const [pulseAnn, setPulseAnn] = useState<string | null>(null);
90
+ const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
88
91
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
89
92
  // live mirror of overlay input text: refs commit synchronously, so the
90
93
  // RETURN handler never reads a stale value mid-typing
@@ -96,11 +99,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
96
99
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
97
100
  const [theme, setTheme] = useState(DARK);
98
101
  useEffect(() => {
99
- const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
100
- keysRef.current = cfg.keys;
101
- keyBindings.setKeys(cfg.keys);
102
- setTheme(cfg.theme);
103
- controller.applyConfig(cfg);
102
+ const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
103
+ keysRef.current = config.keys;
104
+ keyBindings.setKeys(config.keys);
105
+ setTheme(config.theme);
106
+ controller.applyConfig(config);
104
107
  }, [session?.workspace.repoRoot, controller, keyBindings]);
105
108
  useEffect(
106
109
  () => () => {
@@ -113,8 +116,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
113
116
  const display = controller.display();
114
117
  const rows = controller.rows();
115
118
  const marks = useMemo(
116
- () => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
117
- [session, display, pulseAnn],
119
+ () => (session ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined) : new Map<number, Mark[]>()),
120
+ [session, display, pulsedAnnotationId],
118
121
  );
119
122
  /** Annotation ids whose anchor resolved against the working copy. */
120
123
  const resolvedIds = useMemo(() => {
@@ -127,38 +130,43 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
127
130
  const resolved = session?.status === "resolved";
128
131
  const isDiff = session?.artifact.type === "diff";
129
132
 
133
+ // ── the guided walk's view model ────────────
134
+ const walkFileList = controller.files();
135
+ const walking = isDiff && walk !== null;
136
+ const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
137
+
130
138
  // driving needs committed layout, so it runs after render; any transition
131
139
  // out of span mode clears the renderer selection (compose paints its own
132
140
  // mark, and a mouse drag never changes the mode, so it survives)
133
141
  useEffect(() => {
134
- if (mode.m === "span") planSheetRef.current?.driveSpanSelection(mode.span);
142
+ if (mode.type === "span") planSheetRef.current?.driveSpanSelection(mode.span);
135
143
  else planSheetRef.current?.clearSelection();
136
144
  }, [mode]);
137
145
 
138
146
  // ── selection symmetry: one selected id, both sides ──
139
147
  const pulse = (id: string): void => {
140
- setPulseAnn(id);
148
+ setPulsedAnnotationId(id);
141
149
  if (pulseTimer.current) clearTimeout(pulseTimer.current);
142
- pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
150
+ pulseTimer.current = setTimeout(() => setPulsedAnnotationId(null), 2000);
143
151
  };
144
152
 
145
153
  /** Card activation scrolls the document to the anchor and pulses it. */
146
154
  const revealAnchor = (annotationId: string): void => {
147
- for (const [dispIdx, blockMarks] of marks) {
155
+ for (const [displayIndex, blockMarks] of marks) {
148
156
  if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
149
- setCursor(dispIdx);
150
- planSheetRef.current?.revealBlock(dispIdx);
157
+ setCursor(displayIndex);
158
+ planSheetRef.current?.revealBlock(displayIndex);
151
159
  return;
152
160
  }
153
161
  };
154
162
 
155
163
  const selectCardFromDocument = (annotationId: string): void => {
156
- setFocusedAnn(annotationId);
164
+ setFocusedAnnotationId(annotationId);
157
165
  railRef.current?.revealCard(annotationId);
158
166
  };
159
167
 
160
168
  const selectCardFromRail = (annotationId: string): void => {
161
- setFocusedAnn(annotationId);
169
+ setFocusedAnnotationId(annotationId);
162
170
  pulse(annotationId);
163
171
  revealAnchor(annotationId);
164
172
  };
@@ -169,7 +177,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
169
177
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
170
178
  if (!annotation) return;
171
179
  liveInput.current = annotation.body;
172
- setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
180
+ setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
173
181
  };
174
182
 
175
183
  /** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
@@ -184,64 +192,64 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
184
192
 
185
193
  // ── keyboard grammar: build state, reduce, dispatch ──
186
194
  const dispatch = (intent: Intent): void => {
187
- switch (intent.t) {
195
+ switch (intent.type) {
188
196
  case "exit":
189
197
  return void onExit?.(0);
190
198
  case "status":
191
- return controller.setStatus(intent.msg);
199
+ return controller.setStatus(intent.message);
192
200
  case "move": {
193
- const len = isDiff ? rows.length : display.length;
194
- if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
195
- else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
201
+ const navigableCount = isDiff ? rows.length : display.length;
202
+ if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
203
+ else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
196
204
  else if (intent.to === "top") setCursor(0);
197
- else setCursor(len - 1);
205
+ else setCursor(navigableCount - 1);
198
206
  return;
199
207
  }
200
208
  case "inboxMove": {
201
- const len = inbox?.length ?? 0;
202
- setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
209
+ const navigableCount = inbox?.length ?? 0;
210
+ setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
203
211
  return;
204
212
  }
205
213
  case "openSession": {
206
- const s = inbox?.[inboxCursor];
207
- if (s) controller.open(s.id);
214
+ const selected = inbox?.[inboxCursor];
215
+ if (selected) controller.open(selected.id);
208
216
  return;
209
217
  }
210
218
  case "startSpan": {
211
- const d = display[cursor];
212
- if (!d?.work) return;
213
- const span = startSpan(cursor, displayText(d));
214
- if (span) setMode({ m: "span", span });
219
+ const block = display[cursor];
220
+ if (!block?.work) return;
221
+ const span = startSpan(cursor, displayText(block));
222
+ if (span) setMode({ type: "span", span });
215
223
  return;
216
224
  }
217
225
  case "spanKey":
218
- if (mode.m === "span") {
219
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
220
- setMode({ m: "span", span });
226
+ if (mode.type === "span") {
227
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
228
+ setMode({ type: "span", span });
221
229
  }
222
230
  return;
223
231
  case "openCompose": {
224
232
  liveInput.current = "";
225
- if (intent.from === "span" && mode.m === "span") {
233
+ if (intent.from === "span" && mode.type === "span") {
226
234
  setMode({
227
- m: "compose",
235
+ type: "compose",
228
236
  kind: intent.kind,
229
- dispIdx: mode.span.dispIdx,
237
+ displayIndex: mode.span.displayIndex,
230
238
  start: mode.span.start,
231
239
  end: mode.span.end,
232
240
  text: "",
233
241
  });
234
242
  } else if (isDiff) {
235
243
  const row = rows[cursor];
236
- if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
244
+ if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
237
245
  } else {
238
246
  // a mouse drag leaves a native selection; it wins over the cursor block
239
247
  const native = planSheetRef.current?.readSelection() ?? null;
240
248
  if (native) {
241
- setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
249
+ setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
242
250
  } else {
243
- const d = display[cursor];
244
- if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
251
+ const block = display[cursor];
252
+ if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
245
253
  }
246
254
  }
247
255
  return;
@@ -251,55 +259,66 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
251
259
  liveInput.current = "";
252
260
  // the confirm card lives in the review tab; opening submit reveals it
253
261
  setRailTab("review");
254
- return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
262
+ return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
255
263
  case "cut":
256
264
  return controller.cut(cursor);
257
265
  case "edit":
258
266
  return runEditorHandOff();
259
267
  case "editCard":
260
- if (focusedAnn) openCardEdit(focusedAnn);
268
+ if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
261
269
  return;
262
- case "nextAnn":
263
- case "prevAnn": {
264
- const anns = session?.annotations ?? [];
265
- if (!anns.length) return;
266
- const idx = anns.findIndex((a) => a.id === focusedAnn);
267
- const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
268
- return void selectCardFromDocument(anns[next]!.id);
270
+ case "nextAnnotation":
271
+ case "prevAnnotation": {
272
+ const annotations = session?.annotations ?? [];
273
+ if (!annotations.length) return;
274
+ const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
275
+ const nextIndex =
276
+ focusedIndex === -1
277
+ ? 0
278
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
279
+ return void selectCardFromDocument(annotations[nextIndex]!.id);
269
280
  }
281
+ case "walkStart":
282
+ return controller.walkStart();
283
+ case "walkForward":
284
+ return controller.walkForward();
285
+ case "walkBack":
286
+ return controller.walkBack();
287
+ case "walkLeave":
288
+ return controller.walkLeave();
270
289
  case "removeAnnotation":
271
- if (focusedAnn) {
272
- controller.removeAnnotation(focusedAnn);
273
- setFocusedAnn(undefined);
290
+ if (focusedAnnotationId) {
291
+ controller.removeAnnotation(focusedAnnotationId);
292
+ setFocusedAnnotationId(undefined);
274
293
  }
275
294
  return;
276
295
  case "deselect":
277
296
  planSheetRef.current?.clearSelection();
278
- setFocusedAnn(undefined);
279
- setPulseAnn(null);
297
+ setFocusedAnnotationId(undefined);
298
+ setPulsedAnnotationId(null);
280
299
  return;
281
300
  case "closeOverlay":
282
- return void setMode({ m: "normal" });
301
+ return void setMode({ type: "normal" });
283
302
  case "saveCompose": {
284
303
  const body = liveInput.current.trim();
285
- if (mode.m === "railEdit") {
304
+ if (mode.type === "railEdit") {
286
305
  if (session && body) controller.updateAnnotation(mode.id, body);
287
- return void setMode({ m: "normal" });
306
+ return void setMode({ type: "normal" });
288
307
  }
289
- if (mode.m !== "compose") return;
308
+ if (mode.type !== "compose") return;
290
309
  if (session && body) {
291
- const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
292
- if (annotationId) setFocusedAnn(annotationId);
310
+ const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
311
+ if (annotationId) setFocusedAnnotationId(annotationId);
293
312
  }
294
- return void setMode({ m: "normal" });
313
+ return void setMode({ type: "normal" });
295
314
  }
296
315
  case "submitVerdict":
297
- if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
298
- return void setMode({ m: "normal" });
316
+ if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
317
+ return void setMode({ type: "normal" });
299
318
  case "cycleVerdict": {
300
- if (mode.m !== "submit") return;
301
- const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
302
- return void setMode({ ...mode, verdict: VERDICTS[idx]! });
319
+ if (mode.type !== "submit") return;
320
+ const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
321
+ return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
303
322
  }
304
323
  case "finishReview":
305
324
  return controller.finishReview();
@@ -311,15 +330,17 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
311
330
  };
312
331
 
313
332
  const overlay: KeyState["overlay"] =
314
- mode.m === "compose" || mode.m === "railEdit"
333
+ mode.type === "compose" || mode.type === "railEdit"
315
334
  ? "compose"
316
- : mode.m === "submit"
335
+ : mode.type === "submit"
317
336
  ? "submit"
318
337
  : completion.phase === "prompt"
319
338
  ? "completion-prompt"
320
339
  : completion.phase === "counting"
321
340
  ? "completion-counting"
322
- : "none";
341
+ : walking
342
+ ? "walk"
343
+ : "none";
323
344
 
324
345
  useKeyboard((key) => {
325
346
  const state: KeyState = {
@@ -327,13 +348,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
327
348
  readOnly,
328
349
  overlay,
329
350
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
330
- spanMode: mode.m === "span",
351
+ spanMode: mode.type === "span",
331
352
  resolved: !!resolved,
332
353
  hasInboxItems: !!inbox?.length,
333
354
  annotationCount: session?.annotations.length ?? 0,
334
- hasFocusedAnnotation: focusedAnn !== undefined,
355
+ hasFocusedAnnotation: focusedAnnotationId !== undefined,
356
+ walkAtEnd: walk !== null && walk.index >= walkFileList.length,
335
357
  cursorAnnotatable: isDiff
336
- ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
358
+ ? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
337
359
  : !!display[cursor]?.work,
338
360
  };
339
361
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
@@ -364,53 +386,53 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
364
386
  );
365
387
  }
366
388
 
367
- const s = session!;
368
- const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
389
+ const activeSession = session!;
390
+ const pendingCount = reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
369
391
 
370
- if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
392
+ if ((completion.phase === "prompt" || completion.phase === "counting") && activeSession.verdict) {
371
393
  return (
372
394
  <ThemeProvider theme={theme}>
373
395
  <CompletionOverlay
374
- verdict={s.verdict.kind}
396
+ verdict={activeSession.verdict.kind}
375
397
  completion={completion}
376
398
  status={status}
377
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
399
+ returnsTo={returnPaneFor(activeSession.artifact.meta.herdrPane) ? (activeSession.artifact.meta.agent ?? "the agent") : undefined}
378
400
  />
379
401
  </ThemeProvider>
380
402
  );
381
403
  }
382
404
 
383
405
  const composeState =
384
- mode.m === "compose" && !isDiff
406
+ mode.type === "compose" && !isDiff
385
407
  ? {
386
408
  kind: mode.kind,
387
- dispIdx: mode.dispIdx,
388
- quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
409
+ displayIndex: mode.displayIndex,
410
+ quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
389
411
  draft: {
390
412
  text: mode.text,
391
413
  onInput: (text: string) => {
392
414
  liveInput.current = text;
393
415
  setMode({ ...mode, text });
394
416
  },
395
- onSave: () => dispatch({ t: "saveCompose" }),
396
- onCancel: () => dispatch({ t: "closeOverlay" }),
417
+ onSave: () => dispatch({ type: "saveCompose" }),
418
+ onCancel: () => dispatch({ type: "closeOverlay" }),
397
419
  },
398
420
  }
399
421
  : null;
400
422
 
401
423
  const activeSpan =
402
- mode.m === "span"
403
- ? { dispIdx: mode.span.dispIdx, start: mode.span.start, end: mode.span.end }
404
- : mode.m === "compose" && !isDiff
424
+ mode.type === "span"
425
+ ? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
426
+ : mode.type === "compose" && !isDiff
405
427
  ? // the compose anchor stays painted selection-style while the box is open
406
- { dispIdx: mode.dispIdx, start: mode.start, end: mode.end }
428
+ { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
407
429
  : null;
408
430
 
409
- const onLineActivate = (dispIdx: number): void => {
431
+ const onLineActivate = (displayIndex: number): void => {
410
432
  // releasing a drag-selection lands here too; a live selection is not a click
411
433
  if (renderer?.hasSelection) return;
412
- setCursor(dispIdx);
413
- const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
434
+ setCursor(displayIndex);
435
+ const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
414
436
  if (annotationId) selectCardFromDocument(annotationId);
415
437
  };
416
438
 
@@ -424,60 +446,67 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
424
446
  const onSubmitRequest = (): void => {
425
447
  if (readOnly) return controller.setStatus("observer - read-only");
426
448
  if (resolved) return;
427
- dispatch({ t: "openSubmit" });
449
+ dispatch({ type: "openSubmit" });
428
450
  };
429
451
 
430
452
  const submitConfirmState =
431
- mode.m === "submit"
453
+ mode.type === "submit"
432
454
  ? {
433
455
  verdict: mode.verdict,
434
456
  summary: mode.summary,
435
- annotationCount: s.annotations.length,
436
- blockingCount: s.annotations.filter(annotationBlocking).length,
457
+ annotationCount: reviewerAnnotations(activeSession).length,
458
+ blockingCount: reviewerAnnotations(activeSession).filter(annotationBlocking).length,
459
+ // walk coverage keeps partial passes honest at the verdict
460
+ viewedSummary:
461
+ isDiff && activeSession.viewedPaths !== undefined
462
+ ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
463
+ : undefined,
437
464
  onInput: (summary: string) => {
438
465
  liveInput.current = summary;
439
466
  setMode({ ...mode, summary });
440
467
  },
441
468
  onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
442
- onSubmit: () => dispatch({ t: "submitVerdict" }),
443
- onCancel: () => dispatch({ t: "closeOverlay" }),
469
+ onSubmit: () => dispatch({ type: "submitVerdict" }),
470
+ onCancel: () => dispatch({ type: "closeOverlay" }),
444
471
  }
445
472
  : null;
446
473
 
447
474
  const cardEditState =
448
- mode.m === "railEdit"
475
+ mode.type === "railEdit"
449
476
  ? {
450
477
  id: mode.id,
451
478
  text: mode.text,
452
479
  onInput: (text: string) => {
453
480
  liveInput.current = text;
454
- setMode({ m: "railEdit", id: mode.id, text });
481
+ setMode({ type: "railEdit", id: mode.id, text });
455
482
  },
456
- onSave: () => dispatch({ t: "saveCompose" }),
457
- onCancel: () => dispatch({ t: "closeOverlay" }),
483
+ onSave: () => dispatch({ type: "saveCompose" }),
484
+ onCancel: () => dispatch({ type: "closeOverlay" }),
458
485
  }
459
486
  : null;
460
487
 
461
488
  const headerItems: BreadcrumbItem[] = [
462
489
  { label: "cueloop", tone: "accent" },
463
- { label: `${s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev ${s.revisions.length}`, tone: "dim" },
464
- ...(resolved ? [{ label: `resolved: ${s.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
490
+ { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
491
+ ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
465
492
  ...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
466
493
  ...(status ? [{ label: status, tone: "accent" as const }] : []),
467
494
  ];
468
495
 
469
- keyBindings.setContext({ overlay, spanMode: mode.m === "span" });
496
+ keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
470
497
  const hintMode: HintMode = readOnly
471
498
  ? "read-only"
472
- : mode.m === "submit"
499
+ : mode.type === "submit"
473
500
  ? "submit"
474
- : mode.m === "span"
501
+ : mode.type === "span"
475
502
  ? "span"
476
- : mode.m === "compose" || mode.m === "railEdit"
503
+ : mode.type === "compose" || mode.type === "railEdit"
477
504
  ? "compose"
478
- : focusedAnn !== undefined
479
- ? "card"
480
- : "normal";
505
+ : walking
506
+ ? "walk"
507
+ : focusedAnnotationId !== undefined
508
+ ? "card"
509
+ : "normal";
481
510
  const railWidth = terminalWidth >= 100 ? 34 : 28;
482
511
 
483
512
  return (
@@ -486,11 +515,18 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
486
515
  <Breadcrumb items={headerItems} />
487
516
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
488
517
  {isDiff ? (
489
- <DiffSheet rows={rows} cursor={cursor} annotations={s.annotations} focusedAnnotationId={focusedAnn} />
518
+ // the sheet dims to reading-quiet colors while the wizard has focus
519
+ <DiffSheet
520
+ rows={rows}
521
+ cursor={cursor}
522
+ annotations={activeSession.annotations}
523
+ focusedAnnotationId={focusedAnnotationId}
524
+ theme={walking ? dimmedTheme(theme) : undefined}
525
+ />
490
526
  ) : (
491
527
  <PlanSheet
492
528
  ref={planSheetRef}
493
- session={s}
529
+ session={activeSession}
494
530
  display={display}
495
531
  marks={marks}
496
532
  cursor={cursor}
@@ -503,8 +539,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
503
539
  )}
504
540
  <ReviewRail
505
541
  ref={railRef}
506
- session={s}
507
- selectedId={focusedAnn}
542
+ session={activeSession}
543
+ selectedId={focusedAnnotationId}
508
544
  resolvedIds={isDiff ? null : resolvedIds}
509
545
  railTab={railTab}
510
546
  pendingCount={pendingCount}
@@ -517,10 +553,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
517
553
  width={railWidth}
518
554
  />
519
555
  </box>
520
- {mode.m === "compose" && isDiff ? (
556
+ {mode.type === "compose" && isDiff ? (
521
557
  <ComposeBar
522
558
  kind={mode.kind}
523
- quote={rows[mode.dispIdx]?.text ?? ""}
559
+ quote={rows[mode.displayIndex]?.text ?? ""}
524
560
  text={mode.text}
525
561
  onInput={(text) => {
526
562
  liveInput.current = text;
@@ -530,6 +566,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
530
566
  ) : (
531
567
  <StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
532
568
  )}
569
+ {walking && walk !== null ? (
570
+ <WalkWizard
571
+ files={walkFileList}
572
+ index={walk.index}
573
+ viewedPaths={viewedPaths}
574
+ note={
575
+ walkFileList[walk.index] !== undefined
576
+ ? noteForFile(activeSession.annotations, walkFileList[walk.index]!.path)
577
+ : undefined
578
+ }
579
+ terminalWidth={terminalWidth}
580
+ onSubmitRequest={() => {
581
+ dispatch({ type: "walkLeave" });
582
+ dispatch({ type: "openSubmit" });
583
+ }}
584
+ onBack={() => dispatch({ type: "walkBack" })}
585
+ />
586
+ ) : null}
533
587
  </box>
534
588
  </ThemeProvider>
535
589
  );