@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
@@ -9,17 +9,25 @@ import type {
9
9
  import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
10
10
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
11
11
  import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
12
+ import type { FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
12
13
  import {
13
14
  ApprovalAction,
14
15
  ExecutionPhase,
16
+ FileChangeSetStatus,
15
17
  MessageType,
16
18
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
17
- import { isTerminalPhase } from "@stigmer/react";
19
+ import {
20
+ isTerminalPhase,
21
+ FileReviewContext,
22
+ type FileReviewContextValue,
23
+ } from "@stigmer/react";
24
+ import { displayFileChangeSets } from "@stigmer/sdk";
18
25
  import { MessageEntry } from "./MessageEntry.js";
19
26
  import { ToolCallGroup } from "./ToolCallGroup.js";
20
27
  import { SubAgentBlock } from "./SubAgentBlock.js";
21
28
  import { ExecutionProgress } from "./ExecutionProgress.js";
22
29
  import { ApprovalPrompt } from "./ApprovalPrompt.js";
30
+ import { FileReviewRecord } from "./FileReviewRecord.js";
23
31
 
24
32
  /** Props for {@link MessageThread}. */
25
33
  export interface MessageThreadProps {
@@ -38,6 +46,14 @@ export interface MessageThreadProps {
38
46
  readonly submittingApprovalIds?: ReadonlySet<string>;
39
47
  /** Whether tool call groups should be rendered in expanded mode. Toggled via Ctrl+O. */
40
48
  readonly expandToolCalls?: boolean;
49
+ /**
50
+ * Whether to render a read-only settled record for each decided/reconciled/
51
+ * failed change set (the terminal analogue of the web's in-thread record,
52
+ * DD-27 D2). Off by default so bare consumers stay minimal; `SessionView`
53
+ * opts in. Pending (AWAITING_REVIEW) sets are never records here — their
54
+ * decision surface is the docked `FileReviewPrompt`.
55
+ */
56
+ readonly showFileReviewRecords?: boolean;
41
57
  }
42
58
 
43
59
  type ThreadItem =
@@ -46,71 +62,137 @@ type ThreadItem =
46
62
  | { readonly kind: "sub-agent"; readonly subAgent: SubAgentExecution; readonly key: string }
47
63
  | { readonly kind: "phase"; readonly phase: ExecutionPhase; readonly key: string }
48
64
  | { readonly kind: "pending-message"; readonly content: string; readonly key: string }
65
+ | { readonly kind: "file-review-record"; readonly set: FileChangeSet; readonly key: string }
49
66
  | { readonly kind: "approval"; readonly pendingApproval: PendingApproval; readonly key: string };
50
67
 
68
+ /** A change set is "settled" once a decision has been folded (or reconcile failed). */
69
+ function isSettledSet(status: FileChangeSetStatus): boolean {
70
+ return (
71
+ status === FileChangeSetStatus.DECIDED ||
72
+ status === FileChangeSetStatus.RECONCILED ||
73
+ status === FileChangeSetStatus.FAILED
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Builds the observational thread items for a single execution: the spec
79
+ * message, each AI/human message, tool-call groups, and sub-agent blocks —
80
+ * plus (when `includeFileReviewRecords`) a read-only settled record appended at
81
+ * the execution's tail for each decided/reconciled/failed change set.
82
+ */
83
+ function buildExecutionSegment(
84
+ exec: AgentExecution,
85
+ ei: number,
86
+ includeFileReviewRecords: boolean,
87
+ ): ThreadItem[] {
88
+ const seg: ThreadItem[] = [];
89
+ const messages = exec.status?.messages ?? [];
90
+ const subAgents = exec.status?.subAgentExecutions ?? [];
91
+
92
+ const specMessage = exec.spec?.message;
93
+ if (specMessage && specMessage !== "execute") {
94
+ const humanMsg = create(AgentMessageSchema);
95
+ humanMsg.type = MessageType.MESSAGE_HUMAN;
96
+ humanMsg.content = specMessage;
97
+ seg.push({ kind: "message", message: humanMsg, key: `e${ei}-spec` });
98
+ }
99
+
100
+ for (let mi = 0; mi < messages.length; mi++) {
101
+ const msg = messages[mi];
102
+ if (msg.type === MessageType.MESSAGE_TOOL) continue;
103
+
104
+ const isEmptyAi = msg.type === MessageType.MESSAGE_AI && !msg.content.trim();
105
+
106
+ if (!isEmptyAi) {
107
+ seg.push({ kind: "message", message: msg, key: `e${ei}-m${mi}` });
108
+ }
109
+
110
+ if (msg.type === MessageType.MESSAGE_AI && msg.toolCalls.length > 0) {
111
+ // Split tool calls: "task" tools become sub-agent blocks,
112
+ // everything else goes into a regular tool group.
113
+ const regularTools = msg.toolCalls.filter((tc) => tc.name !== "task");
114
+ const taskTools = msg.toolCalls.filter((tc) => tc.name === "task");
115
+
116
+ if (regularTools.length > 0) {
117
+ seg.push({
118
+ kind: "tool-group",
119
+ toolCalls: regularTools,
120
+ key: `e${ei}-m${mi}-tc`,
121
+ });
122
+ }
123
+
124
+ for (let ti = 0; ti < taskTools.length; ti++) {
125
+ const matchedSub = subAgents.find((sa) => sa.id === taskTools[ti].id);
126
+ if (matchedSub) {
127
+ seg.push({
128
+ kind: "sub-agent",
129
+ subAgent: matchedSub,
130
+ key: `e${ei}-m${mi}-sa-${ti}`,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ if (includeFileReviewRecords) {
138
+ // `displayFileChangeSets` returns the server projection for a live
139
+ // execution and folds the durable ledger for a terminal one, so a settled
140
+ // record renders for both. Appended at the segment tail (the terminal
141
+ // analogue of the web's last-stamped-row anchor); this is the only trace a
142
+ // shell-made set — which stamps no rows — leaves behind (DD-27 D2).
143
+ for (const set of displayFileChangeSets(exec.status)) {
144
+ if (!isSettledSet(set.status) || set.changes.length === 0) continue;
145
+ seg.push({ kind: "file-review-record", set, key: `e${ei}-frr-${set.id}` });
146
+ }
147
+ }
148
+
149
+ return seg;
150
+ }
151
+
152
+ /**
153
+ * Builds the full thread item list and the index at which the live region
154
+ * begins (`liveStart`).
155
+ *
156
+ * The split is by execution identity, not a trailing count: every completed
157
+ * execution's items are immutable history (safe for ink's append-only
158
+ * `<Static>`), while the active execution's items — plus the trailing transient
159
+ * items (phase, approvals, optimistic pending message) — stay live so their
160
+ * review badges can transition and the optimistic message can be cleared
161
+ * without shrinking the Static array.
162
+ */
51
163
  function buildThreadItems(
52
164
  executions: readonly AgentExecution[],
53
165
  activeStreamExecution: AgentExecution | null | undefined,
54
166
  pendingUserMessage: string | null | undefined,
55
167
  includeApprovals: boolean,
56
- ): ThreadItem[] {
168
+ includeFileReviewRecords: boolean,
169
+ ): { items: ThreadItem[]; liveStart: number } {
57
170
  const items: ThreadItem[] = [];
58
171
  const allExecutions = activeStreamExecution
59
172
  ? [...executions, activeStreamExecution]
60
173
  : executions;
61
174
 
62
- for (let ei = 0; ei < allExecutions.length; ei++) {
63
- const exec = allExecutions[ei];
64
- const messages = exec.status?.messages ?? [];
65
- const subAgents = exec.status?.subAgentExecutions ?? [];
175
+ // Boundary between immutable history and the live region. When there is an
176
+ // active execution, it is the item count right before that execution's
177
+ // segment; otherwise the transient tail (below) is the only live region.
178
+ let liveStart = 0;
179
+ let liveStartSet = false;
66
180
 
67
- const specMessage = exec.spec?.message;
68
- if (specMessage && specMessage !== "execute") {
69
- const humanMsg = create(AgentMessageSchema);
70
- humanMsg.type = MessageType.MESSAGE_HUMAN;
71
- humanMsg.content = specMessage;
72
- items.push({ kind: "message", message: humanMsg, key: `e${ei}-spec` });
181
+ for (let ei = 0; ei < allExecutions.length; ei++) {
182
+ if (activeStreamExecution && ei === executions.length) {
183
+ liveStart = items.length;
184
+ liveStartSet = true;
73
185
  }
186
+ items.push(...buildExecutionSegment(allExecutions[ei], ei, includeFileReviewRecords));
187
+ }
74
188
 
75
- for (let mi = 0; mi < messages.length; mi++) {
76
- const msg = messages[mi];
77
- if (msg.type === MessageType.MESSAGE_TOOL) continue;
78
-
79
- const isEmptyAi =
80
- msg.type === MessageType.MESSAGE_AI && !msg.content.trim();
81
-
82
- if (!isEmptyAi) {
83
- items.push({ kind: "message", message: msg, key: `e${ei}-m${mi}` });
84
- }
85
-
86
- if (msg.type === MessageType.MESSAGE_AI && msg.toolCalls.length > 0) {
87
- // Split tool calls: "task" tools become sub-agent blocks,
88
- // everything else goes into a regular tool group.
89
- const regularTools = msg.toolCalls.filter((tc) => tc.name !== "task");
90
- const taskTools = msg.toolCalls.filter((tc) => tc.name === "task");
91
-
92
- if (regularTools.length > 0) {
93
- items.push({
94
- kind: "tool-group",
95
- toolCalls: regularTools,
96
- key: `e${ei}-m${mi}-tc`,
97
- });
98
- }
99
-
100
- for (let ti = 0; ti < taskTools.length; ti++) {
101
- const matchedSub = subAgents.find((sa) => sa.id === taskTools[ti].id);
102
- if (matchedSub) {
103
- items.push({
104
- kind: "sub-agent",
105
- subAgent: matchedSub,
106
- key: `e${ei}-m${mi}-sa-${ti}`,
107
- });
108
- }
109
- }
110
- }
111
- }
189
+ if (!liveStartSet) {
190
+ // No active execution: every execution body is history; only the transient
191
+ // tail appended below (e.g. an optimistic pending message) stays live.
192
+ liveStart = items.length;
112
193
  }
113
194
 
195
+ // --- Transient tail (always live) ---
114
196
  const lastExec = allExecutions[allExecutions.length - 1];
115
197
  const lastPhase =
116
198
  lastExec?.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
@@ -144,20 +226,48 @@ function buildThreadItems(
144
226
  }
145
227
  }
146
228
 
147
- return items;
229
+ return { items, liveStart };
230
+ }
231
+
232
+ /**
233
+ * Builds the id→change-set map that backs the file-review row badges, folding
234
+ * every execution's change sets (via `displayFileChangeSets`, which reads the
235
+ * live projection or the durable ledger). The active stream wins on an id
236
+ * collision so a live decision is reflected before it is persisted.
237
+ */
238
+ function buildChangeSetsById(
239
+ executions: readonly AgentExecution[],
240
+ activeStreamExecution: AgentExecution | null | undefined,
241
+ ): FileReviewContextValue {
242
+ const changeSetsById = new Map<string, FileChangeSet>();
243
+ for (const exec of executions) {
244
+ for (const set of displayFileChangeSets(exec.status)) {
245
+ changeSetsById.set(set.id, set);
246
+ }
247
+ }
248
+ if (activeStreamExecution) {
249
+ for (const set of displayFileChangeSets(activeStreamExecution.status)) {
250
+ changeSetsById.set(set.id, set);
251
+ }
252
+ }
253
+ return { changeSetsById };
148
254
  }
149
255
 
150
256
  /**
151
257
  * Renders a continuous conversation thread from one or more
152
258
  * `AgentExecution` snapshots in the terminal.
153
259
  *
154
- * Composes {@link MessageEntry}, {@link ToolCallGroup},
155
- * {@link ExecutionProgress}, and {@link ApprovalPrompt} into a
156
- * scrolling terminal log.
260
+ * Composes {@link MessageEntry}, {@link ToolCallGroup}, {@link SubAgentBlock},
261
+ * {@link ExecutionProgress}, {@link ApprovalPrompt}, and (for settled change
262
+ * sets) {@link FileReviewRecord} into a scrolling terminal log.
157
263
  *
158
- * Historical items are rendered via Ink's `<Static>` component so
159
- * they are written once and don't re-render, keeping terminal
160
- * output efficient for long conversations.
264
+ * Completed executions are rendered via Ink's `<Static>` component so they are
265
+ * written once and don't re-render, keeping terminal output efficient for long
266
+ * conversations. The active execution renders live so its file-review row
267
+ * badges transition (Pending review → Kept/Discarded) in place, then freeze
268
+ * into `<Static>` when the execution reaches a terminal phase. The thread is
269
+ * wrapped in a `FileReviewContext` provider so every {@link ToolCallItem}
270
+ * can badge its stamped edit rows.
161
271
  */
162
272
  export function MessageThread({
163
273
  executions,
@@ -166,65 +276,56 @@ export function MessageThread({
166
276
  onApprovalSubmit,
167
277
  submittingApprovalIds,
168
278
  expandToolCalls = false,
279
+ showFileReviewRecords = false,
169
280
  }: MessageThreadProps) {
170
281
  const includeApprovals = onApprovalSubmit != null;
171
- const items = useMemo(
282
+ const { items, liveStart } = useMemo(
172
283
  () =>
173
284
  buildThreadItems(
174
285
  executions,
175
286
  activeStreamExecution,
176
287
  pendingUserMessage,
177
288
  includeApprovals,
289
+ showFileReviewRecords,
178
290
  ),
179
- [executions, activeStreamExecution, pendingUserMessage, includeApprovals],
291
+ [
292
+ executions,
293
+ activeStreamExecution,
294
+ pendingUserMessage,
295
+ includeApprovals,
296
+ showFileReviewRecords,
297
+ ],
298
+ );
299
+
300
+ const fileReviewContext = useMemo(
301
+ () => buildChangeSetsById(executions, activeStreamExecution),
302
+ [executions, activeStreamExecution],
180
303
  );
181
304
 
182
- const historyItems = activeStreamExecution ? items.slice(0, -getActiveCount(items)) : items;
183
- const liveItems = activeStreamExecution ? items.slice(items.length - getActiveCount(items)) : [];
305
+ const historyItems = items.slice(0, liveStart);
306
+ const liveItems = items.slice(liveStart);
184
307
 
185
308
  return (
186
- <Box flexDirection="column">
187
- <Static items={historyItems}>
188
- {(item) => (
309
+ <FileReviewContext.Provider value={fileReviewContext}>
310
+ <Box flexDirection="column">
311
+ <Static items={historyItems}>
312
+ {(item) => (
313
+ <Box key={item.key} flexDirection="column" marginBottom={1}>
314
+ {renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
315
+ </Box>
316
+ )}
317
+ </Static>
318
+
319
+ {liveItems.map((item) => (
189
320
  <Box key={item.key} flexDirection="column" marginBottom={1}>
190
321
  {renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
191
322
  </Box>
192
- )}
193
- </Static>
194
-
195
- {liveItems.map((item) => (
196
- <Box key={item.key} flexDirection="column" marginBottom={1}>
197
- {renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
198
- </Box>
199
- ))}
200
- </Box>
323
+ ))}
324
+ </Box>
325
+ </FileReviewContext.Provider>
201
326
  );
202
327
  }
203
328
 
204
- function getActiveCount(items: ThreadItem[]): number {
205
- let count = 0;
206
- for (let i = items.length - 1; i >= 0; i--) {
207
- const item = items[i];
208
- if (
209
- item.kind === "approval" ||
210
- item.kind === "pending-message" ||
211
- item.kind === "phase"
212
- ) {
213
- count++;
214
- } else if (
215
- item.kind === "message" &&
216
- item.message.type === MessageType.MESSAGE_AI &&
217
- item.message.isStreaming
218
- ) {
219
- count++;
220
- break;
221
- } else {
222
- break;
223
- }
224
- }
225
- return count;
226
- }
227
-
228
329
  function renderItem(
229
330
  item: ThreadItem,
230
331
  onApprovalSubmit?: (toolCallId: string, action: ApprovalAction) => void,
@@ -240,6 +341,8 @@ function renderItem(
240
341
  return <SubAgentBlock subAgent={item.subAgent} defaultExpanded={expandToolCalls} />;
241
342
  case "phase":
242
343
  return <ExecutionProgress phase={item.phase} />;
344
+ case "file-review-record":
345
+ return <FileReviewRecord fileChangeSet={item.set} />;
243
346
  case "approval":
244
347
  return onApprovalSubmit ? (
245
348
  <ApprovalPrompt
@@ -1,7 +1,13 @@
1
- import React, { useState } from "react";
1
+ import React, { useContext, useState } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
4
4
  import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
+ import {
6
+ FileReviewContext,
7
+ fileReviewRowState,
8
+ extractPrimaryArg,
9
+ type FileReviewRowState,
10
+ } from "@stigmer/react";
5
11
  import { ToolCallItem } from "./ToolCallItem.js";
6
12
 
7
13
  /** Props for {@link ToolCallGroup}. */
@@ -42,6 +48,45 @@ const STATUS_STYLE: Record<AggregateStatus, { symbol: string; color?: string }>
42
48
  pending: { symbol: "○" },
43
49
  };
44
50
 
51
+ /**
52
+ * A compact file-review cue for the collapsed summary line, so a pending edit is
53
+ * visible without expanding the group. Folds the group's stamped edit rows via
54
+ * the same pure {@link fileReviewRowState} the per-row badges use, reading the
55
+ * change sets from {@link FileReviewContext}. Pending takes precedence (it is
56
+ * the actionable state); otherwise a settled group shows its kept/discarded
57
+ * tally. Returns `null` when no row in the group is stamped.
58
+ */
59
+ function useReviewCue(
60
+ toolCalls: readonly ToolCall[],
61
+ ): { readonly label: string; readonly color: string } | null {
62
+ const { changeSetsById } = useContext(FileReviewContext);
63
+ const counts: Record<FileReviewRowState, number> = {
64
+ pending: 0,
65
+ kept: 0,
66
+ discarded: 0,
67
+ failed: 0,
68
+ };
69
+ for (const tc of toolCalls) {
70
+ if (!tc.fileChangeSetId) continue;
71
+ const state = fileReviewRowState(
72
+ changeSetsById.get(tc.fileChangeSetId),
73
+ extractPrimaryArg(tc),
74
+ );
75
+ if (state) counts[state]++;
76
+ }
77
+ if (counts.pending > 0) {
78
+ return { label: `${counts.pending} pending review`, color: "yellow" };
79
+ }
80
+ if (counts.failed > 0) {
81
+ return { label: `${counts.failed} review failed`, color: "red" };
82
+ }
83
+ const settled: string[] = [];
84
+ if (counts.kept > 0) settled.push(`${counts.kept} kept`);
85
+ if (counts.discarded > 0) settled.push(`${counts.discarded} discarded`);
86
+ if (settled.length > 0) return { label: settled.join(" · "), color: "gray" };
87
+ return null;
88
+ }
89
+
45
90
  /**
46
91
  * Renders a collapsible group of tool calls with an aggregate status.
47
92
  *
@@ -58,6 +103,7 @@ export function ToolCallGroup({
58
103
  const [expanded, setExpanded] = useState(defaultExpanded);
59
104
  const status = deriveAggregateStatus(toolCalls);
60
105
  const style = STATUS_STYLE[status];
106
+ const reviewCue = useReviewCue(toolCalls);
61
107
 
62
108
  useInput(
63
109
  (_input, key) => {
@@ -79,6 +125,9 @@ export function ToolCallGroup({
79
125
  <Text color={style.color}>{style.symbol}</Text>
80
126
  <Text dimColor>{expanded ? "▼" : "▶"}</Text>
81
127
  <Text>{summary}</Text>
128
+ {reviewCue && (
129
+ <Text color={reviewCue.color}>· {reviewCue.label}</Text>
130
+ )}
82
131
  </Box>
83
132
  {expanded && (
84
133
  <Box flexDirection="column" paddingLeft={2} marginTop={1}>
@@ -4,6 +4,11 @@ import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution
4
4
  import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
5
  import { ToolKind, resolveToolKind, normalizeToolResult } from "@stigmer/sdk";
6
6
  import type { ToolResultView } from "@stigmer/sdk";
7
+ import {
8
+ extractPrimaryArg,
9
+ useFileReviewRowState,
10
+ type FileReviewRowState,
11
+ } from "@stigmer/react";
7
12
 
8
13
  /** Props for {@link ToolCallItem}. */
9
14
  export interface ToolCallItemProps {
@@ -37,6 +42,15 @@ const KIND_LABEL: Partial<Record<ToolKind, string>> = {
37
42
  [ToolKind.SUBAGENT]: "Sub-agent",
38
43
  };
39
44
 
45
+ // File-review row badges, mirroring the web console's per-row badge labels and
46
+ // tones. Keyed by the shared `fileReviewRowState` classification.
47
+ const REVIEW_BADGE: Record<FileReviewRowState, { label: string; color: string }> = {
48
+ pending: { label: "Pending review", color: "yellow" },
49
+ kept: { label: "Kept", color: "green" },
50
+ discarded: { label: "Discarded", color: "red" },
51
+ failed: { label: "Review failed", color: "red" },
52
+ };
53
+
40
54
  /**
41
55
  * Renders a single tool call with a status indicator, label, and an optional
42
56
  * expanded result. Labels and results come from the shared `@stigmer/sdk` view
@@ -51,6 +65,16 @@ export function ToolCallItem({ toolCall, expanded = false }: ToolCallItemProps)
51
65
  const view = normalizeToolResult(toolCall);
52
66
  const resultText = expanded ? describeResultView(view) : null;
53
67
 
68
+ // A flowed file edit stamped with its change set id badges the set's live
69
+ // review state right on the row (the observational record stays in place; the
70
+ // decision surface is the docked FileReviewPrompt). Null for unstamped rows
71
+ // and honest-degradation cases (missing/capturing set, superseded edit).
72
+ const reviewState = useFileReviewRowState(
73
+ toolCall.fileChangeSetId,
74
+ extractPrimaryArg(toolCall),
75
+ );
76
+ const reviewBadge = reviewState ? REVIEW_BADGE[reviewState] : null;
77
+
54
78
  return (
55
79
  <Box flexDirection="column">
56
80
  <Box gap={1}>
@@ -59,6 +83,7 @@ export function ToolCallItem({ toolCall, expanded = false }: ToolCallItemProps)
59
83
  {toolCall.status === ToolCallStatus.TOOL_CALL_RUNNING && (
60
84
  <Text dimColor>running</Text>
61
85
  )}
86
+ {reviewBadge && <Text color={reviewBadge.color}>{reviewBadge.label}</Text>}
62
87
  </Box>
63
88
  {view.type === "error" ? (
64
89
  expanded && (
@@ -0,0 +1,135 @@
1
+ import type {
2
+ CapturedFileChange,
3
+ FileChangeProgressEntry,
4
+ FileChangeSet,
5
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
6
+ import {
7
+ FileChangeKind,
8
+ FileDecisionAction,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
+ import { deriveEffectiveVerdicts, fileReviewability } from "@stigmer/react";
11
+
12
+ /**
13
+ * Single-letter badge for a file change's kind, matching the web review card:
14
+ * `A` added, `M` modified, `D` deleted, `R` renamed. A binary change reads as
15
+ * `M` — "binary" is a review-completeness property (from `FileContent.is_binary`
16
+ * via `fileReviewability`), not a distinct edit kind.
17
+ */
18
+ export function kindLetter(kind: FileChangeKind): string {
19
+ switch (kind) {
20
+ case FileChangeKind.ADD:
21
+ return "A";
22
+ case FileChangeKind.DELETE:
23
+ return "D";
24
+ case FileChangeKind.RENAME:
25
+ return "R";
26
+ default:
27
+ return "M";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The one rename-aware path-display rule for the terminal, over the raw
33
+ * before/after/kind triple shared by every file-change shape (reviewable
34
+ * `CapturedFileChange` and slim `FileChangeProgressEntry` alike). Kept private
35
+ * and fed by the typed adapters below so the two surfaces can never grow
36
+ * divergent rename formatting.
37
+ */
38
+ function displayChangePath(
39
+ pathBefore: string,
40
+ pathAfter: string,
41
+ kind: FileChangeKind,
42
+ ): string {
43
+ if (kind === FileChangeKind.RENAME && pathBefore && pathAfter) {
44
+ return `${pathBefore} → ${pathAfter}`;
45
+ }
46
+ return pathAfter || pathBefore;
47
+ }
48
+
49
+ /**
50
+ * The path to show for a reviewable change: the post-edit path, or the pre-edit
51
+ * path for a deletion. A rename shows `before → after` so both sides are visible.
52
+ */
53
+ export function changeDisplayPath(change: CapturedFileChange): string {
54
+ return displayChangePath(change.pathBefore, change.pathAfter, change.kind);
55
+ }
56
+
57
+ /**
58
+ * The path to show for a mid-run progress entry (DD-32), using the same
59
+ * rename-aware rule as {@link changeDisplayPath}. The slim entry carries no
60
+ * bodies or digests, only the same before/after/kind triple, so the terminal
61
+ * renders its path identically to the reviewable surfaces.
62
+ */
63
+ export function progressEntryDisplayPath(entry: FileChangeProgressEntry): string {
64
+ return displayChangePath(entry.pathBefore, entry.pathAfter, entry.kind);
65
+ }
66
+
67
+ /**
68
+ * The honest, one-line reason a change cannot be kept as-is, or `null` when it
69
+ * is fully reviewable. Mirrors the web card's per-file block-reason copy so the
70
+ * terminal reviewer sees the same explanation.
71
+ */
72
+ export function blockReasonNote(change: CapturedFileChange): string | null {
73
+ const reviewability = fileReviewability(change);
74
+ switch (reviewability.kind) {
75
+ case "reviewable":
76
+ return null;
77
+ case "binary":
78
+ return "binary — no text diff; keep as-is or discard";
79
+ case "unavailable":
80
+ switch (reviewability.reason) {
81
+ case "secret":
82
+ return "contents withheld (looks like a secret) — discard only";
83
+ case "size":
84
+ return "diff too large to display — discard only";
85
+ default:
86
+ return "diff unavailable — discard only";
87
+ }
88
+ }
89
+ }
90
+
91
+ /** The effective per-file verdict counts for a settled change set. */
92
+ export interface SettledCounts {
93
+ /** Files kept (effective verdict APPROVE). */
94
+ readonly kept: number;
95
+ /** Files discarded (effective verdict REJECT). */
96
+ readonly discarded: number;
97
+ /** Files with no effective verdict (neither kept nor discarded). */
98
+ readonly notReviewed: number;
99
+ }
100
+
101
+ /**
102
+ * Folds a settled change set's decisions into kept / discarded / not-reviewed
103
+ * counts, using the same `deriveEffectiveVerdicts` fold the web uses so a
104
+ * "Keep all" set correctly reads every file as kept.
105
+ */
106
+ export function settledCounts(set: FileChangeSet): SettledCounts {
107
+ const verdicts = deriveEffectiveVerdicts(set);
108
+ let kept = 0;
109
+ let discarded = 0;
110
+ for (const change of set.changes) {
111
+ const verdict = verdicts.get(change.id);
112
+ if (verdict === FileDecisionAction.APPROVE) kept++;
113
+ else if (verdict === FileDecisionAction.REJECT) discarded++;
114
+ }
115
+ return {
116
+ kept,
117
+ discarded,
118
+ notReviewed: set.changes.length - kept - discarded,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * A one-line summary of a settled change set's outcome, e.g. `2 kept · 1
124
+ * discarded`, or `3 files changed` when nothing was individually decided.
125
+ */
126
+ export function settledSummary(set: FileChangeSet): string {
127
+ const { kept, discarded, notReviewed } = settledCounts(set);
128
+ const parts: string[] = [];
129
+ if (kept) parts.push(`${kept} kept`);
130
+ if (discarded) parts.push(`${discarded} discarded`);
131
+ if (notReviewed) parts.push(`${notReviewed} not reviewed`);
132
+ if (parts.length > 0) return parts.join(" · ");
133
+ const n = set.changes.length;
134
+ return `${n} file${n === 1 ? "" : "s"} changed`;
135
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,23 @@ export { ToolCallGroup, type ToolCallGroupProps } from "./components/ToolCallGro
19
19
  export { SubAgentBlock, type SubAgentBlockProps } from "./components/SubAgentBlock.js";
20
20
  export { TodoList, type TodoListProps } from "./components/TodoList.js";
21
21
  export { ApprovalPrompt, type ApprovalPromptProps } from "./components/ApprovalPrompt.js";
22
+ export { FileReviewPrompt, type FileReviewPromptProps } from "./components/FileReviewPrompt.js";
23
+ export { FileReviewRecord, type FileReviewRecordProps } from "./components/FileReviewRecord.js";
24
+ export {
25
+ FileChangeProgressBar,
26
+ type FileChangeProgressBarProps,
27
+ } from "./components/FileChangeProgressBar.js";
28
+
29
+ // File-review pure helpers (kind letters, paths, block reasons, settled counts)
30
+ export {
31
+ kindLetter,
32
+ changeDisplayPath,
33
+ progressEntryDisplayPath,
34
+ blockReasonNote,
35
+ settledCounts,
36
+ settledSummary,
37
+ type SettledCounts,
38
+ } from "./file-review.js";
22
39
  export { ExecutionProgress, type ExecutionProgressProps } from "./components/ExecutionProgress.js";
23
40
  export { FollowUpInput, type FollowUpInputProps } from "./components/FollowUpInput.js";
24
41
  export { UsageWidget, type UsageWidgetProps } from "./components/UsageWidget.js";