@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (84) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +232 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +100 -0
  8. package/src/App.submit-confirm.test.tsx +165 -0
  9. package/src/App.test.tsx +20 -58
  10. package/src/App.tsx +0 -0
  11. package/src/App.walk.test.tsx +208 -0
  12. package/src/components/AnnotationCard.stories.tsx +75 -0
  13. package/src/components/AnnotationCard.tsx +136 -0
  14. package/src/components/Breadcrumb.stories.tsx +19 -0
  15. package/src/components/Breadcrumb.tsx +38 -0
  16. package/src/components/CodeBlock.stories.tsx +25 -0
  17. package/src/components/CodeBlock.tsx +68 -0
  18. package/src/components/CompletionOverlay.stories.tsx +23 -0
  19. package/src/components/CompletionOverlay.tsx +49 -0
  20. package/src/components/ComposeBar.stories.tsx +18 -0
  21. package/src/components/ComposeBar.tsx +54 -0
  22. package/src/components/ConfirmCard.stories.tsx +37 -0
  23. package/src/components/ConfirmCard.tsx +117 -0
  24. package/src/components/DiffSheet.stories.tsx +44 -0
  25. package/src/components/DiffSheet.tsx +243 -0
  26. package/src/components/InboxList.stories.tsx +18 -0
  27. package/src/components/InboxList.tsx +42 -0
  28. package/src/components/PlanSheet.stories.tsx +81 -0
  29. package/src/components/PlanSheet.tsx +309 -0
  30. package/src/components/ReviewRail.stories.tsx +103 -0
  31. package/src/components/ReviewRail.tsx +158 -0
  32. package/src/components/SettingsDialog.stories.tsx +63 -0
  33. package/src/components/SettingsDialog.tsx +132 -0
  34. package/src/components/SettingsRows.stories.tsx +18 -0
  35. package/src/components/SettingsRows.tsx +92 -0
  36. package/src/components/WalkWizard.stories.tsx +89 -0
  37. package/src/components/WalkWizard.tsx +146 -0
  38. package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
  39. package/src/components/primitives/Button.stories.tsx +32 -0
  40. package/src/components/primitives/Button.tsx +44 -0
  41. package/src/components/primitives/Card.stories.tsx +25 -0
  42. package/src/components/primitives/Card.tsx +58 -0
  43. package/src/components/primitives/Dialog.stories.tsx +24 -0
  44. package/src/components/primitives/Dialog.tsx +52 -0
  45. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  46. package/src/components/primitives/StatusBar.tsx +22 -0
  47. package/src/components/primitives/Tabs.stories.tsx +29 -0
  48. package/src/components/primitives/Tabs.tsx +97 -0
  49. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  50. package/src/components/primitives/Toolbar.tsx +14 -0
  51. package/src/components/stories-app.tsx +94 -0
  52. package/src/components/stories.test.tsx +51 -0
  53. package/src/components/story-fixtures.ts +90 -0
  54. package/src/components/story.ts +66 -0
  55. package/src/components/syntax-highlight.ts +57 -0
  56. package/src/components/theme-context.stories.tsx +41 -0
  57. package/src/components/theme-context.tsx +30 -0
  58. package/src/components/truncate-text.ts +6 -0
  59. package/src/config.test.ts +54 -29
  60. package/src/config.ts +53 -13
  61. package/src/editor.test.ts +110 -0
  62. package/src/editor.ts +101 -13
  63. package/src/herdr.test.ts +37 -0
  64. package/src/herdr.ts +21 -0
  65. package/src/integrations.test.ts +29 -0
  66. package/src/integrations.ts +28 -0
  67. package/src/key-bindings.test.ts +120 -0
  68. package/src/key-bindings.ts +353 -0
  69. package/src/keymap.test.ts +307 -0
  70. package/src/keymap.ts +204 -0
  71. package/src/run.ts +9 -5
  72. package/src/serve.test.ts +6 -6
  73. package/src/serve.ts +7 -7
  74. package/src/session-controller.ts +496 -0
  75. package/src/test-support.ts +87 -0
  76. package/src/theme.ts +23 -1
  77. package/src/view-diff.test.ts +8 -8
  78. package/src/view-diff.ts +22 -22
  79. package/src/view-plan.test.ts +179 -0
  80. package/src/view-plan.ts +362 -0
  81. package/src/walk.test.ts +110 -0
  82. package/src/walk.ts +65 -0
  83. package/src/view.test.ts +0 -157
  84. package/src/view.ts +0 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The review-session controller: every daemon round-trip and mutation
