@aragon-agent/cli 0.5.6 → 0.5.9

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 (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -16,8 +16,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  * parallel product. What inline still guarantees is its GEOMETRY contract — no
17
17
  * fixed frame, `<Static>` keeps the settled history, no private ANSI — not its
18
18
  * pixel-for-pixel appearance.
19
+ *
20
+ * Two things changed with tui-render-performance:
21
+ * - `EntryView` is `React.memo`'d with an explicit comparator (L2 / R3), which
22
+ * is only worth anything because `mapEntry` already preserves object
23
+ * identity for untouched entries;
24
+ * - `TranscriptList` (the FULL-SCREEN body) mounts only the entries inside the
25
+ * viewport and replaces the rest with two spacer boxes (L3 / R1), and the
26
+ * inline `Transcript` clamps its live region so `ink.js:121` is unreachable
27
+ * (L5 / R6).
19
28
  */
20
- import { useRef } from 'react';
29
+ import React, { useCallback, useRef } from 'react';
21
30
  import { Box, Static, Text } from 'ink';
22
31
  import Spinner from 'ink-spinner';
23
32
  import { pickGlyphs, toolGlyph } from './glyphs.js';
@@ -28,8 +37,21 @@ import { AssistantEntry } from './entries/AssistantEntry.js';
28
37
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
29
38
  import { TeamCard, teamCardColor } from './entries/TeamCard.js';
30
39
  import { TodoCard, todoCardColor } from './entries/TodoCard.js';
40
+ import { RetryCard, retryCardColor } from './entries/RetryCard.js';
41
+ import { FastCard, fastCardColor } from './entries/FastCard.js';
42
+ import { MeasuredEntry } from './layout/MeasuredEntry.js';
43
+ import { useViewportGeometry } from './layout/viewport-geometry.js';
44
+ import { estimateEntryRows, heightKey, selectWindow, VIRTUAL_LIMITS, } from './layout/virtual-window.js';
45
+ import { useTerminalSize } from './layout/useTerminalSize.js';
31
46
  /** Keep the last K entries in the live region so a mutating tail stays hot. */
32
47
  const LIVE_TAIL = 1;
48
+ /**
49
+ * Rows the inline live region leaves for the composer, the prompt echo and the
50
+ * one-row margin Ink needs before `outputHeight >= stdout.rows` (I-L5-1).
51
+ */
52
+ const INLINE_LIVE_MARGIN = 4;
53
+ /** Floor on the clamp, so a very short terminal still shows something useful. */
54
+ const INLINE_LIVE_MIN_ROWS = 4;
33
55
  /**
34
56
  * Largest prefix length P such that `entries[0..P)` are all terminal, none is
35
57
  * within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
@@ -63,10 +85,34 @@ export function computeSettledCount(entries, expandedToolIds) {
63
85
  // on every frame for the rest of the session.
64
86
  if (e.kind === 'todo' && e.live)
65
87
  break;
88
+ // Nor is a retry card that is still counting down or still in flight: it is
89
+ // rewritten on every `retryScheduled` / `retryAttempt` and once a second by
90
+ // `retryTick`, and `<Static>` cannot un-print what it has already drawn.
91
+ // `session/persist.ts` forces a loaded card to `interrupted`, which is the
92
+ // other half — a card that never settles is re-rendered on every frame for
93
+ // the rest of the session (R-10).
94
+ if (e.kind === 'retry' && (e.phase === 'waiting' || e.phase === 'retrying'))
95
+ break;
96
+ // A live review card is not settled either: it is rewritten once, when the
97
+ // call comes back, and `<Static>` cannot un-print what it has already drawn
98
+ // (C-5). `session/persist.ts` forces `live: false` on load, which is the
99
+ // other half.
100
+ if (e.kind === 'fast' && e.live)
101
+ break;
66
102
  count = i + 1;
67
103
  }
68
104
  return count;
69
105
  }
106
+ /**
107
+ * The value `EntryView` should receive for `nowSec` on THIS entry.
108
+ *
109
+ * `undefined` for everything that is not a running tool, which is what keeps
110
+ * every other card's memo boundary intact — `undefined === undefined` in the
111
+ * comparator, on every frame, forever.
112
+ */
113
+ function nowSecFor(entry, nowSec) {
114
+ return entry.kind === 'tool' && entry.status === 'running' ? nowSec : undefined;
115
+ }
70
116
  function noticeColor(level, theme) {
71
117
  if (level === 'error')
72
118
  return theme.noticeError;
@@ -81,7 +127,7 @@ function noticeSymbol(level, theme) {
81
127
  return theme.symbols.warn;
82
128
  return theme.symbols.info;
83
129
  }
84
- function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, }) {
130
+ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, mode, theme, caps, liveClampRows, nowSec, }) {
85
131
  const glyphs = pickGlyphs(caps);
86
132
  const separation = separationRows(prev, entry, density);
87
133
  const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
@@ -93,20 +139,80 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
93
139
  // both an animation and a Unicode-only glyph, so reduced motion and an
94
140
  // ASCII terminal fall back to the same static marker (P2-11 / A-10).
95
141
  const animate = entry.streaming && !reducedMotion && caps.unicode;
96
- return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted, theme: theme, caps: caps }));
142
+ return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
143
+ // `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so
144
+ // nothing on the entry reaches a component by itself (P1-6b).
145
+ thinkingMs: entry.thinkingMs, revealable: mode === 'fullscreen', liveClampRows: liveClampRows, theme: theme, caps: caps }));
97
146
  }
