@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/serve.test.ts CHANGED
@@ -38,7 +38,7 @@ afterEach(async () => {
38
38
  });
39
39
 
40
40
  /** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
41
- function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
41
+ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs = 8000): Promise<string> {
42
42
  return new Promise((resolve, reject) => {
43
43
  const conn = new Client();
44
44
  let out = "";
@@ -55,8 +55,8 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
55
55
  .on("ready", () => {
56
56
  conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
57
57
  if (err) return finish(err);
58
- stream.on("data", (d: Buffer) => {
59
- out += d.toString("utf8");
58
+ stream.on("data", (chunk: Buffer) => {
59
+ out += chunk.toString("utf8");
60
60
  if (until(out)) finish();
61
61
  });
62
62
  stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
@@ -78,7 +78,7 @@ describe("cueloop serve", () => {
78
78
  });
79
79
 
80
80
  test("an anonymous ssh client gets the observer TUI for the session", async () => {
81
- const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
81
+ const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
82
82
  expect(bytes).toContain("cueloop");
83
83
  expect(bytes).toContain("Rollout Plan");
84
84
  // observer chrome, not the controller's mutating hint bar
@@ -87,8 +87,8 @@ describe("cueloop serve", () => {
87
87
 
88
88
  test("two observers can watch at once", async () => {
89
89
  const [a, b] = await Promise.all([
90
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
91
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
90
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
91
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
92
92
  ]);
93
93
  expect(a).toContain("Rollout Plan");
94
94
  expect(b).toContain("Rollout Plan");
package/src/serve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SSH-served TUI (#22 share path): `cueloop serve` lets teammates join a
2
+ * SSH-served TUI: `cueloop serve` lets teammates join a
3
3
  * review session over plain ssh. Every SSH connection renders <App> in
4
4
  * observer mode (readOnly) against the same local daemon; the one writable
5
5
  * controller stays the local owner's own `cueloop` TUI.
@@ -44,8 +44,8 @@ export interface ServeHandle {
44
44
  close(): Promise<void>;
45
45
  }
46
46
 
47
- export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle> {
48
- const home = opts.home ?? cueloopHome();
47
+ export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
48
+ const home = options.home ?? cueloopHome();
49
49
  const sshDir = join(home, "ssh");
50
50
  mkdirSync(sshDir, { recursive: true, mode: 0o700 });
51
51
 
@@ -53,15 +53,15 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
53
53
  // password-less by design; see the trust model in the module comment
54
54
  auth: "open",
55
55
  hostKey: { path: join(sshDir, "host_key") },
56
- startupBanner: opts.banner ?? true,
56
+ startupBanner: options.banner ?? true,
57
57
  idleTimeout: "2h",
58
- onError: opts.onError,
58
+ onError: options.onError,
59
59
  }).serve((session) => {
60
60
  const root = createRoot(session.renderer);
61
61
  root.render(
62
62
  React.createElement(App, {
63
63
  home,
64
- sessionId: opts.sessionId,
64
+ sessionId: options.sessionId,
65
65
  readOnly: true,
66
66
  // q disconnects only this observer, never the server
67
67
  onExit: () => session.end(),
@@ -70,6 +70,6 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
70
70
  session.onClose(() => root.unmount());
71
71
  });
72
72
 
73
- const info = await server.listen(opts.port ?? 2222, opts.host ?? "127.0.0.1");
73
+ const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
74
74
  return { ...info, close: () => server.close() };
75
75
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The review-session controller (#70): every daemon round-trip and mutation
2
+ * The review-session controller: every daemon round-trip and mutation
3
3
  * verb behind one React-free object. It owns connect/autostart/subscribe,
4
4
  * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
5
  * verbs (cut/edit/annotate/submit/...), and the post-submit completion
@@ -22,8 +22,9 @@ import {
22
22
  } from "@cueloop/schema";
23
23
  import { Registry, type Exporter } from "@cueloop/extension-api";
24
24
  import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
25
- import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
25
+ import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
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 {
@@ -74,27 +81,36 @@ export interface ReviewController {
74
81
  connect(): void;
75
82
  close(): void;
76
83
  /** Loaded config parts the controller acts on: auto-close and exporters. */
