@cueloop/client 0.1.0-alpha.14 → 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.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.14",
11
- "@cueloop/extension-api": "0.1.0-alpha.14",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.14",
13
- "@cueloop/schema": "0.1.0-alpha.14",
10
+ "@cueloop/daemon": "0.1.0-alpha.16",
11
+ "@cueloop/extension-api": "0.1.0-alpha.16",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.16",
13
+ "@cueloop/schema": "0.1.0-alpha.16",
14
14
  "@opentui/core": "^0.5.1",
15
15
  "@opentui/keymap": "0.5.1",
16
16
  "@opentui/react": "^0.5.1",
@@ -60,37 +60,39 @@ async function submitApprove(setup: Setup): Promise<void> {
60
60
  }
61
61
 
62
62
  describe("completion overlay", () => {
63
- test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
63
+ test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
64
64
  let exited = -1;
65
65
  const setup = await renderApp((code) => (exited = code));
66
66
  await submitApprove(setup);
67
- const frame = setup.captureCharFrame();
68
- expect(frame).toContain("✓ review approved");
69
- expect(frame).toContain("a always close after submit");
67
+ const frame = await waitForText(setup, "closing in 5s");
68
+ expect(frame).toContain("review approved");
69
+ expect(frame).toContain("close [return]");
70
+ expect(frame).toContain("return to plan [esc]");
70
71
  await press(setup, "enter");
71
72
  expect(exited).toBe(0);
72
73
  });
73
74
 
