@pi-kaush/pi-tool-call-markers 0.2.4 → 0.2.5

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/src/index.ts CHANGED
@@ -4,26 +4,25 @@ import {
4
4
  ToolExecutionComponent,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
- Box,
8
7
  Container,
9
8
  sliceByColumn,
10
9
  truncateToWidth,
11
10
  visibleWidth,
11
+ wrapTextWithAnsi,
12
12
  } from "@earendil-works/pi-tui";
13
+ import { runChatContainerHooks } from "./container-hooks.ts";
13
14
 
14
- const BADGE = " ⚙️";
15
- const BADGE_WIDTH = visibleWidth(BADGE);
15
+ const OUTER_INSET = 2;
16
+ const GROUP_MARKER = "%";
16
17
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
17
18
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
18
19
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
19
20
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
20
- const BOLD_ON_RE = /\u001b\[1m/g;
21
21
  const COLLAPSE_PARALLEL_ENV = "PI_TOOL_CALL_MARKERS_COLLAPSE_PARALLEL";
22
22
 
23
23
  type ThemeLike = {
24
24
  bold(text: string): string;
25
25
  fg(color: string, text: string): string;
26
- bg(color: string, text: string): string;
27
26
  };
28
27
 
29
28
  type ComponentLike = {
@@ -33,12 +32,6 @@ type ComponentLike = {
33
32
 
34
33
  type ComponentContainer = ComponentLike & {
35
34
  children?: unknown[];
36
- removeChild?(component: unknown): void;
37
- };
38
-
39
- type TextComponent = {
40
- text?: string;
41
- setText?(text: string): void;
42
35
  };
43
36
 
44
37
  type ToolExecutionRow = {
@@ -57,15 +50,11 @@ type ToolExecutionRow = {
57
50
  compactTitle?: unknown;
58
51
  };
59
52
  contentBox?: ComponentContainer;
60
- contentText?: TextComponent;
61
- selfRenderContainer?: ComponentContainer;
62
53
  callRendererComponent?: ComponentLike;
63
54
  imageComponents?: unknown[];
64
55
  imageSpacers?: unknown[];
65
- hasRendererDefinition?(): boolean;
66
56
  getRenderShell?(): "default" | "self";
67
57
  getTextOutput?(): string;
68
- removeChild?(component: unknown): void;
69
58
  };
70
59
 
71
60
  type PresentationPatchState = {
@@ -85,6 +74,7 @@ type GroupingPatchState = {
85
74
  presentation: PresentationPatchState;
86
75
  originalRender: (width: number) => string[];
87
76
  patchedRender?: (width: number) => string[];
77
+ disabled?: boolean;
88
78
  };
89
79
 
90
80
  type GroupRenderCache = {
@@ -107,157 +97,46 @@ function stripAnsi(text: string): string {
107
97
  return text.replace(ANSI_RE, "");
108
98
  }
109
99
 
110
- function hasVisibleContent(line: string): boolean {
111
- return stripAnsi(line).trim().length > 0;
112
- }
113
-
114
- function prefixBadge(line: string): string {
115
- let index = 0;
116
- while (line[index] === "\x1b" && line[index + 1] === "[") {
117
- const end = line.indexOf("m", index + 2);
118
- if (end === -1) break;
119
- index = end + 1;
120
- }
121
- return line.slice(0, index) + BADGE + line.slice(index);
122
- }
123
-
124
- function hasGearBadge(line: string): boolean {
125
- return stripAnsi(line).trimStart().startsWith("⚙️");
126
- }
127
-
128
- function boldLeadingToolToken(
129
- line: string,
130
- token: string,
131
- theme: ThemeLike,
132
- ): string {
133
- const visible = stripAnsi(line);
134
- const prefix = visible.match(/^\s*(?:⚙️\s*)?/)?.[0] ?? "";
135
- if (!visible.startsWith(token, prefix.length)) return line;
136
-
137
- const start = visibleWidth(prefix);
138
- const tokenWidth = visibleWidth(token);
139
- const before = sliceByColumn(line, 0, start);
140
- const styledToken = sliceByColumn(line, start, tokenWidth);
141
- const after = sliceByColumn(
142
- line,
143
- start + tokenWidth,
144
- visibleWidth(line),
145
- ).replace(BOLD_ON_RE, "");
146
- return before + theme.bold(styledToken) + "\x1b[22m" + after;
147
- }
148
-
149
- function decorateHeader(
150
- row: ToolExecutionRow,
151
- lines: string[],
152
- width: number,
153
- theme?: ThemeLike,
154
- ): string[] {
155
- const lineIndex = lines.findIndex(hasVisibleContent);
156
- if (lineIndex === -1) return lines;
157
-
158
- const next = [...lines];
159
- let header = next[lineIndex];
160
- if (header === undefined) return lines;
161
- if (!hasGearBadge(header) && width > BADGE_WIDTH) {
162
- header = truncateToWidth(prefixBadge(header), width, "", false);
163
- }
164
-
165
- const token = row.toolName === "bash" ? "$" : row.toolName;
166
- if (theme && token) header = boldLeadingToolToken(header, token, theme);
167
- next[lineIndex] = header;
168
- return next;
169
- }
170
-
171
- function removeResultComponent(container?: ComponentContainer): boolean {
172
- if (
173
- !container ||
174
- !Array.isArray(container.children) ||
175
- typeof container.removeChild !== "function"
176
- )
177
- return false;
178
- for (const child of container.children.slice(1)) container.removeChild(child);
179
- return true;
180
- }
100
+ // Command output can carry cursor-moving control bytes (git progress writes
101
+ // \r) and raw terminal sequences. Collapsed rows are single-line, so result
102
+ // and scraped text is stripped of display sequences and control bytes before
103
+ // it can reach a row; a bare \r would otherwise return the cursor to column 0
104
+ // and overwrite the row's own marker.
105
+ const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
106
+ // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
107
+ // leave the hyperlink payload behind as visible text.
108
+ const DISPLAY_ANSI_RE =
109
+ /\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
181
110
 
182
- function collapseGenericResult(row: ToolExecutionRow): boolean {
183
- const text = row.contentText?.text;
184
- if (
185
- typeof text !== "string" ||
186
- typeof row.contentText?.setText !== "function"
187
- )
188
- return false;
189
-
190
- const output = row.getTextOutput?.();
191
- if (!output) return true;
192
- const suffix = `\n${output}`;
193
- if (!text.endsWith(suffix)) return false;
194
- row.contentText.setText(text.slice(0, -suffix.length));
195
- return true;
111
+ function sanitizeInline(text: string): string {
112
+ return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
196
113
  }
197
114
 
198
- function hideResultImages(row: ToolExecutionRow): void {
199
- if (typeof row.removeChild !== "function") return;
200
- for (const image of row.imageComponents ?? []) row.removeChild(image);
201
- for (const spacer of row.imageSpacers ?? []) row.removeChild(spacer);
202
- row.imageComponents = [];
203
- row.imageSpacers = [];
204
- }
205
-
206
- function collapseSuccessfulResult(
207
- row: ToolExecutionRow,
208
- theme?: ThemeLike,
209
- ): void {
210
- if (
211
- row.expanded !== false ||
212
- row.isPartial !== false ||
213
- !row.result ||
214
- hasImageResult(row)
215
- )
216
- return;
217
-
218
- if (row.getRenderShell?.() === "self") {
219
- collapseSelfRenderedRow(row, theme);
220
- return;
221
- }
222
-
223
- // Default-shell failures keep Pi's native error render, which already
224
- // applies the error background. rowHasFailed also catches MCP tools that
225
- // use the default shell (e.g. mcpScript) and report via details.error.
226
- if (rowHasFailed(row)) return;
227
-
228
- const collapsed = row.hasRendererDefinition?.()
229
- ? removeResultComponent(row.contentBox)
230
- : collapseGenericResult(row);
231
- if (collapsed) {
232
- hideResultImages(row);
233
- decorateSuccessfulCall(row, theme);
234
- }
115
+ function hasVisibleContent(line: string): boolean {
116
+ return stripAnsi(line).trim().length > 0;
235
117
  }
236
118
 
237
- // Self-rendered tools (e.g. MCP adapter rows) own their framing, so there is
238
- // no result component to trim. Swap the whole container for a single summary
239
- // line in the same style as collapsed default-shell rows. Pi rebuilds the
240
- // container on every updateDisplay, so this never corrupts expanded renders.
241
- // Failures collapse too, with the error background and the first error line,
242
- // since MCP error output tends to be a huge markdown blob.
243
- function collapseSelfRenderedRow(
244
- row: ToolExecutionRow,
245
- theme?: ThemeLike,
246
- ): void {
247
- const container = row.selfRenderContainer;
248
- if (!theme || !container || !Array.isArray(container.children)) return;
119
+ type InsetLayout = {
120
+ contentWidth: number;
121
+ left: number;
122
+ };
249
123
 
250
- const failed = rowHasFailed(row);
251
- const tail = failed
252
- ? renderedErrorOutcome(row, theme)
253
- : (renderedOutcome(row, theme) ?? renderedGenericOutcome(theme));
254
- const box = new Box(1, 1, (text) =>
255
- theme.bg(failed ? "toolErrorBg" : "toolSuccessBg", text),
256
- );
257
- box.addChild(
258
- selfRenderedSummaryComponent(row, theme, tail, failed ? 0.5 : 1),
124
+ function insetLayout(width: number): InsetLayout {
125
+ const max = Math.max(1, width);
126
+ // Keep enough room for "% x → …" before spending columns on decoration.
127
+ // Normal terminal widths receive the full two-column inset on both sides.
128
+ const decoration = Math.min(OUTER_INSET * 2, Math.max(0, max - 8));
129
+ const left = Math.min(OUTER_INSET, Math.ceil(decoration / 2));
130
+ const right = decoration - left;
131
+ return { contentWidth: Math.max(1, max - left - right), left };
132
+ }
133
+
134
+ function insetLines(lines: string[], width: number): string[] {
135
+ const layout = insetLayout(width);
136
+ const prefix = " ".repeat(layout.left);
137
+ return lines.map(
138
+ (line) => prefix + truncateToWidth(line, layout.contentWidth, "", false),
259
139
  );
260
- container.children = [box];
261
140
  }
262
141
 
263
142
  // The MCP adapter reports outcomes in details.error but only promotes
@@ -306,35 +185,10 @@ function rowHasFailed(row: ToolExecutionRow): boolean {
306
185
  function renderedErrorOutcome(row: ToolExecutionRow, theme: ThemeLike): string {
307
186
  const firstLine =
308
187
  resultText(row)
309
- .split("\n")
310
- .map((line) => line.trim())
188
+ .split(/\r\n|[\r\n]/)
189
+ .map((line) => sanitizeInline(line).trim())
311
190
  .find((line) => line.length > 0) ?? "error";
312
- return `${theme.fg("muted", "→")} ${theme.fg("error", firstLine)}`;
313
- }
314
-
315
- function selfRenderedSummaryComponent(
316
- row: ToolExecutionRow,
317
- theme: ThemeLike,
318
- tail: string,
319
- maxTailShare = 1,
320
- ): ComponentLike {
321
- return {
322
- render(width: number): string[] {
323
- // The Box already shrinks our width by its horizontal padding before
324
- // calling render, so only the badge deducts from this headline;
325
- // counting the padding again starves the label at narrow widths.
326
- const budget = Math.max(1, width - BADGE_WIDTH);
327
- const summary = selfRenderedSummary(row, budget);
328
- // Long error lines would otherwise evict the call summary entirely;
329
- // cap the tail's share so the label always survives.
330
- const cappedTail =
331
- maxTailShare >= 1
332
- ? tail
333
- : capFailureTail(tail, Math.floor(budget * maxTailShare), theme);
334
- return [fitSummaryTail(summary, cappedTail, budget)];
335
- },
336
- invalidate() {},
337
- };
191
+ return `${theme.fg("error", "→")} ${theme.fg("error", firstLine)}`;
338
192
  }
339
193
 
340
194
  // Trim a failure tail to its quota of the budget, counting the appended
@@ -346,7 +200,7 @@ function selfRenderedSummaryComponent(
346
200
  function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
347
201
  const tailWidth = visibleWidth(tail);
348
202
  if (tailWidth <= cap) return tail;
349
- const ellipsis = theme.fg("muted", "…");
203
+ const ellipsis = theme.fg("error", "…");
350
204
  const ellipsisWidth = visibleWidth(ellipsis);
351
205
  if (cap <= 0) return "";
352
206
  if (ellipsisWidth >= cap) return sliceByColumn(tail, 0, cap, true);
@@ -354,7 +208,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
354
208
  }
355
209
 
356
210
  function renderedGenericOutcome(theme: ThemeLike): string {
357
- return `${theme.fg("muted", "→")} ${theme.fg("success", "done")}`;
211
+ return theme.fg("muted", "→ done");
358
212
  }
359
213
 
360
214
  // The MCP adapter stashes its call's first line in renderer state before
@@ -503,6 +357,17 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
503
357
  const json = squashJsonish(args);
504
358
  return json ? `mcp ${json}` : "mcp status";
505
359
  }
360
+ // Pi's edit tool renders its own shell and carries { path, edits } args;
361
+ // mirror Pi's native `edit <path>` call line instead of dumping the payload.
362
+ if (
363
+ token === "edit" &&
364
+ args &&
365
+ typeof args === "object" &&
366
+ !Array.isArray(args)
367
+ ) {
368
+ const path = (args as Record<string, unknown>).path;
369
+ if (typeof path === "string" && path.length > 0) return `${token} ${path}`;
370
+ }
506
371
  const title = selfRenderedCallTitle(row);
507
372
  const json = squashJsonish(args);
508
373
  // Direct tools put only the bare name in their own title, so append the
@@ -567,91 +432,6 @@ function isLiveRow(row: ToolExecutionRow): boolean {
567
432
  );
568
433
  }
569
434
 
570
- function liveTailText(
571
- row: ToolExecutionRow,
572
- droppedVisible: boolean,
573
- theme?: ThemeLike,
574
- ): string | undefined {
575
- if (!theme) return undefined;
576
- const elapsed = liveElapsedText(row);
577
- if (!droppedVisible && !elapsed) return undefined;
578
- const parts: string[] = [];
579
- if (droppedVisible) parts.push("…");
580
- if (elapsed) parts.push("·", elapsed);
581
- return theme.fg("muted", parts.join(" "));
582
- }
583
-
584
- function fitLiveHeader(
585
- header: string,
586
- tail: string,
587
- width: number,
588
- droppedVisible: boolean,
589
- ): string {
590
- const max = Math.max(1, width);
591
- const head = header.trimEnd();
592
- if (visibleWidth(head) + visibleWidth(tail) + 1 <= max)
593
- return `${head} ${tail}`;
594
- const headWidth = Math.max(1, max - visibleWidth(tail) - 1);
595
- const headSuffix = droppedVisible ? "" : "…";
596
- return `${truncateToWidth(head, headWidth, headSuffix, false)} ${tail}`;
597
- }
598
-
599
- // While a call streams or runs, pin it to a single header line with the
600
- // elapsed time inline, so the block never grows and never collapses on
601
- // completion; the header text simply settles into the outcome summary. The
602
- // cap is composed before the Box applies background and padding, so the live
603
- // line keeps the tool background edge to edge and the block's bottom padding.
604
- function capLiveRowDisplay(row: ToolExecutionRow, theme?: ThemeLike): void {
605
- // Pin live self-rendered rows (e.g. MCP calls) to the same one-line summary
606
- // they settle into, so the block never grows and hops on completion.
607
- if (row.getRenderShell?.() === "self") {
608
- const container = row.selfRenderContainer;
609
- if (!theme || !container || !Array.isArray(container.children)) return;
610
- const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
611
- box.addChild(
612
- selfRenderedSummaryComponent(row, theme, theme.fg("muted", "…")),
613
- );
614
- container.children = [box];
615
- return;
616
- }
617
- if (row.hasRendererDefinition?.()) {
618
- const container = row.contentBox;
619
- if (!container || !Array.isArray(container.children)) return;
620
- const hadExtraChildren = container.children.length > 1;
621
- removeResultComponent(container);
622
- const original = container.children[0] as ComponentLike | undefined;
623
- if (!original || typeof original.render !== "function") return;
624
- container.children[0] = {
625
- render(width: number): string[] {
626
- const lines = original.render(width);
627
- const headerIndex = lines.findIndex(hasVisibleContent);
628
- if (headerIndex === -1) return lines;
629
- const header = lines[headerIndex] ?? "";
630
- const droppedVisible =
631
- hadExtraChildren ||
632
- lines.slice(headerIndex + 1).some(hasVisibleContent);
633
- const tail = liveTailText(row, droppedVisible, theme);
634
- return [
635
- tail ? fitLiveHeader(header, tail, width, droppedVisible) : header,
636
- ];
637
- },
638
- invalidate() {
639
- original.invalidate();
640
- },
641
- };
642
- return;
643
- }
644
-
645
- const text = row.contentText;
646
- if (typeof text?.text !== "string" || typeof text.setText !== "function")
647
- return;
648
- const lines = text.text.split("\n");
649
- const title = lines[0] ?? "";
650
- const droppedVisible = lines.slice(1).some((line) => line.trim().length > 0);
651
- const tail = liveTailText(row, droppedVisible, theme);
652
- text.setText(tail ? `${title} ${tail}` : title);
653
- }
654
-
655
435
  function isCollapsibleSuccess(row: ToolExecutionRow): boolean {
656
436
  return (
657
437
  row.expanded === false &&
@@ -689,6 +469,7 @@ function isGroupableToolRow(
689
469
  renderAt: (index: number) => string[],
690
470
  state: PresentationPatchState,
691
471
  ): boolean {
472
+ if (row.toolName === "subagent") return false;
692
473
  if (!isLiveRow(row) && !isCollapsibleSuccess(row)) return false;
693
474
  return (
694
475
  state.collapseParallel ||
@@ -823,7 +604,7 @@ function renderedOutcome(
823
604
  ): string | undefined {
824
605
  const summary = outcomeSummary(row);
825
606
  if (!summary) return undefined;
826
- return `${theme.fg("muted", "→")} ${theme.fg("success", summary)}`;
607
+ return theme.fg("muted", `→ ${summary}`);
827
608
  }
828
609
 
829
610
  function renderedGroupedOutcome(
@@ -834,7 +615,7 @@ function renderedGroupedOutcome(
834
615
  if (outcome) return outcome;
835
616
  if (isLiveRow(row)) {
836
617
  const elapsed = liveElapsedText(row);
837
- return theme.fg("muted", elapsed ? ${elapsed}` : "…");
618
+ return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
838
619
  }
839
620
  // Self-rendered tools have no per-tool outcome summary; keep the group's
840
621
  // settled tail consistent with collapsed singletons.
@@ -842,70 +623,46 @@ function renderedGroupedOutcome(
842
623
  return undefined;
843
624
  }
844
625
 
845
- // A budget too small for the suffix keeps literal text instead, so a
846
- // one-column label never collapses into a bare ellipsis.
847
- function truncatePlain(text: string, width: number, suffix = "…"): string {
626
+ // A budget too small for the suffix keeps literal styled text instead, so a
627
+ // one-column label never collapses into a bare ellipsis or loses its status.
628
+ function truncateStyled(text: string, width: number, suffix = "…"): string {
848
629
  const max = Math.max(0, width);
849
- const plain = stripAnsi(text);
850
- if (visibleWidth(plain) <= max) return plain;
630
+ if (visibleWidth(text) <= max) return text;
851
631
 
852
632
  const suffixWidth = visibleWidth(suffix);
853
- if (suffixWidth >= max) return sliceByColumn(plain, 0, max, true);
854
- return `${sliceByColumn(plain, 0, max - suffixWidth, true)}${suffix}`;
633
+ if (suffixWidth >= max) return sliceByColumn(text, 0, max, true);
634
+ return truncateToWidth(text, max, suffix, false);
855
635
  }
856
636
 
857
- function fitSummary(summary: string, width: number): string {
637
+ function fitSummary(summary: string, width: number, suffix = "…"): string {
858
638
  const max = Math.max(1, width);
859
- return visibleWidth(summary) <= max ? summary : truncatePlain(summary, max);
639
+ return visibleWidth(summary) <= max
640
+ ? summary
641
+ : truncateStyled(summary, max, suffix);
860
642
  }
861
643
 
862
644
  // Fits "head tail" into width while always reserving at least one literal
863
645
  // head column plus the joining space; the tail takes what is left and
864
646
  // disappears entirely when no columns remain.
865
- function fitSummaryTail(head: string, tail: string, width: number): string {
647
+ function fitSummaryTail(
648
+ head: string,
649
+ tail: string,
650
+ width: number,
651
+ suffix = "…",
652
+ ): string {
866
653
  const max = Math.max(1, width);
867
654
  const headWidth = visibleWidth(head);
868
655
  const tailWidth = visibleWidth(tail);
869
656
  if (headWidth + 1 + tailWidth <= max) return `${head.trimEnd()} ${tail}`;
870
- if (tailWidth === 0) return truncatePlain(head, max);
871
- if (headWidth === 0) return truncatePlain(tail, max);
657
+ if (tailWidth === 0) return truncateStyled(head, max, suffix);
658
+ if (headWidth === 0) return truncateStyled(tail, max, suffix);
872
659
 
873
660
  const labelBudget = Math.min(headWidth, Math.max(1, max - tailWidth - 1));
874
661
  const tailBudget = Math.min(tailWidth, Math.max(0, max - labelBudget - 1));
875
- if (tailBudget === 0) return truncatePlain(head, max);
662
+ if (tailBudget === 0) return truncateStyled(head, max, suffix);
876
663
  const fittedTail =
877
- tailWidth <= tailBudget ? tail : truncatePlain(tail, tailBudget);
878
- return `${truncatePlain(head, labelBudget)} ${fittedTail}`;
879
- }
880
-
881
- function decorateSuccessfulCall(
882
- row: ToolExecutionRow,
883
- theme?: ThemeLike,
884
- ): void {
885
- if (!theme || !Array.isArray(row.contentBox?.children)) return;
886
- const original = row.contentBox.children[0] as ComponentLike | undefined;
887
- const outcome = renderedOutcome(row, theme);
888
- if (!original || typeof original.render !== "function" || !outcome) return;
889
-
890
- row.contentBox.children[0] = {
891
- render(width: number): string[] {
892
- const lines = original.render(width);
893
- const visibleLines = lines.filter(hasVisibleContent);
894
- const line = visibleLines[0];
895
- if (line === undefined) return lines;
896
-
897
- const rendered = visibleLines.slice(0, 3).map(trimRenderedLine);
898
- if (visibleLines.length > rendered.length)
899
- rendered.push(theme.fg("muted", "…"));
900
- const summary = rendered.join(theme.fg("muted", " · "));
901
- return [
902
- fitSummaryTail(summary, outcome, Math.max(1, width - BADGE_WIDTH)),
903
- ];
904
- },
905
- invalidate() {
906
- original.invalidate();
907
- },
908
- };
664
+ tailWidth <= tailBudget ? tail : truncateStyled(tail, tailBudget, suffix);
665
+ return `${truncateStyled(head, labelBudget, suffix)} ${fittedTail}`;
909
666
  }
910
667
 
911
668
  function removeTrailingExpandHint(text: string): string {
@@ -920,12 +677,12 @@ function removeTrailingExpandHint(text: string): string {
920
677
  }
921
678
 
922
679
  function trimRenderedLine(text: string): string {
923
- const plain = stripAnsi(text);
680
+ const plain = sanitizeInline(stripAnsi(text));
924
681
  const leading = plain.match(/^\s*/)?.[0] ?? "";
925
682
  const trimmed = plain.trim();
926
683
  if (!trimmed) return "";
927
684
  return sliceByColumn(
928
- text,
685
+ plain,
929
686
  visibleWidth(leading),
930
687
  visibleWidth(trimmed),
931
688
  ).trimEnd();
@@ -987,25 +744,347 @@ function renderedCallSummary(
987
744
 
988
745
  const fallback = compactArgs(row.args);
989
746
  return fallback
990
- ? theme.fg("accent", fallback)
747
+ ? theme.fg("muted", fallback)
991
748
  : theme.fg("muted", "(no arguments)");
992
749
  }
993
750
 
751
+ function styledCallLabel(
752
+ label: string,
753
+ theme: ThemeLike,
754
+ color = "muted",
755
+ ): string {
756
+ const plain = sanitizeInline(stripAnsi(label)).trim();
757
+ const match = /^(\S+)(.*)$/s.exec(plain);
758
+ if (!match) return theme.fg(color, plain);
759
+ return (
760
+ theme.fg(color, theme.bold(match[1] ?? "")) +
761
+ theme.fg(color, match[2] ?? "")
762
+ );
763
+ }
764
+
765
+ function collapsedCallLabel(
766
+ row: ToolExecutionRow,
767
+ width: number,
768
+ theme: ThemeLike,
769
+ color = "muted",
770
+ ): string {
771
+ if (row.getRenderShell?.() === "self") {
772
+ return styledCallLabel(selfRenderedCallLabel(row), theme, color);
773
+ }
774
+
775
+ const token = row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
776
+ const summary = renderedCallSummary(row, Math.max(1, width), theme);
777
+ const plainSummary = stripAnsi(summary).trim();
778
+ const label = plainSummary ? `${token} ${plainSummary}` : token;
779
+ return styledCallLabel(label, theme, color);
780
+ }
781
+
782
+ function collapsedOutcome(
783
+ row: ToolExecutionRow,
784
+ width: number,
785
+ theme: ThemeLike,
786
+ ): string | undefined {
787
+ if (rowHasFailed(row)) {
788
+ return capFailureTail(
789
+ renderedErrorOutcome(row, theme),
790
+ Math.max(3, Math.floor(width / 2)),
791
+ theme,
792
+ );
793
+ }
794
+ if (isLiveRow(row)) {
795
+ const elapsed = liveElapsedText(row);
796
+ return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
797
+ }
798
+ if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
799
+ return renderedOutcome(row, theme);
800
+ }
801
+
802
+ function collapsedHeadline(
803
+ row: ToolExecutionRow,
804
+ width: number,
805
+ theme: ThemeLike,
806
+ ): string {
807
+ // Failed rows render entirely in error so the row reads as the one that
808
+ // failed, not just its outcome tail.
809
+ const color = rowHasFailed(row) ? "error" : "muted";
810
+ const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
811
+ const budget = Math.max(1, width - visibleWidth(marker));
812
+ const label = collapsedCallLabel(row, budget, theme, color);
813
+ const outcome = collapsedOutcome(row, budget, theme);
814
+ // The truncation suffix inherits the row tone; pi-tui's truncation resets
815
+ // around a plain suffix, which would render it in the terminal default
816
+ // foreground instead of the row color.
817
+ const suffix = theme.fg(color, "…");
818
+ return (
819
+ marker +
820
+ (outcome
821
+ ? fitSummaryTail(label, outcome, budget, suffix)
822
+ : fitSummary(label, budget, suffix))
823
+ );
824
+ }
825
+
826
+ function isRecord(value: unknown): value is Record<string, unknown> {
827
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
828
+ }
829
+
830
+ type SubagentStep = {
831
+ agent: string;
832
+ profile: string | undefined;
833
+ task: string;
834
+ };
835
+
836
+ type SubagentPlan = {
837
+ kind: "single" | "parallel" | "chain";
838
+ scope: string;
839
+ steps: SubagentStep[];
840
+ };
841
+
842
+ function parseSubagentArgs(args: unknown): SubagentPlan | undefined {
843
+ if (!isRecord(args)) return undefined;
844
+ const scope =
845
+ args.agentScope === "project" || args.agentScope === "both"
846
+ ? args.agentScope
847
+ : "user";
848
+ const hasParallel = Array.isArray(args.tasks) && args.tasks.length > 0;
849
+ const hasChain = Array.isArray(args.chain) && args.chain.length > 0;
850
+ const hasSingle =
851
+ typeof args.task === "string" && args.task.trim().length > 0;
852
+ if (Number(hasParallel) + Number(hasChain) + Number(hasSingle) !== 1) {
853
+ return undefined;
854
+ }
855
+
856
+ const parseStep = (item: unknown): SubagentStep | undefined => {
857
+ if (!isRecord(item) || typeof item.task !== "string") return undefined;
858
+ return {
859
+ agent:
860
+ typeof item.agent === "string" && item.agent.trim()
861
+ ? item.agent.trim()
862
+ : "...",
863
+ // The subagent extension suppresses the profile badge when a model
864
+ // override is present; mirror that here.
865
+ profile:
866
+ typeof item.profile === "string" &&
867
+ item.profile.trim() &&
868
+ item.model === undefined
869
+ ? item.profile.trim()
870
+ : undefined,
871
+ task: item.task,
872
+ };
873
+ };
874
+
875
+ if (hasParallel) {
876
+ const steps = (args.tasks as unknown[]).map(parseStep);
877
+ if (steps.some((step) => step === undefined)) return undefined;
878
+ return { kind: "parallel", scope, steps: steps as SubagentStep[] };
879
+ }
880
+ if (hasChain) {
881
+ const steps = (args.chain as unknown[]).map(parseStep);
882
+ if (steps.some((step) => step === undefined)) return undefined;
883
+ return { kind: "chain", scope, steps: steps as SubagentStep[] };
884
+ }
885
+ const single = parseStep(args);
886
+ if (!single) return undefined;
887
+ return { kind: "single", scope, steps: [single] };
888
+ }
889
+
890
+ // Splits a scraped plan line's content into the agent display name (which may
891
+ // carry a leading emoji) and the remaining badge/preview text.
892
+ function parseStepContent(content: string): {
893
+ displayName: string;
894
+ bareName: string;
895
+ rest: string;
896
+ } {
897
+ const tokens = content.trim().split(/\s+/).filter(Boolean);
898
+ if (tokens.length === 0) return { displayName: "", bareName: "", rest: "" };
899
+ const first = tokens[0]!;
900
+ const hasEmoji = [...first].some((char) => (char.codePointAt(0) ?? 0) > 0x7f);
901
+ const nameTokens = hasEmoji && tokens.length > 1 ? 2 : 1;
902
+ const displayName = tokens.slice(0, nameTokens).join(" ");
903
+ return {
904
+ displayName,
905
+ bareName: tokens[nameTokens - 1] ?? displayName,
906
+ rest: tokens.slice(nameTokens).join(" "),
907
+ };
908
+ }
909
+
910
+ // Agent display names (emoji + name) come from the subagent extension's own
911
+ // call component when available; args only carry the bare agent name.
912
+ function scrapeSubagentDisplayNames(
913
+ row: ToolExecutionRow,
914
+ ): Map<string, string> {
915
+ const names = new Map<string, string>();
916
+ let component = row.callRendererComponent;
917
+ if (!component && Array.isArray(row.contentBox?.children)) {
918
+ component = row.contentBox.children[0] as ComponentLike | undefined;
919
+ }
920
+ if (!component || typeof component.render !== "function") return names;
921
+ try {
922
+ for (const raw of component.render(120)) {
923
+ const line = sanitizeInline(stripAnsi(raw)).trim();
924
+ if (!line || /^subagent\b/.test(line)) continue;
925
+ const { displayName, bareName } = parseStepContent(
926
+ line.replace(/^\d+\.\s*/, ""),
927
+ );
928
+ if (displayName && bareName && displayName !== bareName) {
929
+ names.set(bareName, displayName);
930
+ }
931
+ }
932
+ } catch {
933
+ // Display-name scraping is cosmetic; args still carry the bare name.
934
+ }
935
+ return names;
936
+ }
937
+
938
+ function subagentStepPreview(task: string): string {
939
+ const clean = sanitizeInline(task)
940
+ .replace(/\{previous\}/gi, " ")
941
+ .replace(/\s+/g, " ")
942
+ .trim();
943
+ return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
944
+ }
945
+
946
+ // Subagents render as an ordinary unboxed tool block: a `%` heading with the
947
+ // plan kind/count/scope, then the numbered chain steps or parallel tasks with
948
+ // agent names in accent. Everything else stays muted (or error on failure),
949
+ // matching the shared tool-row aesthetic.
950
+ function renderSubagentPlan(
951
+ row: ToolExecutionRow,
952
+ width: number,
953
+ theme: ThemeLike,
954
+ ): string[] | undefined {
955
+ const plan = parseSubagentArgs(row.args);
956
+ if (!plan || width < 12) return undefined;
957
+
958
+ const failed = rowHasFailed(row);
959
+ const color = failed ? "error" : "muted";
960
+ const nameColor = failed ? "error" : "accent";
961
+ const suffix = theme.fg(color, "…");
962
+ const displayNames = scrapeSubagentDisplayNames(row);
963
+ // Agent/profile/task values are model-supplied; sanitize them like every
964
+ // other collapsed-row text so control bytes cannot reach the terminal.
965
+ const displayOf = (agent: string) =>
966
+ theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
967
+ const detailOf = (step: SubagentStep) =>
968
+ theme.fg(
969
+ color,
970
+ `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
971
+ );
972
+
973
+ const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
974
+ const budget = Math.max(1, width - visibleWidth(marker));
975
+ const outcome = failed
976
+ ? collapsedOutcome(row, budget, theme)
977
+ : isLiveRow(row)
978
+ ? collapsedOutcome(row, budget, theme)
979
+ : row.result
980
+ ? renderedGenericOutcome(theme)
981
+ : theme.fg("warning", "…");
982
+ const headline = (label: string) =>
983
+ marker +
984
+ (outcome
985
+ ? fitSummaryTail(label, outcome, budget, suffix)
986
+ : fitSummary(label, budget, suffix));
987
+
988
+ if (plan.kind === "single") {
989
+ const step = plan.steps[0]!;
990
+ return [
991
+ headline(
992
+ theme.fg(color, theme.bold("subagent")) +
993
+ " " +
994
+ displayOf(step.agent) +
995
+ detailOf(step),
996
+ ),
997
+ ];
998
+ }
999
+
1000
+ const kindLabel =
1001
+ plan.kind === "chain"
1002
+ ? `chain (${plan.steps.length} ${plan.steps.length === 1 ? "step" : "steps"})`
1003
+ : `parallel (${plan.steps.length} tasks)`;
1004
+ const lines = [
1005
+ headline(
1006
+ theme.fg(color, theme.bold("subagent")) +
1007
+ " " +
1008
+ theme.fg(color, kindLabel) +
1009
+ theme.fg(color, ` [${plan.scope}]`),
1010
+ ),
1011
+ ];
1012
+ const shown = plan.steps.slice(0, 3);
1013
+ for (let index = 0; index < shown.length; index++) {
1014
+ const step = shown[index]!;
1015
+ const number =
1016
+ plan.kind === "chain" ? `${theme.fg(color, `${index + 1}.`)} ` : "";
1017
+ lines.push(` ${number}${displayOf(step.agent)}${detailOf(step)}`);
1018
+ }
1019
+ if (plan.steps.length > shown.length) {
1020
+ lines.push(
1021
+ ` ${theme.fg(color, `... +${plan.steps.length - shown.length} more`)}`,
1022
+ );
1023
+ }
1024
+ return lines;
1025
+ }
1026
+
1027
+ function imageResultLines(
1028
+ row: ToolExecutionRow,
1029
+ width: number,
1030
+ theme: ThemeLike,
1031
+ ): string[] {
1032
+ if (!hasImageResult(row)) return [];
1033
+ const lines: string[] = [];
1034
+ const output = row.getTextOutput?.() || resultText(row);
1035
+ if (output) {
1036
+ const sanitized = output
1037
+ .split("\n")
1038
+ .map((line) => sanitizeInline(line))
1039
+ .join("\n");
1040
+ lines.push(
1041
+ ...wrapTextWithAnsi(
1042
+ theme.fg("toolOutput", sanitized),
1043
+ Math.max(1, width),
1044
+ ),
1045
+ );
1046
+ }
1047
+
1048
+ for (let index = 0; index < (row.imageComponents?.length ?? 0); index++) {
1049
+ const spacer = row.imageSpacers?.[index];
1050
+ if (spacer) lines.push(...renderComponent(spacer, width));
1051
+ const image = row.imageComponents?.[index];
1052
+ if (image) lines.push(...renderComponent(image, width));
1053
+ }
1054
+ return lines.map((line) => truncateToWidth(line, width, "", false));
1055
+ }
1056
+
1057
+ function renderCollapsedToolRow(
1058
+ row: ToolExecutionRow,
1059
+ width: number,
1060
+ theme: ThemeLike,
1061
+ ): string[] {
1062
+ const layout = insetLayout(width);
1063
+ const plan =
1064
+ row.toolName === "subagent"
1065
+ ? renderSubagentPlan(row, layout.contentWidth, theme)
1066
+ : undefined;
1067
+ const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1068
+ body.push(...imageResultLines(row, layout.contentWidth, theme));
1069
+ return ["", ...insetLines(body, width)];
1070
+ }
1071
+
994
1072
  function compactBulletLine(
995
1073
  summary: string,
996
1074
  outcome: string | undefined,
997
1075
  width: number,
998
1076
  theme: ThemeLike,
1077
+ color = "muted",
999
1078
  ): string {
1000
- const prefix = ` ${theme.fg("muted", "•")} `;
1079
+ const prefix = ` ${theme.fg(color, "•")} `;
1001
1080
  const indent = visibleWidth(prefix);
1002
1081
  if (width <= indent) return truncateToWidth(prefix, width, "", false);
1003
1082
  const available = width - indent;
1004
1083
  return (
1005
1084
  prefix +
1006
1085
  (outcome
1007
- ? fitSummaryTail(summary, outcome, available)
1008
- : fitSummary(summary, available))
1086
+ ? fitSummaryTail(summary, outcome, available, theme.fg(color, "…"))
1087
+ : fitSummary(summary, available, theme.fg(color, "…")))
1009
1088
  );
1010
1089
  }
1011
1090
 
@@ -1022,16 +1101,25 @@ function groupedCallComponent(
1022
1101
  if (lines.length > 0) lines.push("");
1023
1102
  const token =
1024
1103
  row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
1025
- const heading = theme.fg(
1026
- "toolTitle",
1027
- `${BADGE} ${theme.bold(token)}`,
1028
- );
1104
+ const heading = `${theme.fg(
1105
+ "muted",
1106
+ theme.bold(GROUP_MARKER),
1107
+ )} ${theme.fg("muted", theme.bold(token))}`;
1029
1108
  lines.push(truncateToWidth(heading, width, "", false));
1030
1109
  previousToolName = row.toolName;
1031
1110
  }
1111
+ const color = rowHasFailed(row) ? "error" : "muted";
1032
1112
  const call = renderedCallSummary(row, Math.max(1, width - 4), theme);
1033
1113
  const outcome = renderedGroupedOutcome(row, theme);
1034
- lines.push(compactBulletLine(call, outcome, width, theme));
1114
+ lines.push(
1115
+ compactBulletLine(
1116
+ theme.fg(color, stripAnsi(call)),
1117
+ outcome,
1118
+ width,
1119
+ theme,
1120
+ color,
1121
+ ),
1122
+ );
1035
1123
  }
1036
1124
  return lines;
1037
1125
  },
@@ -1039,21 +1127,6 @@ function groupedCallComponent(
1039
1127
  };
1040
1128
  }
1041
1129
 
1042
- function renderWithTemporaryChild(
1043
- container: ComponentContainer,
1044
- child: ComponentLike,
1045
- render: () => string[],
1046
- ): string[] {
1047
- const children = container.children;
1048
- if (!Array.isArray(children)) return render();
1049
- container.children = [child];
1050
- try {
1051
- return render();
1052
- } finally {
1053
- container.children = children;
1054
- }
1055
- }
1056
-
1057
1130
  function sameMembers(
1058
1131
  left: ToolExecutionRow[],
1059
1132
  right: ToolExecutionRow[],
@@ -1084,18 +1157,13 @@ function renderGroupedToolRows(
1084
1157
  state: PresentationPatchState,
1085
1158
  ): string[] {
1086
1159
  const theme = state.theme;
1087
- if (!theme)
1088
- return decorateHeader(
1089
- row,
1090
- state.originalRender.call(row, width),
1091
- width,
1092
- theme,
1093
- );
1160
+ if (!theme) return state.originalRender.call(row, width);
1094
1161
  const themeSample =
1095
1162
  theme.fg("toolTitle", "x") +
1096
1163
  theme.fg("muted", "x") +
1097
- theme.bg("toolPendingBg", "x") +
1098
- theme.bg("toolSuccessBg", "x");
1164
+ theme.fg("dim", "x") +
1165
+ theme.fg("warning", "x") +
1166
+ theme.fg("error", "x");
1099
1167
  const cached = state.groupCache.get(row);
1100
1168
  const hasLiveMembers = rows.some(isLiveRow);
1101
1169
  if (
@@ -1109,68 +1177,21 @@ function renderGroupedToolRows(
1109
1177
  return cached.lines;
1110
1178
  }
1111
1179
 
1180
+ const layout = insetLayout(width);
1112
1181
  const summary = groupedCallComponent(rows, theme);
1113
- let lines: string[];
1114
- if (!row.hasRendererDefinition?.()) {
1115
- const text = row.contentText;
1116
- const previous = text?.text;
1117
- if (typeof previous !== "string" || typeof text?.setText !== "function") {
1118
- return decorateHeader(
1119
- row,
1120
- state.originalRender.call(row, width),
1121
- width,
1122
- theme,
1123
- );
1124
- }
1125
- text.setText(summary.render(Math.max(1, width - 2)).join("\n"));
1126
- try {
1127
- lines = state.originalRender.call(row, width);
1128
- } finally {
1129
- text.setText(previous);
1130
- }
1131
- } else if (row.getRenderShell?.() === "self") {
1132
- const container = row.selfRenderContainer;
1133
- if (!container)
1134
- return decorateHeader(
1135
- row,
1136
- state.originalRender.call(row, width),
1137
- width,
1138
- theme,
1139
- );
1140
- const box = new Box(1, 1, (text) =>
1141
- theme.bg(hasLiveMembers ? "toolPendingBg" : "toolSuccessBg", text),
1142
- );
1143
- box.addChild(summary);
1144
- lines = renderWithTemporaryChild(container, box, () =>
1145
- state.originalRender.call(row, width),
1146
- );
1147
- } else {
1148
- const container = row.contentBox;
1149
- if (!container)
1150
- return decorateHeader(
1151
- row,
1152
- state.originalRender.call(row, width),
1153
- width,
1154
- theme,
1155
- );
1156
- lines = renderWithTemporaryChild(container, summary, () =>
1157
- state.originalRender.call(row, width),
1158
- );
1159
- }
1160
-
1161
- const decorated = decorateHeader(row, lines, width, theme);
1182
+ const lines = ["", ...insetLines(summary.render(layout.contentWidth), width)];
1162
1183
  if (hasLiveMembers) {
1163
1184
  state.groupCache.delete(row);
1164
1185
  } else {
1165
1186
  state.groupCache.set(row, {
1166
- lines: decorated,
1187
+ lines,
1167
1188
  members: [...rows],
1168
1189
  memberVersions: rows.map((member) => state.rowVersions.get(member) ?? 0),
1169
1190
  themeSample,
1170
1191
  width,
1171
1192
  });
1172
1193
  }
1173
- return decorated;
1194
+ return lines;
1174
1195
  }
1175
1196
 
1176
1197
  function renderContainerWithToolGroups(
@@ -1234,9 +1255,8 @@ function renderContainerWithToolGroups(
1234
1255
  }
1235
1256
 
1236
1257
  for (const member of group) presentation.rowGroups.set(member, group);
1237
- // An active member supplies the pending background. Once every call
1238
- // settles, the first member supplies the success background. Either way,
1239
- // the grouped row keeps the same number of lines.
1258
+ // Use a live member while any call is pending, then the first member once
1259
+ // settled. Either way, the grouped row keeps the same number of lines.
1240
1260
  const shellRow = group.find(isLiveRow) ?? child;
1241
1261
  lines.push(...renderGroupedToolRows(shellRow, group, width, presentation));
1242
1262
  index = lastMemberIndex;
@@ -1259,6 +1279,7 @@ function installGroupingPatch(
1259
1279
  const existing = proto[GROUPING_PATCHED];
1260
1280
  if (existing) {
1261
1281
  existing.presentation = presentation;
1282
+ existing.disabled = false;
1262
1283
  return existing;
1263
1284
  }
1264
1285
 
@@ -1271,10 +1292,19 @@ function installGroupingPatch(
1271
1292
  width: number,
1272
1293
  ): string[] {
1273
1294
  const children = this.children;
1274
- if (!Array.isArray(children) || !children.some(isToolExecutionRow)) {
1295
+ if (
1296
+ state.disabled ||
1297
+ !Array.isArray(children) ||
1298
+ !children.some(isToolExecutionRow)
1299
+ ) {
1275
1300
  return state.originalRender.call(this, width);
1276
1301
  }
1302
+ let restoreHooks: () => void = () => {};
1277
1303
  try {
1304
+ // Other container-level concerns (pi-content-layout's system-text
1305
+ // inset) decorate children through the shared hooks before grouping
1306
+ // rewrites the rows; delegation alone cannot reach them from here.
1307
+ restoreHooks = runChatContainerHooks(this, children, width);
1278
1308
  return renderContainerWithToolGroups(
1279
1309
  children,
1280
1310
  width,
@@ -1282,6 +1312,8 @@ function installGroupingPatch(
1282
1312
  );
1283
1313
  } catch {
1284
1314
  return state.originalRender.call(this, width);
1315
+ } finally {
1316
+ restoreHooks();
1285
1317
  }
1286
1318
  };
1287
1319
 
@@ -1300,6 +1332,10 @@ function installGroupingPatch(
1300
1332
 
1301
1333
  function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
1302
1334
  if (!state) return;
1335
+ // Even when another extension's wrapper sits on top of ours, grouping must
1336
+ // not outlive its owner: the wrapper delegates once disabled, and a later
1337
+ // reinstall re-enables it.
1338
+ state.disabled = true;
1303
1339
  const proto = Container?.prototype as unknown as ComponentContainer & {
1304
1340
  [GROUPING_PATCHED]?: GroupingPatchState;
1305
1341
  render?: (width: number) => string[];
@@ -1359,20 +1395,23 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1359
1395
  state.groupCache.delete(this);
1360
1396
  }
1361
1397
  state.originalUpdateDisplay.call(this);
1362
- try {
1363
- if (isLiveRow(this)) capLiveRowDisplay(this, state.theme);
1364
- else collapseSuccessfulResult(this, state.theme);
1365
- } catch {
1366
- // Presentation is cosmetic; preserve Pi's renderer if its internals change.
1367
- }
1368
1398
  };
1369
1399
  const patchedRender = function renderWithToolPresentation(
1370
1400
  this: ToolExecutionRow,
1371
1401
  width: number,
1372
1402
  ): string[] {
1373
1403
  const lines = state.originalRender.call(this, width);
1404
+ if (
1405
+ typeof this.expanded !== "boolean" ||
1406
+ typeof this.isPartial !== "boolean" ||
1407
+ this.expanded ||
1408
+ !state.theme ||
1409
+ (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
1410
+ ) {
1411
+ return lines;
1412
+ }
1374
1413
  try {
1375
- return decorateHeader(this, lines, width, state.theme);
1414
+ return renderCollapsedToolRow(this, width, state.theme);
1376
1415
  } catch {
1377
1416
  return lines;
1378
1417
  }