3
+ * verb behind one React-free object. It owns connect/autostart/subscribe,
4
+ * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
+ * verbs (cut/edit/annotate/submit/...), and the post-submit completion
6
+ * lifecycle including the notes-vault export and the herdr return-focus.
7
+ * App subscribes to snapshots and keeps only view state.
8
+ */
9
+
10
+ import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
11
+ import { DaemonClient } from "@cueloop/daemon/client";
12
+ import {
13
+ cutBlock,
14
+ detectHerdr,
15
+ makeAnchor,
16
+ newAnnotationId,
17
+ parseBlocks,
18
+ resolveAnchor,
19
+ restoreBlock,
20
+ restoreLine,
21
+ returnPaneFor,
22
+ type ReviewSession,
23
+ type VerdictKind,
24
+ } from "@cueloop/schema";
25
+ import { loadBundledExporters, type BundledExporter } from "./integrations";
26
+ import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
27
+ import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
28
+ import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
29
+ import { editInEditor } from "./editor";
30
+ import { focusHerdrPane } from "./herdr";
31
+ import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
32
+
33
+ /**
34
+ * Post-submit lifecycle (a review pane should hand you back to the agent,
35
+ * not linger): idle → counting (a visible countdown, the default) → exit.
36
+ * esc dismisses to the resolved view; a remembers the countdown as default.
37
+ */
38
+ export type Completion =
39
+ | { phase: "idle" }
40
+ | { phase: "prompt" }
41
+ | { phase: "counting"; remaining: number }
42
+ | { phase: "dismissed" };
43
+
44
+ /** Seconds the completion overlay counts down before it hands back. */
45
+ export const DEFAULT_AUTO_CLOSE = 5;
46
+
47
+ export interface ControllerSnapshot {
48
+ session: ReviewSession | null;
49
+ inbox: ReviewSession[] | null;
50
+ status: string;
51
+ error: string | null;
52
+ completion: Completion;
53
+ /**
54
+ * Annotations whose anchor stopped resolving after the last editor
55
+ * hand-off - the reconciliation banner count. 0 = no banner.
56
+ */
57
+ editOrphanCount: number;
58
+ /**
59
+ * The guided walk's cursor (diff sessions): which wizard step is on
60
+ * screen. index === file count is the end card. null = not walking; the
61
+ * viewed set itself rides the session record, so leaving loses nothing.
62
+ */
63
+ walk: { index: number } | null;
64
+ }
65
+
66
+ export interface ReviewControllerOptions {
67
+ home?: string;
68
+ sessionId?: string;
69
+ /** Observer mode: stored for the key reducer's read-only gate. */
70
+ readOnly?: boolean;
71
+ onExit?: (code: number) => void;
72
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
73
+ clock?: Clock;
74
+ }
75
+
76
+ export interface ReviewController {
77
+ readonly readOnly: boolean;
78
+ /** Snapshot listeners (stable identity - safe for useSyncExternalStore). */
79
+ subscribe(listener: () => void): () => void;
80
+ getSnapshot(): ControllerSnapshot;
81
+ /** Dial the daemon (autostart), subscribe to events, fetch session or inbox. */
82
+ connect(): void;
83
+ close(): void;
84
+ /** Loaded config parts the controller acts on: auto-close and exporters. */
85
+ applyConfig(config: CueloopConfig): void;
86
+ setStatus(message: string): void;
87
+ /** Derived projections, cached per session identity. */
88
+ display(): DisplayBlock[];
89
+ rows(): DiffRow[];
90
+ /** The walk's step list, derived from the diff rows. */
91
+ files(): WalkFile[];
92
+ working(): string;
93
+ /** Open a session from the inbox. */
94
+ open(id: string): void;
95
+ /** Cut the block under the cursor, or restore a cut one. */
96
+ cut(displayIndex: number): void;
97
+ /** The $EDITOR hand-off on the working copy. */
98
+ edit(): void;
99
+ /**
100
+ * Anchor and store an annotation; both plan and diff anchor constructions.
101
+ * Returns the minted annotation id so the view can select the new card.
102
+ */
103
+ annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
104
+ /** Rewrite a stored annotation's body in place (the rail-card edit). */
105
+ updateAnnotation(id: string, body: string): void;
106
+ removeAnnotation(id: string): void;
107
+ setWorkingCopy(content: string | undefined): void;
108
+ /** Enter the guided walk at the first unviewed file (diff sessions). */
109
+ walkStart(): void;
110
+ /** Mark the current file viewed (persists with the session) and advance. */
111
+ walkForward(): void;
112
+ walkBack(): void;
113
+ /** Leave the walk; the viewed set stays on the session record. */
114
+ walkLeave(): void;
115
+ /** Resolve the review, run the export, start the completion hand-back. */
116
+ submit(verdict: VerdictKind, summary: string): void;
117
+ /** Close the review and, inside herdr, bounce focus back to the agent. */
118
+ finishReview(): void;
119
+ dismissCompletion(): void;
120
+ /** From the completion prompt: persist auto-close and start the countdown. */
121
+ optInAutoClose(): void;
122
+ }
123
+
124
+ export function createReviewController(options: ReviewControllerOptions): ReviewController {
125
+ return new Controller(options);
126
+ }
127
+
128
+ class Controller implements ReviewController {
129
+ readonly readOnly: boolean;
130
+ private client: DaemonClient | null = null;
131
+ private closed = false;
132
+ private snapshot: ControllerSnapshot = {
133
+ session: null,
134
+ inbox: null,
135
+ status: "",
136
+ error: null,
137
+ completion: { phase: "idle" },
138
+ editOrphanCount: 0,
139
+ walk: null,
140
+ };
141
+ private listeners = new Set<() => void>();
142
+ private autoClose: AutoClose = "off";
143
+ private editor: string | undefined;
144
+ private exporters: BundledExporter[] = [];
145
+ private readonly clock: Clock;
146
+ private countdown: TimerHandle | undefined;
147
+ /** Projections keyed by session identity so renders reuse one computation. */
148
+ private derivedFor: ReviewSession | null = null;
149
+ private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
150
+ display: [],
151
+ rows: [],
152
+ files: [],
153
+ };
154
+
155
+ constructor(private readonly options: ReviewControllerOptions) {
156
+ this.readOnly = options.readOnly ?? false;
157
+ this.clock = options.clock ?? new SystemClock();
158
+ }
159
+
160
+ subscribe = (listener: () => void): (() => void) => {
161
+ this.listeners.add(listener);
162
+ return () => this.listeners.delete(listener);
163
+ };
164
+
165
+ getSnapshot = (): ControllerSnapshot => this.snapshot;
166
+
167
+ private update(patch: Partial<ControllerSnapshot>): void {
168
+ this.snapshot = { ...this.snapshot, ...patch };
169
+ for (const listener of this.listeners) listener();
170
+ }
171
+
172
+ connect(): void {
173
+ void (async () => {
174
+ try {
175
+ const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
176
+ if (this.closed) return void client.close();
177
+ this.client = client;
178
+ client.onEvent((event) => {
179
+ // another controller/observer changed state: re-fetch
180
+ const session = this.snapshot.session;
181
+ if (session && event.sessionId === session.id) void this.refreshSession(session.id);
182
+ if (!this.options.sessionId) void this.refreshInbox();
183
+ });
184
+ await client.subscribe();
185
+ if (this.options.sessionId) {
186
+ this.update({ session: await client.sessionGet(this.options.sessionId) });
187
+ } else {
188
+ this.update({ inbox: await client.sessionList({ status: "pending" }) });
189
+ }
190
+ } catch (err) {
191
+ this.update({ error: err instanceof Error ? err.message : String(err) });
192
+ }
193
+ })();
194
+ }
195
+
196
+ close(): void {
197
+ this.closed = true;
198
+ this.clearCountdown();
199
+ this.client?.close();
200
+ }
201
+
202
+ private clearCountdown(): void {
203
+ if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
204
+ this.countdown = undefined;
205
+ }
206
+
207
+ applyConfig(config: CueloopConfig): void {
208
+ this.autoClose = config.ui.autoClose;
209
+ this.editor = config.ui.editor;
210
+ void loadBundledExporters(config.integrations).then((exporters) => {
211
+ this.exporters = exporters;
212
+ });
213
+ }
214
+
215
+ setStatus(message: string): void {
216
+ this.update({ status: message });
217
+ }
218
+
219
+ // ── derived projections ─────────────────────
220
+ private ensureDerived(): void {
221
+ const session = this.snapshot.session;
222
+ if (this.derivedFor === session) return;
223
+ this.derivedFor = session;
224
+ const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
225
+ this.derived = {
226
+ display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
227
+ rows,
228
+ files: walkFiles(rows),
229
+ };
230
+ }
231
+
232
+ display(): DisplayBlock[] {
233
+ this.ensureDerived();
234
+ return this.derived.display;
235
+ }
236
+
237
+ rows(): DiffRow[] {
238
+ this.ensureDerived();
239
+ return this.derived.rows;
240
+ }
241
+
242
+ files(): WalkFile[] {
243
+ this.ensureDerived();
244
+ return this.derived.files;
245
+ }
246
+
247
+ working(): string {
248
+ const session = this.snapshot.session;
249
+ return session ? (session.workingCopy ?? session.artifact.content) : "";
250
+ }
251
+
252
+ // Refreshes race the connection teardown: an event can arrive while close()
253
+ // is rejecting in-flight requests, and a fire-and-forget refresh must never
254
+ // surface that as an unhandled rejection.
255
+ private async refreshSession(id: string): Promise<void> {
256
+ try {
257
+ if (this.client) this.update({ session: await this.client.sessionGet(id) });
258
+ } catch (error) {
259
+ if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
260
+ }
261
+ }
262
+
263
+ private async refreshInbox(): Promise<void> {
264
+ try {
265
+ if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
266
+ } catch (error) {
267
+ if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
268
+ }
269
+ }
270
+
271
+ /** Optimistic apply: the daemon response is the next session snapshot. */
272
+ private apply(mutation: Promise<ReviewSession>): void {
273
+ mutation.then((session) => this.update({ session })).catch((error: unknown) =>
274
+ this.setStatus(String(error instanceof Error ? error.message : error)),
275
+ );
276
+ }
277
+
278
+ // ── verbs ───────────────────────────────────
279
+ open(id: string): void {
280
+ this.locallyViewed.clear();
281
+ const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
282
+ if (cached) this.update({ session: cached });
283
+ else void this.refreshSession(id);
284
+ }
285
+
286
+ cut(displayIndex: number): void {
287
+ const session = this.snapshot.session;
288
+ if (!session || session.status === "resolved") return;
289
+ const block = this.display()[displayIndex];
290
+ if (!block) return;
291
+ const working = this.working();
292
+ if (block.type === "del") {
293
+ const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
294
+ // restoreBlock returns undefined when the block structure round-trips
295
+ // to the submitted revision - the working copy is gone
296
+ const restored = restoreBlock(session.artifact.content, working, block.base!, line);
297
+ this.setWorkingCopy(restored);
298
+ this.setStatus("cut restored");
299
+ } else if (block.work) {
300
+ this.setWorkingCopy(cutBlock(working, block.work));
301
+ this.setStatus("block cut - it serializes into the diff");
302
+ }
303
+ }
304
+
305
+ edit(): void {
306
+ const session = this.snapshot.session;
307
+ if (!session || session.status === "resolved") return;
308
+ try {
309
+ const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
310
+ if (result.changed) {
311
+ this.setWorkingCopy(result.content);
312
+ this.reconcileAnnotations(session, result.content);
313
+ this.setStatus("edits tracked - one diff");
314
+ } else this.setStatus("no changes");
315
+ } catch (err) {
316
+ this.setStatus(err instanceof Error ? err.message : String(err));
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Edit-exit reconciliation: re-resolve every annotation against the edited
322
+ * working copy with the quote-primary cascade. Annotations that stop
323
+ * resolving stay stored (the feedback serializer flags orphaned anchors);
324
+ * the count feeds the one-line banner above the sheet.
325
+ */
326
+ private reconcileAnnotations(session: ReviewSession, editedContent: string): void {
327
+ const editedBlocks = parseBlocks(editedContent);
328
+ const orphanCount = session.annotations.filter(
329
+ (annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
330
+ ).length;
331
+ this.update({ editOrphanCount: orphanCount });
332
+ }
333
+
334
+ annotate(
335
+ kind: "comment" | "suggestion",
336
+ displayIndex: number,
337
+ start: number,
338
+ end: number,
339
+ body: string,
340
+ ): string | undefined {
341
+ const session = this.snapshot.session;
342
+ if (!session) return undefined;
343
+ let anchor;
344
+ if (session.artifact.type === "diff") {
345
+ anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
346
+ } else {
347
+ const display = this.display();
348
+ const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
349
+ const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
350
+ anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
351
+ }
352
+ const annotationId = newAnnotationId();
353
+ this.apply(
354
+ this.client!.sessionAnnotate(session.id, {
355
+ id: annotationId,
356
+ kind,
357
+ anchor,
358
+ body,
359
+ }),
360
+ );
361
+ this.setStatus(kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
362
+ return annotationId;
363
+ }
364
+
365
+ updateAnnotation(id: string, body: string): void {
366
+ const session = this.snapshot.session;
367
+ if (!session) return;
368
+ const existing = session.annotations.find((annotation) => annotation.id === id);
369
+ if (!existing) return;
370
+ // the daemon's annotate verb upserts by id: same id + anchor, new body
371
+ this.apply(
372
+ this.client!.sessionAnnotate(session.id, {
373
+ id: existing.id,
374
+ kind: existing.kind,
375
+ anchor: existing.anchor,
376
+ body,
377
+ }),
378
+ );
379
+ this.setStatus("annotation updated");
380
+ }
381
+
382
+ removeAnnotation(id: string): void {
383
+ const session = this.snapshot.session;
384
+ if (!session) return;
385
+ this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
386
+ this.setStatus("annotation deleted");
387
+ }
388
+
389
+ setWorkingCopy(content: string | undefined): void {
390
+ const session = this.snapshot.session;
391
+ if (!session) return;
392
+ this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
393
+ }
394
+
395
+ // ── the guided walk ─────────────────────────
396
+ // Marks sent but possibly not yet reflected in the session snapshot; the
397
+ // union keeps the walk title truthful and rapid advances from losing marks.
398
+ private locallyViewed = new Set<string>();
399
+
400
+ private viewedSet(): Set<string> {
401
+ return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
402
+ }
403
+
404
+ walkStart(): void {
405
+ const session = this.snapshot.session;
406
+ if (!session || session.artifact.type !== "diff") return;
407
+ const files = this.files();
408
+ if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
409
+ // resume at the first unviewed file; a finished walk reopens on the end card
410
+ this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
411
+ }
412
+
413
+ walkForward(): void {
414
+ const walk = this.snapshot.walk;
415
+ const session = this.snapshot.session;
416
+ if (!walk || !session) return;
417
+ const files = this.files();
418
+ const current = files[walk.index];
419
+ if (!current) return; // already on the end card
420
+ // advancing IS the viewed mark: the step is complete once you move past
421
+ // it; the daemon verb merges, so only the new path travels
422
+ if (!this.viewedSet().has(current.path)) {
423
+ this.locallyViewed.add(current.path);
424
+ this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
425
+ }
426
+ this.update({ walk: { index: walk.index + 1 } });
427
+ }
428
+
429
+ walkBack(): void {
430
+ const walk = this.snapshot.walk;
431
+ if (!walk) return;
432
+ this.update({ walk: { index: Math.max(0, walk.index - 1) } });
433
+ }
434
+
435
+ walkLeave(): void {
436
+ if (this.snapshot.walk) this.update({ walk: null });
437
+ }
438
+
439
+ submit(verdict: VerdictKind, summary: string): void {
440
+ const session = this.snapshot.session;
441
+ if (!session) return;
442
+ this.walkLeave();
443
+ this.client!.sessionResolve(session.id, verdict, summary)
444
+ .then((resolved) => {
445
+ // The completion overlay heading already states the verdict, so the
446
+ // status line stays empty here - only export/error messages fill it.
447
+ this.update({ session: resolved, status: "" });
448
+ // notes-vault export: guarded by each exporter's policy (default manual = no-op)
449
+ for (const exporter of this.exporters) {
450
+ if (!exporter.runsOn(verdict)) continue;
451
+ void exporter.run(resolved).then((exportResult) => {
452
+ this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
453
+ });
454
+ }
455
+ // Hand the reviewer back to the agent. The default is a visible
456
+ // countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
457
+ // a remembers the choice. A configured delay overrides; 0 closes now.
458
+ const delay = this.autoClose;
459
+ if (delay === 0) this.finishReview();
460
+ else if (typeof delay === "number") this.startCounting(delay);
461
+ else this.startCounting(DEFAULT_AUTO_CLOSE);
462
+ })
463
+ .catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
464
+ }
465
+
466
+ // ── completion hand-back ────────────────────
467
+ private startCounting(remaining: number): void {
468
+ if (remaining <= 0) return this.finishReview();
469
+ this.update({ completion: { phase: "counting", remaining } });
470
+ this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
471
+ }
472
+
473
+ finishReview(): void {
474
+ this.clearCountdown();
475
+ const herdr = detectHerdr();
476
+ const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
477
+ if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
478
+ this.options.onExit?.(0);
479
+ }
480
+
481
+ dismissCompletion(): void {
482
+ this.clearCountdown();
483
+ this.update({ completion: { phase: "dismissed" } });
484
+ }
485
+
486
+ optInAutoClose(): void {
487
+ // Remember the default countdown for future submits, persisted to the user
488
+ // config. The countdown is already running; this only makes it the default.
489
+ try {
490
+ persistAutoClose(DEFAULT_AUTO_CLOSE);
491
+ } catch {
492
+ // a read-only config dir must not block closing the review
493
+ }
494
+ this.autoClose = DEFAULT_AUTO_CLOSE;
495
+ }
496
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared driving helpers for the virtual-terminal App suites: key presses
3
+ * settle through a macrotask yield (input parser + React scheduler), one
4
+ * render pass, and the renderer's visual-idle wait - no fixed-duration
5
+ * sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
6
+ * generous pass budgets.
7
+ */
8
+
9
+ import type { TestRendererSetup } from "@opentui/core/testing";
10
+
11
+ /** Pass budget for waits that include daemon or subprocess round-trips. */
12
+ export const WAIT_PASSES = { maxPasses: 400 };
13
+
14
+ /**
15
+ * One macrotask yield lets the input parser and the React scheduler run,
16
+ * then a render pass commits the result before the visual-idle wait.
17
+ */
18
+ export async function settle(setup: TestRendererSetup): Promise<void> {
19
+ await new Promise((resolve) => setTimeout(resolve, 0));
20
+ await setup.renderOnce();
21
+ await setup.waitForVisualIdle();
22
+ }
23
+
24
+ /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
25
+ export async function press(setup: TestRendererSetup, key: string): Promise<void> {
26
+ if (key === "enter") setup.mockInput.pressKey("RETURN");
27
+ else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
28
+ else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
29
+ else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
30
+ else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
31
+ else return typeText(setup, key);
32
+ await settle(setup);
33
+ }
34
+
35
+ export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
36
+ await setup.mockInput.typeText(text);
37
+ await settle(setup);
38
+ }
39
+
40
+ const WAIT_DEADLINE_MS = 30_000;
41
+
42
+ /**
43
+ * waitForFrame gives up as soon as the renderer is idle, but external timers
44
+ * (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
45
+ * event loop while nothing is scheduled. Between attempts, one real
46
+ * event-loop turn plus a render pass lets those land; the deadline bounds
47
+ * the whole wait.
48
+ */
49
+ async function waitForFramePredicate(
50
+ setup: TestRendererSetup,
51
+ predicate: (frame: string) => boolean,
52
+ ): Promise<string> {
53
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
54
+ for (;;) {
55
+ try {
56
+ return await setup.waitForFrame(predicate, { maxPasses: 50 });
57
+ } catch (error) {
58
+ if (Date.now() > deadline) throw error;
59
+ await new Promise((resolve) => setTimeout(resolve, 1));
60
+ await setup.renderOnce();
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Wait until a state predicate holds (daemon round-trips included). */
66
+ export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
67
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
68
+ for (;;) {
69
+ try {
70
+ return await setup.waitFor(predicate, { maxPasses: 50 });
71
+ } catch (error) {
72
+ if (Date.now() > deadline) throw error;
73
+ await new Promise((resolve) => setTimeout(resolve, 1));
74
+ await setup.renderOnce();
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Wait until the frame contains the needle (daemon round-trips included). */
80
+ export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
81
+ return waitForFramePredicate(setup, (frame) => frame.includes(needle));
82
+ }
83
+
84
+ /** Wait until the frame no longer contains the needle. */
85
+ export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
86
+ return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
87
+ }
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.
@@ -23,6 +23,27 @@ export interface Theme {
23
23
  markSuggestionBg: string;
24
24
  insFg: string;
25
25
  delFg: string;
26
+ /** Dimmed layer painted behind centered dialogs. */
27
+ backdrop: string;
28
+ }
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
+ };
26
47
  }
27
48
 
28
49
  export const DARK: Theme = {
@@ -43,4 +64,5 @@ export const DARK: Theme = {
43
64
  markSuggestionBg: "#20351f",
44
65
  insFg: "#62d96b",
45
66
  delFg: "#ff6b6b",
67
+ backdrop: "#14141b",
46
68
  }
@@ -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);