@f5-sales-demo/xcsh 21.43.0 → 21.43.2

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "21.43.0",
4
+ "version": "21.43.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -64,13 +64,13 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@agentclientprotocol/sdk": "1.4.0",
67
- "@f5-sales-demo/pi-agent-core": "21.43.0",
68
- "@f5-sales-demo/pi-ai": "21.43.0",
69
- "@f5-sales-demo/pi-natives": "21.43.0",
70
- "@f5-sales-demo/pi-resource-management": "21.43.0",
71
- "@f5-sales-demo/pi-tui": "21.43.0",
72
- "@f5-sales-demo/pi-utils": "21.43.0",
73
- "@f5-sales-demo/xcsh-stats": "21.43.0",
67
+ "@f5-sales-demo/pi-agent-core": "21.43.2",
68
+ "@f5-sales-demo/pi-ai": "21.43.2",
69
+ "@f5-sales-demo/pi-natives": "21.43.2",
70
+ "@f5-sales-demo/pi-resource-management": "21.43.2",
71
+ "@f5-sales-demo/pi-tui": "21.43.2",
72
+ "@f5-sales-demo/pi-utils": "21.43.2",
73
+ "@f5-sales-demo/xcsh-stats": "21.43.2",
74
74
  "@mozilla/readability": "^0.6",
75
75
  "@sinclair/typebox": "0.34.52",
76
76
  "@tobilu/qmd": "2.8.3",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "21.43.0",
21
- "commit": "a95a45b1b0f62f266192db3318181cb45e01df42",
22
- "shortCommit": "a95a45b",
20
+ "version": "21.43.2",
21
+ "commit": "117d89d2d2ba9619e4c652d28b6db7c7d17eafd9",
22
+ "shortCommit": "117d89d",
23
23
  "branch": "main",
24
- "tag": "v21.43.0",
25
- "commitDate": "2026-09-24T02:37:27+00:00",
26
- "buildDate": "2026-09-24T03:49:41.397Z",
24
+ "tag": "v21.43.2",
25
+ "commitDate": "2026-09-24T04:11:20+00:00",
26
+ "buildDate": "2026-09-24T05:21:13.289Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/a95a45b1b0f62f266192db3318181cb45e01df42",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.43.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/117d89d2d2ba9619e4c652d28b6db7c7d17eafd9",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.43.2"
33
33
  };
@@ -33,6 +33,7 @@ import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { renderStatusLine } from "../../tui";
36
+ import { renderStructuredRow, renderStructuredTreeLine } from "../../tui/tree-list";
36
37
  import { convertToPng } from "../../utils/image-convert";
37
38
  import { sanitizeWithImagePassthrough } from "../../utils/image-passthrough";
38
39
  import { renderDiff } from "./diff";
@@ -699,6 +700,7 @@ export class ToolExecutionComponent extends Container {
699
700
  */
700
701
  #formatToolExecution(width = 80): string {
701
702
  const lines: string[] = [];
703
+ const contentWidth = Math.max(1, width - 2); // Text has one cell of padding on each side.
702
704
  const icon = this.#isPartial ? "pending" : undefined;
703
705
  lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme));
704
706
 
