@stigmer/ink 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/SessionView.d.ts.map +1 -1
- package/app/SessionView.js +11 -1
- package/app/SessionView.js.map +1 -1
- package/components/FileReviewPrompt.d.ts +43 -0
- package/components/FileReviewPrompt.d.ts.map +1 -0
- package/components/FileReviewPrompt.js +172 -0
- package/components/FileReviewPrompt.js.map +1 -0
- package/components/FileReviewRecord.d.ts +18 -0
- package/components/FileReviewRecord.d.ts.map +1 -0
- package/components/FileReviewRecord.js +32 -0
- package/components/FileReviewRecord.js.map +1 -0
- package/components/MessageThread.d.ts +19 -7
- package/components/MessageThread.d.ts.map +1 -1
- package/components/MessageThread.js +145 -77
- package/components/MessageThread.js.map +1 -1
- package/components/ToolCallGroup.d.ts.map +1 -1
- package/components/ToolCallGroup.js +42 -2
- package/components/ToolCallGroup.js.map +1 -1
- package/components/ToolCallItem.d.ts.map +1 -1
- package/components/ToolCallItem.js +16 -1
- package/components/ToolCallItem.js.map +1 -1
- package/file-review.d.ts +41 -0
- package/file-review.d.ts.map +1 -0
- package/file-review.js +94 -0
- package/file-review.js.map +1 -0
- package/index.d.ts +3 -0
- package/index.d.ts.map +1 -1
- package/index.js +4 -0
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/file-review.test.tsx +496 -0
- package/src/app/SessionView.tsx +39 -3
- package/src/components/FileReviewPrompt.tsx +328 -0
- package/src/components/FileReviewRecord.tsx +63 -0
- package/src/components/MessageThread.tsx +199 -96
- package/src/components/ToolCallGroup.tsx +50 -1
- package/src/components/ToolCallItem.tsx +25 -0
- package/src/file-review.ts +109 -0
- package/src/index.ts +12 -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 {
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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},
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
-
[
|
|
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 =
|
|
183
|
-
const liveItems =
|
|
305
|
+
const historyItems = items.slice(0, liveStart);
|
|
306
|
+
const liveItems = items.slice(liveStart);
|
|
184
307
|
|
|
185
308
|
return (
|
|
186
|
-
<
|
|
187
|
-
<
|
|
188
|
-
{
|
|
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
|
-
</
|
|
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,109 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapturedFileChange,
|
|
3
|
+
FileChangeSet,
|
|
4
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
5
|
+
import {
|
|
6
|
+
FileChangeKind,
|
|
7
|
+
FileDecisionAction,
|
|
8
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
9
|
+
import { deriveEffectiveVerdicts, fileReviewability } from "@stigmer/react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Single-letter badge for a file change's kind, matching the web review card:
|
|
13
|
+
* `A` added, `M` modified, `D` deleted, `R` renamed. A binary change reads as
|
|
14
|
+
* `M` — "binary" is a review-completeness property (from `FileContent.is_binary`
|
|
15
|
+
* via `fileReviewability`), not a distinct edit kind.
|
|
16
|
+
*/
|
|
17
|
+
export function kindLetter(kind: FileChangeKind): string {
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case FileChangeKind.ADD:
|
|
20
|
+
return "A";
|
|
21
|
+
case FileChangeKind.DELETE:
|
|
22
|
+
return "D";
|
|
23
|
+
case FileChangeKind.RENAME:
|
|
24
|
+
return "R";
|
|
25
|
+
default:
|
|
26
|
+
return "M";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The path to show for a change: the post-edit path, or the pre-edit path for a
|
|
32
|
+
* deletion. A rename shows `before → after` so both sides are visible.
|
|
33
|
+
*/
|
|
34
|
+
export function changeDisplayPath(change: CapturedFileChange): string {
|
|
35
|
+
if (change.kind === FileChangeKind.RENAME && change.pathBefore && change.pathAfter) {
|
|
36
|
+
return `${change.pathBefore} → ${change.pathAfter}`;
|
|
37
|
+
}
|
|
38
|
+
return change.pathAfter || change.pathBefore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The honest, one-line reason a change cannot be kept as-is, or `null` when it
|
|
43
|
+
* is fully reviewable. Mirrors the web card's per-file block-reason copy so the
|
|
44
|
+
* terminal reviewer sees the same explanation.
|
|
45
|
+
*/
|
|
46
|
+
export function blockReasonNote(change: CapturedFileChange): string | null {
|
|
47
|
+
const reviewability = fileReviewability(change);
|
|
48
|
+
switch (reviewability.kind) {
|
|
49
|
+
case "reviewable":
|
|
50
|
+
return null;
|
|
51
|
+
case "binary":
|
|
52
|
+
return "binary — no text diff; keep as-is or discard";
|
|
53
|
+
case "unavailable":
|
|
54
|
+
switch (reviewability.reason) {
|
|
55
|
+
case "secret":
|
|
56
|
+
return "contents withheld (looks like a secret) — discard only";
|
|
57
|
+
case "size":
|
|
58
|
+
return "diff too large to display — discard only";
|
|
59
|
+
default:
|
|
60
|
+
return "diff unavailable — discard only";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The effective per-file verdict counts for a settled change set. */
|
|
66
|
+
export interface SettledCounts {
|
|
67
|
+
/** Files kept (effective verdict APPROVE). */
|
|
68
|
+
readonly kept: number;
|
|
69
|
+
/** Files discarded (effective verdict REJECT). */
|
|
70
|
+
readonly discarded: number;
|
|
71
|
+
/** Files with no effective verdict (neither kept nor discarded). */
|
|
72
|
+
readonly notReviewed: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Folds a settled change set's decisions into kept / discarded / not-reviewed
|
|
77
|
+
* counts, using the same `deriveEffectiveVerdicts` fold the web uses so a
|
|
78
|
+
* "Keep all" set correctly reads every file as kept.
|
|
79
|
+
*/
|
|
80
|
+
export function settledCounts(set: FileChangeSet): SettledCounts {
|
|
81
|
+
const verdicts = deriveEffectiveVerdicts(set);
|
|
82
|
+
let kept = 0;
|
|
83
|
+
let discarded = 0;
|
|
84
|
+
for (const change of set.changes) {
|
|
85
|
+
const verdict = verdicts.get(change.id);
|
|
86
|
+
if (verdict === FileDecisionAction.APPROVE) kept++;
|
|
87
|
+
else if (verdict === FileDecisionAction.REJECT) discarded++;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
kept,
|
|
91
|
+
discarded,
|
|
92
|
+
notReviewed: set.changes.length - kept - discarded,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A one-line summary of a settled change set's outcome, e.g. `2 kept · 1
|
|
98
|
+
* discarded`, or `3 files changed` when nothing was individually decided.
|
|
99
|
+
*/
|
|
100
|
+
export function settledSummary(set: FileChangeSet): string {
|
|
101
|
+
const { kept, discarded, notReviewed } = settledCounts(set);
|
|
102
|
+
const parts: string[] = [];
|
|
103
|
+
if (kept) parts.push(`${kept} kept`);
|
|
104
|
+
if (discarded) parts.push(`${discarded} discarded`);
|
|
105
|
+
if (notReviewed) parts.push(`${notReviewed} not reviewed`);
|
|
106
|
+
if (parts.length > 0) return parts.join(" · ");
|
|
107
|
+
const n = set.changes.length;
|
|
108
|
+
return `${n} file${n === 1 ? "" : "s"} changed`;
|
|
109
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,18 @@ 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
|
+
|
|
25
|
+
// File-review pure helpers (kind letters, paths, block reasons, settled counts)
|
|
26
|
+
export {
|
|
27
|
+
kindLetter,
|
|
28
|
+
changeDisplayPath,
|
|
29
|
+
blockReasonNote,
|
|
30
|
+
settledCounts,
|
|
31
|
+
settledSummary,
|
|
32
|
+
type SettledCounts,
|
|
33
|
+
} from "./file-review.js";
|
|
22
34
|
export { ExecutionProgress, type ExecutionProgressProps } from "./components/ExecutionProgress.js";
|
|
23
35
|
export { FollowUpInput, type FollowUpInputProps } from "./components/FollowUpInput.js";
|
|
24
36
|
export { UsageWidget, type UsageWidgetProps } from "./components/UsageWidget.js";
|