@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.
Files changed (52) hide show
  1. package/app/SessionView.d.ts.map +1 -1
  2. package/app/SessionView.js +2 -1
  3. package/app/SessionView.js.map +1 -1
  4. package/components/ApprovalPrompt.d.ts +15 -1
  5. package/components/ApprovalPrompt.d.ts.map +1 -1
  6. package/components/ApprovalPrompt.js +9 -3
  7. package/components/ApprovalPrompt.js.map +1 -1
  8. package/components/FileChangeProgressBar.d.ts +46 -0
  9. package/components/FileChangeProgressBar.d.ts.map +1 -0
  10. package/components/FileChangeProgressBar.js +40 -0
  11. package/components/FileChangeProgressBar.js.map +1 -0
  12. package/components/FileDiffBody.d.ts +30 -0
  13. package/components/FileDiffBody.d.ts.map +1 -0
  14. package/components/FileDiffBody.js +106 -0
  15. package/components/FileDiffBody.js.map +1 -0
  16. package/components/FileReviewAtoms.d.ts +23 -0
  17. package/components/FileReviewAtoms.d.ts.map +1 -0
  18. package/components/FileReviewAtoms.js +26 -0
  19. package/components/FileReviewAtoms.js.map +1 -0
  20. package/components/FileReviewPrompt.d.ts.map +1 -1
  21. package/components/FileReviewPrompt.js +16 -5
  22. package/components/FileReviewPrompt.js.map +1 -1
  23. package/components/FileReviewRecord.d.ts.map +1 -1
  24. package/components/FileReviewRecord.js +5 -3
  25. package/components/FileReviewRecord.js.map +1 -1
  26. package/components/MessageThread.d.ts.map +1 -1
  27. package/components/MessageThread.js +11 -3
  28. package/components/MessageThread.js.map +1 -1
  29. package/file-review.d.ts +27 -3
  30. package/file-review.d.ts.map +1 -1
  31. package/file-review.js +43 -6
  32. package/file-review.js.map +1 -1
  33. package/index.d.ts +4 -1
  34. package/index.d.ts.map +1 -1
  35. package/index.js +4 -1
  36. package/index.js.map +1 -1
  37. package/package.json +4 -4
  38. package/src/__tests__/components.test.tsx +65 -0
  39. package/src/__tests__/data-pickers.test.tsx +1 -6
  40. package/src/__tests__/file-change-progress.test.tsx +295 -0
  41. package/src/__tests__/file-review.test.tsx +318 -3
  42. package/src/__tests__/test-support.tsx +25 -0
  43. package/src/app/SessionView.tsx +12 -0
  44. package/src/components/ApprovalPrompt.tsx +33 -11
  45. package/src/components/FileChangeProgressBar.tsx +96 -0
  46. package/src/components/FileDiffBody.tsx +164 -0
  47. package/src/components/FileReviewAtoms.tsx +37 -0
  48. package/src/components/FileReviewPrompt.tsx +34 -2
  49. package/src/components/FileReviewRecord.tsx +11 -0
  50. package/src/components/MessageThread.tsx +15 -2
  51. package/src/file-review.ts +59 -6
  52. package/src/index.ts +9 -0
@@ -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
- <Text
186
- key={opt.shortcut}
187
- color={idx === selectedIndex ? opt.color : undefined}
188
- dimColor={idx !== selectedIndex}
189
- bold={idx === selectedIndex}
190
- >
191
- {idx === selectedIndex ? "▸ " : " "}
192
- [{opt.shortcut}] {opt.label}
193
- </Text>
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 { blockReasonNote, changeDisplayPath, kindLetter } from "../file-review.js";
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
- /** The per-file review list: kind letter, path, verdict/keepability, and a hint. */
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":
@@ -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 to show for a change: the post-edit path, or the pre-edit path for a
32
- * deletion. A rename shows `before → after` so both sides are visible.
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
- export function changeDisplayPath(change: CapturedFileChange): string {
35
- if (change.kind === FileChangeKind.RENAME && change.pathBefore && change.pathAfter) {
36
- return `${change.pathBefore} → ${change.pathAfter}`;
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 change.pathAfter || change.pathBefore;
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";