74
- test("a opts in: persists the config and starts the countdown", async () => {
75
+ test("a remembers the countdown as the persisted default", async () => {
75
76
  const setup = await renderApp();
76
77
  await submitApprove(setup);
78
+ await waitForText(setup, "closing in 5s");
77
79
  await press(setup, "a");
78
- await waitForText(setup, "closing in 3s");
79
80
  expect(existsSync(configPath)).toBe(true);
80
- expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
81
- // each countdown second is one manual-clock tick, not wall time
81
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
82
+ // the countdown keeps running; each second is one manual-clock tick
82
83
  clock.advance(1000);
83
- await waitForText(setup, "closing in 2s");
84
+ await waitForText(setup, "closing in 4s");
84
85
  });
85
86
 
86
87
  test("esc dismisses to the resolved read-only view", async () => {
87
88
  const setup = await renderApp();
88
89
  await submitApprove(setup);
90
+ await waitForText(setup, "closing in 5s");
89
91
  // a bare ESC sits in the input parser's escape-sequence disambiguation
90
92
  // window before it is delivered - the frame wait absorbs it
91
93
  await press(setup, "escape");
92
94
  const frame = await waitForText(setup, "resolved: approve");
93
- expect(frame).not.toContain("always close after submit");
95
+ expect(frame).not.toContain("closing in");
94
96
  });
95
97
 
96
98
  test("configured auto_close counts down and exits without interaction", async () => {
@@ -72,7 +72,7 @@ describe("diff review", () => {
72
72
 
73
73
  await press(setup, "enter"); // submit
74
74
  await press(setup, "enter"); // confirm request_changes
75
- await waitForText(setup, "✎ feedback sent");
75
+ await waitForText(setup, "feedback sent");
76
76
  const resolved = server.core.sessionGet(session.id);
77
77
  expect(resolved.verdict!.feedback).toContain("new Map()");
78
78
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
@@ -121,7 +121,7 @@ describe("rail submit confirm", () => {
121
121
  await setup.mockInput.typeText("Tighten the steps.");
122
122
  await press(setup, "enter");
123
123
  // the completion flow after submit is unchanged
124
- await waitForText(setup, "✎ feedback sent");
124
+ await waitForText(setup, "feedback sent");
125
125
  const stored = server.core.sessionGet(session.id);
126
126
  expect(stored.status).toBe("resolved");
127
127
  expect(stored.verdict!.kind).toBe("request_changes");
package/src/App.test.tsx CHANGED
@@ -148,13 +148,13 @@ describe("submit", () => {
148
148
  expect(setup.captureCharFrame()).toContain("[Changes]");
149
149
  await type(setup, "Expand the steps.");
150
150
  await press(setup, "enter");
151
- await waitForText(setup, "✎ feedback sent");
151
+ await waitForText(setup, "feedback sent");
152
152
  const stored = server.core.sessionGet(session.id);
153
153
  expect(stored.status).toBe("resolved");
154
154
  expect(stored.verdict!.kind).toBe("request_changes");
155
155
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
156
156
  // submit hands the reviewer back to the agent via the completion overlay
157
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
157
+ expect(setup.captureCharFrame()).toContain("feedback sent");
158
158
  });
159
159
 
160
160
  test("approve via ←/→ verdict cycling", async () => {
package/src/App.tsx CHANGED
@@ -16,9 +16,10 @@
16
16
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
17
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
18
18
  import type { Clock } from "@opentui/core";
19
- import { type ReviewSession, type VerdictKind } from "@cueloop/schema";
19
+ import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
20
20
  import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
21
- import { DARK } from "./theme";
21
+ import { noteForFile, viewedCount } from "./walk";
22
+ import { DARK, dimmedTheme } from "./theme";
22
23
  import { DEFAULT_KEYS, loadConfig } from "./config";
23
24
  import { returnPaneFor } from "./herdr";
24
25
  import { createReviewController } from "./session-controller";
@@ -34,6 +35,7 @@ import { VERDICTS } from "./components/ConfirmCard";
34
35
  import { CompletionOverlay } from "./components/CompletionOverlay";
35
36
  import { InboxList } from "./components/InboxList";
36
37
  import { ComposeBar } from "./components/ComposeBar";
38
+ import { WalkWizard } from "./components/WalkWizard";
37
39
 
38
40
  export interface AppProps {
39
41
  home?: string;
@@ -55,8 +57,13 @@ type Mode =
55
57
  | { m: "railEdit"; id: string; text: string }
56
58
  | { m: "submit"; verdict: VerdictKind; summary: string };
57
59
 
60
+ /** Reviewer-authored annotations only: agent notes never count as feedback. */
61
+ function reviewerAnnotations(session: ReviewSession) {
62
+ return session.annotations.filter((annotation) => !isAgentNote(annotation));
63
+ }
64
+
58
65
  function defaultVerdict(session: ReviewSession): VerdictKind {
59
- return session.annotations.length || session.workingCopy !== undefined ? "request_changes" : "approve";
66
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
60
67
  }
61
68
 
62
69
  export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
@@ -69,7 +76,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
69
76
  controller.connect();
70
77
  return () => controller.close();
71
78
  }, [controller]);
72
- const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
79
+ const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
73
80
  controller.subscribe,
74
81
  controller.getSnapshot,
75
82
  controller.getSnapshot,
@@ -127,6 +134,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
127
134
  const resolved = session?.status === "resolved";
128
135
  const isDiff = session?.artifact.type === "diff";
129
136
 
137
+ // ── the guided walk's view model ────────────
138
+ const walkFileList = controller.files();
139
+ const walking = isDiff && walk !== null;
140
+ const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
141
+
130
142
  // driving needs committed layout, so it runs after render; any transition
131
143
  // out of span mode clears the renderer selection (compose paints its own
132
144
  // mark, and a mouse drag never changes the mode, so it survives)
@@ -267,6 +279,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
267
279
  const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
268
280
  return void selectCardFromDocument(anns[next]!.id);
269
281
  }
282
+ case "walkStart":
283
+ return controller.walkStart();
284
+ case "walkForward":
285
+ return controller.walkForward();
286
+ case "walkBack":
287
+ return controller.walkBack();
288
+ case "walkLeave":
289
+ return controller.walkLeave();
270
290
  case "removeAnnotation":
271
291
  if (focusedAnn) {
272
292
  controller.removeAnnotation(focusedAnn);
@@ -319,7 +339,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
319
339
  ? "completion-prompt"
320
340
  : completion.phase === "counting"
321
341
  ? "completion-counting"
322
- : "none";
342
+ : walking
343
+ ? "walk"
344
+ : "none";
323
345
 
324
346
  useKeyboard((key) => {
325
347
  const state: KeyState = {
@@ -332,6 +354,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
332
354
  hasInboxItems: !!inbox?.length,
333
355
  annotationCount: session?.annotations.length ?? 0,
334
356
  hasFocusedAnnotation: focusedAnn !== undefined,
357
+ walkAtEnd: walk !== null && walk.index >= walkFileList.length,
335
358
  cursorAnnotatable: isDiff
336
359
  ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
337
360
  : !!display[cursor]?.work,
@@ -365,7 +388,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
365
388
  }
366
389
 
367
390
  const s = session!;
368
- const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
391
+ const pendingCount = reviewerAnnotations(s).length + (s.workingCopy !== undefined ? 1 : 0);
369
392
 
370
393
  if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
371
394
  return (
@@ -432,8 +455,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
432
455
  ? {
433
456
  verdict: mode.verdict,
434
457
  summary: mode.summary,
435
- annotationCount: s.annotations.length,
436
- blockingCount: s.annotations.filter(annotationBlocking).length,
458
+ annotationCount: reviewerAnnotations(s).length,
459
+ blockingCount: reviewerAnnotations(s).filter(annotationBlocking).length,
460
+ // walk coverage keeps partial passes honest at the verdict
461
+ viewedSummary:
462
+ isDiff && s.viewedPaths !== undefined
463
+ ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
464
+ : undefined,
437
465
  onInput: (summary: string) => {
438
466
  liveInput.current = summary;
439
467
  setMode({ ...mode, summary });
@@ -475,9 +503,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
475
503
  ? "span"
476
504
  : mode.m === "compose" || mode.m === "railEdit"
477
505
  ? "compose"
478
- : focusedAnn !== undefined
479
- ? "card"
480
- : "normal";
506
+ : walking
507
+ ? "walk"
508
+ : focusedAnn !== undefined
509
+ ? "card"
510
+ : "normal";
481
511
  const railWidth = terminalWidth >= 100 ? 34 : 28;
482
512
 
483
513
  return (
@@ -486,7 +516,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
486
516
  <Breadcrumb items={headerItems} />
487
517
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
488
518
  {isDiff ? (
489
- <DiffSheet rows={rows} cursor={cursor} annotations={s.annotations} focusedAnnotationId={focusedAnn} />
519
+ // the sheet dims to reading-quiet colors while the wizard has focus
520
+ <DiffSheet
521
+ rows={rows}
522
+ cursor={cursor}
523
+ annotations={s.annotations}
524
+ focusedAnnotationId={focusedAnn}
525
+ theme={walking ? dimmedTheme(theme) : undefined}
526
+ />
490
527
  ) : (
491
528
  <PlanSheet
492
529
  ref={planSheetRef}
@@ -530,6 +567,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
530
567
  ) : (
531
568
  <StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
532
569
  )}
570
+ {walking && walk !== null ? (
571
+ <WalkWizard
572
+ files={walkFileList}
573
+ index={walk.index}
574
+ viewedPaths={viewedPaths}
575
+ note={
576
+ walkFileList[walk.index] !== undefined
577
+ ? noteForFile(s.annotations, walkFileList[walk.index]!.path)
578
+ : undefined
579
+ }
580
+ terminalWidth={terminalWidth}
581
+ onSubmitRequest={() => {
582
+ dispatch({ t: "walkLeave" });
583
+ dispatch({ t: "openSubmit" });
584
+ }}
585
+ onBack={() => dispatch({ t: "walkBack" })}
586
+ />
587
+ ) : null}
533
588
  </box>
534
589
  </ThemeProvider>
535
590
  );
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The guided walk in the virtual terminal (#83): the wizard steps every
3
+ * changed file with a plain progress title, ] marks viewed and persists with
4
+ * the session, esc keeps progress, w resumes at the first unviewed file
5
+ * (across App instances - the daemon round-trip), the agent-note block
6
+ * renders only for files carrying a note, and the end card hands over to the
7
+ * submit confirm with the honest viewed count.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import React from "react";
15
+ import { testRender } from "@opentui/react/test-utils";
16
+ import { DaemonServer } from "@cueloop/daemon";
17
+ import type { ReviewSession } from "@cueloop/schema";
18
+ import { App } from "./App";
19
+ import { DARK } from "./theme";
20
+ import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
21
+
22
+ const PATCH = `diff --git a/src/a.ts b/src/a.ts
23
+ index 111..222 100644
24
+ --- a/src/a.ts
25
+ +++ b/src/a.ts
26
+ @@ -1,2 +1,2 @@
27
+ export const a = 1;
28
+ -export const b = 2;
29
+ +export const b = 3;
30
+ diff --git a/src/b.ts b/src/b.ts
31
+ index 333..444 100644
32
+ --- a/src/b.ts
33
+ +++ b/src/b.ts
34
+ @@ -1,2 +1,2 @@
35
+ // header
36
+ -old line
37
+ +new line
38
+ diff --git a/src/c.ts b/src/c.ts
39
+ index 555..666 100644
40
+ --- a/src/c.ts
41
+ +++ b/src/c.ts
42
+ @@ -1,1 +1,2 @@
43
+ keep
44
+ +added tail
45
+ `;
46
+
47
+ let home: string;
48
+ let server: DaemonServer;
49
+ let session: ReviewSession;
50
+
51
+ beforeEach(() => {
52
+ home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
53
+ server = new DaemonServer({ home, idleExitMs: 0 });
54
+ server.start();
55
+ session = server.core.sessionCreate({
56
+ workspace: { repoRoot: "/repo", branch: "main" },
57
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
58
+ });
59
+ });
60
+ afterEach(() => {
61
+ server.stop();
62
+ rmSync(home, { recursive: true, force: true });
63
+ });
64
+
65
+ async function renderApp() {
66
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
67
+ // Wait for session content, not just the chrome: pressing w before the diff
68
+ // loads drops the walk intent (no session, no diff view) and hangs the test.
69
+ await waitForText(setup, "src/a.ts");
70
+ return setup;
71
+ }
72
+
73
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
74
+
75
+ /** Foreground colors (hex) of every styled span containing the needle. */
76
+ function foregroundsOf(setup: Setup, needle: string): string[] {
77
+ const foregrounds: string[] = [];
78
+ for (const line of setup.captureSpans().lines) {
79
+ for (const span of line.spans) {
80
+ if (!span.text.includes(needle)) continue;
81
+ const [red, green, blue] = span.fg.toInts();
82
+ foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
83
+ }
84
+ }
85
+ return foregrounds;
86
+ }
87
+
88
+ describe("the guided walk", () => {
89
+ test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
90
+ const setup = await renderApp();
91
+ await press(setup, "w");
92
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
93
+ expect(setup.captureCharFrame()).toContain("src/a.ts");
94
+ expect(setup.captureCharFrame()).toContain("+1 -1");
95
+
96
+ await press(setup, "]");
97
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
98
+ // the viewed mark rides the session record, not the client
99
+ await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
100
+
101
+ // [ steps back; the revisited card shows its viewed marker
102
+ await press(setup, "[");
103
+ await waitForText(setup, "file 1 of 3 · 1 viewed");
104
+ expect(setup.captureCharFrame()).toContain("· viewed");
105
+ await press(setup, "]");
106
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
107
+
108
+ await press(setup, "]");
109
+ await waitForText(setup, "file 3 of 3 · 2 viewed");
110
+ await press(setup, "]");
111
+ await waitForText(setup, "walk complete");
112
+ expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
113
+ expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
114
+
115
+ // the end card's return leaves the walk and opens the rail confirm
116
+ await press(setup, "enter");
117
+ await waitForText(setup, "submit review");
118
+ expect(setup.captureCharFrame()).toContain("3/3 files viewed");
119
+ await press(setup, "enter");
120
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
121
+ });
122
+
123
+ test("esc keeps progress and w resumes at the first unviewed file", async () => {
124
+ const setup = await renderApp();
125
+ await press(setup, "w");
126
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
127
+ await press(setup, "]");
128
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
129
+
130
+ await press(setup, "escape");
131
+ await waitForTextGone(setup, "file 2 of 3");
132
+ // the sheet is back at full strength: no wizard card on screen
133
+ expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
134
+
135
+ await press(setup, "w");
136
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
137
+ });
138
+
139
+ test("a half-walked review resumes across App instances via the daemon", async () => {
140
+ const first = await renderApp();
141
+ await press(first, "w");
142
+ await waitForText(first, "file 1 of 3 · 0 viewed");
143
+ await press(first, "]");
144
+ await press(first, "]");
145
+ await waitForText(first, "file 3 of 3 · 2 viewed");
146
+ await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
147
+ first.renderer.destroy();
148
+
149
+ // a fresh client reads the viewed set back from the session record
150
+ const second = await renderApp();
151
+ await press(second, "w");
152
+ await waitForText(second, "file 3 of 3 · 2 viewed");
153
+ expect(second.captureCharFrame()).toContain("src/c.ts");
154
+ // two full App boots with daemon round-trips need more than the default budget
155
+ }, 15_000);
156
+
157
+ test("the agent-note block renders only for files carrying a note", async () => {
158
+ server.core.sessionAnnotate(session.id, {
159
+ id: "note-b",
160
+ kind: "note",
161
+ anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
162
+ body: "Swaps the stale line for the new constant.",
163
+ });
164
+ const setup = await renderApp();
165
+ await press(setup, "w");
166
+ await waitForText(setup, "file 1 of 3");
167
+ expect(setup.captureCharFrame()).not.toContain("agent note");
168
+
169
+ await press(setup, "]");
170
+ await waitForText(setup, "agent note");
171
+ expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
172
+
173
+ // a note is agent context, not reviewer feedback: it neither counts nor
174
+ // flips the default verdict, and never comes back in the feedback doc
175
+ await press(setup, "escape");
176
+ await waitForTextGone(setup, "agent note");
177
+ await press(setup, "enter");
178
+ await waitForText(setup, "0 annotations");
179
+ await press(setup, "enter");
180
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
181
+ const resolved = server.core.sessionGet(session.id);
182
+ expect(resolved.verdict!.kind).toBe("approve");
183
+ expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
184
+ });
185
+
186
+ test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
187
+ const setup = await renderApp();
188
+ // before the walk the sheet's added line wears the insertion color
189
+ await waitForText(setup, "+new line");
190
+ expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
191
+
192
+ await press(setup, "w");
193
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
194
+ // dimmed: the sheet line drops to the dim token...
195
+ expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
196
+ // ...while the wizard preview keeps the insertion color
197
+ expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
198
+ });
199
+
200
+ test("walking a resolved review answers read-only", async () => {
201
+ server.core.sessionResolve(session.id, "approve", "");
202
+ const setup = await renderApp();
203
+ await waitForText(setup, "resolved");
204
+ await press(setup, "w");
205
+ await waitForText(setup, "review submitted - read-only");
206
+ expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
207
+ });
208
+ });
@@ -33,18 +33,17 @@ export function CompletionOverlay({ verdict, completion, status, returnsTo, them
33
33
  alignItems: "center",
34
34
  }}
35
35
  >
36
- <text fg={approved ? tokens.green : tokens.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
36
+ <text fg={approved ? tokens.green : tokens.accent}>{approved ? "review approved" : "feedback sent"}</text>
37
37
  <text> </text>
38
38
  <text fg={tokens.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
39
+ {returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
39
40
  {status ? <text fg={tokens.textDim}>{status}</text> : null}
40
41
  <text> </text>
41
- {completion.phase === "counting" ? (
42
- <text fg={tokens.textDim}>
43
- closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
44
- </text>
45
- ) : (
46
- <text fg={tokens.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
47
- )}
42
+ <text fg={tokens.textDim}>
43
+ close [return]
44
+ {completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
45
+ {" · return to plan [esc] · always [a]"}
46
+ </text>
48
47
  </box>
49
48
  );
50
49
  }
@@ -20,3 +20,18 @@ export const RequestChanges: Story = {
20
20
  expectedColors: [DARK.red],
21
21
  size: { width: 40, height: 14 },
22
22
  };
23
+
24
+ export const WithViewedSummary: Story = {
25
+ render: () => (
26
+ <ConfirmCard
27
+ verdict="approve"
28
+ summary=""
29
+ annotationCount={1}
30
+ blockingCount={0}
31
+ viewedSummary="2/3 files viewed"
32
+ {...callbacks}
33
+ />
34
+ ),
35
+ expectedColors: [DARK.green, DARK.textDim],
36
+ size: { width: 40, height: 14 },
37
+ };
@@ -28,6 +28,11 @@ export interface ConfirmCardProps {
28
28
  summary: string;
29
29
  annotationCount: number;
30
30
  blockingCount: number;
31
+ /**
32
+ * The guided walk's honest coverage line for diff sessions, e.g.
33
+ * "2/3 files viewed". Undefined = no walk data, the row does not render.
34
+ */
35
+ viewedSummary?: string;
31
36
  onInput: (summary: string) => void;
32
37
  onSelectVerdict: (verdict: VerdictKind) => void;
33
38
  onSubmit: () => void;
@@ -76,6 +81,7 @@ export function ConfirmCard({
76
81
  summary,
77
82
  annotationCount,
78
83
  blockingCount,
84
+ viewedSummary,
79
85
  onInput,
80
86
  onSelectVerdict,
81
87
  onSubmit,
@@ -86,12 +92,13 @@ export function ConfirmCard({
86
92
  return (
87
93
  <Card
88
94
  title=" submit review "
89
- contentRows={CONFIRM_CONTENT_ROWS}
95
+ contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
90
96
  borderColor={tokens.accent}
91
97
  marginRight={1}
92
98
  theme={theme}
93
99
  >
94
100
  <text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
101
+ {viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
95
102
  <box style={{ height: 1 }} />
96
103
  <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
97
104
  <box style={{ height: 1 }} />
@@ -0,0 +1,89 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { WalkFile } from "../walk";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { WalkWizard } from "./WalkWizard";
6
+
7
+ export const meta: StoryMeta = { title: "WalkWizard" };
8
+
9
+ const FILES: WalkFile[] = [
10
+ {
11
+ path: "packages/daemon/src/store.ts",
12
+ added: 12,
13
+ removed: 3,
14
+ preview: [
15
+ { sign: "+", text: "const viewed = new Set<string>();" },
16
+ { sign: "+", text: "record.viewedPaths = [...viewed];" },
17
+ { sign: "-", text: "// viewed tracking lives in the client only" },
18
+ ],
19
+ },
20
+ {
21
+ path: "packages/client/src/walk.ts",
22
+ added: 48,
23
+ removed: 0,
24
+ preview: [{ sign: "+", text: "export function firstUnviewedIndex(files, viewed) {" }],
25
+ },
26
+ { path: "packages/schema/src/types.ts", added: 3, removed: 0, preview: [{ sign: "+", text: "viewedPaths?: string[];" }] },
27
+ ];
28
+
29
+ const noop = (): void => {};
30
+
31
+ export const FirstFile: Story = {
32
+ render: () => (
33
+ <WalkWizard
34
+ files={FILES}
35
+ index={0}
36
+ viewedPaths={new Set()}
37
+ terminalWidth={80}
38
+ onSubmitRequest={noop}
39
+ onBack={noop}
40
+ />
41
+ ),
42
+ // the main card wears the accent border; stats carry the +/- colors
43
+ expectedColors: [DARK.accent, DARK.green, DARK.red, DARK.insFg, DARK.delFg],
44
+ };
45
+
46
+ export const WithAgentNote: Story = {
47
+ render: () => (
48
+ <WalkWizard
49
+ files={FILES}
50
+ index={1}
51
+ viewedPaths={new Set(["packages/daemon/src/store.ts"])}
52
+ note="New module: pure helpers that pick the next unviewed file. No IO, unit tested."
53
+ terminalWidth={80}
54
+ onSubmitRequest={noop}
55
+ onBack={noop}
56
+ />
57
+ ),
58
+ // the agent-note block wears the plain gray border token, not the accent
59
+ expectedColors: [DARK.accent, DARK.border, DARK.textMuted],
60
+ };
61
+
62
+ export const EndCard: Story = {
63
+ render: () => (
64
+ <WalkWizard
65
+ files={FILES}
66
+ index={3}
67
+ viewedPaths={new Set(FILES.map((file) => file.path))}
68
+ terminalWidth={80}
69
+ onSubmitRequest={noop}
70
+ onBack={noop}
71
+ />
72
+ ),
73
+ // the end card wears the green border and the solid submit button
74
+ expectedColors: [DARK.green, DARK.accent, DARK.accentInk],
75
+ };
76
+
77
+ export const EndCardPartialPass: Story = {
78
+ render: () => (
79
+ <WalkWizard
80
+ files={FILES}
81
+ index={3}
82
+ viewedPaths={new Set(["packages/daemon/src/store.ts"])}
83
+ terminalWidth={80}
84
+ onSubmitRequest={noop}
85
+ onBack={noop}
86
+ />
87
+ ),
88
+ expectedColors: [DARK.green],
89
+ };