@@ -706,7 +708,14 @@ export class ToolExecutionComponent extends Container {
706
708
  if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
707
709
  const preview = formatArgsInline(argsObject, Math.max(20, width - 10));
708
710
  if (preview) {
709
- lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`);
711
+ lines.push(
712
+ ...renderStructuredRow(
713
+ theme.fg("dim", preview),
714
+ ` ${theme.fg("dim", theme.tree.last)} `,
715
+ " ",
716
+ contentWidth,
717
+ ),
718
+ );
710
719
  }
711
720
  }
712
721
 
@@ -722,7 +731,7 @@ export class ToolExecutionComponent extends Container {
722
731
  JSON_TREE_MAX_LINES_EXPANDED,
723
732
  JSON_TREE_SCALAR_LEN_EXPANDED,
724
733
  );
725
- lines.push(...tree.lines);
734
+ lines.push(...tree.lines.flatMap(line => renderStructuredTreeLine(line, contentWidth)));
726
735
  if (tree.truncated) {
727
736
  lines.push(theme.fg("dim", "…"));
728
737
  }
@@ -748,7 +757,7 @@ export class ToolExecutionComponent extends Container {
748
757
  const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
749
758
 
750
759
  if (tree.lines.length > 0) {
751
- lines.push(...tree.lines);
760
+ lines.push(...tree.lines.flatMap(line => renderStructuredTreeLine(line, contentWidth)));
752
761
  if (!this.#expanded) {
753
762
  lines.push(formatExpandHint(theme, this.#expanded, true));
754
763
  } else if (tree.truncated) {
@@ -26,7 +26,8 @@ import {
26
26
  type ReportFindingDetails,
27
27
  type SubmitReviewDetails,
28
28
  } from "../tools/review";
29
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
29
+ import { Hasher, type RenderCache, renderStatusLine } from "../tui";
30
+ import { renderStructuredRow, renderStructuredTreeLine } from "../tui/tree-list";
30
31
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
31
32
  import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types";
32
33
 
@@ -404,7 +405,7 @@ function renderTaskSection(
404
405
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
405
406
  const taskLines = trimmed.split("\n");
406
407
  for (const line of taskLines.slice(0, maxExpanded)) {
407
- lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
408
+ lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
408
409
  }
409
410
  if (taskLines.length > maxExpanded) {
410
411
  lines.push(`${continuePrefix} ${theme.fg("dim", formatMoreItems(taskLines.length - maxExpanded, "line"))}`);
@@ -476,9 +477,6 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
476
477
  * Render the tool call arguments.
477
478
  */
478
479
  export function renderCall(args: TaskParams, _options: RenderResultOptions, theme: Theme): Component {
479
- const lines: string[] = [];
480
- lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme));
481
-
482
480
  const contextTemplate = args.context ?? "";
483
481
  const context = contextTemplate.trim();
484
482
  const hasContext = context.length > 0;
@@ -487,26 +485,50 @@ export function renderCall(args: TaskParams, _options: RenderResultOptions, them
487
485
  const vertical = theme.fg("dim", theme.tree.vertical);
488
486
  const showIsolated = "isolated" in args && args.isolated === true;
489
487
 
490
- if (hasContext) {
491
- lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
492
- for (const line of context.split("\n")) {
493
- const content = line ? theme.fg("muted", replaceTabs(line)) : "";
494
- lines.push(` ${vertical} ${content}`);
495
- }
496
- const taskPrefix = showIsolated ? branch : last;
497
- lines.push(` ${taskPrefix} ${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
498
- if (showIsolated) {
499
- lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`);
500
- }
501
- return new Text(lines.join("\n"), 0, 0);
502
- }
503
-
504
- lines.push(`${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
505
- if (showIsolated) {
506
- lines.push(`${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`);
507
- }
508
-
509
- return new Text(lines.join("\n"), 0, 0);
488
+ return {
489
+ render(width) {
490
+ const lines = [renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme)];
491
+ if (hasContext) {
492
+ lines.push(...renderStructuredRow(theme.fg("dim", "Context"), ` ${branch} `, ` ${vertical} `, width));
493
+ for (const line of context.split("\n")) {
494
+ lines.push(...renderStructuredRow(theme.fg("muted", line), ` ${vertical} `, ` ${vertical} `, width));
495
+ }
496
+ const taskPrefix = showIsolated ? branch : last;
497
+ lines.push(
498
+ ...renderStructuredRow(
499
+ `${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`,
500
+ ` ${taskPrefix} `,
501
+ ` ${showIsolated ? vertical : " "} `,
502
+ width,
503
+ ),
504
+ );
505
+ if (showIsolated)
506
+ lines.push(
507
+ ...renderStructuredRow(
508
+ `${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`,
509
+ ` ${last} `,
510
+ " ",
511
+ width,
512
+ ),
513
+ );
514
+ } else {
515
+ lines.push(
516
+ ...renderStructuredRow(
517
+ `${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`,
518
+ "",
519
+ "",
520
+ width,
521
+ ),
522
+ );
523
+ if (showIsolated)
524
+ lines.push(
525
+ ...renderStructuredRow(`${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`, "", "", width),
526
+ );
527
+ }
528
+ return lines;
529
+ },
530
+ invalidate() {},
531
+ };
510
532
  }
511
533
 
512
534
  /**
@@ -996,8 +1018,8 @@ export function renderResult(
996
1018
  }
997
1019
  }
998
1020
 
999
- const indented = lines.map(line =>
1000
- line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
1021
+ const indented = lines.flatMap(line =>
1022
+ line.length > 0 ? renderStructuredTreeLine(line, Math.max(1, width - 3)).map(row => ` ${row}`) : [""],
1001
1023
  );
1002
1024
  cached = { key, lines: indented };
1003
1025
  return indented;
@@ -523,6 +523,7 @@ export const calculatorToolRenderer = {
523
523
  const treeLines = renderTreeList(
524
524
  {
525
525
  items: outputs,
526
+ viewportWidth: width,
526
527
  expanded,
527
528
  maxCollapsed: COLLAPSED_LIST_LIMIT,
528
529
  itemType: "result",
@@ -530,7 +531,7 @@ export const calculatorToolRenderer = {
530
531
  },
531
532
  uiTheme,
532
533
  );
533
- const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
534
+ const lines = [truncateToWidth(header, width, Ellipsis.Omit), ...treeLines];
534
535
  cached = { key, lines };
535
536
  return lines;
536
537
  },
package/src/tools/find.ts CHANGED
@@ -366,6 +366,7 @@ export const findToolRenderer = {
366
366
  const listLines = renderTreeList(
367
367
  {
368
368
  items: lines,
369
+ viewportWidth: width,
369
370
  expanded,
370
371
  maxCollapsed: COLLAPSED_LIST_LIMIT,
371
372
  itemType: "file",
@@ -373,7 +374,7 @@ export const findToolRenderer = {
373
374
  },
374
375
  uiTheme,
375
376
  );
376
- const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
377
+ const result = [truncateToWidth(header, width, Ellipsis.Omit), ...listLines];
377
378
  cached = { key, lines: result };
378
379
  return result;
379
380
  },
@@ -419,12 +420,17 @@ export const findToolRenderer = {
419
420
  const fileLines = renderFileList(
420
421
  {
421
422
  files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
423
+ viewportWidth: width,
422
424
  expanded,
423
425
  maxCollapsed: COLLAPSED_LIST_LIMIT,
424
426
  },
425
427
  uiTheme,
426
428
  );
427
- const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
429
+ const result = [
430
+ truncateToWidth(header, width, Ellipsis.Omit),
431
+ ...fileLines,
432
+ ...extraLines.map(l => truncateToWidth(l, width, Ellipsis.Omit)),
433
+ ];
428
434
  cached = { key, lines: result };
429
435
  return result;
430
436
  },
@@ -292,19 +292,23 @@ export const searchToolBm25Renderer = {
292
292
  return new Text(`${header}\n${uiTheme.fg("muted", emptyMessage)}`, 0, 0);
293
293
  }
294
294
 
295
- const lines = [header];
296
- const treeLines = renderTreeList(
297
- {
298
- items: details.tools,
299
- expanded: options.expanded,
300
- maxCollapsed: COLLAPSED_MATCH_LIMIT,
301
- itemType: "tool",
302
- renderItem: match => renderMatchLines(match, uiTheme),
295
+ return {
296
+ render(width) {
297
+ const treeLines = renderTreeList(
298
+ {
299
+ items: details.tools,
300
+ viewportWidth: width,
301
+ expanded: options.expanded,
302
+ maxCollapsed: COLLAPSED_MATCH_LIMIT,
303
+ itemType: "tool",
304
+ renderItem: match => renderMatchLines(match, uiTheme),
305
+ },
306
+ uiTheme,
307
+ );
308
+ return [header, ...treeLines];
303
309
  },
304
- uiTheme,
305
- );
306
- lines.push(...treeLines);
307
- return new Text(lines.join("\n"), 0, 0);
310
+ invalidate() {},
311
+ };
308
312
  },
309
313
 
310
314
  mergeCallAndResult: true,
@@ -412,29 +412,33 @@ export const todoWriteToolRenderer = {
412
412
  }
413
413
 
414
414
  const { expanded } = options;
415
- const lines: string[] = [header];
416
415
  const indent = phases.length > 1 ? " " : "";
417
- for (const phase of phases) {
418
- if (phases.length > 1) {
419
- lines.push(uiTheme.fg("contentAccent", `${indent}${uiTheme.tree.hook} ${phase.name}`));
420
- }
421
- const treeLines = renderTreeList(
422
- {
423
- items: phase.tasks,
424
- expanded,
425
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
426
- itemType: "todo",
427
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
428
- },
429
- uiTheme,
430
- );
431
- for (const line of treeLines) lines.push(`${indent}${line}`);
432
- }
433
-
434
- const summary = renderTodoSummary(allTasks, uiTheme);
435
- if (summary !== null) lines.push(`${indent}${summary}`);
436
-
437
- return new Text(lines.join("\n"), 0, 0);
416
+ return {
417
+ render(width) {
418
+ const lines: string[] = [header];
419
+ for (const phase of phases) {
420
+ if (phases.length > 1) {
421
+ lines.push(uiTheme.fg("contentAccent", `${indent}${uiTheme.tree.hook} ${phase.name}`));
422
+ }
423
+ const treeLines = renderTreeList(
424
+ {
425
+ items: phase.tasks,
426
+ viewportWidth: Math.max(1, width - indent.length),
427
+ expanded,
428
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
429
+ itemType: "todo",
430
+ renderItem: todo => formatTodoLine(todo, uiTheme, ""),
431
+ },
432
+ uiTheme,
433
+ );
434
+ for (const line of treeLines) lines.push(`${indent}${line}`);
435
+ }
436
+ const summary = renderTodoSummary(allTasks, uiTheme);
437
+ if (summary !== null) lines.push(`${indent}${summary}`);
438
+ return lines;
439
+ },
440
+ invalidate() {},
441
+ };
438
442
  },
439
443
  mergeCallAndResult: true,
440
444
  };
@@ -13,17 +13,19 @@ export interface FileEntry {
13
13
 
14
14
  export interface FileListOptions {
15
15
  files: FileEntry[];
16
+ viewportWidth?: number;
16
17
  expanded?: boolean;
17
18
  maxCollapsed?: number;
18
19
  showIcons?: boolean;
19
20
  }
20
21
 
21
22
  export function renderFileList(options: FileListOptions, theme: Theme): string[] {
22
- const { files, expanded = false, maxCollapsed = 8, showIcons = true } = options;
23
+ const { files, expanded = false, maxCollapsed = 8, showIcons = true, viewportWidth } = options;
23
24
 
24
25
  return renderTreeList(
25
26
  {
26
27
  items: files,
28
+ viewportWidth,
27
29
  expanded,
28
30
  maxCollapsed,
29
31
  itemType: "file",
@@ -4,6 +4,7 @@
4
4
  import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
5
5
  import type { Theme, ThemeColor } from "../modes/theme/theme";
6
6
  import { getImageLineMask } from "../utils/image-passthrough";
7
+ import { renderStructuredTreeLine } from "./tree-list";
7
8
  import type { State } from "./types";
8
9
  import type { RenderCache } from "./utils";
9
10
  import { Ellipsis, getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
@@ -18,7 +19,7 @@ export interface OutputBlockOptions {
18
19
  header?: string;
19
20
  headerMeta?: string;
20
21
  state?: State;
21
- sections?: Array<{ label?: string; lines: string[] }>;
22
+ sections?: Array<{ label?: string; lines: string[]; structured?: boolean }>;
22
23
  width: number;
23
24
  applyBg?: boolean;
24
25
  /** Override the state-derived border color. Always takes precedence, including on error. Use for branded core tools. */
@@ -108,7 +109,9 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
108
109
  continue;
109
110
  }
110
111
  const wrappedLines = wrapContent
111
- ? wrapTextWithAnsi(line.trimEnd(), contentWidth)
112
+ ? section.structured
113
+ ? renderStructuredTreeLine(line.trimEnd(), contentWidth)
114
+ : wrapTextWithAnsi(line.trimEnd(), contentWidth)
112
115
  : // Clip cleanly with no trailing ellipsis — a column of "…" down the
113
116
  // right edge of a clipped diagram is clutter, not information.
114
117
  [truncateToWidth(line.trimEnd(), contentWidth, Ellipsis.Omit)];
@@ -165,6 +168,7 @@ export class CachedOutputBlock {
165
168
  if (options.sections) {
166
169
  for (const s of options.sections) {
167
170
  h.optional(s.label);
171
+ h.bool(s.structured ?? false);
168
172
  for (const line of s.lines) {
169
173
  h.str(line);
170
174
  }
@@ -2,12 +2,38 @@
2
2
  * Hierarchical tree list rendering helper.
3
3
  */
4
4
 
5
- import { replaceTabs } from "@f5-sales-demo/pi-tui";
5
+ import { sanitizeText } from "@f5-sales-demo/pi-natives";
6
+ import { replaceTabs, sliceWithWidth, visibleWidth, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
6
7
  import type { Theme } from "../modes/theme/theme";
7
8
  import { formatMoreItems } from "../tools/render-utils";
8
9
  import type { TreeContext } from "./types";
9
10
  import { getTreeBranch, getTreeContinuePrefix } from "./utils";
10
11
 
12
+ /** Wrap a structured transcript row while keeping its tree rail on every line. */
13
+ export function renderStructuredRow(
14
+ content: string,
15
+ firstPrefix: string,
16
+ continuationPrefix: string,
17
+ width: number,
18
+ ): string[] {
19
+ const normalized = replaceTabs(content);
20
+ const firstWidth = visibleWidth(firstPrefix);
21
+ const continuationWidth = visibleWidth(continuationPrefix);
22
+ const available = Math.max(1, width - Math.max(firstWidth, continuationWidth));
23
+ const wrapped = normalized ? wrapTextWithAnsi(normalized, available) : [""];
24
+ return wrapped.map((line, index) => `${index === 0 ? firstPrefix : continuationPrefix}${line}`);
25
+ }
26
+
27
+ /** Wrap an existing tree row without discarding ANSI styling in its prefix. */
28
+ export function renderStructuredTreeLine(line: string, width: number): string[] {
29
+ const prefix = sanitizeText(line).match(/^[ │├└╰─]*/)?.[0] ?? "";
30
+ const prefixWidth = prefix.length;
31
+ const firstPrefix = sliceWithWidth(line, 0, prefixWidth, true).text;
32
+ const content = sliceWithWidth(line, prefixWidth, Math.max(0, visibleWidth(line) - prefixWidth), true).text;
33
+ const continuationPrefix = prefix.replaceAll("├", "│").replaceAll(/[└╰─]/g, " ");
34
+ return renderStructuredRow(content, firstPrefix, continuationPrefix, width);
35
+ }
36
+
11
37
  export interface TreeListOptions<T> {
12
38
  items: T[];
13
39
  expanded?: boolean;
@@ -17,13 +43,23 @@ export interface TreeListOptions<T> {
17
43
  */
18
44
  maxCollapsedLines?: number;
19
45
  itemType?: string;
46
+ /** Available terminal columns when list rows should wrap with their tree rails. */
47
+ viewportWidth?: number;
20
48
  /** Called once per item with `isLast: false` during budget calculation;
21
49
  * line count MUST NOT vary based on `isLast`. */
22
50
  renderItem: (item: T, context: TreeContext) => string | string[];
23
51
  }
24
52
 
25
53
  export function renderTreeList<T>(options: TreeListOptions<T>, theme: Theme): string[] {
26
- const { items, expanded = false, maxCollapsed = 8, maxCollapsedLines, itemType = "item", renderItem } = options;
54
+ const {
55
+ items,
56
+ expanded = false,
57
+ maxCollapsed = 8,
58
+ maxCollapsedLines,
59
+ itemType = "item",
60
+ renderItem,
61
+ viewportWidth,
62
+ } = options;
27
63
  const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed);
28
64
  const linesBudget = !expanded && maxCollapsedLines !== undefined ? maxCollapsedLines : Infinity;
29
65
 
@@ -49,7 +85,13 @@ export function renderTreeList<T>(options: TreeListOptions<T>, theme: Theme): st
49
85
  if (linesBudget !== Infinity) {
50
86
  fittingCount = 0;
51
87
  for (let i = 0; i < maxItems; i++) {
52
- const count = preRendered[i]!.length;
88
+ const count =
89
+ viewportWidth === undefined
90
+ ? preRendered[i]!.length
91
+ : preRendered[i]!.reduce(
92
+ (total, line) => total + renderStructuredRow(line, "├─ ", "│ ", viewportWidth).length,
93
+ 0,
94
+ );
53
95
  const remainingAfter = items.length - (i + 1);
54
96
  const reservedSummaryLines = remainingAfter > 0 ? 1 : 0;
55
97
  if (fittedLineCount + count + reservedSummaryLines > linesBudget) break;
@@ -70,9 +112,11 @@ export function renderTreeList<T>(options: TreeListOptions<T>, theme: Theme): st
70
112
  const continuePrefix = `${theme.fg("dim", getTreeContinuePrefix(isLast, theme))}`;
71
113
  const itemLines = preRendered[i]!;
72
114
  if (itemLines.length === 0) continue;
73
- lines.push(`${prefix}${replaceTabs(itemLines[0]!)}`);
115
+ if (viewportWidth === undefined) lines.push(`${prefix}${replaceTabs(itemLines[0]!)}`);
116
+ else lines.push(...renderStructuredRow(itemLines[0]!, prefix, continuePrefix, viewportWidth));
74
117
  for (let j = 1; j < itemLines.length; j++) {
75
- lines.push(`${continuePrefix}${replaceTabs(itemLines[j]!)}`);
118
+ if (viewportWidth === undefined) lines.push(`${continuePrefix}${replaceTabs(itemLines[j]!)}`);
119
+ else lines.push(...renderStructuredRow(itemLines[j]!, continuePrefix, continuePrefix, viewportWidth));
76
120
  }
77
121
  }
78
122
 
@@ -216,6 +216,7 @@ export function renderSearchResult(
216
216
  const sourceTree = renderTreeList(
217
217
  {
218
218
  items: sources,
219
+ viewportWidth: Math.max(1, width - 3),
219
220
  expanded,
220
221
  maxCollapsed: MAX_COLLAPSED_ITEMS,
221
222
  itemType: "source",
@@ -226,7 +227,7 @@ export function renderSearchResult(
226
227
  : typeof src.url === "string" && src.url.trim()
227
228
  ? src.url
228
229
  : "Untitled";
229
- const title = truncateToWidth(titleText, width);
230
+ const title = titleText;
230
231
  const url = typeof src.url === "string" ? src.url : "";
231
232
  const domain = url ? getDomain(url) : "";
232
233
  const age =
@@ -240,9 +241,7 @@ export function renderSearchResult(
240
241
  if (age) metaParts.push(theme.fg("muted", age));
241
242
  const metaSep = theme.fg("dim", theme.sep.dot);
242
243
  const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
243
- const srcLines: string[] = [
244
- truncateToWidth(`${theme.fg("contentAccent", title)}${metaSuffix}`, width),
245
- ];
244
+ const srcLines: string[] = [`${theme.fg("contentAccent", title)}${metaSuffix}`];
246
245
  const snippetText = typeof src.snippet === "string" ? src.snippet : "";
247
246
  if (snippetText.trim()) {
248
247
  const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, width);
@@ -250,7 +249,7 @@ export function renderSearchResult(
250
249
  srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`));
251
250
  }
252
251
  }
253
- if (url) srcLines.push(theme.fg("mdLinkUrl", truncateToWidth(url, width)));
252
+ if (url) srcLines.push(theme.fg("mdLinkUrl", url));
254
253
  return srcLines;
255
254
  },
256
255
  },
@@ -268,6 +267,7 @@ export function renderSearchResult(
268
267
  const answerTree = renderTreeList(
269
268
  {
270
269
  items: answerTreeLines,
270
+ viewportWidth: Math.max(1, contentWidth),
271
271
  expanded: true,
272
272
  maxCollapsed: answerTreeLines.length,
273
273
  itemType: "line",
@@ -303,10 +303,12 @@ export function renderSearchResult(
303
303
  {
304
304
  label: theme.fg("toolTitle", "Answer"),
305
305
  lines: answerTree,
306
+ structured: true,
306
307
  },
307
308
  {
308
309
  label: theme.fg("toolTitle", "Sources"),
309
310
  lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")],
311
+ structured: true,
310
312
  },
311
313
  { label: theme.fg("toolTitle", "Metadata"), lines: allMetaLines },
312
314
  ],