mixdog 0.9.0 → 0.9.1

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 (57) hide show
  1. package/package.json +3 -3
  2. package/scripts/session-ingest-smoke.mjs +2 -2
  3. package/src/headless-role.mjs +1 -1
  4. package/src/lib/mixdog-debug.cjs +0 -22
  5. package/src/lib/plugin-paths.cjs +1 -7
  6. package/src/lib/rules-builder.cjs +2 -2
  7. package/src/mixdog-session-runtime.mjs +0 -1
  8. package/src/repl.mjs +0 -2
  9. package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +27 -49
  11. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +5 -20
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +8 -27
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +52 -182
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +8 -30
  15. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +258 -0
  16. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -8
  18. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  19. package/src/runtime/channels/index.mjs +0 -30
  20. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  21. package/src/runtime/channels/lib/config.mjs +0 -1
  22. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  23. package/src/runtime/channels/lib/executor.mjs +0 -3
  24. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  25. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  26. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  27. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  28. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  29. package/src/runtime/channels/lib/transcript-discovery.mjs +1 -10
  30. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  31. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  32. package/src/runtime/memory/index.mjs +3 -24
  33. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  34. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  35. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  36. package/src/runtime/memory/lib/session-ingest.mjs +9 -7
  37. package/src/runtime/search/index.mjs +2 -7
  38. package/src/runtime/search/lib/config.mjs +0 -4
  39. package/src/runtime/search/lib/state.mjs +1 -15
  40. package/src/runtime/search/lib/web-tools.mjs +0 -1
  41. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  42. package/src/standalone/seeds.mjs +1 -11
  43. package/src/tui/App.jsx +35 -12
  44. package/src/tui/components/PromptInput.jsx +63 -2
  45. package/src/tui/components/ToolExecution.jsx +7 -2
  46. package/src/tui/components/tool-output-format.mjs +156 -22
  47. package/src/tui/components/tool-output-format.test.mjs +93 -1
  48. package/src/tui/dist/index.mjs +473 -116
  49. package/src/tui/markdown/format-token.mjs +267 -108
  50. package/src/tui/markdown/format-token.test.mjs +105 -9
  51. package/src/tui/theme.mjs +10 -0
  52. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  53. package/vendor/ink/build/ink.js +54 -8
  54. package/src/hooks/lib/permission-rules.cjs +0 -170
  55. package/src/hooks/lib/settings-loader.cjs +0 -112
  56. package/src/lib/hook-pipe-path.cjs +0 -10
  57. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
