@quandev104/pi-style 0.1.1 → 0.1.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.
@@ -1,16 +1,16 @@
1
1
  // Boxed quick-edit / substitute-edit / target-edit renderer.
2
2
 
3
3
  import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
5
4
  import { stripAnsi } from "../../../shared/ansi.js";
6
- import type { BoxTheme } from "../../../shared/box.js";
7
5
  import {
8
- formatBoxedFooterFromValues,
6
+ type BoxTheme,
7
+ boxInnerWidth,
9
8
  getTextOutput,
10
9
  renderBoxedToolCall,
11
10
  renderBoxedToolResult,
12
11
  } from "../../../shared/box.js";
13
- import { buildSplitRows, countDiffStats, renderDiffMeter, SplitDiffComponent } from "../../../shared/split-diff.js";
12
+ import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
13
+ import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
14
14
  import { getStateElapsedMs } from "./session-config.js";
15
15
  import { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
16
16
 
@@ -99,8 +99,27 @@ function extractQuickEditDiff(text: string): string | undefined {
99
99
  return diffLines.length > 0 ? diffLines.join("\n") : undefined;
100
100
  }
101
101
 
102
- function quickEditFooter(theme: BoxTheme, context: BoxedToolContext, output = ""): string {
103
- return formatBoxedFooterFromValues(theme, getStateElapsedMs(context.state), output);
102
+ /** `Diff · +3 -0` divider label. */
103
+ function quickEditDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
104
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
105
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
106
+ return `Diff · ${plus} ${minus}`;
107
+ }
108
+
109
+ /** Quick-edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
110
+ function quickEditDiffFooter(
111
+ theme: BoxTheme,
112
+ result: { content?: readonly unknown[]; details?: unknown },
113
+ context: BoxedToolContext,
114
+ stats: { additions: number; removals: number },
115
+ ): string {
116
+ const elapsedMs = getElapsedMs(result) ?? getStateElapsedMs(context.state);
117
+ const parts: string[] = [];
118
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
119
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
120
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
121
+ parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
122
+ return parts.join(theme.fg("dim", " · "));
104
123
  }
105
124
 
106
125
  function renderQuickEditResult(
@@ -122,7 +141,7 @@ function renderQuickEditResult(
122
141
  const output = getTextOutput(result);
123
142
  if (context.isError) {
124
143
  return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
125
- footerLines: [quickEditFooter(theme, context, output)],
144
+ footerLines: [quickEditFooter(theme, context)],
126
145
  isError: true,
127
146
  });
128
147
  }
@@ -131,7 +150,7 @@ function renderQuickEditResult(
131
150
  if (!diff) {
132
151
  const fallback = stripAnsi(output).trim() || config.fallbackLabel;
133
152
  return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", fallback)}`], {
134
- footerLines: [quickEditFooter(theme, context, output)],
153
+ footerLines: [quickEditFooter(theme, context)],
135
154
  });
136
155
  }
137
156
 
@@ -141,43 +160,45 @@ function renderQuickEditResult(
141
160
  const language = argPath ? getLanguageFromPath(argPath) : undefined;
142
161
  const shouldHighlight =
143
162
  Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
144
-
145
- const { additions, removals } = countDiffStats(diff);
146
- const meter = renderDiffMeter(theme, additions, removals);
147
- const summary =
148
- `${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
149
- ` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
150
- ` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
151
- ` ${theme.fg("muted", "split")}` +
152
- (meter ? ` ${meter}` : "");
163
+ const stats = countDiffStats(diff);
153
164
 
154
165
  const maxRows = expanded ? 160 : 36;
155
- const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
166
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
167
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
156
168
 
157
169
  return renderBoxedToolResult(
158
170
  theme,
159
171
  {
160
172
  render(width: number): string[] {
161
- const safeWidth = Math.max(20, width);
162
- const headerLines = new Text(summary, 0, 0).render(safeWidth);
163
- return [...headerLines, ...split.render(safeWidth)];
173
+ return diffView.render(width);
164
174
  },
165
175
  invalidate(): void {
166
- split.invalidate();
176
+ diffView.invalidate();
167
177
  },
168
178
  },
169
179
  {
170
- footerLines: [quickEditFooter(theme, context, output)],
180
+ dividerLabel: quickEditDividerLabel(theme, stats),
181
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
182
+ footerLines: [quickEditDiffFooter(theme, result, context, stats)],
171
183
  },
172
184
  );
173
185
  }
174
186
 
187
+ function quickEditFooter(theme: BoxTheme, context: BoxedToolContext): string {
188
+ const elapsedMs = getStateElapsedMs(context.state);
189
+ const parts: string[] = [];
190
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
191
+ parts.push(theme.fg("dim", "1 file"));
192
+ return parts.join(theme.fg("dim", " · "));
193
+ }
194
+
175
195
  export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition {
176
196
  return {
177
197
  call(args, theme, context) {
178
198
  noteExecutionStart(context);
179
199
  const detail = displayPath(String(args?.path ?? ""), context);
180
- return renderBoxedToolCall(theme, config.toolLabel, [`${theme.fg("dim", "Path: ")}${detail}`], {
200
+ return renderBoxedToolCall(theme, config.toolLabel, [], {
201
+ headerDetail: detail,
181
202
  isError: Boolean(context.isError),
182
203
  isPartial: Boolean(context.isPartial),
183
204
  isPending: Boolean(context.isPartial),
@@ -181,7 +181,15 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
181
181
  terminalInputUnsubscribe?.();
182
182
  terminalInputUnsubscribe = undefined;
183
183
  app.sessionShutdown();
184
- compatibility.dispose();
184
+ // Tier C prototype patches stay installed across session switches. Pi renders
185
+ // the restored chat (renderBeforeBind) AFTER session_shutdown but BEFORE the
186
+ // next session_start, so disposing here would rebuild the resumed tool and
187
+ // special-block surfaces with native prototypes and they would never be
188
+ // re-decorated (their boxed output is derived once at updateDisplay time and
189
+ // cached; a later frame render does not re-invoke the renderer selectors).
190
+ // The next start() disposes this report (restoring the native identities)
191
+ // and reinstalls before any new render. On process exit (reason "quit") the
192
+ // terminal is torn down immediately after, so retained patches are harmless.
185
193
  },
186
194
  };
187
195
  }
@@ -84,6 +84,9 @@ export function stripAnsi(value: string): string {
84
84
  break;
85
85
  }
86
86
  }
87
+ // Consume the OSC terminator (BEL, or the ESC\\ already consumed above)
88
+ // so it is not emitted as a visible character.
89
+ if (i + 1 < value.length && value.charCodeAt(i + 1) === 7) i++;
87
90
  }
88
91
  }
89
92
  return output;
@@ -38,6 +38,8 @@ export interface BoxTheme {
38
38
 
39
39
  export interface BoxedRenderOptions {
40
40
  widthKey?: string;
41
+ /** Detail embedded in the top-border title after the tool name (e.g. the path). */
42
+ headerDetail?: string;
41
43
  isError?: boolean;
42
44
  isPartial?: boolean;
43
45
  isPending?: boolean;
@@ -140,7 +142,7 @@ function formatCompactCount(value: number): string {
140
142
  }
141
143
 
142
144
  export function formatBoxedWords(text: string): string {
143
- return `✎ ~${formatCompactCount(countWords(text))} words`;
145
+ return `~${formatCompactCount(countWords(text))} words`;
144
146
  }
145
147
 
146
148
  export function badge(theme: BoxTheme, label: string): string {
@@ -172,11 +174,15 @@ const BOX_HORIZONTAL = "─";
172
174
  const BOX_VERTICAL = "│";
173
175
  const BOX_SIDE_PADDING = 2;
174
176
  const BOX_MIN_WIDTH = 12;
177
+ const BOX_ROUND_TOP_LEFT = "╭";
178
+ const BOX_ROUND_TOP_RIGHT = "╮";
179
+ const BOX_ROUND_BOTTOM_LEFT = "╰";
180
+ const BOX_ROUND_BOTTOM_RIGHT = "╯";
181
+ const BOX_DIVIDER_LEFT = "├";
182
+ const BOX_DIVIDER_RIGHT = "┤";
183
+ /** Dash run before the right corner when a right-side border label is present. */
184
+ const BOX_LABELED_RIGHT_DASH_MIN = 3;
175
185
  const BOX_WIDTH_CACHE = new Map<string, number>();
176
- const COMPACT_TOOL_NAME_WIDTH = safeVisibleWidth("Search");
177
- const COMPACT_FOOTER_ELAPSED_WIDTH = 8;
178
- const COMPACT_FOOTER_EXTRA_WIDTH = 8;
179
- const COMPACT_FOOTER_WORDS_WIDTH = safeVisibleWidth("✎ ~1.2k words");
180
186
 
181
187
  export function boxWidth(width: number): number {
182
188
  return Math.max(BOX_MIN_WIDTH, width);
@@ -319,17 +325,13 @@ function formatBoxedStatusIcon(theme: BoxTheme, isError?: boolean): string {
319
325
 
320
326
  export function formatBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
321
327
  const rawTitle = `➔ ${name}`;
322
- const coloredTitle = `${colorFromExtra(theme, "bashPromptColor", "bashMode", rawTitle)} ${formatBoxedStatusIcon(theme, isError)}`;
323
- const title = typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
324
- return `${title} ${boxText(theme, "|")}`;
325
- }
326
-
327
- function formatCompactBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
328
- const paddedName = padVisibleRight(name, COMPACT_TOOL_NAME_WIDTH);
329
- const rawTitle = `➔ ${paddedName}`;
330
- const coloredTitle = `${colorFromExtra(theme, "bashPromptColor", "bashMode", rawTitle)} ${formatBoxedStatusIcon(theme, isError)}`;
331
- const title = typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
332
- return `${title} ${boxText(theme, "|")}`;
328
+ // On failure the whole title turns error-colored (not just the ) so a failed
329
+ // tool reads instantly; on success the tool keeps its identity color and only
330
+ // the carries the success color.
331
+ const coloredTitle = isError
332
+ ? theme.fg("error", `${rawTitle} ✗`)
333
+ : `${colorFromExtra(theme, "bashPromptColor", "bashMode", rawTitle)} ${formatBoxedStatusIcon(theme, false)}`;
334
+ return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
333
335
  }
334
336
 
335
337
  function boxText(theme: BoxTheme, text: string): string {
@@ -349,8 +351,76 @@ export function boxBorder(theme: BoxTheme, left: string, right: string, width: n
349
351
  return boxFrameText(theme, `${left}${BOX_HORIZONTAL.repeat(innerWidth)}${right}`);
350
352
  }
351
353
 
352
- function padVisibleRight(text: string, width: number): string {
353
- return `${text}${" ".repeat(Math.max(0, width - safeVisibleWidth(text)))}`;
354
+ /**
355
+ * Border line with an optional label embedded after the left corner and an
356
+ * optional right-side label before the right corner, e.g.:
357
+ *
358
+ * ╭─ ➔ Bash ✓ ────────────╮
359
+ * ├─ Response ────────────┤
360
+ * ╰─ 0.00s · ~45 words ──── Ctrl+O for more ───╯
361
+ */
362
+ export function boxLabeledBorder(
363
+ theme: BoxTheme,
364
+ start: string,
365
+ end: string,
366
+ leftLabel: string,
367
+ rightLabel: string | undefined,
368
+ width: number,
369
+ ): string {
370
+ const renderedWidth = boxWidth(width);
371
+ let left = leftLabel ?? "";
372
+ const right = rightLabel ?? "";
373
+ let leftWidth = safeVisibleWidth(left);
374
+ const rightWidth = safeVisibleWidth(right);
375
+ const leftOverhead = left ? 3 : 0; // "─ " prefix + " " suffix
376
+ const rightOverhead = right ? 2 : 0; // " " prefix + " " suffix
377
+ let rightFill = right ? BOX_LABELED_RIGHT_DASH_MIN : 0;
378
+ let fill =
379
+ renderedWidth - start.length - end.length - leftOverhead - leftWidth - rightOverhead - rightWidth - rightFill;
380
+
381
+ if (right && fill < 0) {
382
+ rightFill = 1;
383
+ fill =
384
+ renderedWidth - start.length - end.length - leftOverhead - leftWidth - rightOverhead - rightWidth - rightFill;
385
+ }
386
+
387
+ if (fill < 0) {
388
+ // Too narrow for the labels: truncate the left label, keeping at least one
389
+ // filler dash so the border stays closed.
390
+ const reserved =
391
+ start.length + end.length + leftOverhead + (right ? rightOverhead + rightWidth + rightFill : 0) + 1;
392
+ const maxLeft = renderedWidth - reserved;
393
+ left = maxLeft > 0 ? safeTruncateToWidth(left, maxLeft, "…") : "";
394
+ leftWidth = safeVisibleWidth(left);
395
+ fill =
396
+ renderedWidth -
397
+ start.length -
398
+ end.length -
399
+ (left ? leftWidth + leftOverhead : 0) -
400
+ (right ? rightOverhead + rightWidth + rightFill : 0);
401
+ }
402
+
403
+ // Style each border segment separately. Embedded labels carry their own
404
+ // foreground escapes that end in \x1b[39m (reset to the terminal default);
405
+ // applying the border color to the whole line in one wrap would leave every
406
+ // dash after a label in the default color, making one border render with
407
+ // mixed brightness.
408
+ const parts: string[] = [boxFrameText(theme, `${start}${left ? "─ " : ""}`)];
409
+ if (left) parts.push(left);
410
+ parts.push(boxFrameText(theme, `${left ? " " : ""}${BOX_HORIZONTAL.repeat(Math.max(0, fill))}`));
411
+ if (right) {
412
+ parts.push(boxFrameText(theme, " "), right, boxFrameText(theme, ` ${BOX_HORIZONTAL.repeat(rightFill)}`));
413
+ }
414
+ parts.push(boxFrameText(theme, end));
415
+ return parts.join("");
416
+ }
417
+
418
+ /** Empty content line used for breathing room inside a box. */
419
+ export function boxBlankLine(theme: BoxTheme, width: number): string {
420
+ const renderedWidth = boxWidth(width);
421
+ const contentWidth = boxInnerWidth(renderedWidth);
422
+ const sidePad = " ".repeat(BOX_SIDE_PADDING);
423
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${" ".repeat(contentWidth)}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
354
424
  }
355
425
 
356
426
  export function boxLineWithRight(theme: BoxTheme, left: string, right: string, width: number): string {
@@ -471,20 +541,30 @@ export function renderBoxedToolCall(
471
541
  render(width: number): string[] {
472
542
  if (cache?.width === width) return cache.lines;
473
543
  const title = formatBoxedToolTitle(theme, toolName, options.isError);
544
+ const headerLabel = options.headerDetail ? `${title} · ${options.headerDetail}` : title;
474
545
  const renderedWidth = boxWidth(width);
475
546
  const lines = [
476
- boxBorder(theme, "┌", "┐", renderedWidth),
477
- boxLine(theme, title, renderedWidth),
478
- boxInsetDivider(theme, renderedWidth),
547
+ boxLabeledBorder(theme, BOX_ROUND_TOP_LEFT, BOX_ROUND_TOP_RIGHT, headerLabel, undefined, renderedWidth),
548
+ boxBlankLine(theme, renderedWidth),
479
549
  ...detailLines.flatMap((line) => boxedWrappedLines(theme, line, renderedWidth)),
480
550
  ];
481
551
  if (options.isPending) {
482
552
  const pendingText = options.pendingText ?? "Waiting for output…";
483
553
  lines.push(
484
- boxInsetDivider(theme, renderedWidth),
485
- ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
486
- boxBorder(theme, "└", "┘", renderedWidth),
554
+ boxBlankLine(theme, renderedWidth),
555
+ boxLabeledBorder(
556
+ theme,
557
+ BOX_ROUND_BOTTOM_LEFT,
558
+ BOX_ROUND_BOTTOM_RIGHT,
559
+ theme.fg("dim", `… ${pendingText}`),
560
+ undefined,
561
+ renderedWidth,
562
+ ),
487
563
  );
564
+ } else {
565
+ // Leave the box open with trailing breathing room; the result renderer
566
+ // continues it with the Response divider.
567
+ lines.push(boxBlankLine(theme, renderedWidth));
488
568
  }
489
569
  cache = { width, lines };
490
570
  return lines;
@@ -513,27 +593,42 @@ export function renderCompactBoxedToolCall(
513
593
  invalidate() {},
514
594
  render(width: number): string[] {
515
595
  const renderedWidth = boxWidth(width);
516
- const title = `${formatCompactBoxedToolTitle(theme, toolName, options.isError)} ${detailLine}`;
596
+ const title = formatBoxedToolTitle(theme, toolName, options.isError);
597
+ const headerLabel = detailLine ? `${title} · ${detailLine}` : title;
517
598
  const compactFooter =
518
599
  typeof options.state?.[COMPACT_FOOTER_KEY] === "string" ? options.state[COMPACT_FOOTER_KEY] : "";
519
600
  const _footerIsError = Boolean(options.state?.[COMPACT_FOOTER_ERROR_KEY]);
520
601
  const _footerIsPartial = Boolean(options.state?.[COMPACT_FOOTER_PARTIAL_KEY]);
602
+ const lines = [
603
+ boxLabeledBorder(theme, BOX_ROUND_TOP_LEFT, BOX_ROUND_TOP_RIGHT, headerLabel, undefined, renderedWidth),
604
+ boxBlankLine(theme, renderedWidth),
605
+ ];
521
606
  if (compactFooter) {
522
- return [
523
- boxBorder(theme, "┌", "┐", renderedWidth),
524
- boxLineWithRight(theme, title, compactFooter, renderedWidth),
525
- boxBorder(theme, "└", "┘", renderedWidth),
526
- ];
527
- }
528
-
529
- const lines = [boxBorder(theme, "┌", "┐", renderedWidth), boxLine(theme, title, renderedWidth)];
530
- if (options.isPending) {
607
+ lines.push(
608
+ boxLabeledBorder(
609
+ theme,
610
+ BOX_ROUND_BOTTOM_LEFT,
611
+ BOX_ROUND_BOTTOM_RIGHT,
612
+ compactFooter,
613
+ undefined,
614
+ renderedWidth,
615
+ ),
616
+ );
617
+ } else if (options.isPending) {
531
618
  const pendingText = options.pendingText ?? "Waiting for output…";
532
619
  lines.push(
533
- boxInsetDivider(theme, renderedWidth),
534
- ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
535
- boxBorder(theme, "└", "┘", renderedWidth),
620
+ boxLabeledBorder(
621
+ theme,
622
+ BOX_ROUND_BOTTOM_LEFT,
623
+ BOX_ROUND_BOTTOM_RIGHT,
624
+ theme.fg("dim", `… ${pendingText}`),
625
+ undefined,
626
+ renderedWidth,
627
+ ),
536
628
  );
629
+ } else {
630
+ // No footer yet (transient, or the result opens the Response divider):
631
+ // leave the box open so the result renderer continues the same box.
537
632
  }
538
633
  return lines;
539
634
  },
@@ -552,6 +647,12 @@ export function renderBoxedToolResult(
552
647
  widthKey?: string;
553
648
  referenceLines?: string[];
554
649
  renderLineBudget?: number;
650
+ /** Left-side label embedded in the divider between the call and the result. May be a function of the box width (e.g. for width-dependent layout labels). */
651
+ dividerLabel?: string | ((width: number) => string);
652
+ /** Right-side label embedded in the divider between the call and the result. */
653
+ dividerRightLabel?: string;
654
+ /** Right-side label embedded in the bottom border (e.g. the expand hint). */
655
+ expandHint?: string;
555
656
  isError?: boolean;
556
657
  isPartial?: boolean;
557
658
  } = {},
@@ -572,16 +673,31 @@ export function renderBoxedToolResult(
572
673
  bodyLines.length > 0
573
674
  ? [...errorPrefix, ...bodyLines]
574
675
  : [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
575
- const footerLines = options.footerLines ?? [];
576
- const renderedFooterLines =
577
- footerLines.length > 0
578
- ? [boxInsetDivider(theme, renderedWidth), ...footerLines.map((line) => boxLine(theme, line, renderedWidth))]
579
- : [];
676
+ const footerText = (options.footerLines ?? []).join(" · ");
677
+ const dividerText =
678
+ typeof options.dividerLabel === "function"
679
+ ? options.dividerLabel(renderedWidth)
680
+ : (options.dividerLabel ?? "Response");
580
681
  const rendered = [
581
- boxInsetDivider(theme, renderedWidth),
682
+ boxLabeledBorder(
683
+ theme,
684
+ BOX_DIVIDER_LEFT,
685
+ BOX_DIVIDER_RIGHT,
686
+ theme.fg("dim", dividerText),
687
+ options.dividerRightLabel ? theme.fg("dim", options.dividerRightLabel) : undefined,
688
+ renderedWidth,
689
+ ),
690
+ boxBlankLine(theme, renderedWidth),
582
691
  ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
583
- ...renderedFooterLines,
584
- boxBorder(theme, "└", "┘", renderedWidth),
692
+ boxBlankLine(theme, renderedWidth),
693
+ boxLabeledBorder(
694
+ theme,
695
+ BOX_ROUND_BOTTOM_LEFT,
696
+ BOX_ROUND_BOTTOM_RIGHT,
697
+ footerText,
698
+ options.expandHint ? theme.fg("dim", options.expandHint) : undefined,
699
+ renderedWidth,
700
+ ),
585
701
  ];
586
702
  cache = { width, lines: rendered };
587
703
  return rendered;
@@ -600,36 +716,21 @@ export function formatBoxedFooterFromValues(
600
716
  elapsedMs: number | undefined,
601
717
  output: string,
602
718
  extraParts: string[] = [],
603
- fixedColumns = false,
604
719
  ): string {
605
720
  const wall = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
606
- const elapsedPart = `${theme.fg("text", "◷")} ${theme.fg("dim", wall)}`;
721
+ const elapsedPart = theme.fg("text", wall);
607
722
  const extraPartList = extraParts.filter(Boolean).map((part) => theme.fg("dim", part));
608
723
  const wordsPart = theme.fg("dim", formatBoxedWords(output));
609
- const parts = fixedColumns
610
- ? [
611
- padVisibleRight(elapsedPart, COMPACT_FOOTER_ELAPSED_WIDTH),
612
- ...extraPartList.map((part) => padVisibleRight(part, COMPACT_FOOTER_EXTRA_WIDTH)),
613
- padVisibleRight(wordsPart, COMPACT_FOOTER_WORDS_WIDTH),
614
- ]
615
- : [elapsedPart, ...extraPartList, wordsPart];
616
- return parts.join(theme.fg("dim", " · "));
724
+ return [elapsedPart, ...extraPartList, wordsPart].join(theme.fg("dim", " · "));
617
725
  }
618
726
 
619
727
  function formatBoxedFooterParts(
620
728
  theme: BoxTheme,
621
729
  result: MetricResultLike | undefined,
622
730
  extraParts: string[] = [],
623
- fixedColumns = false,
624
731
  elapsedMs?: number,
625
732
  ): string {
626
- return formatBoxedFooterFromValues(
627
- theme,
628
- elapsedMs ?? getElapsedMs(result),
629
- getTextOutput(result),
630
- extraParts,
631
- fixedColumns,
632
- );
733
+ return formatBoxedFooterFromValues(theme, elapsedMs ?? getElapsedMs(result), getTextOutput(result), extraParts);
633
734
  }
634
735
 
635
736
  export function formatBoxedFooter(
@@ -638,7 +739,7 @@ export function formatBoxedFooter(
638
739
  extraParts: string[] = [],
639
740
  elapsedMs?: number,
640
741
  ): string {
641
- return formatBoxedFooterParts(theme, result, extraParts, false, elapsedMs);
742
+ return formatBoxedFooterParts(theme, result, extraParts, elapsedMs);
642
743
  }
643
744
 
644
745
  export function renderCompactBoxedFooter(
@@ -647,7 +748,7 @@ export function renderCompactBoxedFooter(
647
748
  options: BoxedRenderOptions = {},
648
749
  ): Component {
649
750
  if (options.state && typeof options.state === "object") {
650
- options.state[COMPACT_FOOTER_KEY] = formatBoxedFooterParts(theme, result, [], true, options.elapsedMs);
751
+ options.state[COMPACT_FOOTER_KEY] = formatBoxedFooterParts(theme, result, [], options.elapsedMs);
651
752
  options.state[COMPACT_FOOTER_ERROR_KEY] = Boolean(options.isError);
652
753
  options.state[COMPACT_FOOTER_PARTIAL_KEY] = Boolean(options.isPartial);
653
754
  return { invalidate() {}, render: () => [] };
@@ -658,8 +759,14 @@ export function renderCompactBoxedFooter(
658
759
  render(width: number): string[] {
659
760
  const renderedWidth = boxWidth(width);
660
761
  return [
661
- boxLine(theme, formatBoxedFooterParts(theme, result, [], false, options.elapsedMs), renderedWidth),
662
- boxBorder(theme, "└", "┘", renderedWidth),
762
+ boxLabeledBorder(
763
+ theme,
764
+ BOX_ROUND_BOTTOM_LEFT,
765
+ BOX_ROUND_BOTTOM_RIGHT,
766
+ formatBoxedFooterParts(theme, result, [], options.elapsedMs),
767
+ undefined,
768
+ renderedWidth,
769
+ ),
663
770
  ];
664
771
  },
665
772
  };
@@ -731,7 +838,7 @@ export function formatToolOutputLine(
731
838
  return theme.fg(color, line);
732
839
  }
733
840
 
734
- function selectRenderLines(text: string, maxLines: number, tail = false): { lines: string[]; omitted: number } {
841
+ export function selectRenderLines(text: string, maxLines: number, tail = false): { lines: string[]; omitted: number } {
735
842
  const source = text ?? "";
736
843
  if (!source) return { lines: [], omitted: 0 };
737
844
  const limit = Math.max(0, maxLines);