98
147
  case 'tool':
99
- return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
148
+ return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, patch: entry.patch, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, liveClampRows: liveClampRows,
149
+ // `entry.live` AND NOT `entry.live ?? []` (P2-4): `ToolCard`'s default
150
+ // comparator sees a fresh `[]` as a changed prop, which is why that
151
+ // file already keeps a shared `EMPTY_LINES`.
152
+ live: entry.live, lastOutputAt: entry.lastOutputAt, nowSec: nowSec, theme: theme, caps: caps }));
100
153
  case 'team':
101
154
  return frame(glyphs.teamAgent, teamCardColor(entry.runs, entry.active, entry.aborted, theme), _jsx(TeamCard, { requested: entry.requested, runs: entry.runs, aborted: entry.aborted, durationMs: entry.durationMs, active: entry.active, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
102
155
  case 'todo':
103
156
  return frame(toolGlyph(glyphs, 'todo_write'), todoCardColor(entry.doneCount, entry.total, entry.live, theme), _jsx(TodoCard, { items: entry.items, doneCount: entry.doneCount, total: entry.total, live: entry.live, interrupted: entry.interrupted, theme: theme, caps: caps }));
157
+ case 'retry':
158
+ return frame(entry.phase === 'exhausted' ? glyphs.error : glyphs.retry, retryCardColor(entry.phase, theme), _jsx(RetryCard, { attempt: entry.attempt, maxRetries: entry.maxRetries, errorType: entry.errorType, phase: entry.phase, resumeAt: entry.resumeAt, totalRetries: entry.totalRetries, elapsedMs: entry.elapsedMs,
159
+ // READ HERE, not threaded from `App`, and that is what keeps the memo
160
+ // boundary intact: a `now` prop on every `EntryView` would change on
161
+ // every frame and re-render the whole transcript. The `retryTick` action
162
+ // refreshes THIS entry's identity once a second, which re-renders this
163
+ // one card and gives it a fresh clock; nothing else moves.
164
+ now: Date.now(),
165
+ // NOT THREADED FROM `App`: reaching this renderer means a TUI is
166
+ // mounted, and the TUI always binds Esc to abort. Headless mode writes
167
+ // its retry lines to stderr and never mounts an entry component, and the
168
+ // exit replay goes through `transcript-text.ts`. `RetryCard` keeps the
169
+ // prop so the render tests can assert the suppressed form.
170
+ reducedMotion: reducedMotion, theme: theme, caps: caps }));
171
+ case 'fast':
172
+ return frame(entry.status === 'failed' ? glyphs.toolError : glyphs.teamAgent, fastCardColor(entry.status, theme), _jsx(FastCard, { reviewIndex: entry.reviewIndex, model: entry.model, status: entry.status, text: entry.text, detail: entry.detail, turn: entry.turn, durationMs: entry.durationMs, live: entry.live, reducedMotion: reducedMotion, theme: theme, caps: caps }));
104
173
  case 'notice':
105
174
  return frame(noticeSymbol(entry.level, theme), noticeColor(entry.level, theme), _jsx(Text, { color: noticeColor(entry.level, theme), children: entry.text }));
106
175
  default:
107
176
  return null;
108
177
  }
109
178
  }
179
+ /**
180
+ * THE memo boundary that makes a long transcript cheap (L2 / R3).
181
+ *
182
+ * The comparator is written out rather than defaulted because `prev` is the one
183
+ * prop whose identity matters for a reason the type does not show: it feeds
184
+ * `separationRows`, so an entry whose PREDECESSOR changed kind must re-render
185
+ * even though the entry itself did not.
186
+ *
187
+ * I-L2-1 — `theme` and `caps` are `useMemo`d in `App.tsx` and are therefore
188
+ * referentially stable across frames. If a future change makes either a fresh
189
+ * object per render, EVERY boundary below becomes a no-op AND NOTHING FAILS;
190
+ * `render-memo.test.tsx` asserts that stability directly so the trap is a red
191
+ * test rather than a silent regression.
192
+ */
193
+ export const EntryView = React.memo(EntryViewImpl, (a, b) => {
194
+ return (a.entry === b.entry &&
195
+ a.prev === b.prev &&
196
+ a.expanded === b.expanded &&
197
+ a.thinkingVisible === b.thinkingVisible &&
198
+ a.reducedMotion === b.reducedMotion &&
199
+ a.density === b.density &&
200
+ a.mode === b.mode &&
201
+ a.theme === b.theme &&
202
+ a.caps === b.caps &&
203
+ a.liveClampRows === b.liveClampRows &&
204
+ /*
205
+ * ELEVENTH TERM, AND OMITTING IT IS THE TRAP I-L2-1 DESCRIBES ONE COMMENT UP
206
+ * (P1-3). This comparator is a CLOSED LIST: a prop absent from it changes
207
+ * without invalidating the boundary, so `ToolCard` is never re-rendered and
208
+ * the stall row freezes at whatever second it first drew — AC-26's "and the
209
+ * seconds advance" failing with nothing reporting it.
210
+ *
211
+ * It is free: `nowSecFor` returns `undefined` for every entry that is not a
212
+ * running tool, and `undefined === undefined` on every frame.
213
+ */
214
+ a.nowSec === b.nowSec);
215
+ });
110
216
  /**
111
217
  * Full-screen transcript body — the same entry renderers with NO `<Static>`.
112
218
  *
@@ -121,20 +227,64 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
121
227
  * mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
122
228
  * un-advanced has no side effect.
123
229
  *
124
- * Rendering budget (I-3): yoga lays out every child even when `overflow: hidden`
125
- * clips it, so cost grows linearly with the entry count. Only the last
126
- * `windowSize` entries are rendered; the reducer and `/save` keep everything.
230
+ * VIRTUALISED (L3 / R1). `windowSize` is now the SCROLL HORIZON — how far back
231
+ * the user can scroll — and no longer the rendering budget, because entries
232
+ * outside the viewport are not mounted at all. What bounds the frame is
233
+ * `selectWindow`, and the two spacers are what keep the scroll maths honest
234
+ * while they are absent.
127
235
  */
128
- export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, }) {
236
+ export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, windowSize, cols, heights, mountedSink, }) {
129
237
  const size = Math.max(1, Math.floor(windowSize));
130
238
  const visible = entries.length > size ? entries.slice(-size) : entries;
131
239
  const collapsed = entries.length - visible.length;
132
240
  const glyphs = pickGlyphs(caps);
133
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [collapsed > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [glyphs.ellipsis, " ", collapsed, " earlier ", collapsed === 1 ? 'entry' : 'entries', " collapsed (/save exports the full session)"] })), visible.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? visible[i - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id)))] }));
241
+ const geometry = useViewportGeometry();
242
+ // Ctrl+T changes every assistant entry's height without changing any entry,
243
+ // so it has to reach the cache key or the spacers freeze at the wrong size.
244
+ const flags = thinkingVisible ? 't' : '';
245
+ // ONE key builder, shared by the lookup below and by `MeasuredEntry`'s report.
246
+ // Two spellings of the same key is a SILENT failure and not a compile error:
247
+ // the measurement lands under a key `resolve` never asks for, every entry
248
+ // falls back to its estimate for the rest of the session, and the spacers
249
+ // quietly encode heights nothing will ever correct.
250
+ const keyOf = (entry) => heightKey(entry, cols, !!expandedToolIds[entry.id], density, flags);
251
+ const { resolve, report } = heights;
252
+ const heightOf = useCallback((index) => {
253
+ const entry = visible[index];
254
+ // `thinkingVisible` IS PASSED, not left to the parameter's conservative
255
+ // default (P1-6a / condition 5). With thinking hidden the default would
256
+ // inflate every assistant entry that thought by its whole wrapped length,
257
+ // and an entry estimated at 400 rows against a real height of 1 is exactly
258
+ // the one `selectWindow` then never mounts and never re-measures.
259
+ return resolve(keyOf(entry), () => estimateEntryRows(entry, cols, density, !!expandedToolIds[entry.id], thinkingVisible));
260
+ },
261
+ // Memoised only incidentally: `visible` IS `entries` while the session is
262
+ // shorter than the horizon and a fresh slice once it is past it, so this
263
+ // rebuilds on some frames and not others. Either is correct — `selectWindow`
264
+ // consumes it synchronously below and it is never handed to a child — and
265
+ // `keyOf` is omitted from the list because every input it closes over is
266
+ // already in it.
267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
268
+ [visible, expandedToolIds, cols, density, flags, resolve, thinkingVisible]);
269
+ const selection = selectWindow({
270
+ entries: visible,
271
+ heightOf,
272
+ viewportRows: geometry.viewportRows,
273
+ offset: geometry.offset,
274
+ overscan: VIRTUAL_LIMITS.overscan,
275
+ });
276
+ const slice = visible.slice(selection.startIndex, selection.endIndex);
277
+ if (mountedSink)
278
+ mountedSink.current = slice.length;
279
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [collapsed > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [glyphs.ellipsis, " ", collapsed, " earlier ", collapsed === 1 ? 'entry' : 'entries', " collapsed (/save exports the full session)"] })), selection.leadingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.leadingRows })), slice.map((entry, i) => {
280
+ const index = selection.startIndex + i;
281
+ return (_jsx(MeasuredEntry, { heightKey: keyOf(entry), onMeasure: report, children: _jsx(EntryView, { entry: entry, prev: index > 0 ? visible[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }) }, entry.id));
282
+ }), selection.trailingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.trailingRows }))] }));
134
283
  }
