@stigmer/ink 3.1.1 → 3.1.3

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 (45) hide show
  1. package/app/SessionView.d.ts.map +1 -1
  2. package/app/SessionView.js +12 -1
  3. package/app/SessionView.js.map +1 -1
  4. package/components/FileChangeProgressBar.d.ts +46 -0
  5. package/components/FileChangeProgressBar.d.ts.map +1 -0
  6. package/components/FileChangeProgressBar.js +55 -0
  7. package/components/FileChangeProgressBar.js.map +1 -0
  8. package/components/FileReviewPrompt.d.ts +43 -0
  9. package/components/FileReviewPrompt.d.ts.map +1 -0
  10. package/components/FileReviewPrompt.js +172 -0
  11. package/components/FileReviewPrompt.js.map +1 -0
  12. package/components/FileReviewRecord.d.ts +18 -0
  13. package/components/FileReviewRecord.d.ts.map +1 -0
  14. package/components/FileReviewRecord.js +32 -0
  15. package/components/FileReviewRecord.js.map +1 -0
  16. package/components/MessageThread.d.ts +19 -7
  17. package/components/MessageThread.d.ts.map +1 -1
  18. package/components/MessageThread.js +145 -77
  19. package/components/MessageThread.js.map +1 -1
  20. package/components/ToolCallGroup.d.ts.map +1 -1
  21. package/components/ToolCallGroup.js +42 -2
  22. package/components/ToolCallGroup.js.map +1 -1
  23. package/components/ToolCallItem.d.ts.map +1 -1
  24. package/components/ToolCallItem.js +16 -1
  25. package/components/ToolCallItem.js.map +1 -1
  26. package/file-review.d.ts +48 -0
  27. package/file-review.d.ts.map +1 -0
  28. package/file-review.js +113 -0
  29. package/file-review.js.map +1 -0
  30. package/index.d.ts +4 -0
  31. package/index.d.ts.map +1 -1
  32. package/index.js +5 -0
  33. package/index.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/__tests__/file-change-progress.test.tsx +295 -0
  36. package/src/__tests__/file-review.test.tsx +496 -0
  37. package/src/app/SessionView.tsx +51 -3
  38. package/src/components/FileChangeProgressBar.tsx +122 -0
  39. package/src/components/FileReviewPrompt.tsx +328 -0
  40. package/src/components/FileReviewRecord.tsx +63 -0
  41. package/src/components/MessageThread.tsx +199 -96
  42. package/src/components/ToolCallGroup.tsx +50 -1
  43. package/src/components/ToolCallItem.tsx +25 -0
  44. package/src/file-review.ts +135 -0
  45. package/src/index.ts +17 -0
