@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
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Structured file patches (agent-activity-presentation §3.3).
3
+ *
4
+ * PURE, NO I/O, NO REACT, ASCII-ONLY MARKERS. `glyphs.test.ts`'s static scan
5
+ * walks ALL of `src/` (skipping only `__tests__/` and two exempt files), so this
6
+ * module sits inside that guard rail directly rather than by analogy. The
7
+ * substantive reason is the one `reducer.ts` gives for `PREVIEW_TRUNCATION_MARK`:
8
+ * a string produced outside `src/ui/**` has no `TermCapabilities` in scope and
9
+ * must be safe on a legacy `cmd.exe`.
10
+ *
11
+ * The ladder is ANCHORED SPLIT -> BOUNDED LCS -> BLOCK REPLACE, and every
12
+ * recursion path terminates in one of three leaf returns. `blockReplace` is
13
+ * exactly what `renderUnifiedDiff` produced before this round — every old row as
14
+ * `del`, then every new row as `add` — kept as the explicit, bounded fallback so
15
+ * a pathological input degrades to today's behaviour rather than hanging.
16
+ *
17
+ * `buildPatch` is TOTAL: it never throws, for any pair of strings.
18
+ */
19
+ export const PATCH_LIMITS = {
20
+ /**
21
+ * Context rows kept either side of a change, FOR THE UI.
22
+ *
23
+ * The text renderer does NOT use this (D-20): `renderUnifiedDiff` keeps its
24
+ * own `CONTEXT_LINES = 2` default so the model-facing diff does not silently
25
+ * widen by two rows on every single-region edit. One algorithm, two widths,
26
+ * chosen by the caller.
27
+ */
28
+ context: 3,
29
+ /**
30
+ * Hunks separated by this many context rows or fewer are merged into one.
31
+ *
32
+ * MUST STAY `>= 2 * context` FOR EVERY CALLER'S CONTEXT WIDTH, or two adjacent
33
+ * hunks expanded by `context` would overlap and the same row would be rendered
34
+ * twice with two different line numbers. 6 covers both widths in use (3 and 2).
35
+ */
36
+ mergeGap: 6,
37
+ /** Rendered rows a patch may carry, across all hunks. */
38
+ maxLines: 200,
39
+ /** Total characters across all `PatchLine.text`. Mirrors STORED_PREVIEW_CHARS. */
40
+ maxChars: 8_000,
41
+ /** A single row is truncated past this, with `TRUNCATION_MARK`. */
42
+ maxLineChars: 500,
43
+ /** Above this cell count the LCS leaf is refused; the segment becomes a block replace. */
44
+ lcsCellBudget: 160_000, // 400 x 400
45
+ /** Recursion depth ceiling for the anchor split. */
46
+ maxDepth: 32,
47
+ /** Old-side bytes above which `write_file` does not read for a diff. */
48
+ readMaxBytes: 2 * 1024 * 1024,
49
+ /** Model-facing diff text ceiling — a bound `edit_file` does not have today. */
50
+ modelDiffMaxChars: 4_000,
51
+ };
52
+ /**
53
+ * Appended to a row clipped at `maxLineChars`.
54
+ *
55
+ * SPELLED HERE RATHER THAN IMPORTED from `agent/reducer.ts`, so that `tools/`
56
+ * does not take a runtime dependency on the view model for one three-character
57
+ * string. `patch.test.ts` asserts the two spellings are equal, so the pair
58
+ * cannot drift silently — which is the property the import would have bought.
59
+ */
60
+ export const TRUNCATION_MARK = '...';
61
+ /** The note emitted when exactly one side ends with a newline. */
62
+ export const NO_NEWLINE_NOTE = '(no newline at end of file)';
63
+ // ---------------------------------------------------------------------------
64
+ // Entry point
65
+ // ---------------------------------------------------------------------------
66
+ export function buildPatch(oldText, newText, options) {
67
+ const context = clampContext(options.context);
68
+ const oldLines = oldText === null ? [] : splitLines(oldText);
69
+ const newLines = splitLines(newText);
70
+ const rows = oldText === null
71
+ ? newLines.map((_, i) => ({ kind: 'add', b: i }))
72
+ : collectRows(oldLines, newLines, context);
73
+ if (oldText !== null && endsWithNewline(oldText) !== endsWithNewline(newText)) {
74
+ rows.push({ kind: 'ctx', note: true });
75
+ }
76
+ // Counted BEFORE truncation, so the `+6 -5` summary always describes the real
77
+ // change even when the body is clipped.
78
+ let added = 0;
79
+ let removed = 0;
80
+ for (const row of rows) {
81
+ if (row.kind === 'add')
82
+ added += 1;
83
+ else if (row.kind === 'del')
84
+ removed += 1;
85
+ }
86
+ const budgeted = applyBudgets(assembleHunks(rows, oldLines, newLines, context));
87
+ return {
88
+ path: options.path,
89
+ kind: oldText === null && !options.degraded ? 'create' : 'update',
90
+ added,
91
+ removed,
92
+ hunks: budgeted.hunks,
93
+ truncated: budgeted.truncated,
94
+ lineCount: budgeted.lineCount,
95
+ ...(options.degraded ? { degraded: options.degraded } : {}),
96
+ };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Step 0 — split and normalize
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Split on `\n`, dropping the phantom trailing element a file-final newline
103
+ * produces. An empty string is ZERO lines, not one empty line.
104
+ *
105
+ * The RAW string is what later comparisons see, `\r` included: a CRLF-to-LF
106
+ * conversion is a change and must be reported as one. Only `PatchLine.text` has
107
+ * the CR stripped, and only for rendering.
108
+ */
109
+ function splitLines(text) {
110
+ if (text.length === 0)
111
+ return [];
112
+ const lines = text.split('\n');
113
+ if (lines[lines.length - 1] === '')
114
+ lines.pop();
115
+ return lines;
116
+ }
117
+ function endsWithNewline(text) {
118
+ return text.endsWith('\n');
119
+ }
120
+ function renderText(raw) {
121
+ return raw.endsWith('\r') ? raw.slice(0, -1) : raw;
122
+ }
123
+ /**
124
+ * The context width, clamped to HALF the merge gap.
125
+ *
126
+ * The ceiling is structural, not taste: two groups survive as separate hunks
127
+ * only when more than `mergeGap` context rows separate them, and each is then
128
+ * expanded by `context` rows from its own side. At `context > mergeGap / 2` the
129
+ * two expansions overlap and the same source row is rendered twice, under two
130
+ * different line numbers, with nothing reporting it. Both callers pass 3 or 2.
131
+ */
132
+ function clampContext(requested) {
133
+ const ceiling = Math.floor(PATCH_LIMITS.mergeGap / 2);
134
+ if (requested === undefined || !Number.isFinite(requested)) {
135
+ return Math.min(PATCH_LIMITS.context, ceiling);
136
+ }
137
+ return Math.max(0, Math.min(ceiling, Math.floor(requested)));
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Steps 2-3 — trim, then the anchored split
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * Trim the common prefix and suffix, diff the middle, and emit at most `context`
144
+ * unchanged rows on either side.
145
+ *
146
+ * Emitting only the fringe rather than the whole file is what keeps this O(change)
147
+ * in allocations: a one-line edit in a 50 000-line file builds a handful of rows,
148
+ * not fifty thousand.
149
+ */
150
+ function collectRows(oldLines, newLines, context) {
151
+ let start = 0;
152
+ const maxStart = Math.min(oldLines.length, newLines.length);
153
+ while (start < maxStart && oldLines[start] === newLines[start])
154
+ start += 1;
155
+ let endOld = oldLines.length - 1;
156
+ let endNew = newLines.length - 1;
157
+ while (endOld >= start && endNew >= start && oldLines[endOld] === newLines[endNew]) {
158
+ endOld -= 1;
159
+ endNew -= 1;
160
+ }
161
+ // Identical files: no hunks at all, rather than today's stray context rows.
162
+ if (start > endOld && start > endNew)
163
+ return [];
164
+ const rows = [];
165
+ const lead = Math.min(context, start);
166
+ for (let i = start - lead; i < start; i += 1)
167
+ rows.push({ kind: 'ctx', a: i, b: i });
168
+ const middle = { aLo: start, aHi: endOld, bLo: start, bHi: endNew };
169
+ for (const row of diffRange(oldLines, newLines, middle, 0))
170
+ rows.push(row);
171
+ const tail = Math.min(context, oldLines.length - 1 - endOld);
172
+ for (let k = 1; k <= tail; k += 1)
173
+ rows.push({ kind: 'ctx', a: endOld + k, b: endNew + k });
174
+ return rows;
175
+ }
176
+ /**
177
+ * Patience-style recursive split.
178
+ *
179
+ * TERMINATION: the three degenerate cases and the depth cap return leaves
180
+ * outright; the recursive branch only runs when at least one anchor was kept,
181
+ * and every sub-range it builds excludes that anchor's rows on both sides — so
182
+ * each call is on a strictly smaller range.
183
+ */
184
+ function diffRange(a, b, r, depth) {
185
+ if (r.aLo > r.aHi && r.bLo > r.bHi)
186
+ return [];
187
+ if (r.aLo > r.aHi)
188
+ return addRows(r.bLo, r.bHi);
189
+ if (r.bLo > r.bHi)
190
+ return delRows(r.aLo, r.aHi);
191
+ if (depth >= PATCH_LIMITS.maxDepth)
192
+ return blockReplace(r);
193
+ const anchors = pickAnchors(a, b, r);
194
+ if (anchors.length === 0) {
195
+ const cells = (r.aHi - r.aLo + 1) * (r.bHi - r.bLo + 1);
196
+ return cells <= PATCH_LIMITS.lcsCellBudget ? lcsDiff(a, b, r) : blockReplace(r);
197
+ }
198
+ const out = [];
199
+ let ai = r.aLo;
200
+ let bi = r.bLo;
201
+ for (const anchor of anchors) {
202
+ const gap = { aLo: ai, aHi: anchor.a - 1, bLo: bi, bHi: anchor.b - 1 };
203
+ for (const row of diffRange(a, b, gap, depth + 1))
204
+ out.push(row);
205
+ out.push({ kind: 'ctx', a: anchor.a, b: anchor.b });
206
+ ai = anchor.a + 1;
207
+ bi = anchor.b + 1;
208
+ }
209
+ const rest = { aLo: ai, aHi: r.aHi, bLo: bi, bHi: r.bHi };
210
+ for (const row of diffRange(a, b, rest, depth + 1))
211
+ out.push(row);
212
+ return out;
213
+ }
214
+ function addRows(lo, hi) {
215
+ const out = [];
216
+ for (let i = lo; i <= hi; i += 1)
217
+ out.push({ kind: 'add', b: i });
218
+ return out;
219
+ }
220
+ function delRows(lo, hi) {
221
+ const out = [];
222
+ for (let i = lo; i <= hi; i += 1)
223
+ out.push({ kind: 'del', a: i });
224
+ return out;
225
+ }
226
+ /** Today's behaviour, kept as the bounded fallback: every del, then every add. */
227
+ function blockReplace(r) {
228
+ return [...delRows(r.aLo, r.aHi), ...addRows(r.bLo, r.bHi)];
229
+ }
230
+ /** Lines occurring EXACTLY ONCE on both sides, kept in a common increasing order. */
231
+ function pickAnchors(a, b, r) {
232
+ const aSeen = new Map();
233
+ const aAt = new Map();
234
+ for (let i = r.aLo; i <= r.aHi; i += 1) {
235
+ const line = a[i];
236
+ aSeen.set(line, (aSeen.get(line) ?? 0) + 1);
237
+ aAt.set(line, i);
238
+ }
239
+ const bSeen = new Map();
240
+ const bAt = new Map();
241
+ for (let i = r.bLo; i <= r.bHi; i += 1) {
242
+ const line = b[i];
243
+ bSeen.set(line, (bSeen.get(line) ?? 0) + 1);
244
+ bAt.set(line, i);
245
+ }
246
+ const pairs = [];
247
+ for (const [line, count] of aSeen) {
248
+ if (count !== 1 || bSeen.get(line) !== 1)
249
+ continue;
250
+ pairs.push({ a: aAt.get(line), b: bAt.get(line) });
251
+ }
252
+ pairs.sort((x, y) => x.a - y.a);
253
+ return longestIncreasingByB(pairs);
254
+ }
255
+ /** Longest strictly-increasing subsequence by `b`, via binary-searched piles. */
256
+ function longestIncreasingByB(pairs) {
257
+ const tails = [];
258
+ const prev = new Array(pairs.length).fill(-1);
259
+ for (let i = 0; i < pairs.length; i += 1) {
260
+ let lo = 0;
261
+ let hi = tails.length;
262
+ while (lo < hi) {
263
+ const mid = (lo + hi) >> 1;
264
+ if (pairs[tails[mid]].b < pairs[i].b)
265
+ lo = mid + 1;
266
+ else
267
+ hi = mid;
268
+ }
269
+ if (lo > 0)
270
+ prev[i] = tails[lo - 1];
271
+ tails[lo] = i;
272
+ }
273
+ const out = [];
274
+ let k = tails.length > 0 ? tails[tails.length - 1] : -1;
275
+ while (k >= 0) {
276
+ out.push(pairs[k]);
277
+ k = prev[k];
278
+ }
279
+ return out.reverse();
280
+ }
281
+ /** Textbook LCS over a leaf small enough to afford it (`lcsCellBudget`). */
282
+ function lcsDiff(a, b, r) {
283
+ const n = r.aHi - r.aLo + 1;
284
+ const m = r.bHi - r.bLo + 1;
285
+ const width = m + 1;
286
+ const dp = new Int32Array((n + 1) * width);
287
+ for (let i = n - 1; i >= 0; i -= 1) {
288
+ for (let j = m - 1; j >= 0; j -= 1) {
289
+ dp[i * width + j] =
290
+ a[r.aLo + i] === b[r.bLo + j]
291
+ ? dp[(i + 1) * width + j + 1] + 1
292
+ : Math.max(dp[(i + 1) * width + j], dp[i * width + j + 1]);
293
+ }
294
+ }
295
+ const out = [];
296
+ let i = 0;
297
+ let j = 0;
298
+ while (i < n && j < m) {
299
+ if (a[r.aLo + i] === b[r.bLo + j]) {
300
+ out.push({ kind: 'ctx', a: r.aLo + i, b: r.bLo + j });
301
+ i += 1;
302
+ j += 1;
303
+ }
304
+ else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) {
305
+ out.push({ kind: 'del', a: r.aLo + i });
306
+ i += 1;
307
+ }
308
+ else {
309
+ out.push({ kind: 'add', b: r.bLo + j });
310
+ j += 1;
311
+ }
312
+ }
313
+ for (; i < n; i += 1)
314
+ out.push({ kind: 'del', a: r.aLo + i });
315
+ for (; j < m; j += 1)
316
+ out.push({ kind: 'add', b: r.bLo + j });
317
+ return out;
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // Step 4 — hunks
321
+ // ---------------------------------------------------------------------------
322
+ /**
323
+ * Group changed rows into hunks, expanding each group by `context` rows.
324
+ *
325
+ * THE NOTE ROW COUNTS AS A CHANGE for grouping purposes even though it renders
326
+ * as context: a file whose ONLY difference is its final newline has no `add` and
327
+ * no `del` row at all, and grouping on kind alone would produce zero hunks and
328
+ * report the change as nothing.
329
+ */
330
+ function assembleHunks(rows, oldLines, newLines, context) {
331
+ const changed = [];
332
+ for (let i = 0; i < rows.length; i += 1) {
333
+ const row = rows[i];
334
+ if (row.kind !== 'ctx' || row.note)
335
+ changed.push(i);
336
+ }
337
+ if (changed.length === 0)
338
+ return [];
339
+ const groups = [];
340
+ let from = changed[0];
341
+ let to = changed[0];
342
+ for (let k = 1; k < changed.length; k += 1) {
343
+ const idx = changed[k];
344
+ if (idx - to - 1 <= PATCH_LIMITS.mergeGap) {
345
+ to = idx;
346
+ continue;
347
+ }
348
+ groups.push([from, to]);
349
+ from = idx;
350
+ to = idx;
351
+ }
352
+ groups.push([from, to]);
353
+ return groups.map(([lo, hi]) => {
354
+ const slice = rows.slice(Math.max(0, lo - context), Math.min(rows.length, hi + context + 1));
355
+ return makeHunk(slice.map((row) => toPatchLine(row, oldLines, newLines)));
356
+ });
357
+ }
358
+ function toPatchLine(row, oldLines, newLines) {
359
+ if (row.note)
360
+ return { kind: 'ctx', text: NO_NEWLINE_NOTE };
361
+ const line = { kind: row.kind, text: '' };
362
+ if (row.a !== undefined) {
363
+ line.oldLine = row.a + 1;
364
+ line.text = renderText(oldLines[row.a]);
365
+ }
366
+ if (row.b !== undefined) {
367
+ line.newLine = row.b + 1;
368
+ line.text = renderText(newLines[row.b]);
369
+ }
370
+ return line;
371
+ }
372
+ /** Derive the `@@` header numbers from the rows the hunk actually carries. */
373
+ function makeHunk(lines) {
374
+ let oldStart = 0;
375
+ let oldCount = 0;
376
+ let newStart = 0;
377
+ let newCount = 0;
378
+ for (const line of lines) {
379
+ if (line.oldLine !== undefined) {
380
+ if (oldCount === 0)
381
+ oldStart = line.oldLine;
382
+ oldCount += 1;
383
+ }
384
+ if (line.newLine !== undefined) {
385
+ if (newCount === 0)
386
+ newStart = line.newLine;
387
+ newCount += 1;
388
+ }
389
+ }
390
+ return { oldStart, oldCount, newStart, newCount, lines };
391
+ }
392
+ /**
393
+ * Clip long rows, then drop whole hunks from the TAIL until the row and
394
+ * character budgets hold. Tail-first, because the top of a change is where a
395
+ * reader starts.
396
+ *
397
+ * THE FIRST HUNK IS KEPT EVEN WHEN IT ALONE EXCEEDS THE BUDGET, and its rows are
398
+ * clipped from the tail instead. A 300-line `write_file` is one hunk of 300 add
399
+ * rows against a 200-row ceiling: dropping whole hunks only would leave ZERO,
400
+ * and the card would render a `+300 -0` summary over nothing — precisely the
401
+ * "reported as a byte count" failure this feature exists to fix (see the Issues
402
+ * Found section of the design).
403
+ */
404
+ function applyBudgets(input) {
405
+ const hunks = input.map(clipHunkText);
406
+ let rows = 0;
407
+ let chars = 0;
408
+ let keep = 0;
409
+ for (const hunk of hunks) {
410
+ const hunkRows = hunk.lines.length;
411
+ const hunkChars = hunk.lines.reduce((n, line) => n + line.text.length, 0);
412
+ const fits = rows + hunkRows <= PATCH_LIMITS.maxLines && chars + hunkChars <= PATCH_LIMITS.maxChars;
413
+ // The FIRST hunk is accepted whether or not it fits; see the note above.
414
+ if (keep > 0 && !fits)
415
+ break;
416
+ rows += hunkRows;
417
+ chars += hunkChars;
418
+ keep += 1;
419
+ }
420
+ const kept = hunks.slice(0, Math.max(1, Math.min(keep, hunks.length)));
421
+ let truncated = kept.length < hunks.length;
422
+ if (rows > PATCH_LIMITS.maxLines || chars > PATCH_LIMITS.maxChars) {
423
+ kept[0] = clipHunkRows(kept[0]);
424
+ truncated = true;
425
+ }
426
+ return {
427
+ hunks: kept,
428
+ truncated,
429
+ lineCount: kept.reduce((n, hunk) => n + hunk.lines.length, 0),
430
+ };
431
+ }
432
+ function clipHunkText(hunk) {
433
+ return {
434
+ ...hunk,
435
+ lines: hunk.lines.map((line) => line.text.length <= PATCH_LIMITS.maxLineChars
436
+ ? line
437
+ : { ...line, text: `${line.text.slice(0, PATCH_LIMITS.maxLineChars)}${TRUNCATION_MARK}` }),
438
+ };
439
+ }
440
+ /** Keep the head of one hunk's rows, and re-derive its header numbers from it. */
441
+ function clipHunkRows(hunk) {
442
+ const lines = [];
443
+ let chars = 0;
444
+ for (const line of hunk.lines) {
445
+ if (lines.length >= PATCH_LIMITS.maxLines)
446
+ break;
447
+ if (lines.length > 0 && chars + line.text.length > PATCH_LIMITS.maxChars)
448
+ break;
449
+ lines.push(line);
450
+ chars += line.text.length;
451
+ }
452
+ return makeHunk(lines);
453
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Raw child-process output to terminal-safe rows (agent-activity-presentation-live
3
+ * L2 / §3.2).
4
+ *
5
+ * NOTHING ELSE IN THIS TREE STRIPS ANSI (E-13). `renderBash` hands each line to
6
+ * `<Text>` unmodified (`ui/entries/ToolPreview.tsx:180`), and that survives only
7
+ * because the settled preview is printed ONCE, after the command has stopped
8
+ * writing. Redraw the same text every frame while the child is still emitting
9
+ * `\r`-rewritten progress bars and `\x1b[A` cursor moves and it stops being
10
+ * survivable: the terminal honours cursor motion Ink does not know it emitted,
11
+ * and `wrappedRows` measures escape bytes as if they were columns.
12
+ *
13
+ * So this module is the boundary. It runs ONCE, inside the store's `append`
14
+ * (§3.1.1), and every reader downstream is already safe.
15
+ *
16
+ * Pure, ASCII-only, React-free, Ink-free, and TOTAL: `sanitizeChunk` never
17
+ * throws, for any input including lone surrogates, NUL bytes, a chunk consisting
18
+ * solely of `\x1b`, and a 4 MB chunk containing no `\n` at all.
19
+ *
20
+ * D-27 — IT STRIPS, IT DOES NOT INTERPRET. Keeping colour would mean validating
21
+ * it (an unterminated SGR leaks its attribute into the rest of the frame), and
22
+ * interpreting cursor motion would mean owning a screen buffer. Neither is a
23
+ * presentation round's job.
24
+ */
25
+ /** Columns a live row may occupy before it is clipped. */
26
+ export declare const LIVE_ROW_MAX_CHARS = 200;
27
+ /** Tab stop width, matching every terminal's default. */
28
+ export declare const TAB_WIDTH = 8;
29
+ /**
30
+ * The in-progress line's ceiling before it is flushed as a row (§3.2 step 7).
31
+ *
32
+ * AFTER STEP 4 THIS IS A RARE PATH RATHER THAN THE PROGRESS-BAR PATH (D-33). A
33
+ * `\r`-rewritten bar collapses to one row however many times it is redrawn, so
34
+ * reaching 400 characters with no `\n` requires a line that is genuinely that
35
+ * long -- `cat` on a minified bundle. Without the bound the carry is an
36
+ * unbounded accumulator.
37
+ */
38
+ export declare const CARRY_MAX_CHARS: number;
39
+ /**
40
+ * Appended to a row clipped at `LIVE_ROW_MAX_CHARS`.
41
+ *
42
+ * SPELLED HERE RATHER THAN IMPORTED from `agent/reducer.ts`, the rule
43
+ * `patch.ts:95-103` already states for the identical string: `tools/` keeps no
44
+ * runtime dependency on the view model for three characters, and
45
+ * `terminal-output.test.ts` asserts the spellings are equal so the pair cannot
46
+ * drift silently.
47
+ */
48
+ export declare const TRUNCATION_MARK = "...";
49
+ export interface SanitizeResult {
50
+ /** Complete rows, terminal-safe, in order. Each at most `LIVE_ROW_MAX_CHARS` (+ mark). */
51
+ rows: string[];
52
+ /**
53
+ * The in-progress line: stripped, `\r`-collapsed and tab-expanded, bounded by
54
+ * `CARRY_MAX_CHARS`.
55
+ *
56
+ * THIS IS STATE, NOT A ROW, and the difference is load-bearing. It is threaded
57
+ * back into the next `sanitizeChunk` call verbatim, so it is deliberately NOT
58
+ * clipped (a `'...'` spliced into the middle of a line the command is still
59
+ * writing would then be overwritten INTO the stream) and it may carry a
60
+ * trailing incomplete escape awaiting its final byte. Render it with
61
+ * `toDisplayRow`, never directly.
62
+ */
63
+ carry: string;
64
+ }
65
+ /**
66
+ * Remove every escape sequence and control byte except `\t` `\n` `\r`.
67
+ *
68
+ * Exported for the tests, and used by `toDisplayRow` as the second line of
69
+ * defence over a carry that ends mid-escape.
70
+ */
71
+ export declare function stripAnsi(text: string): string;
72
+ /**
73
+ * Apply `\r` as the LINE REWRITE it is (§3.2 step 4 / D-33).
74
+ *
75
+ * A piece containing `\r` is a sequence of overwrites, so the later segment
76
+ * replaces the earlier one's PREFIX and anything the earlier one had beyond it
77
+ * survives -- which is what a real terminal shows, and what keeps R-6's classic
78
+ * `\r`-only tooling from losing content. A thousand progress-bar rewrites
79
+ * collapse to one row holding the final state.
80
+ */
81
+ export declare function applyCarriageReturns(piece: string): string;
82
+ /** Expand tabs to the next multiple of `TAB_WIDTH`. */
83
+ export declare function expandTabs(row: string): string;
84
+ /** Clip a row at `LIVE_ROW_MAX_CHARS`, marking the cut. */
85
+ export declare function clipRow(row: string): string;
86
+ /**
87
+ * The carry as a RENDERED row: the held-back incomplete escape removed, then
88
+ * stripped and clipped.
89
+ *
90
+ * THE PARTIAL MUST BE CUT OFF, NOT STRIPPED (AC-24). `stripAnsi` alone is not
91
+ * enough and fails in the visible direction: its two-byte rule consumes the
92
+ * `\x1b[` of an unfinished CSI and leaves the PARAMETERS behind, so a chunk that
93
+ * split inside `\x1b[31m` renders as `red 3`. The bytes are held for the next
94
+ * chunk to complete; until then they are not text.
95
+ */
96
+ export declare function toDisplayRow(carry: string): string;
97
+ /**
98
+ * Turn a raw chunk into complete rows plus the in-progress line.
99
+ *
100
+ * The order of the steps IS the specification (§3.2), and step 4's "every piece,
101
+ * THE INCOMPLETE TAIL INCLUDED" is the whole of P0-1: a progress bar emits no
102
+ * `\n` for its entire run, so a rule that collapsed only completed lines would
103
+ * never run for npm, pip, curl, docker or any other `\r` writer -- the tail
104
+ * would stay empty, `showLive` false, and the card the single `running` row this
105
+ * round exists to replace.
106
+ */
107
+ export declare function sanitizeChunk(carry: string, chunk: string): SanitizeResult;