135
- export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, }) {
284
+ export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, }) {
136
285
  const highWater = useRef(0);
137
286
  const prevLen = useRef(0);
287
+ const { rows } = useTerminalSize();
138
288
  const raw = computeSettledCount(entries, expandedToolIds);
139
289
  // The transcript shrank (clear / reset / restore) — drop the high-water mark.
140
290
  if (entries.length < prevLen.current)
@@ -144,5 +294,23 @@ export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedM
144
294
  highWater.current = settled;
145
295
  const settledEntries = entries.slice(0, settled);
146
296
  const liveEntries = entries.slice(settled);
147
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: settledEntries, children: (entry, index) => (_jsx(EntryView, { entry: entry, prev: index > 0 ? settledEntries[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id)) }), _jsx(Box, { flexDirection: "column", children: liveEntries.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? liveEntries[i - 1] : entries[settled - 1], expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id))) })] }));
297
+ // I-L5-1 — the live region must stay strictly below `stdout.rows`, or Ink
298
+ // takes the `ink.js:118-123` branch and writes `clearTerminal +
299
+ // fullStaticOutput + output` on EVERY subsequent frame for the rest of the
300
+ // session.
301
+ //
302
+ // `LIVE_TAIL` is 1, so the common case is the design's `rows - 4` less the one
303
+ // row the clamp marker costs. THE DIVISION IS NOT DECORATION: the settled
304
+ // boundary is held back by an expanded card, a running tool, a live dispatch
305
+ // or a live todo list, and in every one of those cases the region holds
306
+ // SEVERAL entries at once — a flat per-entry ceiling of `rows - 4` would then
307
+ // overrun by a multiple of itself. `- 1` pays for each entry's marker row.
308
+ //
309
+ // The residual: an entry contributes at least its header row whatever the
310
+ // clamp says, so the bound holds while the live region holds fewer than about
311
+ // `rows / 2` entries. Bounding it unconditionally would mean refusing to draw
312
+ // a live entry at all, which is worse than a tall frame.
313
+ const liveBudget = Math.max(INLINE_LIVE_MIN_ROWS, rows - INLINE_LIVE_MARGIN);
314
+ const liveClampRows = Math.max(1, Math.floor(liveBudget / Math.max(1, liveEntries.length)) - 1);
315
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: settledEntries, children: (entry, index) => (_jsx(EntryView, { entry: entry, prev: index > 0 ? settledEntries[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, theme: theme, caps: caps }, entry.id)) }), _jsx(Box, { flexDirection: "column", children: liveEntries.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? liveEntries[i - 1] : entries[settled - 1], expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, theme: theme, caps: caps, liveClampRows: liveClampRows, nowSec: nowSecFor(entry, nowSec) }, entry.id))) })] }));
148
316
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The working line's vocabulary (agent-activity-presentation §3.2.1).
3
+ *
4
+ * PURE: no React, no Ink, no clock. Everything here is a function of the two
5
+ * numbers the caller already has, which is what makes the rotation testable
6
+ * without a terminal and without fake timers.
7
+ */
8
+ /** Rotation cadence. Long enough to read, short enough to prove liveness. */
9
+ export declare const PHRASE_ROTATE_MS = 4000;
10
+ /**
11
+ * ASCII ONLY, by the rule this tree is under (`glyphs.ts` header, enforced by
12
+ * `glyphs.test.ts`'s static scan). Present participles, so the line reads as an
13
+ * action in progress; twelve characters or fewer so the row never fights the
14
+ * terminal's width.
15
+ */
16
+ export declare const ACTIVITY_PHRASES: readonly string[];
17
+ /**
18
+ * The phrase for a run at a moment.
19
+ *
20
+ * PURE AND TOTAL: the same inputs give the same word, for any finite numbers,
21
+ * including a `now` before `startedAt`.
22
+ *
23
+ * The run's start seeds the sequence, so two consecutive runs do not open on the
24
+ * same word — which is what makes the line read as alive rather than as a fixed
25
+ * label. (The seed's granularity is one second, so two runs starting inside the
26
+ * same second do share an opening word — P2-6.)
27
+ *
28
+ * `rotate: false` is the reduced-motion path: the word is chosen once for the run
29
+ * and then holds. Rotating text IS motion, and a user who asked for
30
+ * `reducedMotion` asked for the screen to stop changing under them; degrading
31
+ * only the spinner would honor the letter of the setting and not its point.
32
+ */
33
+ export declare function pickActivityPhrase(startedAt: number, now: number, rotate?: boolean): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The working line's vocabulary (agent-activity-presentation §3.2.1).
3
+ *
4
+ * PURE: no React, no Ink, no clock. Everything here is a function of the two
5
+ * numbers the caller already has, which is what makes the rotation testable
6
+ * without a terminal and without fake timers.
7
+ */
8
+ /** Rotation cadence. Long enough to read, short enough to prove liveness. */
9
+ export const PHRASE_ROTATE_MS = 4_000;
10
+ /**
11
+ * ASCII ONLY, by the rule this tree is under (`glyphs.ts` header, enforced by
12
+ * `glyphs.test.ts`'s static scan). Present participles, so the line reads as an
13
+ * action in progress; twelve characters or fewer so the row never fights the
14
+ * terminal's width.
15
+ */
16
+ export const ACTIVITY_PHRASES = [
17
+ 'Assembling', 'Brewing', 'Calibrating', 'Cogitating', 'Composing',
18
+ 'Computing', 'Concocting', 'Conjuring', 'Considering', 'Crunching',
19
+ 'Distilling', 'Divining', 'Drafting', 'Excavating', 'Fathoming',
20
+ 'Fermenting', 'Finessing', 'Foraging', 'Germinating', 'Herding',
21
+ 'Incubating', 'Inferring', 'Marinating', 'Meandering', 'Mulling',
22
+ 'Musing', 'Noodling', 'Percolating', 'Pondering', 'Puzzling',
23
+ 'Ruminating', 'Scheming', 'Sculpting', 'Simmering', 'Sketching',
24
+ 'Spelunking', 'Steeping', 'Stitching', 'Summoning', 'Tinkering',
25
+ 'Unfurling', 'Untangling', 'Weaving', 'Whittling', 'Wrangling',
26
+ ];
27
+ /**
28
+ * The phrase for a run at a moment.
29
+ *
30
+ * PURE AND TOTAL: the same inputs give the same word, for any finite numbers,
31
+ * including a `now` before `startedAt`.
32
+ *
33
+ * The run's start seeds the sequence, so two consecutive runs do not open on the
34
+ * same word — which is what makes the line read as alive rather than as a fixed
35
+ * label. (The seed's granularity is one second, so two runs starting inside the
36
+ * same second do share an opening word — P2-6.)
37
+ *
38
+ * `rotate: false` is the reduced-motion path: the word is chosen once for the run
39
+ * and then holds. Rotating text IS motion, and a user who asked for
40
+ * `reducedMotion` asked for the screen to stop changing under them; degrading
41
+ * only the spinner would honor the letter of the setting and not its point.
42
+ */
43
+ export function pickActivityPhrase(startedAt, now, rotate = true) {
44
+ const n = ACTIVITY_PHRASES.length;
45
+ if (!Number.isFinite(startedAt) || !Number.isFinite(now))
46
+ return ACTIVITY_PHRASES[0];
47
+ const seed = Math.floor(startedAt / 1000);
48
+ const step = rotate ? Math.floor(Math.max(0, now - startedAt) / PHRASE_ROTATE_MS) : 0;
49
+ return ACTIVITY_PHRASES[(((seed + step) % n) + n) % n];
50
+ }
@@ -5,6 +5,13 @@
5
5
  *