@@ -21,6 +21,7 @@ import stripAnsi from 'strip-ansi';
21
21
  import {
22
22
  extraColorizers,
23
23
  highlightCodeLine,
24
+ highlightCodeBlockToLines,
24
25
  colorizeDiffLine,
25
26
  looksLikeUnifiedDiff,
26
27
  LANG_FAMILY,
@@ -35,13 +36,72 @@ const DEFAULT_MARKDOWN_WIDTH = 80;
35
36
  // Hard ceilings so a pathological tool result can never lock the render loop.
36
37
  // CC uses ~MAX_LINES*width*4 for the collapsed fold; for the EXPANDED body we
37
38
  // cap total characters processed and total lines kept, with an explicit marker.
38
- const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
39
- const MAX_EXPANDED_LINES = 80; // keep at most this many rendered lines
39
+ export const MAX_EXPANDED_CHARS = 256 * 1024; // 256 KB of text gets per-line processing
40
+ const MAX_EXPANDED_LINES = 4000; // logical-line safety ceiling; physical mount cap is separate
40
41
  const MAX_JSON_FORMAT_LENGTH = 10_000; // mirror CC's tryJsonFormatContent cap
41
42
  const MAX_HIGHLIGHT_LINE_CHARS = 2000; // skip token-scan on absurdly long lines
42
43
  // Lockstep with ToolExecution collapsed fit budget (MIN_RESULT_LINE_CHARS).
43
44
  const MIN_EXPANDED_BODY_COLS = 24;
44
45
 
46
+ // Hard cap on the number of PHYSICAL (wrapped) rows a single expanded tool /
47
+ // script body may mount. MAX_EXPANDED_LINES bounds LOGICAL lines, but a single
48
+ // very long line with no newlines (minified JSON, a 256 KB single-line blob)
49
+ // wraps into thousands of physical rows — and EACH physical row is a mounted
50
+ // Ink <Text> node that ink re-serializes every frame even while clipped off-
51
+ // screen (clipping only trims write coords, not the serialize pass). So a lone
52
+ // huge expanded item could mount thousands of nodes and stall typing/drag even
53
+ // though it is the only visible transcript item. This cap bounds the mounted
54
+ // node count regardless of line shape; the omitted tail gets a clear marker.
55
+ // It is set generously above what any viewport needs so normal multi-line
56
+ // output (already capped at MAX_EXPANDED_LINES logical lines) is byte-for-byte
57
+ // unchanged — only pathological wide/unwrapped blobs are trimmed. Env-tunable
58
+ // via MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES (0 disables); legacy
59
+ // MIXDOG_TUI_EXPANDED_MAX_ROWS is still honored when the primary var is unset.
60
+ export function resolveToolOutputMaxRenderLines() {
61
+ const raw = process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
62
+ if (raw !== undefined && String(raw).trim() !== '') {
63
+ const v = Number(raw);
64
+ if (Number.isFinite(v) && v <= 0) return 0;
65
+ if (Number.isFinite(v) && v > 0) return Math.floor(v);
66
+ }
67
+ const legacy = process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
68
+ if (legacy !== undefined && String(legacy).trim() !== '') {
69
+ const v = Number(legacy);
70
+ if (Number.isFinite(v) && v > 0) return Math.floor(v);
71
+ }
72
+ return 600;
73
+ }
74
+
75
+ function omittedPhysicalRowsMarker(omitted, isShell) {
76
+ if (isShell) {
77
+ const n = Math.max(1, Math.floor(Number(omitted) || 0));
78
+ return `\u2026 [${n} line${n === 1 ? '' : 's'} omitted above \u2014 showing newest output below]`;
79
+ }
80
+ return '\u2026 [output truncated for display \u2014 collapse (ctrl+o) or re-read a narrower range]';
81
+ }
82
+
83
+ function shellLogicalOmittedMarker({ omittedLines = 0, omittedChars = false }, c) {
84
+ if (omittedLines > 0) {
85
+ return c.synComment(omittedPhysicalRowsMarker(omittedLines, true));
86
+ }
87
+ if (omittedChars) {
88
+ return c.synComment('\u2026 [earlier output omitted above \u2014 showing newest output below]');
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function finalizeShellPhysicalCap(buffer, omitted, maxRows) {
94
+ const bodySlots = Math.max(0, maxRows - 1);
95
+ if (omitted <= 0) {
96
+ return buffer.length > 0 ? buffer : [' '];
97
+ }
98
+ const tail = bodySlots > 0 ? buffer.slice(-bodySlots) : [];
99
+ const extraHidden = Math.max(0, buffer.length - bodySlots);
100
+ const totalOmitted = omitted + extraHidden;
101
+ const out = [omittedPhysicalRowsMarker(totalOmitted, true), ...tail];
102
+ return out.length > 0 ? out.slice(0, maxRows) : [' '];
103
+ }
104
+
45
105
  // `<n>→<content>` (read) OR `<n>:<content>` / `<path>:<n>:<content>` (grep).
46
106
  const READ_LINE_RE = /^(\s*)(\d+)(\u2192)(.*)$/;
47
107
  // Gutter ends with `:<line>:`. Windows absolute paths use `X:\...` so the drive
@@ -198,9 +258,12 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
198
258
 
199
259
  // Oversized guard: slice before any O(n) per-line work, append a marker.
200
260
  let truncatedChars = false;
261
+ let shellOmittedLines = 0;
262
+ let shellOmittedChars = false;
201
263
  if (src.length > MAX_EXPANDED_CHARS) {
202
- src = src.slice(0, MAX_EXPANDED_CHARS);
203
- truncatedChars = true;
264
+ src = isShell ? src.slice(-MAX_EXPANDED_CHARS) : src.slice(0, MAX_EXPANDED_CHARS);
265
+ if (isShell) shellOmittedChars = true;
266
+ else truncatedChars = true;
204
267
  }
205
268
 
206
269
  const carriesAnsi = hasAnsi(src);
@@ -210,8 +273,9 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
210
273
  let lines = src.split('\n');
211
274
  let truncatedLines = false;
212
275
  if (lines.length > MAX_EXPANDED_LINES) {
213
- lines = lines.slice(0, MAX_EXPANDED_LINES);
214
- truncatedLines = true;
276
+ if (isShell) shellOmittedLines = lines.length - MAX_EXPANDED_LINES;
277
+ lines = isShell ? lines.slice(-MAX_EXPANDED_LINES) : lines.slice(0, MAX_EXPANDED_LINES);
278
+ if (!isShell) truncatedLines = true;
215
279
  }
216
280
 
217
281
  const c = extraColorizers();
@@ -223,29 +287,31 @@ export function formatExpandedResult(text, { pathArg = '', isShell = false } = {
223
287
  if (shouldRenderExpandedMarkdown({ src, lines, carriesAnsi, isShell, diffMode, family })) {
224
288
  out = formatExpandedMarkdownLines(src);
225
289
  if (out.length > MAX_EXPANDED_LINES) {
226
- out = out.slice(0, MAX_EXPANDED_LINES);
227
- truncatedLines = true;
290
+ if (isShell) shellOmittedLines += out.length - MAX_EXPANDED_LINES;
291
+ out = isShell ? out.slice(-MAX_EXPANDED_LINES) : out.slice(0, MAX_EXPANDED_LINES);
292
+ if (!isShell) truncatedLines = true;
228
293
  }
229
294
  } else {
230
- out = lines.map((line) => formatLine(line, { c, family, diffMode, carriesAnsi, isShell }));
295
+ if (family && family !== 'md' && !diffMode && !carriesAnsi && !isShell) {
296
+ out = formatSyntaxBlock(lines, { c, family });
297
+ } else {
298
+ out = lines.map((line) => formatLine(line, { c, family, diffMode, carriesAnsi, isShell }));
299
+ }
231
300
  }
232
301
 
302
+ if (isShell) {
303
+ const marker = shellLogicalOmittedMarker({ omittedLines: shellOmittedLines, omittedChars: shellOmittedChars }, c);
304
+ if (marker) return [marker, ...out];
305
+ return out;
306
+ }
233
307
  if (truncatedChars || truncatedLines) {
234
308
  out.push(c.synComment('… [output truncated for display — re-read a narrower range]'));
235
309
  }
236
310
  return out;
237
311
  }
238
312
 
239
- /** Format ONE line: split line-number gutter, then highlight/linkify the body. */
240
- function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
241
- // Shell / already-colored output: keep ANSI verbatim, only fix underline leaks
242
- // and linkify URLs. No gutter split (shell has no <n>→ prefix).
243
- if (carriesAnsi || isShell) {
244
- return linkifyUrls(stripUnderlineAnsi(line));
245
- }
246
-
247
- // Split a read (`<n>→`) or grep (`<file>:<n>:` / `<n>:`) gutter into a dim
248
- // column so the body can be highlighted independently.
313
+ /** Split read/grep line-number gutter from body (shared by per-line and block highlight). */
314
+ function splitGutter(line) {
249
315
  let indent = '';
250
316
  let gutter = '';
251
317
  let body = line;
@@ -262,6 +328,35 @@ function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
262
328
  body = gm[4];
263
329
  }
264
330
  }
331
+ return { indent, gutter, body };
332
+ }
333
+
334
+ /** Block syntax highlight for non-diff code bodies (multi-line tokens color correctly). */
335
+ function formatSyntaxBlock(lines, { c, family }) {
336
+ const parts = lines.map(splitGutter);
337
+ const bodies = parts.map((p) =>
338
+ p.body.length > MAX_HIGHLIGHT_LINE_CHARS ? '' : p.body,
339
+ );
340
+ const highlighted = highlightCodeBlockToLines(bodies.join('\n'), family, c);
341
+ return parts.map((p, i) => {
342
+ const rawBody = p.body;
343
+ const bodyOut = rawBody.length > MAX_HIGHLIGHT_LINE_CHARS
344
+ ? c.body(rawBody)
345
+ : (highlighted[i] ?? '');
346
+ const linked = linkifyUrls(bodyOut);
347
+ return p.gutter ? `${p.indent}${c.synComment(p.gutter)}${linked}` : linked;
348
+ });
349
+ }
350
+
351
+ /** Format ONE line: split line-number gutter, then highlight/linkify the body. */
352
+ function formatLine(line, { c, family, diffMode, carriesAnsi, isShell }) {
353
+ // Shell / already-colored output: keep ANSI verbatim, only fix underline leaks
354
+ // and linkify URLs. No gutter split (shell has no <n>→ prefix).
355
+ if (carriesAnsi || isShell) {
356
+ return linkifyUrls(stripUnderlineAnsi(line));
357
+ }
358
+
359
+ const { indent, gutter, body } = splitGutter(line);
265
360
 
266
361
  const coloredBody = highlightBody(body, { c, family, diffMode });
267
362
  const linked = linkifyUrls(coloredBody);
@@ -354,12 +449,51 @@ function wrapOneExpandedLogicalLine(line, maxWidth) {
354
449
  * Turn logical expanded lines into physical rows that fit the body column.
355
450
  * One output row per left-rail row in ToolExecution (lockstep with App row est.).
356
451
  */
357
- export function wrapExpandedResultLines(logicalLines, columns = 80) {
452
+ export function wrapExpandedResultLines(logicalLines, columns = 80, { isShell = false } = {}) {
453
+ const maxRows = resolveToolOutputMaxRenderLines();
454
+ const capOn = maxRows > 0;
358
455
  const maxWidth = expandedResultBodyWidth(columns);
359
456
  const lines = Array.isArray(logicalLines) ? logicalLines : [];
360
457
  const out = [];
361
- for (const line of lines) {
362
- out.push(...wrapOneExpandedLogicalLine(line, maxWidth));
458
+
459
+ if (!capOn) {
460
+ for (const line of lines) {
461
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) out.push(row);
462
+ }
463
+ return out.length > 0 ? out : [' '];
464
+ }
465
+
466
+ if (isShell) {
467
+ let omitted = 0;
468
+ for (const line of lines) {
469
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) {
470
+ out.push(row);
471
+ if (out.length > maxRows) {
472
+ out.shift();
473
+ omitted += 1;
474
+ }
475
+ }
476
+ }
477
+ return finalizeShellPhysicalCap(out, omitted, maxRows);
478
+ }
479
+
480
+ // Non-shell surfaces keep the head of the expanded body (read/grep/json).
481
+ let truncated = false;
482
+ outer: for (const line of lines) {
483
+ for (const row of wrapOneExpandedLogicalLine(line, maxWidth)) {
484
+ if (out.length < maxRows) {
485
+ out.push(row);
486
+ } else {
487
+ truncated = true;
488
+ break outer;
489
+ }
490
+ }
491
+ }
492
+ if (truncated) {
493
+ const bodySlots = Math.max(0, maxRows - 1);
494
+ if (out.length > bodySlots) out.length = bodySlots;
495
+ if (maxRows > 0) out.push(omittedPhysicalRowsMarker(1, false));
496
+ return out.length > 0 ? out.slice(0, maxRows) : [' '];
363
497
  }
364
498
  return out.length > 0 ? out : [' '];
365
499
  }
@@ -13,6 +13,21 @@ import stringWidth from 'string-width';
13
13
 
14
14
  const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\]8;;[^\x07]*\x07/g, '');
15
15
 
16
+ function withRenderLineCap(cap, fn) {
17
+ const prev = process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
18
+ const legacy = process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
19
+ process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES = String(cap);
20
+ delete process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
21
+ try {
22
+ return fn();
23
+ } finally {
24
+ if (prev === undefined) delete process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES;
25
+ else process.env.MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES = prev;
26
+ if (legacy === undefined) delete process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS;
27
+ else process.env.MIXDOG_TUI_EXPANDED_MAX_ROWS = legacy;
28
+ }
29
+ }
30
+
16
31
  test('inferLangFamily maps known extensions, null otherwise', () => {
17
32
  assert.equal(inferLangFamily('src/x.mjs'), 'js');
18
33
  assert.equal(inferLangFamily('a.py'), 'py');
@@ -31,6 +46,22 @@ test('read line-number gutter is split into a dim column and body is highlighted
31
46
  assert.ok(out[0].includes('\x1b['), 'first line carries color escapes');
32
47
  });
33
48
 
49
+ test('block syntax highlight skips tokenizer for over-long lines (index aligned)', () => {
50
+ // MAX_HIGHLIGHT_LINE_CHARS is 2000 in tool-output-format.mjs
51
+ const giant = 'x'.repeat(2001);
52
+ const read = `1\u2192const a = 1;\n2\u2192${giant}\n3\u2192const b = 2;`;
53
+ const out = formatExpandedResult(read, { pathArg: 'a.mjs' });
54
+ assert.equal(out.length, 3);
55
+ assert.ok(out[0].includes('\x1b['), 'short line before giant is highlighted');
56
+ assert.ok(out[2].includes('\x1b['), 'short line after giant is highlighted');
57
+ const plainMid = stripAnsi(out[1]);
58
+ assert.ok(plainMid.startsWith('2\u2192'), 'gutter preserved on giant line');
59
+ assert.ok(plainMid.endsWith(giant), 'giant body preserved');
60
+ const hljsSpans = (out[1].match(/\x1b\[[0-9;]*m/g) || []).length;
61
+ const shortSpans = (out[0].match(/\x1b\[[0-9;]*m/g) || []).length;
62
+ assert.ok(hljsSpans <= shortSpans + 2, 'giant line not fully tokenized like normal code');
63
+ });
64
+
34
65
  test('grep file:line: gutter is split too', () => {
35
66
  const grep = 'src/a.mjs:5581: value: foo,';
36
67
  const out = formatExpandedResult(grep, { pathArg: 'src/a.mjs' });
@@ -105,9 +136,13 @@ test('unified diff is colored by line class', () => {
105
136
  test('oversize output is capped with a truncation marker', () => {
106
137
  const many = Array.from({ length: 5000 }, (_, i) => `line${i}`).join('\n');
107
138
  const out = formatExpandedResult(many, {});
108
- assert.ok(out.length <= 81);
139
+ assert.ok(out.length <= 4001, 'logical cap keeps at most MAX_EXPANDED_LINES plus marker');
109
140
  assert.ok(/truncated/.test(stripAnsi(out[out.length - 1])));
110
141
 
142
+ const sixHundred = Array.from({ length: 600 }, (_, i) => `row${i}`).join('\n');
143
+ const out600 = formatExpandedResult(sixHundred, {});
144
+ assert.equal(out600.length, 600, 'normal expanded output is not truncated at 80 logical lines');
145
+
111
146
  const huge = 'x'.repeat(300 * 1024);
112
147
  const out2 = formatExpandedResult(huge, {});
113
148
  assert.ok(/truncated/.test(stripAnsi(out2[out2.length - 1])));
@@ -218,3 +253,60 @@ test('read gutter lines skip markdown mode and keep syntax highlight', () => {
218
253
  assert.ok(visible.some((l) => l.includes('const y = 2;')), 'read body preserved');
219
254
  assert.ok(out.some((l) => stripAnsi(l).includes('const y = 2;') && l.includes('\x1b[')), 'code line still highlighted');
220
255
  });
256
+
257
+ test('shell physical cap keeps newest rows across logical lines', () => {
258
+ withRenderLineCap(5, () => {
259
+ const long = 'W'.repeat(240);
260
+ const logical = formatExpandedResult(`${long}\nNEWEST_TAIL_LINE`, { isShell: true });
261
+ const physical = wrapExpandedResultLines(logical, 32, { isShell: true });
262
+ const visible = physical.map(stripAnsi);
263
+ assert.ok(visible.some((l) => l.includes('NEWEST_TAIL_LINE')), 'newest logical line survives rolling cap');
264
+ assert.ok(physical.length <= 5);
265
+ assert.ok(visible.some((l) => /omitted above/i.test(l)));
266
+ });
267
+ });
268
+
269
+ test('exact physical cap row count is not truncated', () => {
270
+ withRenderLineCap(10, () => {
271
+ const logical = Array.from({ length: 10 }, (_, i) => `row-${i}`).map((l) => formatExpandedResult(l, { isShell: true })[0]);
272
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
273
+ assert.equal(physical.length, 10);
274
+ assert.ok(!physical.some((l) => /omitted above/i.test(stripAnsi(l))));
275
+ });
276
+ });
277
+
278
+ test('physical cap of 1 never returns more than one row', () => {
279
+ withRenderLineCap(1, () => {
280
+ const physical = wrapExpandedResultLines(formatExpandedResult('a\nb\nc', { isShell: true }), 80, { isShell: true });
281
+ assert.ok(physical.length <= 1);
282
+ const physicalNonShell = wrapExpandedResultLines(formatExpandedResult('a\nb\nc', {}), 80, { isShell: false });
283
+ assert.ok(physicalNonShell.length <= 1);
284
+ });
285
+ });
286
+
287
+ test('shell physical cap omitted count includes marker slot', () => {
288
+ withRenderLineCap(5, () => {
289
+ const logical = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5'].map((l) => formatExpandedResult(l, { isShell: true })[0]);
290
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
291
+ const visible = physical.map(stripAnsi);
292
+ assert.equal(physical.length, 5);
293
+ assert.match(visible[0], /2 lines omitted above/);
294
+ assert.deepEqual(visible.slice(1), ['r2', 'r3', 'r4', 'r5']);
295
+ });
296
+ withRenderLineCap(1, () => {
297
+ const logical = Array.from({ length: 6 }, (_, i) => `row${i}`).map((l) => formatExpandedResult(l, { isShell: true })[0]);
298
+ const physical = wrapExpandedResultLines(logical, 80, { isShell: true });
299
+ assert.equal(physical.length, 1);
300
+ assert.match(stripAnsi(physical[0]), /6 lines omitted above/);
301
+ });
302
+ });
303
+
304
+ test('shell logical truncation marker precedes retained tail', () => {
305
+ const many = Array.from({ length: 5000 }, (_, i) => `line${i}`).join('\n');
306
+ const out = formatExpandedResult(many, { isShell: true });
307
+ const visible = out.map(stripAnsi);
308
+ assert.ok(visible.length <= 4001);
309
+ assert.ok(/omitted above/i.test(visible[0]), 'shell marker is first row');
310
+ assert.ok(visible[visible.length - 1].includes('line4999'), 'newest logical line is last');
311
+ assert.ok(!visible[visible.length - 1].includes('re-read a narrower range'));
312
+ });