@quandev104/pi-style 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +193 -0
  4. package/dist/extensions/pi-style.js +7897 -0
  5. package/dist/extensions/pi-style.js.map +1 -0
  6. package/extension-src/pi-style/app/command-service.ts +244 -0
  7. package/extension-src/pi-style/app/commands.ts +12 -0
  8. package/extension-src/pi-style/app/config-storage.ts +98 -0
  9. package/extension-src/pi-style/app/doctor.ts +53 -0
  10. package/extension-src/pi-style/app/index.ts +322 -0
  11. package/extension-src/pi-style/app/providers.ts +268 -0
  12. package/extension-src/pi-style/app/render-scheduler.ts +48 -0
  13. package/extension-src/pi-style/app/runtime.ts +404 -0
  14. package/extension-src/pi-style/app/snapshot.ts +15 -0
  15. package/extension-src/pi-style/domain/capabilities.ts +34 -0
  16. package/extension-src/pi-style/domain/config-authorization.ts +20 -0
  17. package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
  18. package/extension-src/pi-style/domain/config-diff.ts +30 -0
  19. package/extension-src/pi-style/domain/config-migrations.ts +52 -0
  20. package/extension-src/pi-style/domain/config-normalization.ts +567 -0
  21. package/extension-src/pi-style/domain/config-presets.ts +51 -0
  22. package/extension-src/pi-style/domain/config-types.ts +115 -0
  23. package/extension-src/pi-style/domain/providers.ts +37 -0
  24. package/extension-src/pi-style/domain/status-presets.ts +60 -0
  25. package/extension-src/pi-style/domain/status-renderer.ts +142 -0
  26. package/extension-src/pi-style/domain/status.ts +430 -0
  27. package/extension-src/pi-style/domain/theme.ts +232 -0
  28. package/extension-src/pi-style/features/.gitkeep +0 -0
  29. package/extension-src/pi-style/features/editor/index.ts +304 -0
  30. package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
  31. package/extension-src/pi-style/features/messages/index.ts +145 -0
  32. package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
  33. package/extension-src/pi-style/features/startup/index.ts +564 -0
  34. package/extension-src/pi-style/features/startup/logo.ts +151 -0
  35. package/extension-src/pi-style/features/status-line/index.ts +319 -0
  36. package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
  37. package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
  38. package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
  39. package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
  40. package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
  41. package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
  42. package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
  43. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
  44. package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
  45. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
  46. package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
  47. package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
  48. package/extension-src/pi-style/features/tools/index.ts +480 -0
  49. package/extension-src/pi-style/pi/commands.ts +17 -0
  50. package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
  51. package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
  52. package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
  53. package/extension-src/pi-style/pi/config-host.ts +52 -0
  54. package/extension-src/pi-style/pi/config-session.ts +105 -0
  55. package/extension-src/pi-style/pi/index.ts +77 -0
  56. package/extension-src/pi-style/pi/operational-state.ts +29 -0
  57. package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
  58. package/extension-src/pi-style/pi/session-usage.ts +62 -0
  59. package/extension-src/pi-style/pi/startup-resources.ts +70 -0
  60. package/extension-src/pi-style/shared/.gitkeep +0 -0
  61. package/extension-src/pi-style/shared/ansi.ts +386 -0
  62. package/extension-src/pi-style/shared/box.ts +805 -0
  63. package/extension-src/pi-style/shared/disposable-store.ts +28 -0
  64. package/extension-src/pi-style/shared/elapsed.ts +88 -0
  65. package/extension-src/pi-style/shared/render-budget.ts +367 -0
  66. package/extension-src/pi-style/shared/split-diff.ts +692 -0
  67. package/extension-src/pi-style/shared/theme-extras.ts +292 -0
  68. package/package.json +68 -0
  69. package/themes/.gitkeep +0 -0