6
6
  * The role glyph and spacing belong to `EntryFrame` (§4.3) — including the
7
7
  * streaming spinner, which IS the role marker while text is arriving.
8
+ *
9
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3):
10
+ * every prop here is a scalar except `theme` and `caps`, both of which are
11
+ * `useMemo`d in `App.tsx` and therefore referentially stable across frames
12
+ * (I-L2-1, asserted by `render-memo.test.tsx`). If either ever becomes a fresh
13
+ * object per render this boundary silently becomes a no-op — which is exactly
14
+ * why that assertion exists rather than a comment.
8
15
  */
9
16
  import React from 'react';
10
17
  import type { Theme } from '../theme.js';
@@ -16,8 +23,38 @@ interface AssistantEntryProps {
16
23
  thinkingVisible: boolean;
17
24
  streaming: boolean;
18
25
  aborted?: boolean;
26
+ /**
27
+ * Sealed reasoning time (§3.1.2). OMITTED WHEN UNKNOWN rather than printed as
28
+ * `0s`: a session restored from a file written by an older build has none, and
29
+ * `thought for 0s` is a lie where a bare `thought` is merely terse.
30
+ */
31
+ thinkingMs?: number;
32
+ /**
33
+ * Whether pressing `Ctrl+T` would actually reveal THIS entry's body — i.e.
34
+ * `mode === 'fullscreen'` (D-16 / P0-2).
35
+ *
36
+ * In inline mode a settled entry has already been printed into Ink's
37
+ * `<Static>`, which cannot un-print or re-print (`Transcript.tsx:9-10`), and
38
+ * the settled boundary is held monotonic precisely so an entry never flows back
39
+ * out. With thinking hidden by default the body is never printed there AT ALL,
40
+ * so a row reading `ctrl+t to show` would be an instruction that provably does
41
+ * nothing. The row degrades to `* thought for 12s` instead — still honest that
42
+ * reasoning happened and how long it took, and silent about a key that cannot
43
+ * deliver.
44
+ */
45
+ revealable?: boolean;
46
+ /**
47
+ * Inline mode only (L5): keep at most this many rows of a LIVE entry, so the
48
+ * non-`<Static>` region cannot reach `stdout.rows`. `undefined` means no
49
+ * clamp on the ANSWER BODY, which is what the full-screen branch passes — so
50
+ * the rendered answer there is byte-identical to a pre-feature build. (The
51
+ * thinking block has its own streaming clamp below and is not covered by that
52
+ * statement.)
53
+ */
54
+ liveClampRows?: number;
19
55
  theme: Theme;
20
56
  caps: TermCapabilities;
21
57
  }