@@ -0,0 +1,496 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { render } from "ink-testing-library";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { FileReviewContext } from "@stigmer/react";
6
+ import {
7
+ ToolCallSchema,
8
+ FileContentSchema,
9
+ AgentMessageSchema,
10
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
11
+ import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
12
+ import {
13
+ FileChangeSetSchema,
14
+ CapturedFileChangeSchema,
15
+ FileDecisionSchema,
16
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
17
+ import type { FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
18
+ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
+ import {
20
+ FileChangeKind,
21
+ FileChangeSetStatus,
22
+ FileDecisionAction,
23
+ FileDecisionScope,
24
+ FileReviewBlockReason,
25
+ DiffCompleteness,
26
+ MessageType,
27
+ ToolCallStatus,
28
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
29
+ import { ToolCallItem } from "../components/ToolCallItem.js";
30
+ import { ToolCallGroup } from "../components/ToolCallGroup.js";
31
+ import { FileReviewPrompt } from "../components/FileReviewPrompt.js";
32
+ import { FileReviewRecord } from "../components/FileReviewRecord.js";
33
+ import { MessageThread } from "../components/MessageThread.js";
34
+
35
+ // --- fixtures -------------------------------------------------------------
36
+
37
+ interface ChangeOpts {
38
+ readonly id: string;
39
+ readonly path: string;
40
+ readonly kind?: FileChangeKind;
41
+ readonly diffComplete?: boolean;
42
+ readonly binary?: boolean;
43
+ readonly blockedReason?: FileReviewBlockReason;
44
+ }
45
+
46
+ function makeChange(opts: ChangeOpts) {
47
+ return create(CapturedFileChangeSchema, {
48
+ id: opts.id,
49
+ pathAfter: opts.kind === FileChangeKind.DELETE ? "" : opts.path,
50
+ pathBefore: opts.kind === FileChangeKind.DELETE ? opts.path : "",
51
+ kind: opts.kind ?? FileChangeKind.MODIFY,
52
+ diffComplete: opts.diffComplete ?? true,
53
+ fileDigest: `fd-${opts.id}`,
54
+ blockedReason: opts.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
55
+ before: opts.binary ? create(FileContentSchema, { isBinary: true }) : undefined,
56
+ after: opts.binary ? create(FileContentSchema, { isBinary: true }) : undefined,
57
+ });
58
+ }
59
+
60
+ interface SetOpts {
61
+ readonly id?: string;
62
+ readonly status: FileChangeSetStatus;
63
+ readonly changes: ReturnType<typeof makeChange>[];
64
+ readonly diffCompleteness?: DiffCompleteness;
65
+ readonly decisions?: {
66
+ readonly scope: FileDecisionScope;
67
+ readonly action: FileDecisionAction;
68
+ readonly fileChangeId?: string;
69
+ }[];
70
+ }
71
+
72
+ function makeSet(opts: SetOpts): FileChangeSet {
73
+ return create(FileChangeSetSchema, {
74
+ id: opts.id ?? "cs-1",
75
+ status: opts.status,
76
+ changes: opts.changes,
77
+ aggregateDigest: "agg-1",
78
+ diffCompleteness: opts.diffCompleteness ?? DiffCompleteness.COMPLETE,
79
+ decisions: (opts.decisions ?? []).map((d) =>
80
+ create(FileDecisionSchema, {
81
+ scope: d.scope,
82
+ action: d.action,
83
+ fileChangeId: d.fileChangeId ?? "",
84
+ }),
85
+ ),
86
+ });
87
+ }
88
+
89
+ function stampedTool(path: string, setId: string): ToolCall {
90
+ return create(ToolCallSchema, {
91
+ id: `tc-${path}`,
92
+ name: "write_file",
93
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
94
+ args: { path },
95
+ fileChangeSetId: setId,
96
+ });
97
+ }
98
+
99
+ function renderRow(toolCall: ToolCall, set: FileChangeSet | null) {
100
+ const changeSetsById = set ? new Map([[set.id, set]]) : new Map();
101
+ return render(
102
+ <FileReviewContext.Provider value={{ changeSetsById }}>
103
+ <ToolCallItem toolCall={toolCall} />
104
+ </FileReviewContext.Provider>,
105
+ );
106
+ }
107
+
108
+ // --- FileReviewPrompt: bulk ----------------------------------------------
109
+
110
+ describe("FileReviewPrompt — bulk", () => {
111
+ const completeSet = makeSet({
112
+ status: FileChangeSetStatus.AWAITING_REVIEW,
113
+ diffCompleteness: DiffCompleteness.COMPLETE,
114
+ changes: [
115
+ makeChange({ id: "a", path: "one.ts" }),
116
+ makeChange({ id: "b", path: "two.ts" }),
117
+ ],
118
+ });
119
+
120
+ it("shows the summary and complete-set bulk actions", () => {
121
+ const { lastFrame } = render(
122
+ <FileReviewPrompt changeSet={completeSet} onSubmit={() => {}} />,
123
+ );
124
+ const out = lastFrame() ?? "";
125
+ expect(out).toContain("2 files awaiting review");
126
+ expect(out).toContain("[a] Approve all");
127
+ expect(out).toContain("[r] Reject all");
128
+ expect(out).toContain("[f] Review files");
129
+ });
130
+
131
+ it("approves the whole set with the aggregate digest (no acknowledge)", () => {
132
+ const onSubmit = vi.fn();
133
+ const { stdin } = render(
134
+ <FileReviewPrompt changeSet={completeSet} onSubmit={onSubmit} />,
135
+ );
136
+ stdin.write("a");
137
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
138
+ scope: FileDecisionScope.CHANGE_SET,
139
+ expectedDigest: "agg-1",
140
+ acknowledgeUnreviewable: false,
141
+ });
142
+ });
143
+
144
+ it("rejects the whole set", () => {
145
+ const onSubmit = vi.fn();
146
+ const { stdin } = render(
147
+ <FileReviewPrompt changeSet={completeSet} onSubmit={onSubmit} />,
148
+ );
149
+ stdin.write("r");
150
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.REJECT, {
151
+ scope: FileDecisionScope.CHANGE_SET,
152
+ expectedDigest: "agg-1",
153
+ acknowledgeUnreviewable: false,
154
+ });
155
+ });
156
+
157
+ it("labels a binary-only set 'Keep all' and acknowledges on keep", () => {
158
+ const binarySet = makeSet({
159
+ status: FileChangeSetStatus.AWAITING_REVIEW,
160
+ diffCompleteness: DiffCompleteness.BINARY_SUMMARY_ONLY,
161
+ changes: [
162
+ makeChange({ id: "img", path: "logo.png", kind: FileChangeKind.BINARY_CHANGE, diffComplete: false, binary: true }),
163
+ makeChange({ id: "txt", path: "readme.md" }),
164
+ ],
165
+ });
166
+ const onSubmit = vi.fn();
167
+ const { stdin, lastFrame } = render(
168
+ <FileReviewPrompt changeSet={binarySet} onSubmit={onSubmit} />,
169
+ );
170
+ expect(lastFrame() ?? "").toContain("[a] Keep all");
171
+ stdin.write("a");
172
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
173
+ scope: FileDecisionScope.CHANGE_SET,
174
+ expectedDigest: "agg-1",
175
+ acknowledgeUnreviewable: true,
176
+ });
177
+ });
178
+
179
+ it("hides bulk keep for a blocked set (no [a]); reject + review only", () => {
180
+ const blockedSet = makeSet({
181
+ status: FileChangeSetStatus.AWAITING_REVIEW,
182
+ diffCompleteness: DiffCompleteness.PARTIAL_BLOCKED,
183
+ changes: [
184
+ makeChange({ id: "s", path: ".env", diffComplete: false, blockedReason: FileReviewBlockReason.SECRET_WITHHELD }),
185
+ makeChange({ id: "ok", path: "app.ts" }),
186
+ ],
187
+ });
188
+ const onSubmit = vi.fn();
189
+ const { stdin, lastFrame } = render(
190
+ <FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
191
+ );
192
+ const out = lastFrame() ?? "";
193
+ expect(out).not.toContain("[a]");
194
+ expect(out).toContain("[r] Reject all");
195
+ expect(out).toContain("[f] Review files");
196
+ stdin.write("a"); // no bulk-approve option → ignored
197
+ expect(onSubmit).not.toHaveBeenCalled();
198
+ });
199
+ });
200
+
201
+ // --- FileReviewPrompt: per-file ------------------------------------------
202
+
203
+ /** Let ink/React flush a state transition between keystrokes. */
204
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 20));
205
+
206
+ describe("FileReviewPrompt — per-file", () => {
207
+ const blockedSet = makeSet({
208
+ status: FileChangeSetStatus.AWAITING_REVIEW,
209
+ diffCompleteness: DiffCompleteness.PARTIAL_BLOCKED,
210
+ changes: [
211
+ makeChange({ id: "ok", path: "app.ts" }),
212
+ makeChange({ id: "s", path: ".env", diffComplete: false, blockedReason: FileReviewBlockReason.SECRET_WITHHELD }),
213
+ ],
214
+ });
215
+
216
+ it("enters per-file mode and keeps a reviewable file with its file digest", async () => {
217
+ const onSubmit = vi.fn();
218
+ const { stdin, lastFrame } = render(
219
+ <FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
220
+ );
221
+ stdin.write("f");
222
+ await tick();
223
+ const out = lastFrame() ?? "";
224
+ expect(out).toContain("app.ts");
225
+ expect(out).toContain(".env");
226
+ expect(out).toContain("keep"); // hint line
227
+ stdin.write("k"); // keep the selected (first) file
228
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
229
+ scope: FileDecisionScope.FILE,
230
+ fileChangeId: "ok",
231
+ expectedDigest: "fd-ok",
232
+ acknowledgeUnreviewable: false,
233
+ });
234
+ });
235
+
236
+ it("refuses to keep an unavailable file but allows discard", async () => {
237
+ const onSubmit = vi.fn();
238
+ const { stdin } = render(
239
+ <FileReviewPrompt changeSet={blockedSet} onSubmit={onSubmit} />,
240
+ );
241
+ stdin.write("f");
242
+ await tick();
243
+ stdin.write("\u001B[B"); // down → select the secret file
244
+ await tick();
245
+ stdin.write("k"); // keep refused for unavailable
246
+ expect(onSubmit).not.toHaveBeenCalled();
247
+ stdin.write("d"); // discard allowed
248
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.REJECT, {
249
+ scope: FileDecisionScope.FILE,
250
+ fileChangeId: "s",
251
+ expectedDigest: "fd-s",
252
+ acknowledgeUnreviewable: false,
253
+ });
254
+ });
255
+
256
+ it("keeps a binary file with an acknowledgement", async () => {
257
+ const binarySet = makeSet({
258
+ status: FileChangeSetStatus.AWAITING_REVIEW,
259
+ diffCompleteness: DiffCompleteness.BINARY_SUMMARY_ONLY,
260
+ changes: [
261
+ makeChange({ id: "img", path: "logo.png", kind: FileChangeKind.BINARY_CHANGE, diffComplete: false, binary: true }),
262
+ ],
263
+ });
264
+ const onSubmit = vi.fn();
265
+ const { stdin } = render(
266
+ <FileReviewPrompt changeSet={binarySet} onSubmit={onSubmit} />,
267
+ );
268
+ stdin.write("f");
269
+ await tick();
270
+ stdin.write("k");
271
+ expect(onSubmit).toHaveBeenCalledWith("cs-1", FileDecisionAction.APPROVE, {
272
+ scope: FileDecisionScope.FILE,
273
+ fileChangeId: "img",
274
+ expectedDigest: "fd-img",
275
+ acknowledgeUnreviewable: true,
276
+ });
277
+ });
278
+ });
279
+
280
+ // --- FileReviewPrompt: state ---------------------------------------------
281
+
282
+ describe("FileReviewPrompt — state", () => {
283
+ const set = makeSet({
284
+ status: FileChangeSetStatus.AWAITING_REVIEW,
285
+ changes: [makeChange({ id: "a", path: "one.ts" })],
286
+ });
287
+
288
+ it("shows submitting and ignores input while a decision is in flight", () => {
289
+ const onSubmit = vi.fn();
290
+ const { stdin, lastFrame } = render(
291
+ <FileReviewPrompt
292
+ changeSet={set}
293
+ onSubmit={onSubmit}
294
+ submittingDecisionKeys={new Set(["cs-1"])}
295
+ />,
296
+ );
297
+ expect(lastFrame() ?? "").toContain("Submitting");
298
+ stdin.write("a");
299
+ expect(onSubmit).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("renders a decision error", () => {
303
+ const { lastFrame } = render(
304
+ <FileReviewPrompt
305
+ changeSet={set}
306
+ onSubmit={() => {}}
307
+ decisionErrors={new Map([["cs-1", new Error("digest mismatch")]])}
308
+ />,
309
+ );
310
+ expect(lastFrame() ?? "").toContain("digest mismatch");
311
+ });
312
+
313
+ it("is inert and hints when isActive is false", () => {
314
+ const onSubmit = vi.fn();
315
+ const { stdin, lastFrame } = render(
316
+ <FileReviewPrompt changeSet={set} onSubmit={onSubmit} isActive={false} />,
317
+ );
318
+ stdin.write("a");
319
+ expect(onSubmit).not.toHaveBeenCalled();
320
+ expect(lastFrame() ?? "").toContain("Resolve the pending approval first");
321
+ });
322
+ });
323
+
324
+ // --- Row badges -----------------------------------------------------------
325
+
326
+ describe("ToolCallItem — file-review badge", () => {
327
+ it("badges a pending set as 'Pending review'", () => {
328
+ const set = makeSet({
329
+ status: FileChangeSetStatus.AWAITING_REVIEW,
330
+ changes: [makeChange({ id: "a", path: "notes.md" })],
331
+ });
332
+ const { lastFrame } = renderRow(stampedTool("notes.md", "cs-1"), set);
333
+ expect(lastFrame() ?? "").toContain("Pending review");
334
+ });
335
+
336
+ it("badges a kept file as 'Kept' and a discarded one as 'Discarded'", () => {
337
+ const kept = makeSet({
338
+ status: FileChangeSetStatus.RECONCILED,
339
+ changes: [makeChange({ id: "a", path: "notes.md" })],
340
+ decisions: [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" }],
341
+ });
342
+ expect(renderRow(stampedTool("notes.md", "cs-1"), kept).lastFrame() ?? "").toContain("Kept");
343
+
344
+ const discarded = makeSet({
345
+ status: FileChangeSetStatus.RECONCILED,
346
+ changes: [makeChange({ id: "a", path: "notes.md" })],
347
+ decisions: [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.REJECT, fileChangeId: "a" }],
348
+ });
349
+ expect(renderRow(stampedTool("notes.md", "cs-1"), discarded).lastFrame() ?? "").toContain("Discarded");
350
+ });
351
+
352
+ it("badges a failed set as 'Review failed'", () => {
353
+ const failed = makeSet({
354
+ status: FileChangeSetStatus.FAILED,
355
+ changes: [makeChange({ id: "a", path: "notes.md" })],
356
+ });
357
+ expect(renderRow(stampedTool("notes.md", "cs-1"), failed).lastFrame() ?? "").toContain("Review failed");
358
+ });
359
+
360
+ it("shows no badge for an unstamped row", () => {
361
+ const tc = create(ToolCallSchema, {
362
+ id: "tc-1",
363
+ name: "write_file",
364
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
365
+ args: { path: "notes.md" },
366
+ });
367
+ const out = renderRow(tc, null).lastFrame() ?? "";
368
+ expect(out).not.toContain("Pending review");
369
+ expect(out).not.toContain("Kept");
370
+ });
371
+
372
+ it("shows no badge when the set is still CAPTURING", () => {
373
+ const capturing = makeSet({
374
+ status: FileChangeSetStatus.CAPTURING,
375
+ changes: [makeChange({ id: "a", path: "notes.md" })],
376
+ });
377
+ const out = renderRow(stampedTool("notes.md", "cs-1"), capturing).lastFrame() ?? "";
378
+ expect(out).not.toContain("Pending review");
379
+ expect(out).not.toContain("Kept");
380
+ });
381
+
382
+ it("shows no badge when the stamped path is absent from the set", () => {
383
+ const decided = makeSet({
384
+ status: FileChangeSetStatus.RECONCILED,
385
+ changes: [makeChange({ id: "a", path: "other.md" })],
386
+ decisions: [{ scope: FileDecisionScope.CHANGE_SET, action: FileDecisionAction.APPROVE }],
387
+ });
388
+ const out = renderRow(stampedTool("notes.md", "cs-1"), decided).lastFrame() ?? "";
389
+ expect(out).not.toContain("Kept");
390
+ });
391
+ });
392
+
393
+ // --- Collapsed-group aggregate cue ---------------------------------------
394
+
395
+ describe("ToolCallGroup — aggregate review cue", () => {
396
+ it("surfaces a pending edit on the collapsed summary", () => {
397
+ const set = makeSet({
398
+ status: FileChangeSetStatus.AWAITING_REVIEW,
399
+ changes: [makeChange({ id: "a", path: "notes.md" })],
400
+ });
401
+ const { lastFrame } = render(
402
+ <FileReviewContext.Provider value={{ changeSetsById: new Map([[set.id, set]]) }}>
403
+ <ToolCallGroup toolCalls={[stampedTool("notes.md", "cs-1")]} />
404
+ </FileReviewContext.Provider>,
405
+ );
406
+ expect(lastFrame() ?? "").toContain("1 pending review");
407
+ });
408
+
409
+ it("shows no cue when no row is stamped", () => {
410
+ const tc = create(ToolCallSchema, { id: "t", name: "read_file", status: ToolCallStatus.TOOL_CALL_COMPLETED });
411
+ const { lastFrame } = render(<ToolCallGroup toolCalls={[tc]} />);
412
+ const out = lastFrame() ?? "";
413
+ expect(out).not.toContain("pending review");
414
+ });
415
+ });
416
+
417
+ // --- Settled record -------------------------------------------------------
418
+
419
+ describe("FileReviewRecord", () => {
420
+ it("summarizes kept/discarded and lists files", () => {
421
+ const set = makeSet({
422
+ status: FileChangeSetStatus.RECONCILED,
423
+ changes: [
424
+ makeChange({ id: "a", path: "one.ts" }),
425
+ makeChange({ id: "b", path: "two.ts" }),
426
+ makeChange({ id: "c", path: "three.ts" }),
427
+ ],
428
+ decisions: [
429
+ { scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" },
430
+ { scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "b" },
431
+ { scope: FileDecisionScope.FILE, action: FileDecisionAction.REJECT, fileChangeId: "c" },
432
+ ],
433
+ });
434
+ const { lastFrame } = render(<FileReviewRecord fileChangeSet={set} />);
435
+ const out = lastFrame() ?? "";
436
+ expect(out).toContain("2 kept · 1 discarded");
437
+ expect(out).toContain("one.ts");
438
+ expect(out).toContain("(discarded)");
439
+ });
440
+
441
+ it("reports a failed reconcile", () => {
442
+ const set = makeSet({
443
+ status: FileChangeSetStatus.FAILED,
444
+ changes: [makeChange({ id: "a", path: "one.ts" })],
445
+ });
446
+ expect(render(<FileReviewRecord fileChangeSet={set} />).lastFrame() ?? "").toContain("review failed");
447
+ });
448
+ });
449
+
450
+ // --- MessageThread integration -------------------------------------------
451
+
452
+ describe("MessageThread — file-review integration", () => {
453
+ function execWithStampedEdit(status: FileChangeSetStatus, decided: boolean) {
454
+ const set = makeSet({
455
+ id: "cs-1",
456
+ status,
457
+ changes: [makeChange({ id: "a", path: "notes.md" })],
458
+ decisions: decided
459
+ ? [{ scope: FileDecisionScope.FILE, action: FileDecisionAction.APPROVE, fileChangeId: "a" }]
460
+ : [],
461
+ });
462
+ const aiMsg = create(AgentMessageSchema, {
463
+ type: MessageType.MESSAGE_AI,
464
+ content: "Editing notes",
465
+ toolCalls: [stampedTool("notes.md", "cs-1")],
466
+ });
467
+ return create(AgentExecutionSchema, {
468
+ metadata: { id: "aex-1" },
469
+ status: {
470
+ phase: undefined,
471
+ messages: [aiMsg],
472
+ fileChangeSets: [set],
473
+ },
474
+ });
475
+ }
476
+
477
+ it("badges a settled row and appends a settled record for a completed execution", () => {
478
+ const exec = execWithStampedEdit(FileChangeSetStatus.RECONCILED, true);
479
+ const { lastFrame } = render(
480
+ <MessageThread executions={[exec]} showFileReviewRecords expandToolCalls />,
481
+ );
482
+ const out = lastFrame() ?? "";
483
+ expect(out).toContain("Kept"); // per-row badge
484
+ expect(out).toContain("File review — 1 kept"); // settled record
485
+ });
486
+
487
+ it("does not render a settled record for an AWAITING set (that is the prompt's job)", () => {
488
+ const exec = execWithStampedEdit(FileChangeSetStatus.AWAITING_REVIEW, false);
489
+ const { lastFrame } = render(
490
+ <MessageThread executions={[]} activeStreamExecution={exec} showFileReviewRecords expandToolCalls />,
491
+ );
492
+ const out = lastFrame() ?? "";
493
+ expect(out).toContain("Pending review"); // the row still badges
494
+ expect(out).not.toContain("File review —"); // but no settled record
495
+ });
496
+ });
@@ -7,6 +7,8 @@ import { TodoList } from "../components/TodoList.js";
7
7
  import { FollowUpInput } from "../components/FollowUpInput.js";
8
8
  import { UsageWidget } from "../components/UsageWidget.js";
9
9
  import { ExecutionProgress } from "../components/ExecutionProgress.js";
10
+ import { FileChangeProgressBar } from "../components/FileChangeProgressBar.js";
11
+ import { FileReviewPrompt } from "../components/FileReviewPrompt.js";
10
12
 
11
13
  /** Interaction mode type used for follow-up executions. */
12
14
  export type InteractionMode = "agent" | "plan";
@@ -135,9 +137,15 @@ export function SessionView({ sessionId, org, mode }: SessionViewProps) {
135
137
  </Box>
136
138
  )}
137
139
 
138
- {conv.activePhase != null && conv.activePhase !== 0 && (
139
- <ExecutionProgress phase={conv.activePhase} />
140
- )}
140
+ {conv.activePhase != null &&
141
+ conv.activePhase !== 0 &&
142
+ conv.fileChangeSets.length === 0 && (
143
+ // Suppressed while a set awaits review: there is no execution phase
144
+ // for review (it lives on FileChangeSet.status), so the phase would
145
+ // otherwise read as an active "Running" spinner while the agent is
146
+ // idle awaiting the reviewer — the FileReviewPrompt is the honest cue.
147
+ <ExecutionProgress phase={conv.activePhase} />
148
+ )}
141
149
 
142
150
  <MessageThread
143
151
  executions={conv.completedExecutions}
@@ -146,6 +154,7 @@ export function SessionView({ sessionId, org, mode }: SessionViewProps) {
146
154
  onApprovalSubmit={conv.submitApproval}
147
155
  submittingApprovalIds={conv.submittingApprovalIds}
148
156
  expandToolCalls={expandTools}
157
+ showFileReviewRecords
149
158
  />
150
159
 
151
160
  {activeTodos && Object.keys(activeTodos).length > 0 && (
@@ -172,6 +181,45 @@ export function SessionView({ sessionId, org, mode }: SessionViewProps) {
172
181
 
173
182
  <UsageWidget executions={allExecutions} />
174
183
 
184
+ {/* Mid-run live capture (DD-32): the transient "N files changing…" strip
185
+ for a still-running turn, covering shell-made changes with no tool row.
186
+ Mutually exclusive with the FileReviewPrompt below — progress shows
187
+ while CAPTURING, the prompt once AWAITING_REVIEW — so it hands off
188
+ cleanly when review opens. Non-interactive; the per-file list reuses the
189
+ existing Ctrl+O expand toggle. */}
190
+ <FileChangeProgressBar
191
+ progress={conv.fileChangeProgress}
192
+ expanded={expandTools}
193
+ />
194
+
195
+ {conv.fileChangeSets.length > 0 && (
196
+ <Box flexDirection="column">
197
+ <FileReviewPrompt
198
+ changeSet={conv.fileChangeSets[0]}
199
+ onSubmit={conv.submitFileDecision}
200
+ submittingDecisionKeys={conv.submittingFileDecisionKeys}
201
+ decisionErrors={conv.fileDecisionErrors}
202
+ // Single active decision surface: yield to a pending tool approval.
203
+ isActive={conv.pendingApprovals.length === 0}
204
+ />
205
+ {conv.fileChangeSets.length > 1 && (
206
+ <Box paddingLeft={2}>
207
+ <Text dimColor>
208
+ {conv.fileChangeSets.length - 1} more awaiting review
209
+ </Text>
210
+ </Box>
211
+ )}
212
+ </Box>
213
+ )}
214
+
215
+ {conv.fileReviewError && (
216
+ <Box paddingLeft={1}>
217
+ <Text color="red">
218
+ File review error: {conv.fileReviewError.message}
219
+ </Text>
220
+ </Box>
221
+ )}
222
+
175
223
  {conv.canSendFollowUp && (
176
224
  <Box paddingLeft={1}>
177
225
  <Text color={activeMode === "plan" ? "yellow" : "cyan"} dimColor>