@stigmer/ink 3.1.2 → 3.1.4
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 +2 -1
- package/app/SessionView.js.map +1 -1
- package/components/ApprovalPrompt.d.ts +15 -1
- package/components/ApprovalPrompt.d.ts.map +1 -1
- package/components/ApprovalPrompt.js +9 -3
- package/components/ApprovalPrompt.js.map +1 -1
- package/components/FileChangeProgressBar.d.ts +46 -0
- package/components/FileChangeProgressBar.d.ts.map +1 -0
- package/components/FileChangeProgressBar.js +40 -0
- package/components/FileChangeProgressBar.js.map +1 -0
- package/components/FileDiffBody.d.ts +30 -0
- package/components/FileDiffBody.d.ts.map +1 -0
- package/components/FileDiffBody.js +106 -0
- package/components/FileDiffBody.js.map +1 -0
- package/components/FileReviewAtoms.d.ts +23 -0
- package/components/FileReviewAtoms.d.ts.map +1 -0
- package/components/FileReviewAtoms.js +26 -0
- package/components/FileReviewAtoms.js.map +1 -0
- package/components/FileReviewPrompt.d.ts.map +1 -1
- package/components/FileReviewPrompt.js +16 -5
- package/components/FileReviewPrompt.js.map +1 -1
- package/components/FileReviewRecord.d.ts.map +1 -1
- package/components/FileReviewRecord.js +5 -3
- package/components/FileReviewRecord.js.map +1 -1
- package/components/MessageThread.d.ts.map +1 -1
- package/components/MessageThread.js +11 -3
- package/components/MessageThread.js.map +1 -1
- package/file-review.d.ts +27 -3
- package/file-review.d.ts.map +1 -1
- package/file-review.js +43 -6
- package/file-review.js.map +1 -1
- package/index.d.ts +4 -1
- package/index.d.ts.map +1 -1
- package/index.js +4 -1
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/components.test.tsx +65 -0
- package/src/__tests__/data-pickers.test.tsx +1 -6
- package/src/__tests__/file-change-progress.test.tsx +295 -0
- package/src/__tests__/file-review.test.tsx +318 -3
- package/src/__tests__/test-support.tsx +25 -0
- package/src/app/SessionView.tsx +12 -0
- package/src/components/ApprovalPrompt.tsx +33 -11
- package/src/components/FileChangeProgressBar.tsx +96 -0
- package/src/components/FileDiffBody.tsx +164 -0
- package/src/components/FileReviewAtoms.tsx +37 -0
- package/src/components/FileReviewPrompt.tsx +34 -2
- package/src/components/FileReviewRecord.tsx +11 -0
- package/src/components/MessageThread.tsx +15 -2
- package/src/file-review.ts +59 -6
- package/src/index.ts +9 -0
package/src/app/SessionView.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { TodoList } from "../components/TodoList.js";
|
|
|
7
7
|
import { FollowUpInput } from "../components/FollowUpInput.js";
|
|
8
8
|
import { UsageWidget } from "../components/UsageWidget.js";
|
|
9
9
|
import { ExecutionProgress } from "../components/ExecutionProgress.js";
|
|
10
|
+
import { FileChangeProgressBar } from "../components/FileChangeProgressBar.js";
|
|
10
11
|
import { FileReviewPrompt } from "../components/FileReviewPrompt.js";
|
|
11
12
|
|
|
12
13
|
/** Interaction mode type used for follow-up executions. */
|
|
@@ -180,6 +181,17 @@ export function SessionView({ sessionId, org, mode }: SessionViewProps) {
|
|
|
180
181
|
|
|
181
182
|
<UsageWidget executions={allExecutions} />
|
|
182
183
|
|
|
184
|
+
{/* Mid-run live capture (DD-32): the transient "N files changing…" strip
|
|
185
|
+
for a still-running turn, covering shell-made changes with no tool row.
|
|
186
|
+
Mutually exclusive with the FileReviewPrompt below — progress shows
|
|
187
|
+
while CAPTURING, the prompt once AWAITING_REVIEW — so it hands off
|
|
188
|
+
cleanly when review opens. Non-interactive; the per-file list reuses the
|
|
189
|
+
existing Ctrl+O expand toggle. */}
|
|
190
|
+
<FileChangeProgressBar
|
|
191
|
+
progress={conv.fileChangeProgress}
|
|
192
|
+
expanded={expandTools}
|
|
193
|
+
/>
|
|
194
|
+
|
|
183
195
|
{conv.fileChangeSets.length > 0 && (
|
|
184
196
|
<Box flexDirection="column">
|
|
185
197
|
<FileReviewPrompt
|
|
@@ -16,6 +16,20 @@ export interface ApprovalPromptProps {
|
|
|
16
16
|
readonly onSubmit: (action: ApprovalAction) => void;
|
|
17
17
|
/** Disables input while an approval submission is in flight. */
|
|
18
18
|
readonly isSubmitting?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Whether this prompt owns the keyboard. Defaults to `true`. When `false` the
|
|
21
|
+
* prompt renders inert (no `useInput`, no selection cursor, options dimmed) so
|
|
22
|
+
* exactly one decision surface is interactive at a time.
|
|
23
|
+
*
|
|
24
|
+
* This is the terminal's single-active-decision-surface rule: stdin is
|
|
25
|
+
* delivered to *every* mounted `useInput`, so several live `ApprovalPrompt`s
|
|
26
|
+
* would each consume the same keystroke and settle at once. The caller
|
|
27
|
+
* ({@link MessageThread}) marks only the first pending approval active; the
|
|
28
|
+
* file-review prompt in turn yields to any pending approval
|
|
29
|
+
* (`FileReviewPrompt.isActive = pendingApprovals.length === 0` in `SessionView`),
|
|
30
|
+
* so the order is: first approval → else the file-review prompt.
|
|
31
|
+
*/
|
|
32
|
+
readonly isActive?: boolean;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
interface ActionOption {
|
|
@@ -70,6 +84,7 @@ export function ApprovalPrompt({
|
|
|
70
84
|
pendingApproval,
|
|
71
85
|
onSubmit,
|
|
72
86
|
isSubmitting = false,
|
|
87
|
+
isActive = true,
|
|
73
88
|
}: ApprovalPromptProps) {
|
|
74
89
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
75
90
|
|
|
@@ -125,6 +140,7 @@ export function ApprovalPrompt({
|
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
142
|
},
|
|
143
|
+
{ isActive: isActive && !isSubmitting },
|
|
128
144
|
);
|
|
129
145
|
|
|
130
146
|
const serverSlug = pendingApproval.mcpServerSlug;
|
|
@@ -181,17 +197,23 @@ export function ApprovalPrompt({
|
|
|
181
197
|
</Box>
|
|
182
198
|
|
|
183
199
|
<Box gap={2} marginTop={1} paddingLeft={2}>
|
|
184
|
-
{options.map((opt, idx) =>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
200
|
+
{options.map((opt, idx) => {
|
|
201
|
+
// When inactive, no option is selected and all read dim — the active
|
|
202
|
+
// prompt's highlight (vs. these dimmed rows) is the cue for which
|
|
203
|
+
// approval owns the keyboard, so no per-prompt hint is needed.
|
|
204
|
+
const selected = isActive && idx === selectedIndex;
|
|
205
|
+
return (
|
|
206
|
+
<Text
|
|
207
|
+
key={opt.shortcut}
|
|
208
|
+
color={selected ? opt.color : undefined}
|
|
209
|
+
dimColor={!selected}
|
|
210
|
+
bold={selected}
|
|
211
|
+
>
|
|
212
|
+
{selected ? "▸ " : " "}
|
|
213
|
+
[{opt.shortcut}] {opt.label}
|
|
214
|
+
</Text>
|
|
215
|
+
);
|
|
216
|
+
})}
|
|
195
217
|
</Box>
|
|
196
218
|
|
|
197
219
|
{isSubmitting && (
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
import { FileLineStats } from "./FileReviewAtoms.js";
|
|
6
|
+
|
|
7
|
+
/** Props for {@link FileChangeProgressBar}. */
|
|
8
|
+
export interface FileChangeProgressBarProps {
|
|
9
|
+
/**
|
|
10
|
+
* The transient mid-run progress snapshot for the active turn (DD-32). Feed
|
|
11
|
+
* this from `useSessionConversation().fileChangeProgress`. Renders nothing when
|
|
12
|
+
* undefined or when no files have changed yet — the server clears it once the
|
|
13
|
+
* turn's change set leaves CAPTURING, so the strip disappears exactly when the
|
|
14
|
+
* decision surface (`FileReviewPrompt`) takes over.
|
|
15
|
+
*/
|
|
16
|
+
readonly progress: FileChangeProgress | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to reveal the per-file list. The compact one-line summary always
|
|
19
|
+
* shows; the list is progressive disclosure. `SessionView` binds this to its
|
|
20
|
+
* existing Ctrl+O "expand" toggle so no new keybinding is introduced. Defaults
|
|
21
|
+
* to `false`.
|
|
22
|
+
*/
|
|
23
|
+
readonly expanded?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The terminal analogue of the web's non-interactive "N files changing…" strip
|
|
28
|
+
* (`@stigmer/react` `FileChangeProgressBar`), for a turn that is still running.
|
|
29
|
+
*
|
|
30
|
+
* WHY IT EXISTS. The transcript streams per-edit tool rows, but a change made by
|
|
31
|
+
* a shell command (a `sed`, a build step) has no tool row, and there is no
|
|
32
|
+
* running net-of-many-edits rollup until the turn ends. This strip fills that
|
|
33
|
+
* gap: a compact, non-interactive count of the workspace delta accumulating
|
|
34
|
+
* during the turn, sitting where the `FileReviewPrompt` will later appear. The
|
|
35
|
+
* two are mutually exclusive per turn (progress shows while CAPTURING; the prompt
|
|
36
|
+
* once AWAITING_REVIEW), so the strip hands off cleanly to the prompt.
|
|
37
|
+
*
|
|
38
|
+
* NEVER A DECISION SURFACE. Progress is non-authoritative and carries no file
|
|
39
|
+
* bodies — it is never reviewable or decidable, and it owns no keyboard input.
|
|
40
|
+
* The reviewed diff is the turn-boundary candidate (`FileReviewPrompt`). A
|
|
41
|
+
* secret-like path is surfaced by name with its counts withheld (zeroed), so the
|
|
42
|
+
* `+N −M` stat renders nothing for it. The quiet/dim register (no border)
|
|
43
|
+
* distinguishes it from the loud, bordered decision prompt.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```tsx
|
|
47
|
+
* const conv = useSessionConversation(sessionId, org);
|
|
48
|
+
* <FileChangeProgressBar progress={conv.fileChangeProgress} expanded={expandTools} />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function FileChangeProgressBar({
|
|
52
|
+
progress,
|
|
53
|
+
expanded = false,
|
|
54
|
+
}: FileChangeProgressBarProps) {
|
|
55
|
+
if (!progress || progress.filesChanged === 0) return null;
|
|
56
|
+
|
|
57
|
+
const { filesChanged, linesAdded, linesRemoved, entries } = progress;
|
|
58
|
+
const hiddenCount = filesChanged - entries.length;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box flexDirection="column" paddingLeft={1}>
|
|
62
|
+
<Box gap={1}>
|
|
63
|
+
<Text dimColor>✎</Text>
|
|
64
|
+
<Text dimColor>
|
|
65
|
+
{filesChanged} file{filesChanged === 1 ? "" : "s"} changing…
|
|
66
|
+
</Text>
|
|
67
|
+
<FileLineStats linesAdded={linesAdded} linesRemoved={linesRemoved} />
|
|
68
|
+
</Box>
|
|
69
|
+
|
|
70
|
+
{expanded && (
|
|
71
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
72
|
+
{entries.map((entry, idx) => {
|
|
73
|
+
const path = progressEntryDisplayPath(entry);
|
|
74
|
+
return (
|
|
75
|
+
<Box key={`${path}-${idx}`} gap={1}>
|
|
76
|
+
<Text dimColor>{kindLetter(entry.kind)}</Text>
|
|
77
|
+
<Text dimColor wrap="truncate-end">
|
|
78
|
+
{path}
|
|
79
|
+
</Text>
|
|
80
|
+
<FileLineStats
|
|
81
|
+
linesAdded={entry.linesAdded}
|
|
82
|
+
linesRemoved={entry.linesRemoved}
|
|
83
|
+
/>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
{hiddenCount > 0 && (
|
|
88
|
+
<Box>
|
|
89
|
+
<Text dimColor>… and {hiddenCount} more</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
)}
|
|
92
|
+
</Box>
|
|
93
|
+
)}
|
|
94
|
+
</Box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { CapturedFileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
4
|
+
import { FileChangeType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import {
|
|
6
|
+
computeDiff,
|
|
7
|
+
useFileChangeContent,
|
|
8
|
+
type DiffHunk,
|
|
9
|
+
} from "@stigmer/react";
|
|
10
|
+
import { toDisplayFileChange } from "@stigmer/sdk";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The most diff lines a single file's body renders before it is capped. A
|
|
14
|
+
* terminal has no scroll-within-region, so an unbounded diff would flood the
|
|
15
|
+
* scrollback; beyond this the remainder is summarized in a `… K more lines`
|
|
16
|
+
* footer (the terminal analogue of the web's `BoundedContent` clamp).
|
|
17
|
+
*/
|
|
18
|
+
const DIFF_MAX_LINES = 200;
|
|
19
|
+
|
|
20
|
+
/** Props for {@link FileDiffBody}. */
|
|
21
|
+
export interface FileDiffBodyProps {
|
|
22
|
+
/** The captured file change whose before/after diff to render. */
|
|
23
|
+
readonly change: CapturedFileChange;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The terminal analogue of `@stigmer/react`'s `FileChangeDiff` — the whole-file
|
|
28
|
+
* branch. Renders a single `CapturedFileChange`'s unified diff for review in the
|
|
29
|
+
* terminal.
|
|
30
|
+
*
|
|
31
|
+
* A capture is always whole-file (`toDisplayFileChange` emits
|
|
32
|
+
* `captureLevel = WHOLE_FILE`), so this deliberately handles only that case: it
|
|
33
|
+
* resolves the before/after text via the shared `useFileChangeContent` behavior
|
|
34
|
+
* hook (inline bodies cost nothing; offloaded bodies are fetched lazily through
|
|
35
|
+
* the server proxy) and diffs them with the shared pure `computeDiff` — the SAME
|
|
36
|
+
* engine the web renders, so the terminal and web diffs are line-for-line
|
|
37
|
+
* identical and cannot drift.
|
|
38
|
+
*
|
|
39
|
+
* Renders body-only (no filename caption): the caller's selected row already
|
|
40
|
+
* names the file and shows its `+N −M`, so a header here would just repeat what
|
|
41
|
+
* the reviewer read one line up.
|
|
42
|
+
*
|
|
43
|
+
* **Requires an `InkStigmerProvider` ancestor.** `useFileChangeContent` reads the
|
|
44
|
+
* Stigmer client via `useStigmer()` (to dereference offloaded diff bytes), which
|
|
45
|
+
* throws outside a provider. In a live session this always holds — `SessionView`
|
|
46
|
+
* renders under `InkStigmerProvider`.
|
|
47
|
+
*/
|
|
48
|
+
export function FileDiffBody({ change }: FileDiffBodyProps) {
|
|
49
|
+
const displayChange = useMemo(() => toDisplayFileChange(change), [change]);
|
|
50
|
+
const { beforeText, afterText, isBinary, isLoading, error, isTruncated, downloadUrl } =
|
|
51
|
+
useFileChangeContent(displayChange);
|
|
52
|
+
|
|
53
|
+
const hunks = useMemo<readonly DiffHunk[]>(() => {
|
|
54
|
+
if (beforeText === null || afterText === null) return [];
|
|
55
|
+
return computeDiff(beforeText, afterText);
|
|
56
|
+
}, [beforeText, afterText]);
|
|
57
|
+
|
|
58
|
+
// Honest state precedence, mirroring the web FileChangeBody's whole-file arm:
|
|
59
|
+
// a binary has no text diff; a server-truncated body cannot be diffed inline;
|
|
60
|
+
// an offloaded body may still be loading or have failed; only then do we trust
|
|
61
|
+
// an empty hunk list to mean the file genuinely has no visible change.
|
|
62
|
+
if (isBinary) {
|
|
63
|
+
return <Notice>Binary file changed.</Notice>;
|
|
64
|
+
}
|
|
65
|
+
if (isTruncated) {
|
|
66
|
+
return (
|
|
67
|
+
<Notice>
|
|
68
|
+
This file is too large to diff inline.
|
|
69
|
+
{downloadUrl ? ` Download the full file: ${downloadUrl}` : ""}
|
|
70
|
+
</Notice>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (isLoading) {
|
|
74
|
+
return <Notice>Loading diff…</Notice>;
|
|
75
|
+
}
|
|
76
|
+
if (error) {
|
|
77
|
+
return <Notice tone="error">Could not load this file's contents.</Notice>;
|
|
78
|
+
}
|
|
79
|
+
if (hunks.length === 0) {
|
|
80
|
+
// A CREATE with nothing to show is a genuinely empty new file; anything else
|
|
81
|
+
// with no renderable diff is the non-committal "no preview" — we never claim
|
|
82
|
+
// emptiness we cannot prove from the capture.
|
|
83
|
+
return (
|
|
84
|
+
<Notice>
|
|
85
|
+
{displayChange.changeType === FileChangeType.CREATE
|
|
86
|
+
? "Empty new file."
|
|
87
|
+
: "No preview available."}
|
|
88
|
+
</Notice>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return <DiffHunks hunks={hunks} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Rendering
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** One renderable diff row: a hunk header, or a single before/after line. */
|
|
100
|
+
type DiffRow =
|
|
101
|
+
| { readonly kind: "header"; readonly text: string }
|
|
102
|
+
| { readonly kind: "line"; readonly type: "added" | "removed" | "context"; readonly content: string };
|
|
103
|
+
|
|
104
|
+
/** Flattens hunks into a single row stream so the line cap spans the whole diff. */
|
|
105
|
+
function flattenHunks(hunks: readonly DiffHunk[]): DiffRow[] {
|
|
106
|
+
const rows: DiffRow[] = [];
|
|
107
|
+
for (const hunk of hunks) {
|
|
108
|
+
rows.push({
|
|
109
|
+
kind: "header",
|
|
110
|
+
text: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
111
|
+
});
|
|
112
|
+
for (const line of hunk.lines) {
|
|
113
|
+
rows.push({ kind: "line", type: line.type, content: line.content });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function DiffHunks({ hunks }: { hunks: readonly DiffHunk[] }) {
|
|
120
|
+
const rows = useMemo(() => flattenHunks(hunks), [hunks]);
|
|
121
|
+
const shown = rows.slice(0, DIFF_MAX_LINES);
|
|
122
|
+
const overflow = rows.length - shown.length;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Box flexDirection="column" marginTop={1}>
|
|
126
|
+
{shown.map((row, i) => (
|
|
127
|
+
<DiffRowText key={i} row={row} />
|
|
128
|
+
))}
|
|
129
|
+
{overflow > 0 && (
|
|
130
|
+
<Text dimColor>… {overflow} more line{overflow === 1 ? "" : "s"}</Text>
|
|
131
|
+
)}
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function DiffRowText({ row }: { row: DiffRow }) {
|
|
137
|
+
if (row.kind === "header") {
|
|
138
|
+
return <Text dimColor>{row.text}</Text>;
|
|
139
|
+
}
|
|
140
|
+
if (row.type === "added") {
|
|
141
|
+
return <Text color="green">+{row.content}</Text>;
|
|
142
|
+
}
|
|
143
|
+
if (row.type === "removed") {
|
|
144
|
+
return <Text color="red">-{row.content}</Text>;
|
|
145
|
+
}
|
|
146
|
+
return <Text dimColor> {row.content}</Text>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A short status line for a non-renderable diff (binary / truncated / loading / error / empty). */
|
|
150
|
+
function Notice({
|
|
151
|
+
children,
|
|
152
|
+
tone = "muted",
|
|
153
|
+
}: {
|
|
154
|
+
readonly children: React.ReactNode;
|
|
155
|
+
readonly tone?: "muted" | "error";
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<Box marginTop={1}>
|
|
159
|
+
<Text color={tone === "error" ? "red" : undefined} dimColor={tone !== "error"}>
|
|
160
|
+
{children}
|
|
161
|
+
</Text>
|
|
162
|
+
</Box>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared presentational atoms for the terminal file-review surfaces — the single
|
|
6
|
+
* source for the `+N −M` line stat, so the decision prompt, the settled record,
|
|
7
|
+
* and the mid-run progress bar can never drift in how they render a file's (or a
|
|
8
|
+
* set's) magnitude. The terminal analogue of `@stigmer/react`'s `FileReviewAtoms`.
|
|
9
|
+
*
|
|
10
|
+
* The change-kind marker is deliberately NOT here: the terminal renders kind via
|
|
11
|
+
* the pure `kindLetter` helper (in `file-review.ts`), already shared across every
|
|
12
|
+
* surface. The web needs a `FileKindBadge` component because its marker carries
|
|
13
|
+
* per-kind color tokens and a11y labels; a plain letter needs neither.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `+N −M` stat for one file (or a whole set), in the diff colour vocabulary.
|
|
18
|
+
* Renders nothing when both counts are zero — the honest signal that no count
|
|
19
|
+
* exists (binary or secret-withheld changes, or records captured before the
|
|
20
|
+
* counts were stamped) — rather than a misleading `+0 -0`, matching the web
|
|
21
|
+
* `FileLineStats` contract.
|
|
22
|
+
*/
|
|
23
|
+
export function FileLineStats({
|
|
24
|
+
linesAdded,
|
|
25
|
+
linesRemoved,
|
|
26
|
+
}: {
|
|
27
|
+
readonly linesAdded: number;
|
|
28
|
+
readonly linesRemoved: number;
|
|
29
|
+
}) {
|
|
30
|
+
if (linesAdded === 0 && linesRemoved === 0) return null;
|
|
31
|
+
return (
|
|
32
|
+
<Text>
|
|
33
|
+
<Text color="green">+{linesAdded}</Text>
|
|
34
|
+
<Text color="red"> -{linesRemoved}</Text>
|
|
35
|
+
</Text>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -15,7 +15,14 @@ import {
|
|
|
15
15
|
fileDecisionKey,
|
|
16
16
|
type FileDecisionOptions,
|
|
17
17
|
} from "@stigmer/react";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
blockReasonNote,
|
|
20
|
+
changeDisplayPath,
|
|
21
|
+
changeSetLineStats,
|
|
22
|
+
kindLetter,
|
|
23
|
+
} from "../file-review.js";
|
|
24
|
+
import { FileLineStats } from "./FileReviewAtoms.js";
|
|
25
|
+
import { FileDiffBody } from "./FileDiffBody.js";
|
|
19
26
|
|
|
20
27
|
/** Props for {@link FileReviewPrompt}. */
|
|
21
28
|
export interface FileReviewPromptProps {
|
|
@@ -111,6 +118,10 @@ export function FileReviewPrompt({
|
|
|
111
118
|
return n;
|
|
112
119
|
}, [changes, verdicts]);
|
|
113
120
|
|
|
121
|
+
// The set's aggregate +N −M, shown beside the header so the bar carries the
|
|
122
|
+
// magnitude of what is being decided, not just the file count (web parity).
|
|
123
|
+
const lineStats = useMemo(() => changeSetLineStats(changeSet), [changeSet]);
|
|
124
|
+
|
|
114
125
|
const labels = bulkLabels(total, decidedCount, binaryOnly);
|
|
115
126
|
|
|
116
127
|
// Busy while any decision for THIS set is in flight (whole-set key or any
|
|
@@ -230,6 +241,10 @@ export function FileReviewPrompt({
|
|
|
230
241
|
<Text color="cyan" bold>
|
|
231
242
|
✎ {total} file{total === 1 ? "" : "s"} awaiting review
|
|
232
243
|
</Text>
|
|
244
|
+
<FileLineStats
|
|
245
|
+
linesAdded={lineStats.linesAdded}
|
|
246
|
+
linesRemoved={lineStats.linesRemoved}
|
|
247
|
+
/>
|
|
233
248
|
</Box>
|
|
234
249
|
|
|
235
250
|
{perFile ? (
|
|
@@ -279,13 +294,19 @@ interface PerFileListProps {
|
|
|
279
294
|
readonly interactive: boolean;
|
|
280
295
|
}
|
|
281
296
|
|
|
282
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* The per-file review list: kind letter, path, verdict/keepability, and a hint,
|
|
299
|
+
* followed by a master-detail diff pane for the selected file. The list itself
|
|
300
|
+
* stays stable as the selection moves (the diff renders in a fixed pane below,
|
|
301
|
+
* not inline between rows), so navigating never reflows the rows above.
|
|
302
|
+
*/
|
|
283
303
|
function PerFileList({
|
|
284
304
|
changes,
|
|
285
305
|
verdicts,
|
|
286
306
|
selectedIndex,
|
|
287
307
|
interactive,
|
|
288
308
|
}: PerFileListProps) {
|
|
309
|
+
const selectedChange = changes[selectedIndex];
|
|
289
310
|
return (
|
|
290
311
|
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
|
291
312
|
{changes.map((change, idx) => {
|
|
@@ -311,6 +332,10 @@ function PerFileList({
|
|
|
311
332
|
>
|
|
312
333
|
{changeDisplayPath(change)}
|
|
313
334
|
</Text>
|
|
335
|
+
<FileLineStats
|
|
336
|
+
linesAdded={change.linesAdded}
|
|
337
|
+
linesRemoved={change.linesRemoved}
|
|
338
|
+
/>
|
|
314
339
|
{decided && <Text dimColor>({decided})</Text>}
|
|
315
340
|
{!decided && note && <Text color="yellow">— {note}</Text>}
|
|
316
341
|
</Box>
|
|
@@ -323,6 +348,13 @@ function PerFileList({
|
|
|
323
348
|
: "read-only"}
|
|
324
349
|
</Text>
|
|
325
350
|
</Box>
|
|
351
|
+
|
|
352
|
+
{/* Master-detail: the selected file's diff renders here (keyed by id so it
|
|
353
|
+
remounts — and lazily re-resolves any offloaded body — as the selection
|
|
354
|
+
moves), below the list so the controls above never shift. */}
|
|
355
|
+
{selectedChange && (
|
|
356
|
+
<FileDiffBody key={selectedChange.id} change={selectedChange} />
|
|
357
|
+
)}
|
|
326
358
|
</Box>
|
|
327
359
|
);
|
|
328
360
|
}
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
import { deriveEffectiveVerdicts } from "@stigmer/react";
|
|
9
9
|
import {
|
|
10
10
|
changeDisplayPath,
|
|
11
|
+
changeSetLineStats,
|
|
11
12
|
kindLetter,
|
|
12
13
|
settledSummary,
|
|
13
14
|
} from "../file-review.js";
|
|
15
|
+
import { FileLineStats } from "./FileReviewAtoms.js";
|
|
14
16
|
|
|
15
17
|
/** Props for {@link FileReviewRecord}. */
|
|
16
18
|
export interface FileReviewRecordProps {
|
|
@@ -32,12 +34,17 @@ export function FileReviewRecord({ fileChangeSet }: FileReviewRecordProps) {
|
|
|
32
34
|
const failed = fileChangeSet.status === FileChangeSetStatus.FAILED;
|
|
33
35
|
const verdicts = deriveEffectiveVerdicts(fileChangeSet);
|
|
34
36
|
const summary = failed ? "review failed" : settledSummary(fileChangeSet);
|
|
37
|
+
const lineStats = changeSetLineStats(fileChangeSet);
|
|
35
38
|
|
|
36
39
|
return (
|
|
37
40
|
<Box flexDirection="column" paddingLeft={2}>
|
|
38
41
|
<Box gap={1}>
|
|
39
42
|
<Text dimColor>✎</Text>
|
|
40
43
|
<Text dimColor>File review — {summary}</Text>
|
|
44
|
+
<FileLineStats
|
|
45
|
+
linesAdded={lineStats.linesAdded}
|
|
46
|
+
linesRemoved={lineStats.linesRemoved}
|
|
47
|
+
/>
|
|
41
48
|
</Box>
|
|
42
49
|
{fileChangeSet.changes.map((change) => {
|
|
43
50
|
const verdict = verdicts.get(change.id);
|
|
@@ -54,6 +61,10 @@ export function FileReviewRecord({ fileChangeSet }: FileReviewRecordProps) {
|
|
|
54
61
|
<Text dimColor wrap="truncate-end">
|
|
55
62
|
{changeDisplayPath(change)}
|
|
56
63
|
</Text>
|
|
64
|
+
<FileLineStats
|
|
65
|
+
linesAdded={change.linesAdded}
|
|
66
|
+
linesRemoved={change.linesRemoved}
|
|
67
|
+
/>
|
|
57
68
|
{label && <Text dimColor>({label})</Text>}
|
|
58
69
|
</Box>
|
|
59
70
|
);
|
|
@@ -302,6 +302,16 @@ export function MessageThread({
|
|
|
302
302
|
[executions, activeStreamExecution],
|
|
303
303
|
);
|
|
304
304
|
|
|
305
|
+
// The single active decision surface: stdin is delivered to every mounted
|
|
306
|
+
// `useInput`, so only the FIRST pending approval owns the keyboard — the rest
|
|
307
|
+
// render inert. The file-review prompt (in `SessionView`) in turn yields while
|
|
308
|
+
// any approval is pending, so the terminal always has exactly one interactive
|
|
309
|
+
// decision surface: first approval → else the file-review prompt.
|
|
310
|
+
const firstApprovalKey = useMemo(
|
|
311
|
+
() => items.find((it) => it.kind === "approval")?.key,
|
|
312
|
+
[items],
|
|
313
|
+
);
|
|
314
|
+
|
|
305
315
|
const historyItems = items.slice(0, liveStart);
|
|
306
316
|
const liveItems = items.slice(liveStart);
|
|
307
317
|
|
|
@@ -311,14 +321,14 @@ export function MessageThread({
|
|
|
311
321
|
<Static items={historyItems}>
|
|
312
322
|
{(item) => (
|
|
313
323
|
<Box key={item.key} flexDirection="column" marginBottom={1}>
|
|
314
|
-
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
|
|
324
|
+
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls, firstApprovalKey)}
|
|
315
325
|
</Box>
|
|
316
326
|
)}
|
|
317
327
|
</Static>
|
|
318
328
|
|
|
319
329
|
{liveItems.map((item) => (
|
|
320
330
|
<Box key={item.key} flexDirection="column" marginBottom={1}>
|
|
321
|
-
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls)}
|
|
331
|
+
{renderItem(item, onApprovalSubmit, submittingApprovalIds, expandToolCalls, firstApprovalKey)}
|
|
322
332
|
</Box>
|
|
323
333
|
))}
|
|
324
334
|
</Box>
|
|
@@ -331,6 +341,7 @@ function renderItem(
|
|
|
331
341
|
onApprovalSubmit?: (toolCallId: string, action: ApprovalAction) => void,
|
|
332
342
|
submittingApprovalIds?: ReadonlySet<string>,
|
|
333
343
|
expandToolCalls?: boolean,
|
|
344
|
+
activeApprovalKey?: string,
|
|
334
345
|
): React.ReactNode {
|
|
335
346
|
switch (item.kind) {
|
|
336
347
|
case "message":
|
|
@@ -353,6 +364,8 @@ function renderItem(
|
|
|
353
364
|
isSubmitting={
|
|
354
365
|
submittingApprovalIds?.has(item.pendingApproval.toolCallId) ?? false
|
|
355
366
|
}
|
|
367
|
+
// Only the first pending approval is interactive (see firstApprovalKey).
|
|
368
|
+
isActive={item.key === activeApprovalKey}
|
|
356
369
|
/>
|
|
357
370
|
) : null;
|
|
358
371
|
case "pending-message":
|
package/src/file-review.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CapturedFileChange,
|
|
3
|
+
FileChangeProgressEntry,
|
|
3
4
|
FileChangeSet,
|
|
4
5
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
|
|
5
6
|
import {
|
|
@@ -28,14 +29,39 @@ export function kindLetter(kind: FileChangeKind): string {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
|
-
* The path
|
|
32
|
-
*
|
|
32
|
+
* The one rename-aware path-display rule for the terminal, over the raw
|
|
33
|
+
* before/after/kind triple shared by every file-change shape (reviewable
|
|
34
|
+
* `CapturedFileChange` and slim `FileChangeProgressEntry` alike). Kept private
|
|
35
|
+
* and fed by the typed adapters below so the two surfaces can never grow
|
|
36
|
+
* divergent rename formatting.
|
|
33
37
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
function displayChangePath(
|
|
39
|
+
pathBefore: string,
|
|
40
|
+
pathAfter: string,
|
|
41
|
+
kind: FileChangeKind,
|
|
42
|
+
): string {
|
|
43
|
+
if (kind === FileChangeKind.RENAME && pathBefore && pathAfter) {
|
|
44
|
+
return `${pathBefore} → ${pathAfter}`;
|
|
37
45
|
}
|
|
38
|
-
return
|
|
46
|
+
return pathAfter || pathBefore;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The path to show for a reviewable change: the post-edit path, or the pre-edit
|
|
51
|
+
* path for a deletion. A rename shows `before → after` so both sides are visible.
|
|
52
|
+
*/
|
|
53
|
+
export function changeDisplayPath(change: CapturedFileChange): string {
|
|
54
|
+
return displayChangePath(change.pathBefore, change.pathAfter, change.kind);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The path to show for a mid-run progress entry (DD-32), using the same
|
|
59
|
+
* rename-aware rule as {@link changeDisplayPath}. The slim entry carries no
|
|
60
|
+
* bodies or digests, only the same before/after/kind triple, so the terminal
|
|
61
|
+
* renders its path identically to the reviewable surfaces.
|
|
62
|
+
*/
|
|
63
|
+
export function progressEntryDisplayPath(entry: FileChangeProgressEntry): string {
|
|
64
|
+
return displayChangePath(entry.pathBefore, entry.pathAfter, entry.kind);
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
/**
|
|
@@ -62,6 +88,33 @@ export function blockReasonNote(change: CapturedFileChange): string | null {
|
|
|
62
88
|
}
|
|
63
89
|
}
|
|
64
90
|
|
|
91
|
+
/** The aggregate `+N −M` line counts for a change set. */
|
|
92
|
+
export interface LineStats {
|
|
93
|
+
/** Total lines added across the set's changes. */
|
|
94
|
+
readonly linesAdded: number;
|
|
95
|
+
/** Total lines removed across the set's changes. */
|
|
96
|
+
readonly linesRemoved: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sums a change set's per-file `linesAdded`/`linesRemoved` into one aggregate,
|
|
101
|
+
* for the set-level `+N −M` shown beside the prompt header and the settled
|
|
102
|
+
* record summary (parity with the web card's collapsed-bar aggregate). Counts
|
|
103
|
+
* every change regardless of verdict — it reports what changed, not what was
|
|
104
|
+
* kept. A file with no counts (binary, secret-withheld, or a record predating
|
|
105
|
+
* the fields) contributes zero, so the aggregate honestly understates rather
|
|
106
|
+
* than guessing; when NO file has counts it is zero and the stat is hidden.
|
|
107
|
+
*/
|
|
108
|
+
export function changeSetLineStats(set: FileChangeSet): LineStats {
|
|
109
|
+
let linesAdded = 0;
|
|
110
|
+
let linesRemoved = 0;
|
|
111
|
+
for (const change of set.changes) {
|
|
112
|
+
linesAdded += change.linesAdded;
|
|
113
|
+
linesRemoved += change.linesRemoved;
|
|
114
|
+
}
|
|
115
|
+
return { linesAdded, linesRemoved };
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
/** The effective per-file verdict counts for a settled change set. */
|
|
66
119
|
export interface SettledCounts {
|
|
67
120
|
/** Files kept (effective verdict APPROVE). */
|
package/src/index.ts
CHANGED
|
@@ -21,14 +21,23 @@ export { TodoList, type TodoListProps } from "./components/TodoList.js";
|
|
|
21
21
|
export { ApprovalPrompt, type ApprovalPromptProps } from "./components/ApprovalPrompt.js";
|
|
22
22
|
export { FileReviewPrompt, type FileReviewPromptProps } from "./components/FileReviewPrompt.js";
|
|
23
23
|
export { FileReviewRecord, type FileReviewRecordProps } from "./components/FileReviewRecord.js";
|
|
24
|
+
export { FileDiffBody, type FileDiffBodyProps } from "./components/FileDiffBody.js";
|
|
25
|
+
export {
|
|
26
|
+
FileChangeProgressBar,
|
|
27
|
+
type FileChangeProgressBarProps,
|
|
28
|
+
} from "./components/FileChangeProgressBar.js";
|
|
29
|
+
export { FileLineStats } from "./components/FileReviewAtoms.js";
|
|
24
30
|
|
|
25
31
|
// File-review pure helpers (kind letters, paths, block reasons, settled counts)
|
|
26
32
|
export {
|
|
27
33
|
kindLetter,
|
|
28
34
|
changeDisplayPath,
|
|
35
|
+
progressEntryDisplayPath,
|
|
29
36
|
blockReasonNote,
|
|
37
|
+
changeSetLineStats,
|
|
30
38
|
settledCounts,
|
|
31
39
|
settledSummary,
|
|
40
|
+
type LineStats,
|
|
32
41
|
type SettledCounts,
|
|
33
42
|
} from "./file-review.js";
|
|
34
43
|
export { ExecutionProgress, type ExecutionProgressProps } from "./components/ExecutionProgress.js";
|