22
- export declare function AssistantEntry(props: AssistantEntryProps): React.ReactElement | null;
58
+ declare function AssistantEntryImpl(props: AssistantEntryProps): React.ReactElement | null;
59
+ export declare const AssistantEntry: React.MemoExoticComponent<typeof AssistantEntryImpl>;
23
60
  export {};
@@ -1,13 +1,44 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Render a streaming assistant entry: an optional collapsible thinking block
4
+ * above the markdown-rendered answer text. Empty entries render nothing (a turn
5
+ * that only produced tool calls has no assistant text).
6
+ *
7
+ * The role glyph and spacing belong to `EntryFrame` (§4.3) — including the
8
+ * streaming spinner, which IS the role marker while text is arriving.
9
+ *
10
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3):
11
+ * every prop here is a scalar except `theme` and `caps`, both of which are
12
+ * `useMemo`d in `App.tsx` and therefore referentially stable across frames
13
+ * (I-L2-1, asserted by `render-memo.test.tsx`). If either ever becomes a fresh
14
+ * object per render this boundary silently becomes a no-op — which is exactly
15
+ * why that assertion exists rather than a comment.
16
+ */
17
+ import React from 'react';
2
18
  import { Box, Text } from 'ink';
3
19
  import { pickGlyphs } from '../glyphs.js';
