@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,122 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { FileChangeProgress } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
4
+ import { kindLetter, progressEntryDisplayPath } from "../file-review.js";
5
+
6
+ /** Props for {@link FileChangeProgressBar}. */
7
+ export interface FileChangeProgressBarProps {
8
+ /**
9
+ * The transient mid-run progress snapshot for the active turn (DD-32). Feed
10
+ * this from `useSessionConversation().fileChangeProgress`. Renders nothing when
11
+ * undefined or when no files have changed yet — the server clears it once the
12
+ * turn's change set leaves CAPTURING, so the strip disappears exactly when the
13
+ * decision surface (`FileReviewPrompt`) takes over.
14
+ */
15
+ readonly progress: FileChangeProgress | undefined;
16
+ /**
17
+ * Whether to reveal the per-file list. The compact one-line summary always
18
+ * shows; the list is progressive disclosure. `SessionView` binds this to its
19
+ * existing Ctrl+O "expand" toggle so no new keybinding is introduced. Defaults
20
+ * to `false`.
21
+ */
22
+ readonly expanded?: boolean;
23
+ }
24
+
25
+ /**
26
+ * The terminal analogue of the web's non-interactive "N files changing…" strip
27
+ * (`@stigmer/react` `FileChangeProgressBar`), for a turn that is still running.
28
+ *
29
+ * WHY IT EXISTS. The transcript streams per-edit tool rows, but a change made by
30
+ * a shell command (a `sed`, a build step) has no tool row, and there is no
31
+ * running net-of-many-edits rollup until the turn ends. This strip fills that
32
+ * gap: a compact, non-interactive count of the workspace delta accumulating
33
+ * during the turn, sitting where the `FileReviewPrompt` will later appear. The
34
+ * two are mutually exclusive per turn (progress shows while CAPTURING; the prompt
35
+ * once AWAITING_REVIEW), so the strip hands off cleanly to the prompt.
36
+ *
37
+ * NEVER A DECISION SURFACE. Progress is non-authoritative and carries no file
38
+ * bodies — it is never reviewable or decidable, and it owns no keyboard input.
39
+ * The reviewed diff is the turn-boundary candidate (`FileReviewPrompt`). A
40
+ * secret-like path is surfaced by name with its counts withheld (zeroed), so the
41
+ * `+N −M` stat renders nothing for it. The quiet/dim register (no border)
42
+ * distinguishes it from the loud, bordered decision prompt.
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * const conv = useSessionConversation(sessionId, org);
47
+ * <FileChangeProgressBar progress={conv.fileChangeProgress} expanded={expandTools} />
48
+ * ```
49
+ */
50
+ export function FileChangeProgressBar({
51
+ progress,
52
+ expanded = false,
53
+ }: FileChangeProgressBarProps) {
54
+ if (!progress || progress.filesChanged === 0) return null;
55
+
56
+ const { filesChanged, linesAdded, linesRemoved, entries } = progress;
57
+ const hiddenCount = filesChanged - entries.length;
58
+
59
+ return (
60
+ <Box flexDirection="column" paddingLeft={1}>
61
+ <Box gap={1}>
62
+ <Text dimColor>✎</Text>
63
+ <Text dimColor>
64
+ {filesChanged} file{filesChanged === 1 ? "" : "s"} changing…
65
+ </Text>
66
+ <LineStats linesAdded={linesAdded} linesRemoved={linesRemoved} />
67
+ </Box>
68
+
69
+ {expanded && (
70
+ <Box flexDirection="column" paddingLeft={2}>
71
+ {entries.map((entry, idx) => {
72
+ const path = progressEntryDisplayPath(entry);
73
+ return (
74
+ <Box key={`${path}-${idx}`} gap={1}>
75
+ <Text dimColor>{kindLetter(entry.kind)}</Text>
76
+ <Text dimColor wrap="truncate-end">
77
+ {path}
78
+ </Text>
79
+ <LineStats
80
+ linesAdded={entry.linesAdded}
81
+ linesRemoved={entry.linesRemoved}
82
+ />
83
+ </Box>
84
+ );
85
+ })}
86
+ {hiddenCount > 0 && (
87
+ <Box>
88
+ <Text dimColor>… and {hiddenCount} more</Text>
89
+ </Box>
90
+ )}
91
+ </Box>
92
+ )}
93
+ </Box>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * The `+N −M` stat for one file (or the whole turn), in the diff colour
99
+ * vocabulary. Renders nothing when both counts are zero — the honest signal that
100
+ * no count exists (binary or secret-withheld changes) — rather than a misleading
101
+ * `+0 -0`, matching the web `FileLineStats` contract.
102
+ *
103
+ * Kept module-local: this is the only consumer today. When `FileReviewPrompt`
104
+ * and `FileReviewRecord` gain `+N −M` (the deferred "CLI +N −M parity"
105
+ * follow-up), lift this into a shared ink atom (the analogue of `sdk/react`'s
106
+ * `FileReviewAtoms`) so the three surfaces cannot drift — do not copy it.
107
+ */
108
+ function LineStats({
109
+ linesAdded,
110
+ linesRemoved,
111
+ }: {
112
+ readonly linesAdded: number;
113
+ readonly linesRemoved: number;
114
+ }) {
115
+ if (linesAdded === 0 && linesRemoved === 0) return null;
116
+ return (
117
+ <Text>
118
+ <Text color="green">+{linesAdded}</Text>
119
+ <Text color="red"> -{linesRemoved}</Text>
120
+ </Text>
121
+ );
122
+ }
@@ -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
+ }