@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.
- package/CHANGELOG.md +10 -0
- package/dist/extensions/pi-style.js +568 -261
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +95 -32
- package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
- package/extension-src/pi-style/features/startup/index.ts +4 -4
- package/extension-src/pi-style/features/tools/boxed/bash.ts +11 -19
- package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
- package/extension-src/pi-style/pi/session-coordinator.ts +9 -1
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +175 -68
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- package/package.json +1 -1
|
@@ -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
|
-
|
|
6
|
+
type BoxTheme,
|
|
7
|
+
boxInnerWidth,
|
|
9
8
|
getTextOutput,
|
|
10
9
|
renderBoxedToolCall,
|
|
11
10
|
renderBoxedToolResult,
|
|
12
11
|
} from "../../../shared/box.js";
|
|
13
|
-
import {
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
diffView.invalidate();
|
|
167
177
|
},
|
|
168
178
|
},
|
|
169
179
|
{
|
|
170
|
-
|
|
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, [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 =
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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, [],
|
|
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
|
-
|
|
662
|
-
|
|
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);
|