@stigmer/ink 3.1.3 → 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 (46) hide show
  1. package/components/ApprovalPrompt.d.ts +15 -1
  2. package/components/ApprovalPrompt.d.ts.map +1 -1
  3. package/components/ApprovalPrompt.js +9 -3
  4. package/components/ApprovalPrompt.js.map +1 -1
  5. package/components/FileChangeProgressBar.d.ts.map +1 -1
  6. package/components/FileChangeProgressBar.js +3 -18
  7. package/components/FileChangeProgressBar.js.map +1 -1
  8. package/components/FileDiffBody.d.ts +30 -0
  9. package/components/FileDiffBody.d.ts.map +1 -0
  10. package/components/FileDiffBody.js +106 -0
  11. package/components/FileDiffBody.js.map +1 -0
  12. package/components/FileReviewAtoms.d.ts +23 -0
  13. package/components/FileReviewAtoms.d.ts.map +1 -0
  14. package/components/FileReviewAtoms.js +26 -0
  15. package/components/FileReviewAtoms.js.map +1 -0
  16. package/components/FileReviewPrompt.d.ts.map +1 -1
  17. package/components/FileReviewPrompt.js +16 -5
  18. package/components/FileReviewPrompt.js.map +1 -1
  19. package/components/FileReviewRecord.d.ts.map +1 -1
  20. package/components/FileReviewRecord.js +5 -3
  21. package/components/FileReviewRecord.js.map +1 -1
  22. package/components/MessageThread.d.ts.map +1 -1
  23. package/components/MessageThread.js +11 -3
  24. package/components/MessageThread.js.map +1 -1
  25. package/file-review.d.ts +17 -0
  26. package/file-review.d.ts.map +1 -1
  27. package/file-review.js +18 -0
  28. package/file-review.js.map +1 -1
  29. package/index.d.ts +3 -1
  30. package/index.d.ts.map +1 -1
  31. package/index.js +3 -1
  32. package/index.js.map +1 -1
  33. package/package.json +4 -4
  34. package/src/__tests__/components.test.tsx +65 -0
  35. package/src/__tests__/data-pickers.test.tsx +1 -6
  36. package/src/__tests__/file-review.test.tsx +318 -3
  37. package/src/__tests__/test-support.tsx +25 -0
  38. package/src/components/ApprovalPrompt.tsx +33 -11
  39. package/src/components/FileChangeProgressBar.tsx +3 -29
  40. package/src/components/FileDiffBody.tsx +164 -0
  41. package/src/components/FileReviewAtoms.tsx +37 -0
  42. package/src/components/FileReviewPrompt.tsx +34 -2
  43. package/src/components/FileReviewRecord.tsx +11 -0
  44. package/src/components/MessageThread.tsx +15 -2
  45. package/src/file-review.ts +27 -0
  46. package/src/index.ts +4 -0
@@ -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 && (
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import type { FileChangeProgress } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
4
4
  import { kindLetter, progressEntryDisplayPath } from "../file-review.js";
5
+ import { FileLineStats } from "./FileReviewAtoms.js";
5
6
 
6
7
  /** Props for {@link FileChangeProgressBar}. */
7
8
  export interface FileChangeProgressBarProps {
@@ -63,7 +64,7 @@ export function FileChangeProgressBar({
63
64
  <Text dimColor>
64
65
  {filesChanged} file{filesChanged === 1 ? "" : "s"} changing…
65
66
  </Text>
66
- <LineStats linesAdded={linesAdded} linesRemoved={linesRemoved} />
67
+ <FileLineStats linesAdded={linesAdded} linesRemoved={linesRemoved} />
67
68
  </Box>
68
69
 
69
70
  {expanded && (
@@ -76,7 +77,7 @@ export function FileChangeProgressBar({
76
77
  <Text dimColor wrap="truncate-end">
77
78
  {path}
78
79
  </Text>
79
- <LineStats
80
+ <FileLineStats
80
81
  linesAdded={entry.linesAdded}
81
82
  linesRemoved={entry.linesRemoved}
82
83
  />
@@ -93,30 +94,3 @@ export function FileChangeProgressBar({
93
94
  </Box>
94
95
  );
95
96
  }
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,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":
@@ -88,6 +88,33 @@ export function blockReasonNote(change: CapturedFileChange): string | null {
88
88
  }
89
89
  }
90
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
+
91
118
  /** The effective per-file verdict counts for a settled change set. */
92
119
  export interface SettledCounts {
93
120
  /** Files kept (effective verdict APPROVE). */
package/src/index.ts CHANGED
@@ -21,10 +21,12 @@ 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";
24
25
  export {
25
26
  FileChangeProgressBar,
26
27
  type FileChangeProgressBarProps,
27
28
  } from "./components/FileChangeProgressBar.js";
29
+ export { FileLineStats } from "./components/FileReviewAtoms.js";
28
30
 
29
31
  // File-review pure helpers (kind letters, paths, block reasons, settled counts)
30
32
  export {
@@ -32,8 +34,10 @@ export {
32
34
  changeDisplayPath,
33
35
  progressEntryDisplayPath,
34
36
  blockReasonNote,
37
+ changeSetLineStats,
35
38
  settledCounts,
36
39
  settledSummary,
40
+ type LineStats,
37
41
  type SettledCounts,
38
42
  } from "./file-review.js";
39
43
  export { ExecutionProgress, type ExecutionProgressProps } from "./components/ExecutionProgress.js";