@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.
- package/app/SessionView.d.ts.map +1 -1
- package/app/SessionView.js +12 -1
- package/app/SessionView.js.map +1 -1
- package/components/FileChangeProgressBar.d.ts +46 -0
- package/components/FileChangeProgressBar.d.ts.map +1 -0
- package/components/FileChangeProgressBar.js +55 -0
- package/components/FileChangeProgressBar.js.map +1 -0
- 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 +48 -0
- package/file-review.d.ts.map +1 -0
- package/file-review.js +113 -0
- package/file-review.js.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -1
- package/index.js +5 -0
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/file-change-progress.test.tsx +295 -0
- package/src/__tests__/file-review.test.tsx +496 -0
- package/src/app/SessionView.tsx +51 -3
- package/src/components/FileChangeProgressBar.tsx +122 -0
- 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 +135 -0
- 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
|
+
}
|