77
- applyConfig(cfg: CueloopConfig): void;
78
- setStatus(msg: string): void;
84
+ applyConfig(config: CueloopConfig): void;
85
+ setStatus(message: string): void;
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;
85
94
  /** Cut the block under the cursor, or restore a cut one. */
86
- cut(dispIdx: number): void;
95
+ cut(displayIndex: number): void;
87
96
  /** The $EDITOR hand-off on the working copy. */
88
97
  edit(): void;
89
98
  /**
90
99
  * Anchor and store an annotation; both plan and diff anchor constructions.
91
100
  * Returns the minted annotation id so the view can select the new card.
92
101
  */
93
- annotate(kind: "comment" | "suggestion", dispIdx: number, start: number, end: number, body: string): string | undefined;
102
+ annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
94
103
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
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. */
@@ -104,21 +120,22 @@ export interface ReviewController {
104
120
  optInAutoClose(): void;
105
121
  }
106
122
 
107
- export function createReviewController(opts: ReviewControllerOptions): ReviewController {
108
- return new Controller(opts);
123
+ export function createReviewController(options: ReviewControllerOptions): ReviewController {
124
+ return new Controller(options);
109
125
  }
110
126
 
111
127
  class Controller implements ReviewController {
112
128
  readonly readOnly: boolean;
113
129
  private client: DaemonClient | null = null;
114
130
  private closed = false;
115
- private snap: ControllerSnapshot = {
131
+ private snapshot: ControllerSnapshot = {
116
132
  session: null,
117
133
  inbox: null,
118
134
  status: "",
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,11 +145,15 @@ 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
- constructor(private readonly opts: ReviewControllerOptions) {
134
- this.readOnly = opts.readOnly ?? false;
135
- this.clock = opts.clock ?? new SystemClock();
154
+ constructor(private readonly options: ReviewControllerOptions) {
155
+ this.readOnly = options.readOnly ?? false;
156
+ this.clock = options.clock ?? new SystemClock();
136
157
  }
137
158
 
138
159
  subscribe = (listener: () => void): (() => void) => {
@@ -140,28 +161,28 @@ class Controller implements ReviewController {
140
161
  return () => this.listeners.delete(listener);
141
162
  };
142
163
 
143
- getSnapshot = (): ControllerSnapshot => this.snap;
164
+ getSnapshot = (): ControllerSnapshot => this.snapshot;
144
165
 
145
166
  private update(patch: Partial<ControllerSnapshot>): void {
146
- this.snap = { ...this.snap, ...patch };
147
- for (const l of this.listeners) l();
167
+ this.snapshot = { ...this.snapshot, ...patch };
168
+ for (const listener of this.listeners) listener();
148
169
  }
149
170
 
150
171
  connect(): void {
151
172
  void (async () => {
152
173
  try {
153
- const client = await DaemonClient.connect({ home: this.opts.home, autostart: true });
174
+ const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
154
175
  if (this.closed) return void client.close();
155
176
  this.client = client;
156
- client.onEvent((e) => {
177
+ client.onEvent((event) => {
157
178
  // another controller/observer changed state: re-fetch
158
- const s = this.snap.session;
159
- if (s && e.sessionId === s.id) void this.refreshSession(s.id);
160
- if (!this.opts.sessionId) void this.refreshInbox();
179
+ const session = this.snapshot.session;
180
+ if (session && event.sessionId === session.id) void this.refreshSession(session.id);
181
+ if (!this.options.sessionId) void this.refreshInbox();
161
182
  });
162
183
  await client.subscribe();
163
- if (this.opts.sessionId) {
164
- this.update({ session: await client.sessionGet(this.opts.sessionId) });
184
+ if (this.options.sessionId) {
185
+ this.update({ session: await client.sessionGet(this.options.sessionId) });
165
186
  } else {
166
187
  this.update({ inbox: await client.sessionList({ status: "pending" }) });
167
188
  }
@@ -182,28 +203,30 @@ class Controller implements ReviewController {
182
203
  this.countdown = undefined;
183
204
  }
184
205
 
185
- applyConfig(cfg: CueloopConfig): void {
186
- this.autoClose = cfg.ui.autoClose;
187
- this.obsidian = cfg.integrations.obsidian;
206
+ applyConfig(config: CueloopConfig): void {
207
+ this.autoClose = config.ui.autoClose;
208
+ this.obsidian = config.integrations.obsidian;
188
209
  // bundled integrations register through the public extension API
189
210
  const registry = new Registry();
190
- void registry.load("obsidian", createObsidianExtension(cfg.integrations.obsidian)).then((rec) => {
191
- this.exporters = rec.exporters;
211
+ void registry.load("obsidian", createObsidianExtension(config.integrations.obsidian)).then((record) => {
212
+ this.exporters = record.exporters;
192
213
  });
193
214
  }
194
215
 
195
- setStatus(msg: string): void {
196
- this.update({ status: msg });
216
+ setStatus(message: string): void {
217
+ this.update({ status: message });
197
218
  }
198
219
 
199
220
  // ── derived projections ─────────────────────
200
221
  private ensureDerived(): void {
201
- const s = this.snap.session;
202
- if (this.derivedFor === s) return;
203
- this.derivedFor = s;
222
+ const session = this.snapshot.session;
223
+ if (this.derivedFor === session) return;
224
+ this.derivedFor = session;
225
+ const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
204
226
  this.derived = {
205
- display: s ? buildDisplay(s.artifact.content, s.workingCopy) : [],
206
- rows: s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [],
227
+ display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
228
+ rows,
229
+ files: walkFiles(rows),
207
230
  };
208
231
  }
209
232
 
@@ -217,9 +240,14 @@ 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
- const s = this.snap.session;
222
- return s ? (s.workingCopy ?? s.artifact.content) : "";
249
+ const session = this.snapshot.session;
250
+ return session ? (session.workingCopy ?? session.artifact.content) : "";
223
251
  }
224
252
 
225
253
  // Refreshes race the connection teardown: an event can arrive while close()
@@ -242,40 +270,41 @@ class Controller implements ReviewController {
242
270
  }
243
271
 
244
272
  /** Optimistic apply: the daemon response is the next session snapshot. */
245
- private apply(p: Promise<ReviewSession>): void {
246
- p.then((session) => this.update({ session })).catch((e: unknown) =>
247
- this.setStatus(String(e instanceof Error ? e.message : e)),
273
+ private apply(mutation: Promise<ReviewSession>): void {
274
+ mutation.then((session) => this.update({ session })).catch((error: unknown) =>
275
+ this.setStatus(String(error instanceof Error ? error.message : error)),
248
276
  );
249
277
  }
250
278
 
251
279
  // ── verbs ───────────────────────────────────
252
280
  open(id: string): void {
253
- const cached = this.snap.inbox?.find((s) => s.id === id);
281
+ this.locallyViewed.clear();
282
+ const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
254
283
  if (cached) this.update({ session: cached });
255
284
  else void this.refreshSession(id);
256
285
  }
257
286
 
258
- cut(dispIdx: number): void {
259
- const session = this.snap.session;
287
+ cut(displayIndex: number): void {
288
+ const session = this.snapshot.session;
260
289
  if (!session || session.status === "resolved") return;
261
- const d = this.display()[dispIdx];
262
- if (!d) return;
290
+ const block = this.display()[displayIndex];
291
+ if (!block) return;
263
292
  const working = this.working();
264
- if (d.type === "del") {
265
- const line = restoreLine(nextWorkBlock(this.display(), dispIdx), working.split("\n").length);
293
+ if (block.type === "del") {
294
+ const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
266
295
  // restoreBlock returns undefined when the block structure round-trips
267
296
  // to the submitted revision - the working copy is gone
268
- const restored = restoreBlock(session.artifact.content, working, d.base!, line);
297
+ const restored = restoreBlock(session.artifact.content, working, block.base!, line);
269
298
  this.setWorkingCopy(restored);
270
299
  this.setStatus("cut restored");
271
- } else if (d.work) {
272
- this.setWorkingCopy(cutBlock(working, d.work));
300
+ } else if (block.work) {
301
+ this.setWorkingCopy(cutBlock(working, block.work));
273
302
  this.setStatus("block cut - it serializes into the diff");
274
303
  }
275
304
  }
276
305
 
277
306
  edit(): void {
278
- const session = this.snap.session;
307
+ const session = this.snapshot.session;
279
308
  if (!session || session.status === "resolved") return;
280
309
  try {
281
310
  const result = editInEditor(this.working(), "plan.md");
@@ -305,21 +334,21 @@ class Controller implements ReviewController {
305
334
 
306
335
  annotate(
307
336
  kind: "comment" | "suggestion",
308
- dispIdx: number,
337
+ displayIndex: number,
309
338
  start: number,
310
339
  end: number,
311
340
  body: string,
312
341
  ): string | undefined {
313
- const session = this.snap.session;
342
+ const session = this.snapshot.session;
314
343
  if (!session) return undefined;
315
344
  let anchor;
316
345
  if (session.artifact.type === "diff") {
317
- anchor = { ...diffRowAnchor(this.rows(), dispIdx), blockIndex: dispIdx };
346
+ anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
318
347
  } else {
319
348
  const display = this.display();
320
- const workBlocks = display.filter((d) => d.work).map((d) => d.work!);
321
- const workIdx = display.slice(0, dispIdx + 1).filter((d) => d.work).length - 1;
322
- anchor = makeAnchor(workBlocks, workIdx, start, end);
349
+ const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
350
+ const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
351
+ anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
323
352
  }
324
353
  const annotationId = newAnnotationId();
325
354
  this.apply(
@@ -335,7 +364,7 @@ class Controller implements ReviewController {
335
364
  }
336
365
 
337
366
  updateAnnotation(id: string, body: string): void {
338
- const session = this.snap.session;
367
+ const session = this.snapshot.session;
339
368
  if (!session) return;
340
369
  const existing = session.annotations.find((annotation) => annotation.id === id);
341
370
  if (!existing) return;
@@ -352,21 +381,66 @@ class Controller implements ReviewController {
352
381
  }
353
382
 
354
383
  removeAnnotation(id: string): void {
355
- const session = this.snap.session;
384
+ const session = this.snapshot.session;
356
385
  if (!session) return;
357
386
  this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
358
387
  this.setStatus("annotation deleted");
359
388
  }
360
389
 
361
390
  setWorkingCopy(content: string | undefined): void {
362
- const session = this.snap.session;
391
+ const session = this.snapshot.session;
363
392
  if (!session) return;
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.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
403
+ }
404
+
405
+ walkStart(): void {
406
+ const session = this.snapshot.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.snapshot.walk;
416
+ const session = this.snapshot.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.snapshot.walk;
432
+ if (!walk) return;
433
+ this.update({ walk: { index: Math.max(0, walk.index - 1) } });
434
+ }
435
+
436
+ walkLeave(): void {
437
+ if (this.snapshot.walk) this.update({ walk: null });
438
+ }
439
+
367
440
  submit(verdict: VerdictKind, summary: string): void {
368
- const session = this.snap.session;
441
+ const session = this.snapshot.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
@@ -376,8 +450,8 @@ class Controller implements ReviewController {
376
450
  const obsidian = this.obsidian;
377
451
  const exporter = this.exporters.get("obsidian");
378
452
  if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
379
- void exporter(resolved).then((r) => {
380
- this.setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
453
+ void exporter(resolved).then((exportResult) => {
454
+ this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
381
455
  });
382
456
  }
383
457
  // Hand the reviewer back to the agent. The default is a visible
@@ -388,7 +462,7 @@ class Controller implements ReviewController {
388
462
  else if (typeof delay === "number") this.startCounting(delay);
389
463
  else this.startCounting(DEFAULT_AUTO_CLOSE);
390
464
  })
391
- .catch((e: unknown) => this.setStatus(String(e instanceof Error ? e.message : e)));
465
+ .catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
392
466
  }
393
467
 
394
468
  // ── completion hand-back ────────────────────
@@ -400,9 +474,9 @@ class Controller implements ReviewController {
400
474
 
401
475
  finishReview(): void {
402
476
  this.clearCountdown();
403
- const pane = returnPaneFor(this.snap.session?.artifact.meta.herdrPane);
477
+ const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
404
478
  if (pane) focusHerdrPane(pane);
405
- this.opts.onExit?.(0);
479
+ this.options.onExit?.(0);
406
480
  }
407
481
 
408
482
  dismissCompletion(): void {
package/src/theme.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Semantic theme tokens (#22). Values carry over from the design-system
2
+ * Semantic theme tokens. Values carry over from the design-system
3
3
  * prototypes (tokens.css). One built-in dark theme for now; the theme
4
4
  * registry (palette-derived themes, terminal theme, user overrides) layers
5
5
  * on top of these slots without touching consumers.
@@ -27,6 +27,25 @@ export interface Theme {
27
27
  backdrop: string;
28
28
  }
29
29
 
30
+ /**
31
+ * The dimmed variant behind the walk wizard: every reading color drops to
32
+ * the dim token so the file list stays legible context, not competition.
33
+ * Layout tokens (backgrounds, cursor) keep their values.
34
+ */
35
+ export function dimmedTheme(theme: Theme): Theme {
36
+ return {
37
+ ...theme,
38
+ text: theme.textDim,
39
+ textMuted: theme.textDim,
40
+ accent: theme.textDim,
41
+ green: theme.textDim,
42
+ red: theme.textDim,
43
+ blue: theme.textDim,
44
+ insFg: theme.textDim,
45
+ delFg: theme.textDim,
46
+ };
47
+ }
48
+
30
49
  export const DARK: Theme = {
31
50
  bg: "#1f1f29",
32
51
  panel: "#1a1a23",
@@ -22,22 +22,22 @@ index 111..222 100644
22
22
  describe("diffRows", () => {
23
23
  test("flattens files, hunks, and signed lines with line numbers", () => {
24
24
  const rows = diffRows(PATCH);
25
- expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
26
- expect(rows[1]!.t).toBe("hunk");
27
- const del = rows.find((r) => r.t === "del")!;
25
+ expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
26
+ expect(rows[1]!.kind).toBe("hunk");
27
+ const del = rows.find((row) => row.kind === "del")!;
28
28
  expect(del.text).toContain("private items = [];");
29
29
  expect(del.oldLine).toBe(3);
30
- const adds = rows.filter((r) => r.t === "add");
30
+ const adds = rows.filter((row) => row.kind === "add");
31
31
  expect(adds[0]!.text).toContain("new Map()");
32
32
  expect(adds[0]!.newLine).toBe(3);
33
33
  expect(adds[1]!.newLine).toBe(4);
34
34
  // two hunks
35
- expect(rows.filter((r) => r.t === "hunk").length).toBe(2);
35
+ expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
36
36
  });
37
37
 
38
38
  test("context lines carry both line numbers", () => {
39
39
  const rows = diffRows(PATCH);
40
- const ctx = rows.find((r) => r.t === "ctx")!;
40
+ const ctx = rows.find((row) => row.kind === "ctx")!;
41
41
  expect(ctx.oldLine).toBe(1);
42
42
  expect(ctx.newLine).toBe(1);
43
43
  });
@@ -46,8 +46,8 @@ describe("diffRows", () => {
46
46
  describe("diffRowAnchor", () => {
47
47
  test("quote is the line, neighbors are the context selectors", () => {
48
48
  const rows = diffRows(PATCH);
49
- const idx = rows.findIndex((r) => r.text.includes("new Map()"));
50
- const anchor = diffRowAnchor(rows, idx);
49
+ const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
50
+ const anchor = diffRowAnchor(rows, rowIndex);
51
51
  expect(anchor.quote).toContain("new Map()");
52
52
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
53
  expect(anchor.suffix.length).toBeGreaterThan(0);
package/src/view-diff.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Diff artifact projection (#18 slice 3): flatten @pierre/diffs' parsed
2
+ * Diff artifact projection: flatten @pierre/diffs' parsed
3
3
  * patch model into render rows. Line-anchored annotations use the same
4
4
  * quote-primary anchors as plans: quote = the line content, prefix/suffix =
5
5
  * the neighbor lines - so the whole anchor/feedback pipeline is shared.
@@ -7,10 +7,10 @@
7
7
 
8
8
  import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
9
9
 
10
- export type DiffRowType = "file" | "hunk" | "ctx" | "add" | "del";
10
+ export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
11
11
 
12
12
  export interface DiffRow {
13
- t: DiffRowType;
13
+ kind: DiffRowKind;
14
14
  text: string;
15
15
  file: string;
16
16
  oldLine?: number;
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
22
22
  const patches = parsePatchFiles(patchText);
23
23
  for (const patch of patches) {
24
24
  for (const file of patch.files) {
25
- rows.push({ t: "file", text: fileLabel(file), file: file.name });
25
+ rows.push({ kind: "file", text: fileLabel(file), file: file.name });
26
26
  for (const hunk of file.hunks) {
27
- rows.push({ t: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
27
+ rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
28
28
  let oldLine = hunk.deletionStart;
29
29
  let newLine = hunk.additionStart;
30
- for (const seg of hunk.hunkContent) {
31
- if (seg.type === "context") {
32
- for (let i = 0; i < seg.lines; i++) {
30
+ for (const segment of hunk.hunkContent) {
31
+ if (segment.type === "context") {
32
+ for (let i = 0; i < segment.lines; i++) {
33
33
  rows.push({
34
- t: "ctx",
35
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
34
+ kind: "ctx",
35
+ text: file.additionLines[segment.additionLineIndex + i] ?? "",
36
36
  file: file.name,
37
37
  oldLine: oldLine++,
38
38
  newLine: newLine++,
39
39
  });
40
40
  }
41
41
  } else {
42
- for (let i = 0; i < seg.deletions; i++) {
42
+ for (let i = 0; i < segment.deletions; i++) {
43
43
  rows.push({
44
- t: "del",
45
- text: file.deletionLines[seg.deletionLineIndex + i] ?? "",
44
+ kind: "del",
45
+ text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
46
46
  file: file.name,
47
47
  oldLine: oldLine++,
48
48
  });
49
49
  }
50
- for (let i = 0; i < seg.additions; i++) {
50
+ for (let i = 0; i < segment.additions; i++) {
51
51
  rows.push({
52
- t: "add",
53
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
52
+ kind: "add",
53
+ text: file.additionLines[segment.additionLineIndex + i] ?? "",
54
54
  file: file.name,
55
55
  newLine: newLine++,
56
56
  });
@@ -68,14 +68,14 @@ function fileLabel(file: FileDiffMetadata): string {
68
68
  }
69
69
 
70
70
  /** Quote-primary anchor for a diff row: neighbors as context selectors. */
71
- export function diffRowAnchor(rows: DiffRow[], idx: number): { quote: string; prefix: string; suffix: string } {
72
- const row = rows[idx]!;
73
- const prev = rows[idx - 1];
74
- const next = rows[idx + 1];
71
+ export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
72
+ const row = rows[rowIndex]!;
73
+ const prev = rows[rowIndex - 1];
74
+ const next = rows[rowIndex + 1];
75
75
  return {
76
76
  quote: row.text,
77
- prefix: prev && (prev.t === "ctx" || prev.t === "add" || prev.t === "del") ? prev.text.slice(-24) : "",
78
- suffix: next && (next.t === "ctx" || next.t === "add" || next.t === "del") ? next.text.slice(0, 24) : "",
77
+ prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
78
+ suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
79
79
  };
80
80
  }
81
81