@@ -0,0 +1,805 @@
1
+ // Boxed tool-call/result rendering primitives.
2
+ //
3
+ // Two deliberate design points:
4
+ //
5
+ // 1. Background painting is removed. Boxed lines are foreground-only; the
6
+ // enclosing native ToolExecutionComponent container applies the semantic
7
+ // status background (toolPendingBg / toolErrorBg / toolSuccessBg), and the
8
+ // message-block Box applies customMessageBg. This avoids double-background
9
+ // conflicts and keeps pi-style's patches renderer-scoped.
10
+ //
11
+ // 2. Theme functions are accessed through a minimal structural interface
12
+ // (BoxTheme) instead of `any`.
13
+
14
+ import { homedir } from "node:os";
15
+ import { relative, resolve } from "node:path";
16
+ import type { Component } from "@earendil-works/pi-tui";
17
+ import { fgHex, isHexColor, stripAnsi } from "./ansi.js";
18
+ import { formatToolMetrics, getElapsedMs, type MetricResultLike } from "./elapsed.js";
19
+ import {
20
+ boxedResultRenderBudget,
21
+ clampRenderLine,
22
+ DEFAULT_COLLAPSED_RENDER_LINES,
23
+ fastBoxLineContent,
24
+ safeTruncateToWidth,
25
+ safeVisibleWidth,
26
+ safeWrapTextWithAnsi,
27
+ } from "./render-budget.js";
28
+ import { getThemeExtra } from "./theme-extras.js";
29
+
30
+ /** Minimal structural view of Pi's Theme as used by the boxed renderers. */
31
+ export interface BoxTheme {
32
+ fg(color: string, text: string): string;
33
+ bold?(text: string): string;
34
+ italic?(text: string): string;
35
+ inverse?(text: string): string;
36
+ getColorMode?(): string;
37
+ }
38
+
39
+ export interface BoxedRenderOptions {
40
+ widthKey?: string;
41
+ isError?: boolean;
42
+ isPartial?: boolean;
43
+ isPending?: boolean;
44
+ pendingText?: string;
45
+ state?: Record<string, unknown>;
46
+ /** Wall-clock elapsed override (used when metrics are not in result.details). */
47
+ elapsedMs?: number;
48
+ }
49
+
50
+ export function isExpanded(options: { expanded?: boolean } | undefined): boolean {
51
+ return typeof options?.expanded === "boolean" ? options.expanded : false;
52
+ }
53
+
54
+ export function shortenPath(path: string): string {
55
+ const home = homedir();
56
+ if (path.startsWith(home)) return `~${path.slice(home.length)}`;
57
+ return path;
58
+ }
59
+
60
+ export function resolveAbsolutePath(rawPath: string, cwd: string): string {
61
+ const path = rawPath.trim();
62
+ if (!path) return "";
63
+
64
+ const home = process.env.HOME;
65
+ if (home && (path === "~" || path.startsWith("~/"))) {
66
+ return path === "~" ? home : resolve(home, path.slice(2));
67
+ }
68
+
69
+ return resolve(cwd, path);
70
+ }
71
+
72
+ export function resolveRelativePath(rawPath: string, cwd: string): string {
73
+ const absPath = resolveAbsolutePath(rawPath, cwd);
74
+ if (!absPath) return "(unknown)";
75
+ const relPath = relative(cwd, absPath).replace(/\\/g, "/");
76
+ return relPath || ".";
77
+ }
78
+
79
+ export function replaceTabs(text: string): string {
80
+ return text.replace(/\t/g, " ");
81
+ }
82
+
83
+ export function getTextOutput(result: MetricResultLike | undefined): string {
84
+ if (!result?.content) return "";
85
+ const textBlocks = result.content.filter((contentBlock) => {
86
+ if (!contentBlock || typeof contentBlock !== "object") return false;
87
+ return (contentBlock as { type?: unknown }).type === "text";
88
+ });
89
+ return textBlocks
90
+ .map((contentBlock) => String((contentBlock as { text?: unknown }).text ?? ""))
91
+ .join("\n")
92
+ .replace(/\r/g, "");
93
+ }
94
+
95
+ export function stripTrailingNotice(text: string): string {
96
+ const normalized = (text ?? "").replace(/\r/g, "").trimEnd();
97
+ if (!normalized) return "";
98
+ if (normalized.startsWith("[") && normalized.endsWith("]")) return "";
99
+ const noticeStart = normalized.lastIndexOf("\n\n[");
100
+ if (noticeStart >= 0 && normalized.endsWith("]")) {
101
+ return normalized.slice(0, noticeStart).trimEnd();
102
+ }
103
+ return normalized;
104
+ }
105
+
106
+ export function extractTrailingNotice(text: string): string | null {
107
+ const normalized = (text ?? "").replace(/\r/g, "").trimEnd();
108
+ if (!normalized) return null;
109
+ if (normalized.startsWith("[") && normalized.endsWith("]")) return normalized;
110
+ const noticeStart = normalized.lastIndexOf("\n\n[");
111
+ if (noticeStart >= 0 && normalized.endsWith("]")) {
112
+ return normalized.slice(noticeStart + 2).trimEnd();
113
+ }
114
+ return null;
115
+ }
116
+
117
+ export function countLines(text: string): number {
118
+ const normalized = (text ?? "").replace(/\r/g, "").replace(/\n+$/g, "");
119
+ if (!normalized) return 0;
120
+ return normalized.split("\n").length;
121
+ }
122
+
123
+ export function countWords(text: string): number {
124
+ let count = 0;
125
+ let inWord = false;
126
+ for (const char of text) {
127
+ const isWord = /[\p{L}\p{N}_'-]/u.test(char);
128
+ if (isWord && !inWord) count++;
129
+ inWord = isWord;
130
+ }
131
+ return count;
132
+ }
133
+
134
+ function formatCompactCount(value: number): string {
135
+ if (value < 1000) return `${Math.round(value)}`;
136
+ if (value < 10000) return `${(value / 1000).toFixed(1)}k`;
137
+ if (value < 1000000) return `${Math.round(value / 1000)}k`;
138
+ if (value < 10000000) return `${(value / 1000000).toFixed(1)}M`;
139
+ return `${Math.round(value / 1000000)}M`;
140
+ }
141
+
142
+ export function formatBoxedWords(text: string): string {
143
+ return `✎ ~${formatCompactCount(countWords(text))} words`;
144
+ }
145
+
146
+ export function badge(theme: BoxTheme, label: string): string {
147
+ const tagBg = getThemeExtra(theme, "tagBgColor");
148
+ if (tagBg && isHexColor(tagBg)) {
149
+ return theme.inverse
150
+ ? theme.inverse(fgHex(theme, tagBg, theme.bold ? theme.bold(` ${label} `) : ` ${label} `))
151
+ : fgHex(theme, tagBg, theme.bold ? theme.bold(` ${label} `) : ` ${label} `);
152
+ }
153
+ return theme.inverse ? theme.inverse(theme.bold ? theme.bold(` ${label} `) : ` ${label} `) : ` ${label} `;
154
+ }
155
+
156
+ export function parens(theme: BoxTheme, text: string, skipTextColor?: boolean): string {
157
+ const bracketColor = getThemeExtra(theme, "parensBracketColor");
158
+ const openParen = bracketColor && isHexColor(bracketColor) ? fgHex(theme, bracketColor, "(") : "(";
159
+ const closeParen = bracketColor && isHexColor(bracketColor) ? fgHex(theme, bracketColor, ")") : ")";
160
+ let innerBase: string;
161
+ if (skipTextColor) {
162
+ innerBase = text;
163
+ } else {
164
+ const textColor = getThemeExtra(theme, "parensTextColor");
165
+ innerBase = textColor && isHexColor(textColor) ? fgHex(theme, textColor, text) : text;
166
+ }
167
+ const inner = typeof theme?.bold === "function" ? theme.bold(innerBase) : `\x1b[1m${innerBase}\x1b[22m`;
168
+ return `${openParen}${inner}${closeParen}`;
169
+ }
170
+
171
+ const BOX_HORIZONTAL = "─";
172
+ const BOX_VERTICAL = "│";
173
+ const BOX_SIDE_PADDING = 2;
174
+ const BOX_MIN_WIDTH = 12;
175
+ 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
+
181
+ export function boxWidth(width: number): number {
182
+ return Math.max(BOX_MIN_WIDTH, width);
183
+ }
184
+
185
+ export function boxInnerWidth(width: number): number {
186
+ return Math.max(1, boxWidth(width) - 2 - BOX_SIDE_PADDING * 2);
187
+ }
188
+
189
+ function _tightBoxWidth(
190
+ availableWidth: number,
191
+ contentLines: string[],
192
+ labelWidths: number[] = [],
193
+ widthKey?: string,
194
+ ): number {
195
+ const contentWidth = contentLines.reduce((max, line) => Math.max(max, safeVisibleWidth(line)), 0);
196
+ const labelWidth = labelWidths.reduce((max, width) => Math.max(max, width), 0);
197
+ const neededWidth = Math.max(
198
+ BOX_MIN_WIDTH,
199
+ contentWidth + 2 + BOX_SIDE_PADDING * 2,
200
+ labelWidth + 2 + BOX_SIDE_PADDING * 2,
201
+ );
202
+ const measuredWidth = Math.min(boxWidth(availableWidth), neededWidth);
203
+ if (!widthKey) return measuredWidth;
204
+ const cachedWidth = BOX_WIDTH_CACHE.get(widthKey) ?? 0;
205
+ const nextWidth = Math.min(boxWidth(availableWidth), Math.max(cachedWidth, measuredWidth));
206
+ BOX_WIDTH_CACHE.set(widthKey, nextWidth);
207
+ return nextWidth;
208
+ }
209
+
210
+ export function boxedToolWidthKey(toolName: string, detail: string): string {
211
+ return `${toolName}:${detail}`;
212
+ }
213
+
214
+ export function formatToolName(toolName: string): string {
215
+ const spaced = toolName
216
+ .replace(/[_-]+/g, " ")
217
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
218
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
219
+ .trim();
220
+ return spaced.replace(/\b\w/g, (char) => char.toUpperCase()) || toolName;
221
+ }
222
+
223
+ function formatToolParamName(name: string): string {
224
+ const spaced = name
225
+ .replace(/[_-]+/g, " ")
226
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
227
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
228
+ .trim();
229
+ return spaced ? spaced[0]?.toUpperCase() + spaced.slice(1) : name;
230
+ }
231
+
232
+ const MAX_PARAM_VALUE_LENGTH = 120;
233
+
234
+ function formatOperationSummary(value: unknown): string | undefined {
235
+ if (!Array.isArray(value) || value.length === 0) return undefined;
236
+ if (!value.every((item) => item && typeof item === "object" && !Array.isArray(item))) return undefined;
237
+ const types = Array.from(new Set(value.map((item) => String((item as { type?: unknown }).type ?? "operation"))));
238
+ const typeSummary =
239
+ types.length === 1
240
+ ? ` (${types[0]})`
241
+ : types.length > 1
242
+ ? ` (${types.slice(0, 3).join(", ")}${types.length > 3 ? ", …" : ""})`
243
+ : "";
244
+ return `${value.length} ${value.length === 1 ? "operation" : "operations"}${typeSummary}`;
245
+ }
246
+
247
+ function formatToolParamValue(value: unknown): string {
248
+ if (value === undefined) return "";
249
+ if (value === null) return "null";
250
+ if (typeof value === "string") {
251
+ if (value.length <= MAX_PARAM_VALUE_LENGTH) return value;
252
+ return `${value.slice(0, MAX_PARAM_VALUE_LENGTH)}…`;
253
+ }
254
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
255
+ if (Array.isArray(value)) {
256
+ if (value.length === 0) return "[]";
257
+ return formatOperationSummary(value) ?? `${value.length} ${value.length === 1 ? "item" : "items"}`;
258
+ }
259
+ if (typeof value === "object" && value !== null) {
260
+ const keys = Object.keys(value);
261
+ if (keys.length === 0) return "{}";
262
+ return `{${keys.length} ${keys.length === 1 ? "key" : "keys"}}`;
263
+ }
264
+ try {
265
+ const json = JSON.stringify(value);
266
+ if (json.length <= MAX_PARAM_VALUE_LENGTH) return json;
267
+ return `${json.slice(0, MAX_PARAM_VALUE_LENGTH)}…`;
268
+ } catch {
269
+ return String(value);
270
+ }
271
+ }
272
+
273
+ export function formatToolParamLines(args: unknown, theme?: BoxTheme): string[] {
274
+ if (args === undefined || args === null) return [];
275
+ if (typeof args !== "object" || Array.isArray(args)) {
276
+ const value = formatToolParamValue(args);
277
+ return value ? [`Params: ${value}`] : [];
278
+ }
279
+
280
+ const entries = Object.entries(args as Record<string, unknown>).filter(([, value]) => value !== undefined);
281
+ if (entries.length === 0) return [];
282
+
283
+ const lines: string[] = [];
284
+ for (const [key, value] of entries) {
285
+ const formattedValue = formatToolParamValue(value);
286
+ if (!formattedValue) continue;
287
+ const [firstLine = "", ...restLines] = formattedValue.replace(/\r/g, "").split("\n");
288
+ const keyLabel = formatToolParamName(key);
289
+ if (theme) {
290
+ lines.push(`${theme.fg("dim", `${keyLabel}:`)} ${theme.fg("text", firstLine)}`);
291
+ lines.push(...restLines.map((line) => ` ${theme.fg("text", line)}`));
292
+ } else {
293
+ lines.push(`${keyLabel}: ${firstLine}`);
294
+ lines.push(...restLines.map((line) => ` ${line}`));
295
+ }
296
+ }
297
+ return lines;
298
+ }
299
+
300
+ const RESET_INTENSITY = "\x1b[22m";
301
+
302
+ function colorFromExtra(theme: BoxTheme, extraKey: string, fallbackColor: string, text: string): string {
303
+ const color = getThemeExtra(theme, extraKey);
304
+ if (color) {
305
+ if (isHexColor(color)) return fgHex(theme, color, text);
306
+ try {
307
+ return theme.fg(color, text);
308
+ } catch {
309
+ // Fall back to semantic theme color below.
310
+ }
311
+ }
312
+ return theme.fg(fallbackColor, text);
313
+ }
314
+
315
+ function formatBoxedStatusIcon(theme: BoxTheme, isError?: boolean): string {
316
+ const icon = isError ? "✗" : "✓";
317
+ return theme.fg(isError ? "error" : "success", icon);
318
+ }
319
+
320
+ export function formatBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
321
+ 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, "|")}`;
333
+ }
334
+
335
+ function boxText(theme: BoxTheme, text: string): string {
336
+ return `${RESET_INTENSITY}${theme.fg("borderMuted", text)}`;
337
+ }
338
+ function boxFrameText(theme: BoxTheme, text: string): string {
339
+ return `${RESET_INTENSITY}${theme.fg("border", text)}`;
340
+ }
341
+
342
+ export function boxedToolBgName(isError?: boolean, isPartial?: boolean): string {
343
+ return isPartial ? "toolPendingBg" : isError ? "toolErrorBg" : "toolSuccessBg";
344
+ }
345
+
346
+ export function boxBorder(theme: BoxTheme, left: string, right: string, width: number): string {
347
+ const renderedWidth = boxWidth(width);
348
+ const innerWidth = renderedWidth - 2;
349
+ return boxFrameText(theme, `${left}${BOX_HORIZONTAL.repeat(innerWidth)}${right}`);
350
+ }
351
+
352
+ function padVisibleRight(text: string, width: number): string {
353
+ return `${text}${" ".repeat(Math.max(0, width - safeVisibleWidth(text)))}`;
354
+ }
355
+
356
+ export function boxLineWithRight(theme: BoxTheme, left: string, right: string, width: number): string {
357
+ const renderedWidth = boxWidth(width);
358
+ const contentWidth = boxInnerWidth(renderedWidth);
359
+ const divider = ` ${boxText(theme, "|")} `;
360
+ const dividerWidth = safeVisibleWidth(divider);
361
+ const rightWidth = safeVisibleWidth(right);
362
+ const sidePad = " ".repeat(BOX_SIDE_PADDING);
363
+
364
+ if (!right || rightWidth + dividerWidth >= contentWidth) {
365
+ return boxLine(theme, right || left, renderedWidth);
366
+ }
367
+
368
+ const maxLeftWidth = Math.max(1, contentWidth - dividerWidth - rightWidth - 1);
369
+ const truncatedLeft = safeTruncateToWidth(left, maxLeftWidth, "…");
370
+ const gap = " ".repeat(Math.max(1, contentWidth - safeVisibleWidth(truncatedLeft) - dividerWidth - rightWidth));
371
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${truncatedLeft}${gap}${divider}${right}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
372
+ }
373
+
374
+ export function boxLine(theme: BoxTheme, content: string, width: number): string {
375
+ const renderedWidth = boxWidth(width);
376
+ const contentWidth = boxInnerWidth(renderedWidth);
377
+ const fastContent = fastBoxLineContent(content, contentWidth);
378
+ const sidePad = " ".repeat(BOX_SIDE_PADDING);
379
+ if (fastContent) {
380
+ const fill = " ".repeat(Math.max(0, contentWidth - fastContent.visibleWidth));
381
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${fastContent.text}${fill}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
382
+ }
383
+
384
+ const truncated = safeTruncateToWidth(content, contentWidth, "…");
385
+ const fill = " ".repeat(Math.max(0, contentWidth - safeVisibleWidth(truncated)));
386
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${truncated}${fill}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
387
+ }
388
+
389
+ export function boxInsetDivider(theme: BoxTheme, width: number): string {
390
+ const renderedWidth = boxWidth(width);
391
+ const lineWidth = boxInnerWidth(renderedWidth);
392
+ const sidePad = " ".repeat(BOX_SIDE_PADDING);
393
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${boxText(theme, BOX_HORIZONTAL.repeat(lineWidth))}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
394
+ }
395
+
396
+ export function boxedWrappedLines(theme: BoxTheme, content: string, width: number): string[] {
397
+ return safeWrapTextWithAnsi(content, boxInnerWidth(width)).map((line) => boxLine(theme, line, width));
398
+ }
399
+
400
+ function boxedTruncatedLine(theme: BoxTheme, content: string, width: number): string {
401
+ return boxLine(theme, safeTruncateToWidth(content, boxInnerWidth(width), "…"), width);
402
+ }
403
+
404
+ type RenderLinesCache = {
405
+ width: number;
406
+ lines: string[];
407
+ };
408
+
409
+ function pushBoundedLines(target: string[], lines: string[], maxLines: number): boolean {
410
+ const slots = maxLines - target.length;
411
+ if (slots <= 0) return false;
412
+ if (lines.length > slots) {
413
+ target.push(...lines.slice(0, slots));
414
+ return false;
415
+ }
416
+ target.push(...lines);
417
+ return true;
418
+ }
419
+
420
+ function renderBoxedOutputLines(
421
+ theme: BoxTheme,
422
+ outputLines: string[],
423
+ width: number,
424
+ rawLineBudget = DEFAULT_COLLAPSED_RENDER_LINES,
425
+ ): string[] {
426
+ const budget = boxedResultRenderBudget(rawLineBudget);
427
+ const headLimit = Math.max(0, Math.min(budget.headLines, budget.maxRenderedLines));
428
+ const tailLimit = Math.max(0, Math.min(budget.tailLines, Math.max(0, budget.maxRenderedLines - headLimit - 1)));
429
+ const head: string[] = [];
430
+ let nextInputIndex = 0;
431
+ let truncated = false;
432
+
433
+ for (; nextInputIndex < outputLines.length; nextInputIndex++) {
434
+ const line = boxedTruncatedLine(theme, outputLines[nextInputIndex] ?? "", width);
435
+ if (!pushBoundedLines(head, [line], headLimit)) {
436
+ truncated = true;
437
+ nextInputIndex++;
438
+ break;
439
+ }
440
+ }
441
+
442
+ if (!truncated && nextInputIndex >= outputLines.length) return head;
443
+
444
+ const tail: string[] = [];
445
+ const tailStart = Math.max(nextInputIndex, outputLines.length - tailLimit);
446
+ for (let i = tailStart; i < outputLines.length; i++) {
447
+ const line = boxedTruncatedLine(theme, outputLines[i] ?? "", width);
448
+ tail.push(line);
449
+ if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
450
+ }
451
+
452
+ const skippedInputLines = Math.max(0, tailStart - nextInputIndex);
453
+ const skippedText =
454
+ skippedInputLines > 0
455
+ ? `… rendered output truncated; ${skippedInputLines} input lines skipped before tail`
456
+ : "… rendered output truncated";
457
+ return [...head, boxLine(theme, theme.fg("muted", skippedText), width), ...tail];
458
+ }
459
+
460
+ export function renderBoxedToolCall(
461
+ theme: BoxTheme,
462
+ toolName: string,
463
+ detailLines: string[],
464
+ options: BoxedRenderOptions = {},
465
+ ): Component {
466
+ let cache: RenderLinesCache | null = null;
467
+ return {
468
+ invalidate() {
469
+ cache = null;
470
+ },
471
+ render(width: number): string[] {
472
+ if (cache?.width === width) return cache.lines;
473
+ const title = formatBoxedToolTitle(theme, toolName, options.isError);
474
+ const renderedWidth = boxWidth(width);
475
+ const lines = [
476
+ boxBorder(theme, "┌", "┐", renderedWidth),
477
+ boxLine(theme, title, renderedWidth),
478
+ boxInsetDivider(theme, renderedWidth),
479
+ ...detailLines.flatMap((line) => boxedWrappedLines(theme, line, renderedWidth)),
480
+ ];
481
+ if (options.isPending) {
482
+ const pendingText = options.pendingText ?? "Waiting for output…";
483
+ lines.push(
484
+ boxInsetDivider(theme, renderedWidth),
485
+ ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
486
+ boxBorder(theme, "└", "┘", renderedWidth),
487
+ );
488
+ }
489
+ cache = { width, lines };
490
+ return lines;
491
+ },
492
+ };
493
+ }
494
+
495
+ const COMPACT_FOOTER_KEY = "__piStyleCompactFooter";
496
+ const COMPACT_FOOTER_ERROR_KEY = "__piStyleCompactFooterError";
497
+ const COMPACT_FOOTER_PARTIAL_KEY = "__piStyleCompactFooterPartial";
498
+
499
+ export function clearCompactBoxedFooter(state: Record<string, unknown> | undefined): void {
500
+ if (!state || typeof state !== "object") return;
501
+ delete state[COMPACT_FOOTER_KEY];
502
+ delete state[COMPACT_FOOTER_ERROR_KEY];
503
+ delete state[COMPACT_FOOTER_PARTIAL_KEY];
504
+ }
505
+
506
+ export function renderCompactBoxedToolCall(
507
+ theme: BoxTheme,
508
+ toolName: string,
509
+ detailLine: string,
510
+ options: BoxedRenderOptions = {},
511
+ ): Component {
512
+ return {
513
+ invalidate() {},
514
+ render(width: number): string[] {
515
+ const renderedWidth = boxWidth(width);
516
+ const title = `${formatCompactBoxedToolTitle(theme, toolName, options.isError)} ${detailLine}`;
517
+ const compactFooter =
518
+ typeof options.state?.[COMPACT_FOOTER_KEY] === "string" ? options.state[COMPACT_FOOTER_KEY] : "";
519
+ const _footerIsError = Boolean(options.state?.[COMPACT_FOOTER_ERROR_KEY]);
520
+ const _footerIsPartial = Boolean(options.state?.[COMPACT_FOOTER_PARTIAL_KEY]);
521
+ 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) {
531
+ const pendingText = options.pendingText ?? "Waiting for output…";
532
+ lines.push(
533
+ boxInsetDivider(theme, renderedWidth),
534
+ ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
535
+ boxBorder(theme, "└", "┘", renderedWidth),
536
+ );
537
+ }
538
+ return lines;
539
+ },
540
+ };
541
+ }
542
+
543
+ type BoxedResultBody = Component | ((contentWidth: number) => string[]);
544
+
545
+ export function renderBoxedToolResult(
546
+ theme: BoxTheme,
547
+ body: BoxedResultBody,
548
+ options: {
549
+ outputLabel?: string;
550
+ footerLines?: string[];
551
+ emptyText?: string;
552
+ widthKey?: string;
553
+ referenceLines?: string[];
554
+ renderLineBudget?: number;
555
+ isError?: boolean;
556
+ isPartial?: boolean;
557
+ } = {},
558
+ ): Component {
559
+ let cache: RenderLinesCache | null = null;
560
+ return {
561
+ invalidate() {
562
+ cache = null;
563
+ if (typeof body !== "function") body.invalidate();
564
+ },
565
+ render(width: number): string[] {
566
+ if (cache?.width === width) return cache.lines;
567
+ const renderedWidth = boxWidth(width);
568
+ const maxContentWidth = boxInnerWidth(renderedWidth);
569
+ const bodyLines = typeof body === "function" ? body(maxContentWidth) : body.render(maxContentWidth);
570
+ const errorPrefix = options.isError ? [theme.fg("error", "✗ Error")] : [];
571
+ const outputLines =
572
+ bodyLines.length > 0
573
+ ? [...errorPrefix, ...bodyLines]
574
+ : [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
+ : [];
580
+ const rendered = [
581
+ boxInsetDivider(theme, renderedWidth),
582
+ ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
583
+ ...renderedFooterLines,
584
+ boxBorder(theme, "└", "┘", renderedWidth),
585
+ ];
586
+ cache = { width, lines: rendered };
587
+ return rendered;
588
+ },
589
+ };
590
+ }
591
+
592
+ export function formatBoxedWallTime(result: MetricResultLike | undefined): string {
593
+ const elapsedMs = getElapsedMs(result);
594
+ if (elapsedMs === undefined) return "--";
595
+ return `${(elapsedMs / 1000).toFixed(2)}s`;
596
+ }
597
+
598
+ export function formatBoxedFooterFromValues(
599
+ theme: BoxTheme,
600
+ elapsedMs: number | undefined,
601
+ output: string,
602
+ extraParts: string[] = [],
603
+ fixedColumns = false,
604
+ ): string {
605
+ const wall = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
606
+ const elapsedPart = `${theme.fg("text", "◷")} ${theme.fg("dim", wall)}`;
607
+ const extraPartList = extraParts.filter(Boolean).map((part) => theme.fg("dim", part));
608
+ 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", " · "));
617
+ }
618
+
619
+ function formatBoxedFooterParts(
620
+ theme: BoxTheme,
621
+ result: MetricResultLike | undefined,
622
+ extraParts: string[] = [],
623
+ fixedColumns = false,
624
+ elapsedMs?: number,
625
+ ): string {
626
+ return formatBoxedFooterFromValues(
627
+ theme,
628
+ elapsedMs ?? getElapsedMs(result),
629
+ getTextOutput(result),
630
+ extraParts,
631
+ fixedColumns,
632
+ );
633
+ }
634
+
635
+ export function formatBoxedFooter(
636
+ theme: BoxTheme,
637
+ result: MetricResultLike | undefined,
638
+ extraParts: string[] = [],
639
+ elapsedMs?: number,
640
+ ): string {
641
+ return formatBoxedFooterParts(theme, result, extraParts, false, elapsedMs);
642
+ }
643
+
644
+ export function renderCompactBoxedFooter(
645
+ theme: BoxTheme,
646
+ result: MetricResultLike | undefined,
647
+ options: BoxedRenderOptions = {},
648
+ ): Component {
649
+ if (options.state && typeof options.state === "object") {
650
+ options.state[COMPACT_FOOTER_KEY] = formatBoxedFooterParts(theme, result, [], true, options.elapsedMs);
651
+ options.state[COMPACT_FOOTER_ERROR_KEY] = Boolean(options.isError);
652
+ options.state[COMPACT_FOOTER_PARTIAL_KEY] = Boolean(options.isPartial);
653
+ return { invalidate() {}, render: () => [] };
654
+ }
655
+
656
+ return {
657
+ invalidate() {},
658
+ render(width: number): string[] {
659
+ const renderedWidth = boxWidth(width);
660
+ return [
661
+ boxLine(theme, formatBoxedFooterParts(theme, result, [], false, options.elapsedMs), renderedWidth),
662
+ boxBorder(theme, "└", "┘", renderedWidth),
663
+ ];
664
+ },
665
+ };
666
+ }
667
+
668
+ const TOOL_BODY_INDENT = 2;
669
+ const TOOL_RIGHT_MARGIN = 1;
670
+
671
+ export function renderToolCallHeader(
672
+ theme: BoxTheme,
673
+ label: string,
674
+ detail: string,
675
+ skipTextColor?: boolean,
676
+ ): Component {
677
+ return renderToolCallHeaderLines(theme, label, [parens(theme, detail, skipTextColor)]);
678
+ }
679
+
680
+ export function getToolBodyWidth(width: number, spaces = TOOL_BODY_INDENT): number {
681
+ return Math.max(1, width - spaces - TOOL_RIGHT_MARGIN);
682
+ }
683
+
684
+ export function renderToolCallHeaderLines(theme: BoxTheme, label: string, detailLines: string[]): Component {
685
+ const prefix = `${badge(theme, label)} `;
686
+ const indent = " ".repeat(safeVisibleWidth(prefix));
687
+ return {
688
+ invalidate() {},
689
+ render(width: number): string[] {
690
+ const bodyWidth = Math.max(1, width - safeVisibleWidth(prefix) - TOOL_RIGHT_MARGIN);
691
+ const output: string[] = [];
692
+ for (let i = 0; i < detailLines.length; i++) {
693
+ const wrapped = safeWrapTextWithAnsi(detailLines[i] ?? "", bodyWidth);
694
+ if (i === 0) {
695
+ output.push(`${prefix}${wrapped[0] ?? ""}`);
696
+ output.push(...wrapped.slice(1).map((line) => `${indent}${line}`));
697
+ } else {
698
+ output.push(...wrapped.map((line) => `${indent}${line}`));
699
+ }
700
+ }
701
+ return output.length > 0 ? output : [prefix.trimEnd()];
702
+ },
703
+ };
704
+ }
705
+
706
+ export function indentToolBody(text: string, spaces = TOOL_BODY_INDENT): string {
707
+ const indent = " ".repeat(spaces);
708
+ return text
709
+ .split("\n")
710
+ .map((line) => (line.length === 0 ? line : `${indent}${line}`))
711
+ .join("\n");
712
+ }
713
+
714
+ export function indentToolBodyLines(lines: string[], spaces = TOOL_BODY_INDENT): string[] {
715
+ const indent = " ".repeat(spaces);
716
+ return lines.map((line) => (line.length === 0 ? line : `${indent}${line}`));
717
+ }
718
+
719
+ export function formatToolOutputLine(
720
+ theme: BoxTheme,
721
+ line: string,
722
+ color: "toolOutput" | "error" | "text" = "toolOutput",
723
+ ): string {
724
+ if (color === "error") return theme.fg("error", line);
725
+
726
+ const clean = stripAnsi(line);
727
+ if (/^##\s/.test(clean)) return theme.fg("muted", line);
728
+ if (/^\?\?\s/.test(clean))
729
+ return theme.bold ? theme.bold(theme.fg("syntaxVariable", line)) : theme.fg("syntaxVariable", line);
730
+
731
+ return theme.fg(color, line);
732
+ }
733
+
734
+ function selectRenderLines(text: string, maxLines: number, tail = false): { lines: string[]; omitted: number } {
735
+ const source = text ?? "";
736
+ if (!source) return { lines: [], omitted: 0 };
737
+ const limit = Math.max(0, maxLines);
738
+ const selected: string[] = [];
739
+ let lineCount = 0;
740
+ let lineStart = 0;
741
+
742
+ for (let i = 0; i <= source.length; i++) {
743
+ if (i < source.length && source[i] !== "\n") continue;
744
+ const rawLine = source.slice(lineStart, i).replace(/\r/g, "");
745
+ lineCount++;
746
+ if (limit > 0) {
747
+ const line = clampRenderLine(rawLine);
748
+ if (tail) {
749
+ selected.push(line);
750
+ if (selected.length > limit) selected.shift();
751
+ } else if (selected.length < limit) {
752
+ selected.push(line);
753
+ }
754
+ }
755
+ lineStart = i + 1;
756
+ }
757
+
758
+ if (selected.length === 1 && selected[0] === "") return { lines: [], omitted: 0 };
759
+ return { lines: selected, omitted: Math.max(0, lineCount - selected.length) };
760
+ }
761
+
762
+ export function renderLines(
763
+ theme: BoxTheme,
764
+ text: string,
765
+ options: { expanded?: boolean },
766
+ cfg: { maxLines: number; tail?: boolean; color?: "toolOutput" | "error"; width?: number } = { maxLines: 10 },
767
+ ): string {
768
+ const color = cfg.color ?? "toolOutput";
769
+ const { lines, omitted } = selectRenderLines(text, cfg.maxLines, cfg.tail);
770
+ const renderWidth = cfg.width ? getToolBodyWidth(cfg.width) : undefined;
771
+ const renderLine = (line: string) => {
772
+ const rendered = renderWidth ? safeTruncateToWidth(line, renderWidth, "…") : line;
773
+ return formatToolOutputLine(theme, rendered, color);
774
+ };
775
+
776
+ if (lines.length === 0) return "";
777
+
778
+ let output = lines.map(renderLine).join("\n");
779
+ if (omitted <= 0) return output;
780
+
781
+ const hintText = isExpanded(options)
782
+ ? `... ${omitted} more lines omitted by render budget`
783
+ : `... ${omitted} more lines, press Ctrl+o to expand`;
784
+ const hint = cfg.width ? safeTruncateToWidth(hintText, Math.max(1, cfg.width - 1), "…") : hintText;
785
+ output += theme.fg("muted", `\n\n${hint}`);
786
+
787
+ return output;
788
+ }
789
+
790
+ export function dimWithElapsed(theme: BoxTheme, summary: string, result: MetricResultLike | undefined): string {
791
+ const metrics = formatToolMetrics(result);
792
+ return metrics
793
+ ? `${theme.fg("dim", summary)} ${theme.fg("dim", "–")} ${theme.italic ? theme.italic(theme.fg("muted", metrics)) : theme.fg("muted", metrics)}`
794
+ : theme.fg("dim", summary);
795
+ }
796
+
797
+ export function renderToolMetricsFooter(theme: BoxTheme, _width: number, metrics: string): string[] {
798
+ return metrics
799
+ ? [theme.italic ? theme.italic(theme.fg("muted", `↳ ${metrics}`)) : theme.fg("muted", `↳ ${metrics}`)]
800
+ : [];
801
+ }
802
+
803
+ export type { MetricResultLike };
804
+ // Re-exported metric helpers so renderers import from a single box module.
805
+ export { getElapsedMs };