4
20
  import { Markdown } from '../Markdown.js';
5
- export function AssistantEntry(props) {
6
- const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, theme, caps } = props;
21
+ import { splitLinesCached } from '../render-cache.js';
22
+ import { clampLiveText } from '../live-clamp.js';
23
+ import { formatDuration } from '../../agent/usage.js';
24
+ function AssistantEntryImpl(props) {
25
+ const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, thinkingMs, revealable, liveClampRows, theme, caps, } = props;
7
26
  const glyphs = pickGlyphs(caps);
8
27
  const hasThinking = !!thinking && thinking.trim().length > 0;
9
28
  const hasText = text.trim().length > 0;
10
29
  if (!hasThinking && !hasText && !streaming)
11
30
  return null;
12
- return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: thinking.split('\n').map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i))) })] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [hasText ? (_jsx(Markdown, { text: text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
31
+ // The thinking block is clamped to its TAIL while streaming even in
32
+ // full-screen mode: it is scratch reasoning the user reads the newest end of,
33
+ // and an unbounded one is the single easiest way to push a live entry past the
34
+ // whole viewport.
35
+ const thinkingRows = liveClampRows ?? (streaming ? STREAMING_THINKING_ROWS : undefined);
36
+ const clampedThinking = hasThinking && thinkingRows !== undefined
37
+ ? clampLiveText(thinking, thinkingRows)
38
+ : { text: thinking ?? '', hiddenRows: 0 };
39
+ const body = liveClampRows === undefined ? { text, hiddenRows: 0 } : clampLiveText(text, liveClampRows);
40
+ return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [clampedThinking.hiddenRows > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.ellipsis, " ", clampedThinking.hiddenRows, " earlier", ' ', clampedThinking.hiddenRows === 1 ? 'line' : 'lines'] })), splitLinesCached(clampedThinking.text).map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i)))] })] })), hasThinking && !thinkingVisible && !streaming && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.thinking, " thought", thinkingMs !== undefined ? ` for ${formatDuration(thinkingMs)}` : '', revealable ? ` ${glyphs.midDot} ctrl+t to show` : ''] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [body.hiddenRows > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.ellipsis, " ", body.hiddenRows, " earlier", ' ', body.hiddenRows === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), hasText ? (_jsx(Markdown, { text: body.text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
13
41
  }
42
+ /** Rows of live thinking kept on screen while an answer streams. */
43
+ const STREAMING_THINKING_ROWS = 24;
44
+ export const AssistantEntry = React.memo(AssistantEntryImpl);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A `FilePatch` as a diff (agent-activity-presentation §3.4): a `+6 -5` summary,
3
+ * then hunk-separated rows with a right-aligned line-number gutter, a `+`/`-`/
4
+ * space sign column and `theme.diff` colors.
5
+ *
6
+ * `wrap="truncate"` ON EVERY ROW IS A CORRECTNESS REQUIREMENT, NOT A TASTE ONE.
7
+ * `estimateEntryRows` computes a diff card's height by COUNTING rows, so a
8
+ * wrapped patch line would occupy two rows while the estimate charged one — an
9
+ * under-estimate, which `virtual-window.ts` names as the unsafe direction. One
10
+ * patch line is one terminal row, always.
11
+ *
12
+ * No new keybinding and no new state: collapse rides on `expandedToolIds` and
13
+ * the existing `Ctrl+O` handler, which targets the MOST RECENT tool card (P2-4).
14
+ *
15
+ * `caps` is deliberately NOT a prop. Every character this component emits is
16
+ * ASCII — `+`, `-`, `@@`, digits — because `+` and `-` are the diff's own
17
+ * vocabulary and `renderUnifiedDiff` already spells them literally. A glyph
18
+ * lookup here would buy nothing and add a memo dependency.
19
+ */
20
+ import React from 'react';
21
+ import type { Theme } from '../theme.js';
22
+ import type { FilePatch, PatchLine } from '../../tools/patch.js';
23
+ /**
24
+ * Diff rows shown before collapse.
25
+ *
26
+ * Higher than the generic preview's 8 (D-11): a diff's rows are half as tall in
27
+ * information terms, and 8 rows of a 3-context hunk is barely one change.
28
+ * DUPLICATED AS A NUMBER in `virtual-window.ts`, matching what
29
+ * `TOOL_COLLAPSED_LINES` already does and the comment that justifies it.
30
+ */
31
+ export declare const DIFF_COLLAPSED_LINES = 12;
32
+ export interface DiffViewProps {
33
+ patch: FilePatch;
34
+ expanded?: boolean;
35
+ /**
36
+ * Inline mode only (L5): ceiling on the rows a LIVE card contributes to the
37
+ * non-`<Static>` region. `undefined` — what the full-screen branch passes —
38
+ * means no clamp.
39
+ */
40
+ liveClampRows?: number;
41
+ theme: Theme;
42
+ }
43
+ /** One rendered row: a patch line, or the `@@` separator between two hunks. */
44
+ interface DiffRow {
45
+ kind: PatchLine['kind'] | 'meta';
46
+ /** The gutter number, or `undefined` for a separator and the newline note. */
47
+ num?: number;
48
+ text: string;
49
+ }
50
+ /**
51
+ * Flatten the hunks into rows, inserting one `@@` separator BETWEEN hunks and
52
+ * none before the first.
53
+ *
54
+ * Exported for `diff-view.test.tsx`, and because the row count it produces is
55
+ * the number `estimateEntryRows` reproduces arithmetically — the two must not
56
+ * disagree.
57
+ */
58
+ export declare function diffRows(patch: FilePatch): DiffRow[];
59
+ declare function DiffViewImpl({ patch, expanded, liveClampRows, theme, }: DiffViewProps): React.ReactElement;
60
+ /**
61
+ * `React.memo` with the DEFAULT comparator, matching `ToolCard`'s boundary.
62
+ * `patch` qualifies as a prop only because the reducer writes it ONCE at
63
+ * `toolExecEnd` and never rebuilds it (P2-9); `theme` is `useMemo`d in `App.tsx`
64
+ * (I-L2-1).
65
+ */
66
+ export declare const DiffView: React.MemoExoticComponent<typeof DiffViewImpl>;
67
+ export {};
@@ -0,0 +1,103 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A `FilePatch` as a diff (agent-activity-presentation §3.4): a `+6 -5` summary,
4
+ * then hunk-separated rows with a right-aligned line-number gutter, a `+`/`-`/
5
+ * space sign column and `theme.diff` colors.
6
+ *
7
+ * `wrap="truncate"` ON EVERY ROW IS A CORRECTNESS REQUIREMENT, NOT A TASTE ONE.
8
+ * `estimateEntryRows` computes a diff card's height by COUNTING rows, so a
9
+ * wrapped patch line would occupy two rows while the estimate charged one — an
10
+ * under-estimate, which `virtual-window.ts` names as the unsafe direction. One
11
+ * patch line is one terminal row, always.
12
+ *
13
+ * No new keybinding and no new state: collapse rides on `expandedToolIds` and
14
+ * the existing `Ctrl+O` handler, which targets the MOST RECENT tool card (P2-4).
15
+ *
16
+ * `caps` is deliberately NOT a prop. Every character this component emits is
17
+ * ASCII — `+`, `-`, `@@`, digits — because `+` and `-` are the diff's own
18
+ * vocabulary and `renderUnifiedDiff` already spells them literally. A glyph
19
+ * lookup here would buy nothing and add a memo dependency.
20
+ */
21
+ import React from 'react';
22
+ import { Box, Text } from 'ink';
23
+ /**
24
+ * Diff rows shown before collapse.
25
+ *
26
+ * Higher than the generic preview's 8 (D-11): a diff's rows are half as tall in
27
+ * information terms, and 8 rows of a 3-context hunk is barely one change.
28
+ * DUPLICATED AS A NUMBER in `virtual-window.ts`, matching what
29
+ * `TOOL_COLLAPSED_LINES` already does and the comment that justifies it.
30
+ */
31
+ export const DIFF_COLLAPSED_LINES = 12;
32
+ /** Gutter width bounds; three digits is the floor so narrow files still align. */
33
+ const GUTTER_MIN = 3;
34
+ const GUTTER_MAX = 6;
35
+ /**
36
+ * Flatten the hunks into rows, inserting one `@@` separator BETWEEN hunks and
37
+ * none before the first.
38
+ *
39
+ * Exported for `diff-view.test.tsx`, and because the row count it produces is
40
+ * the number `estimateEntryRows` reproduces arithmetically — the two must not
41
+ * disagree.
42
+ */
43
+ export function diffRows(patch) {
44
+ const rows = [];
45
+ patch.hunks.forEach((hunk, index) => {
46
+ if (index > 0) {
47
+ rows.push({
48
+ kind: 'meta',
49
+ text: `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`,
50
+ });
51
+ }
52
+ for (const line of hunk.lines) {
53
+ rows.push({
54
+ kind: line.kind,
55
+ // An `add` shows its new number, a `del` its old, a `ctx` its new.
56
+ ...(pickNumber(line) !== undefined ? { num: pickNumber(line) } : {}),
57
+ text: line.text,
58
+ });
59
+ }
60
+ });
61
+ return rows;
62
+ }
63
+ function pickNumber(line) {
64
+ return line.kind === 'del' ? line.oldLine : line.newLine ?? line.oldLine;
65
+ }
66
+ function signOf(kind) {
67
+ if (kind === 'add')
68
+ return '+';
69
+ if (kind === 'del')
70
+ return '-';
71
+ return ' ';
72
+ }
73
+ function colorOf(kind, theme) {
74
+ if (kind === 'add')
75
+ return theme.diff.add;
76
+ if (kind === 'del')
77
+ return theme.diff.remove;
78
+ if (kind === 'meta')
79
+ return theme.diff.meta;
80
+ return theme.diff.context;
81
+ }
82
+ function gutterWidth(rows) {
83
+ let max = 0;
84
+ for (const row of rows)
85
+ if (row.num !== undefined && row.num > max)
86
+ max = row.num;
87
+ return Math.min(GUTTER_MAX, Math.max(GUTTER_MIN, String(max).length));
88
+ }
89
+ function DiffViewImpl({ patch, expanded, liveClampRows, theme, }) {
90
+ const rows = diffRows(patch);
91
+ const ceiling = Math.min(expanded ? rows.length : DIFF_COLLAPSED_LINES, liveClampRows ?? Number.POSITIVE_INFINITY);
92
+ const visible = ceiling >= rows.length ? rows : rows.slice(0, Math.max(0, ceiling));
93
+ const hidden = rows.length - visible.length;
94
+ const width = gutterWidth(rows);
95
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.diff.add, children: ["+", patch.added] }), _jsx(Text, { color: theme.muted, children: " " }), _jsxs(Text, { color: theme.diff.remove, children: ["-", patch.removed] }), patch.truncated && _jsx(Text, { color: theme.muted, children: " (truncated)" }), patch.degraded && (_jsxs(Text, { color: theme.muted, children: [" old side not read: ", patch.degraded] }))] }), visible.map((row, i) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.muted, children: String(row.num ?? '').padStart(width) }), _jsxs(Text, { color: colorOf(row.kind, theme), children: [' ', signOf(row.kind), " ", row.text] })] }, i))), hidden > 0 ? (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })) : (rows.length > DIFF_COLLAPSED_LINES && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] }));
96
+ }
97
+ /**
98
+ * `React.memo` with the DEFAULT comparator, matching `ToolCard`'s boundary.
99
+ * `patch` qualifies as a prop only because the reducer writes it ONCE at
100
+ * `toolExecEnd` and never rebuilds it (P2-9); `theme` is `useMemo`d in `App.tsx`
101
+ * (I-L2-1).
102
+ */
103
+ export const DiffView = React.memo(DiffViewImpl);