@stigmer/ink 3.1.1 → 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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type {
|
|
4
|
+
CapturedFileChange,
|
|
5
|
+
FileChangeSet,
|
|
6
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
7
|
+
import {
|
|
8
|
+
FileDecisionAction,
|
|
9
|
+
FileDecisionScope,
|
|
10
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
11
|
+
import {
|
|
12
|
+
changeSetReviewability,
|
|
13
|
+
fileReviewability,
|
|
14
|
+
deriveEffectiveVerdicts,
|
|
15
|
+
fileDecisionKey,
|
|
16
|
+
type FileDecisionOptions,
|
|
17
|
+
} from "@stigmer/react";
|
|
18
|
+
import { blockReasonNote, changeDisplayPath, kindLetter } from "../file-review.js";
|
|
19
|
+
|
|
20
|
+
/** Props for {@link FileReviewPrompt}. */
|
|
21
|
+
export interface FileReviewPromptProps {
|
|
22
|
+
/** The AWAITING_REVIEW change set this prompt decides. */
|
|
23
|
+
readonly changeSet: FileChangeSet;
|
|
24
|
+
/**
|
|
25
|
+
* Submit a decision for this set (or one file within it). Bound to the active
|
|
26
|
+
* execution by the caller; the terminal analogue of the web dock's
|
|
27
|
+
* `submitFileDecision`.
|
|
28
|
+
*/
|
|
29
|
+
readonly onSubmit: (
|
|
30
|
+
changeSetId: string,
|
|
31
|
+
action: FileDecisionAction,
|
|
32
|
+
options?: FileDecisionOptions,
|
|
33
|
+
) => void;
|
|
34
|
+
/** Keys (via `fileDecisionKey`) currently being submitted — drives busy state. */
|
|
35
|
+
readonly submittingDecisionKeys?: ReadonlySet<string>;
|
|
36
|
+
/** Per-decision failures, keyed via `fileDecisionKey`. */
|
|
37
|
+
readonly decisionErrors?: ReadonlyMap<string, Error>;
|
|
38
|
+
/**
|
|
39
|
+
* Whether this prompt owns the keyboard. False renders a read-only view (no
|
|
40
|
+
* `useInput`) so exactly one decision surface is interactive at a time.
|
|
41
|
+
* Defaults to `true`.
|
|
42
|
+
*/
|
|
43
|
+
readonly isActive?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The bulk approve/reject verb labels for a set, mirroring the web card's
|
|
48
|
+
* `bulkLabels`: "Keep" for a binary-only set (its bytes are applied as-is),
|
|
49
|
+
* "Approve" otherwise; "remaining" when some files are already decided.
|
|
50
|
+
*/
|
|
51
|
+
function bulkLabels(
|
|
52
|
+
total: number,
|
|
53
|
+
decided: number,
|
|
54
|
+
binaryOnly: boolean,
|
|
55
|
+
): { approve: string; reject: string } {
|
|
56
|
+
const verb = binaryOnly ? "Keep" : "Approve";
|
|
57
|
+
if (total <= 1) return { approve: verb, reject: "Reject" };
|
|
58
|
+
if (decided > 0 && decided < total) {
|
|
59
|
+
return { approve: `${verb} remaining`, reject: "Reject remaining" };
|
|
60
|
+
}
|
|
61
|
+
return { approve: `${verb} all`, reject: "Reject all" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface BulkOption {
|
|
65
|
+
readonly label: string;
|
|
66
|
+
readonly shortcut: string;
|
|
67
|
+
readonly color: string;
|
|
68
|
+
readonly run: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The terminal file-review decision surface — the linear-log analogue of the
|
|
73
|
+
* web's composer-docked `FileReviewDock`/`FileReviewCard`.
|
|
74
|
+
*
|
|
75
|
+
* Renders `N files awaiting review` and, gated by `changeSetReviewability`:
|
|
76
|
+
* bulk Approve/Keep all + Reject all (a complete set is approvable as-is; a
|
|
77
|
+
* binary-only set is kept with an acknowledgement; a blocked set can only be
|
|
78
|
+
* rejected in bulk), plus a per-file mode (`f`) for keeping/discarding each file
|
|
79
|
+
* — the only way to resolve a blocked or mixed set, since the server refuses a
|
|
80
|
+
* whole-set approve unless every file is complete. Decisions carry the same
|
|
81
|
+
* digests (`aggregate_digest` / `file_digest`) and `acknowledge_unreviewable`
|
|
82
|
+
* semantics as the web; no decision-model change.
|
|
83
|
+
*
|
|
84
|
+
* Keyboard: `a` approve/keep all · `r` reject all · `f` per-file mode; in
|
|
85
|
+
* per-file mode ↑/↓ move, `k` keep, `d` discard, `esc` back. Input is gated by
|
|
86
|
+
* `isActive` so only one decision surface owns the keyboard at a time.
|
|
87
|
+
*/
|
|
88
|
+
export function FileReviewPrompt({
|
|
89
|
+
changeSet,
|
|
90
|
+
onSubmit,
|
|
91
|
+
submittingDecisionKeys,
|
|
92
|
+
decisionErrors,
|
|
93
|
+
isActive = true,
|
|
94
|
+
}: FileReviewPromptProps) {
|
|
95
|
+
const [perFile, setPerFile] = useState(false);
|
|
96
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
97
|
+
|
|
98
|
+
const changes = changeSet.changes;
|
|
99
|
+
const total = changes.length;
|
|
100
|
+
const reviewability = changeSetReviewability(changeSet);
|
|
101
|
+
const binaryOnly = reviewability === "binary-only";
|
|
102
|
+
const canBulkApprove = reviewability !== "blocked";
|
|
103
|
+
|
|
104
|
+
const verdicts = useMemo(
|
|
105
|
+
() => deriveEffectiveVerdicts(changeSet),
|
|
106
|
+
[changeSet],
|
|
107
|
+
);
|
|
108
|
+
const decidedCount = useMemo(() => {
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (const c of changes) if (verdicts.has(c.id)) n++;
|
|
111
|
+
return n;
|
|
112
|
+
}, [changes, verdicts]);
|
|
113
|
+
|
|
114
|
+
const labels = bulkLabels(total, decidedCount, binaryOnly);
|
|
115
|
+
|
|
116
|
+
// Busy while any decision for THIS set is in flight (whole-set key or any
|
|
117
|
+
// per-file key). Blocks input to avoid a double-submit mid-request.
|
|
118
|
+
const busy = useMemo(() => {
|
|
119
|
+
if (!submittingDecisionKeys) return false;
|
|
120
|
+
for (const key of submittingDecisionKeys) {
|
|
121
|
+
if (key === changeSet.id || key.startsWith(`${changeSet.id}:`)) return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}, [submittingDecisionKeys, changeSet.id]);
|
|
125
|
+
|
|
126
|
+
const errorText = useMemo(() => {
|
|
127
|
+
if (!decisionErrors) return null;
|
|
128
|
+
for (const [key, err] of decisionErrors) {
|
|
129
|
+
if (key === changeSet.id || key.startsWith(`${changeSet.id}:`)) {
|
|
130
|
+
return err.message;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}, [decisionErrors, changeSet.id]);
|
|
135
|
+
|
|
136
|
+
const submitBulk = (action: FileDecisionAction) => {
|
|
137
|
+
onSubmit(changeSet.id, action, {
|
|
138
|
+
scope: FileDecisionScope.CHANGE_SET,
|
|
139
|
+
expectedDigest: changeSet.aggregateDigest,
|
|
140
|
+
acknowledgeUnreviewable: action === FileDecisionAction.APPROVE && binaryOnly,
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const submitFile = (change: CapturedFileChange, action: FileDecisionAction) => {
|
|
145
|
+
onSubmit(changeSet.id, action, {
|
|
146
|
+
scope: FileDecisionScope.FILE,
|
|
147
|
+
fileChangeId: change.id,
|
|
148
|
+
expectedDigest: change.fileDigest,
|
|
149
|
+
acknowledgeUnreviewable:
|
|
150
|
+
action === FileDecisionAction.APPROVE &&
|
|
151
|
+
fileReviewability(change).kind === "binary",
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Rebuilt each render (cheap; 2-3 options) so the `run` closures always bind
|
|
156
|
+
// the current `changeSet`/`onSubmit` — a memo keyed on the labels would hold
|
|
157
|
+
// a stale submit closure when the set updates without changing its labels.
|
|
158
|
+
const bulkOptions: BulkOption[] = [];
|
|
159
|
+
if (canBulkApprove) {
|
|
160
|
+
bulkOptions.push({
|
|
161
|
+
label: labels.approve,
|
|
162
|
+
shortcut: "a",
|
|
163
|
+
color: "green",
|
|
164
|
+
run: () => submitBulk(FileDecisionAction.APPROVE),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
bulkOptions.push({
|
|
168
|
+
label: labels.reject,
|
|
169
|
+
shortcut: "r",
|
|
170
|
+
color: "red",
|
|
171
|
+
run: () => submitBulk(FileDecisionAction.REJECT),
|
|
172
|
+
});
|
|
173
|
+
bulkOptions.push({
|
|
174
|
+
label: "Review files",
|
|
175
|
+
shortcut: "f",
|
|
176
|
+
color: "cyan",
|
|
177
|
+
run: () => {
|
|
178
|
+
setSelectedIndex(0);
|
|
179
|
+
setPerFile(true);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
useInput(
|
|
184
|
+
(input, key) => {
|
|
185
|
+
if (busy) return;
|
|
186
|
+
|
|
187
|
+
if (perFile) {
|
|
188
|
+
if (key.escape) {
|
|
189
|
+
setPerFile(false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key.upArrow) {
|
|
193
|
+
setSelectedIndex((i) => (i > 0 ? i - 1 : total - 1));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (key.downArrow) {
|
|
197
|
+
setSelectedIndex((i) => (i < total - 1 ? i + 1 : 0));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const change = changes[selectedIndex];
|
|
201
|
+
if (!change) return;
|
|
202
|
+
if (input === "k") {
|
|
203
|
+
// Keep is refused for a file with no reviewable/keepable bytes.
|
|
204
|
+
if (fileReviewability(change).kind !== "unavailable") {
|
|
205
|
+
submitFile(change, FileDecisionAction.APPROVE);
|
|
206
|
+
}
|
|
207
|
+
} else if (input === "d") {
|
|
208
|
+
submitFile(change, FileDecisionAction.REJECT);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Bulk mode.
|
|
214
|
+
const match = bulkOptions.find((o) => o.shortcut === input.toLowerCase());
|
|
215
|
+
if (match) match.run();
|
|
216
|
+
},
|
|
217
|
+
{ isActive: isActive && !busy },
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<Box
|
|
222
|
+
flexDirection="column"
|
|
223
|
+
paddingLeft={2}
|
|
224
|
+
paddingTop={1}
|
|
225
|
+
paddingBottom={1}
|
|
226
|
+
borderStyle="round"
|
|
227
|
+
borderColor="cyan"
|
|
228
|
+
>
|
|
229
|
+
<Box gap={1}>
|
|
230
|
+
<Text color="cyan" bold>
|
|
231
|
+
✎ {total} file{total === 1 ? "" : "s"} awaiting review
|
|
232
|
+
</Text>
|
|
233
|
+
</Box>
|
|
234
|
+
|
|
235
|
+
{perFile ? (
|
|
236
|
+
<PerFileList
|
|
237
|
+
changes={changes}
|
|
238
|
+
verdicts={verdicts}
|
|
239
|
+
selectedIndex={selectedIndex}
|
|
240
|
+
interactive={isActive && !busy}
|
|
241
|
+
/>
|
|
242
|
+
) : (
|
|
243
|
+
<Box gap={2} marginTop={1} paddingLeft={2}>
|
|
244
|
+
{bulkOptions.map((opt) => (
|
|
245
|
+
<Text key={opt.shortcut} color={opt.color}>
|
|
246
|
+
[{opt.shortcut}] {opt.label}
|
|
247
|
+
</Text>
|
|
248
|
+
))}
|
|
249
|
+
</Box>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{busy && (
|
|
253
|
+
<Box paddingLeft={2} marginTop={1}>
|
|
254
|
+
<Text dimColor>Submitting...</Text>
|
|
255
|
+
</Box>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{errorText && (
|
|
259
|
+
<Box paddingLeft={2} marginTop={1}>
|
|
260
|
+
<Text color="red">Decision failed: {errorText}</Text>
|
|
261
|
+
</Box>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{!isActive && !perFile && (
|
|
265
|
+
<Box paddingLeft={2} marginTop={1}>
|
|
266
|
+
<Text dimColor italic>
|
|
267
|
+
Resolve the pending approval first
|
|
268
|
+
</Text>
|
|
269
|
+
</Box>
|
|
270
|
+
)}
|
|
271
|
+
</Box>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface PerFileListProps {
|
|
276
|
+
readonly changes: readonly CapturedFileChange[];
|
|
277
|
+
readonly verdicts: ReadonlyMap<string, FileDecisionAction>;
|
|
278
|
+
readonly selectedIndex: number;
|
|
279
|
+
readonly interactive: boolean;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The per-file review list: kind letter, path, verdict/keepability, and a hint. */
|
|
283
|
+
function PerFileList({
|
|
284
|
+
changes,
|
|
285
|
+
verdicts,
|
|
286
|
+
selectedIndex,
|
|
287
|
+
interactive,
|
|
288
|
+
}: PerFileListProps) {
|
|
289
|
+
return (
|
|
290
|
+
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
|
291
|
+
{changes.map((change, idx) => {
|
|
292
|
+
const selected = idx === selectedIndex;
|
|
293
|
+
const verdict = verdicts.get(change.id);
|
|
294
|
+
const note = blockReasonNote(change);
|
|
295
|
+
const decided =
|
|
296
|
+
verdict === FileDecisionAction.APPROVE
|
|
297
|
+
? "kept"
|
|
298
|
+
: verdict === FileDecisionAction.REJECT
|
|
299
|
+
? "discarded"
|
|
300
|
+
: null;
|
|
301
|
+
return (
|
|
302
|
+
<Box key={change.id} gap={1}>
|
|
303
|
+
<Text color={selected ? "cyan" : undefined}>
|
|
304
|
+
{selected ? "▸" : " "}
|
|
305
|
+
</Text>
|
|
306
|
+
<Text dimColor>{kindLetter(change.kind)}</Text>
|
|
307
|
+
<Text
|
|
308
|
+
color={selected ? "cyan" : undefined}
|
|
309
|
+
bold={selected}
|
|
310
|
+
wrap="truncate-end"
|
|
311
|
+
>
|
|
312
|
+
{changeDisplayPath(change)}
|
|
313
|
+
</Text>
|
|
314
|
+
{decided && <Text dimColor>({decided})</Text>}
|
|
315
|
+
{!decided && note && <Text color="yellow">— {note}</Text>}
|
|
316
|
+
</Box>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
<Box marginTop={1}>
|
|
320
|
+
<Text dimColor>
|
|
321
|
+
{interactive
|
|
322
|
+
? "↑/↓ move · [k] keep · [d] discard · [esc] back"
|
|
323
|
+
: "read-only"}
|
|
324
|
+
</Text>
|
|
325
|
+
</Box>
|
|
326
|
+
</Box>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
4
|
+
import {
|
|
5
|
+
FileChangeSetStatus,
|
|
6
|
+
FileDecisionAction,
|
|
7
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
8
|
+
import { deriveEffectiveVerdicts } from "@stigmer/react";
|
|
9
|
+
import {
|
|
10
|
+
changeDisplayPath,
|
|
11
|
+
kindLetter,
|
|
12
|
+
settledSummary,
|
|
13
|
+
} from "../file-review.js";
|
|
14
|
+
|
|
15
|
+
/** Props for {@link FileReviewRecord}. */
|
|
16
|
+
export interface FileReviewRecordProps {
|
|
17
|
+
/** A settled (decided / reconciled / failed) change set to record. */
|
|
18
|
+
readonly fileChangeSet: FileChangeSet;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A read-only, append-only record of a SETTLED file-review change set — the
|
|
23
|
+
* terminal analogue of the web's in-thread `FileReviewRecordRow` (DD-27 D2).
|
|
24
|
+
*
|
|
25
|
+
* Its primary job is the no-stamped-row case: a set changed only via shell
|
|
26
|
+
* commands stamps no edit rows, so the per-row badges leave no trace and this
|
|
27
|
+
* record is the only evidence of what was reviewed and decided. Sets with
|
|
28
|
+
* stamped rows already carry their outcome on the (frozen) row badges; this
|
|
29
|
+
* record complements them with a one-line summary and file list.
|
|
30
|
+
*/
|
|
31
|
+
export function FileReviewRecord({ fileChangeSet }: FileReviewRecordProps) {
|
|
32
|
+
const failed = fileChangeSet.status === FileChangeSetStatus.FAILED;
|
|
33
|
+
const verdicts = deriveEffectiveVerdicts(fileChangeSet);
|
|
34
|
+
const summary = failed ? "review failed" : settledSummary(fileChangeSet);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
38
|
+
<Box gap={1}>
|
|
39
|
+
<Text dimColor>✎</Text>
|
|
40
|
+
<Text dimColor>File review — {summary}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
{fileChangeSet.changes.map((change) => {
|
|
43
|
+
const verdict = verdicts.get(change.id);
|
|
44
|
+
const label = failed
|
|
45
|
+
? null
|
|
46
|
+
: verdict === FileDecisionAction.APPROVE
|
|
47
|
+
? "kept"
|
|
48
|
+
: verdict === FileDecisionAction.REJECT
|
|
49
|
+
? "discarded"
|
|
50
|
+
: "not reviewed";
|
|
51
|
+
return (
|
|
52
|
+
<Box key={change.id} paddingLeft={2} gap={1}>
|
|
53
|
+
<Text dimColor>{kindLetter(change.kind)}</Text>
|
|
54
|
+
<Text dimColor wrap="truncate-end">
|
|
55
|
+
{changeDisplayPath(change)}
|
|
56
|
+
</Text>
|
|
57
|
+
{label && <Text dimColor>({label})</Text>}
|
|
58
|
+
</Box>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</Box>
|
|
62
|
+
);
|
|
63
|
+
}
|