@quandev104/pi-style 0.1.1 → 0.1.3
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 +25 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5881 -4929
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -2
- package/extension-src/pi-style/app/index.ts +0 -1
- package/extension-src/pi-style/domain/config-authorization.ts +1 -2
- package/extension-src/pi-style/domain/config-normalization.ts +3 -3
- package/extension-src/pi-style/domain/config-types.ts +2 -2
- package/extension-src/pi-style/domain/theme.ts +3 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +97 -40
- 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 +395 -19
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- 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/find.ts +48 -48
- package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
- package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
- package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
- package/extension-src/pi-style/features/tools/index.ts +14 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
- package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
- package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
- package/extension-src/pi-style/pi/config-session.ts +0 -2
- package/extension-src/pi-style/pi/index.ts +35 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +210 -69
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- package/extension-src/pi-style/shared/theme-extras.ts +0 -2
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { StatusSnapshot } from "../domain/status.js";
|
|
3
|
+
import { closeActiveBatch } from "../features/tools/boxed/batch.js";
|
|
3
4
|
import { registerPiStyleCommand } from "./commands.js";
|
|
4
5
|
import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
|
|
5
6
|
import { usageFromSession } from "./session-usage.js";
|
|
@@ -10,6 +11,24 @@ function usagePatch(ctx: ExtensionContext): StatusSnapshot {
|
|
|
10
11
|
return usage ? { usage } : {};
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Add Pi's read-only tools (grep/find/ls) to the active tool set if they are
|
|
16
|
+
* registered. Preserves any other active tools (e.g. extension tools).
|
|
17
|
+
* Only calls setActiveTools when something actually changed.
|
|
18
|
+
*/
|
|
19
|
+
function activateReadOnlyTools(pi: ExtensionAPI): void {
|
|
20
|
+
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
21
|
+
const active = new Set(pi.getActiveTools());
|
|
22
|
+
let changed = false;
|
|
23
|
+
for (const name of ["grep", "find", "ls"] as const) {
|
|
24
|
+
if (available.has(name) && !active.has(name)) {
|
|
25
|
+
active.add(name);
|
|
26
|
+
changed = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (changed) pi.setActiveTools([...active]);
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
let compatibilityTestHooks: CompatibilityTestHooks = {};
|
|
14
33
|
export function __setCompatibilityTestHooks(hooks: CompatibilityTestHooks): () => void {
|
|
15
34
|
const previous = compatibilityTestHooks;
|
|
@@ -26,10 +45,10 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
26
45
|
// product gate `compatibility.allowCorePatches: false` (or `enabled: false`) in config.
|
|
27
46
|
for (const [name, description] of [
|
|
28
47
|
["pi-style-core-patches", "Enable pi-style message/tool core patches"],
|
|
29
|
-
["pi-style-message-user", "Enable pi-style user message prefix"],
|
|
30
48
|
["pi-style-message-assistant", "Enable pi-style assistant message prefix"],
|
|
31
49
|
["pi-style-message-special-blocks", "Enable pi-style boxed compaction/skill/branch/custom message blocks"],
|
|
32
50
|
["pi-style-tools", "Enable pi-style tool renderer decoration"],
|
|
51
|
+
["pi-style-readonly-tools", "Enable grep/find/ls read-only tools in the active tool set"],
|
|
33
52
|
] as const)
|
|
34
53
|
pi.registerFlag(name, { type: "boolean", description, default: true });
|
|
35
54
|
// ASCII markers stay opt-in; unicode markers are the default.
|
|
@@ -37,6 +56,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
37
56
|
const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
|
|
38
57
|
registerPiStyleCommand(pi, coordinator.app);
|
|
39
58
|
pi.on("session_start", async (event, ctx) => {
|
|
59
|
+
// Pi only activates read/bash/edit/write by default; grep/find/ls are
|
|
60
|
+
// registered but inactive (kept out of the model's tool list to keep the
|
|
61
|
+
// core small). Activate them so the TUI shows them and the model can call
|
|
62
|
+
// them directly, mirroring Claude Code's glob/grep/read tool set.
|
|
63
|
+
if (pi.getFlag("pi-style-readonly-tools") === true) {
|
|
64
|
+
activateReadOnlyTools(pi);
|
|
65
|
+
}
|
|
40
66
|
await coordinator.start(event, ctx);
|
|
41
67
|
});
|
|
42
68
|
pi.on("agent_start", () => coordinator.app.runtime.current?.dismissStartup());
|
|
@@ -54,6 +80,14 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
54
80
|
);
|
|
55
81
|
pi.on("thinking_level_select", (event) => coordinator.app.update({ thinkingLevel: event.level }, "immediate"));
|
|
56
82
|
pi.on("session_info_changed", (event) => coordinator.app.update({ sessionName: event.name }, "coalesced"));
|
|
83
|
+
pi.on("message_start", () => {
|
|
84
|
+
// A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
|
|
85
|
+
// new message start a fresh batch instead of joining the previous one.
|
|
86
|
+
closeActiveBatch();
|
|
87
|
+
// grep/bash tree panels are NOT cleared here: historical panels must keep
|
|
88
|
+
// their state so Pi re-renders of previous messages (scroll/resume) stay
|
|
89
|
+
// intact. Only session boundaries reset them (session-coordinator).
|
|
90
|
+
});
|
|
57
91
|
pi.on("message_update", () => coordinator.app.update({}, "coalesced"));
|
|
58
92
|
// Usage (tokens + cost) is aggregated from finalized session entries at
|
|
59
93
|
// message/turn boundaries, mirroring Pi's native footer; per-chunk updates
|
|
@@ -2,8 +2,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { ConfigFilePort } from "../app/config-storage.js";
|
|
4
4
|
import { createPiStyleApp, type PiStyleApp } from "../app/index.js";
|
|
5
|
+
import { resolveTheme } from "../domain/theme.js";
|
|
5
6
|
import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
|
|
6
|
-
import {
|
|
7
|
+
import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
|
|
8
|
+
import { resetBatchRegistry } from "../features/tools/boxed/batch.js";
|
|
9
|
+
import { resetGrepRegistry } from "../features/tools/boxed/grep.js";
|
|
10
|
+
import { setToolsRenderConfig, type ToolsRenderConfig } from "../features/tools/boxed/session-config.js";
|
|
7
11
|
import { createCompatibilityCoordinator } from "./compatibility-coordinator.js";
|
|
8
12
|
import {
|
|
9
13
|
type CompatibilityCleanupResult,
|
|
@@ -45,6 +49,8 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
45
49
|
let active = false;
|
|
46
50
|
let tuiSession = false;
|
|
47
51
|
let terminalInputUnsubscribe: (() => void) | undefined;
|
|
52
|
+
let sessionTheme: unknown;
|
|
53
|
+
let sessionUi: import("@earendil-works/pi-coding-agent").ExtensionUIContext | undefined;
|
|
48
54
|
const source = createConfigSourceAdapter(
|
|
49
55
|
pi,
|
|
50
56
|
filePort,
|
|
@@ -68,6 +74,22 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
68
74
|
),
|
|
69
75
|
);
|
|
70
76
|
};
|
|
77
|
+
/** Render-scoped tool config: line budgets + the resolved open-tree glyph. */
|
|
78
|
+
const applyToolsRenderConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
|
|
79
|
+
setToolsRenderConfig({
|
|
80
|
+
...config.tools,
|
|
81
|
+
batchOpenGlyph: resolveTheme(sessionTheme as never, config, process.env).glyph("batchOpen"),
|
|
82
|
+
nerdFonts: resolveTheme(sessionTheme as never, config, process.env).mode === "nerd",
|
|
83
|
+
} satisfies ToolsRenderConfig);
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Hide Pi's "Thinking..." placeholder label: an empty label renders zero
|
|
87
|
+
* lines, so the thinking block leaves no trace while content stays hidden.
|
|
88
|
+
* Passing undefined restores the default label.
|
|
89
|
+
*/
|
|
90
|
+
const applyMessagesConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
|
|
91
|
+
sessionUi?.setHiddenThinkingLabel?.(config.messages.hideThinkingLabel ? "" : undefined);
|
|
92
|
+
};
|
|
71
93
|
const app: PiStyleApp = createPiStyleApp(
|
|
72
94
|
undefined,
|
|
73
95
|
{
|
|
@@ -79,6 +101,10 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
79
101
|
(config) => {
|
|
80
102
|
productGate = app.productPolicy.corePatchGate;
|
|
81
103
|
if (!active) return;
|
|
104
|
+
// Apply render-scoped tool config live so `/pi-style set tools.*` takes
|
|
105
|
+
// effect immediately (line budgets, dimOutput, open-tree glyph, …).
|
|
106
|
+
applyToolsRenderConfig(config);
|
|
107
|
+
applyMessagesConfig(config);
|
|
82
108
|
if (compatibility.report) {
|
|
83
109
|
const cleanup = compatibility.dispose();
|
|
84
110
|
if (!cleanup.complete) {
|
|
@@ -100,7 +126,6 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
100
126
|
authorization = readSessionAuthorization(pi);
|
|
101
127
|
compatibility.captureAuthorization(
|
|
102
128
|
authorization.core,
|
|
103
|
-
authorization.user,
|
|
104
129
|
authorization.assistant,
|
|
105
130
|
authorization.specialBlocks,
|
|
106
131
|
authorization.tools,
|
|
@@ -120,6 +145,11 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
120
145
|
tuiSession = ctx.mode === "tui";
|
|
121
146
|
app.setProjectTrusted(ctx.isProjectTrusted());
|
|
122
147
|
source.setSession(cwd, ctx.isProjectTrusted());
|
|
148
|
+
// Drop any batch state carried over from the previous session (Pi renders
|
|
149
|
+
// the restored chat between session_shutdown and the next session_start).
|
|
150
|
+
resetBatchRegistry();
|
|
151
|
+
resetGrepRegistry();
|
|
152
|
+
resetBashTreeRegistry();
|
|
123
153
|
active = false;
|
|
124
154
|
await app.reload();
|
|
125
155
|
productGate = app.productPolicy.corePatchGate;
|
|
@@ -127,7 +157,10 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
127
157
|
compatibility.install(app.config, ctx.mode === "tui", productGate);
|
|
128
158
|
// Session-scoped render configuration for the boxed tool/message surfaces.
|
|
129
159
|
// Populated once per session (never inside render).
|
|
130
|
-
|
|
160
|
+
sessionTheme = ctx.ui?.theme as never;
|
|
161
|
+
sessionUi = ctx.ui as import("@earendil-works/pi-coding-agent").ExtensionUIContext | undefined;
|
|
162
|
+
applyToolsRenderConfig(app.config);
|
|
163
|
+
applyMessagesConfig(app.config);
|
|
131
164
|
if (ctx.ui?.theme) setSpecialBlockTheme(ctx.ui.theme as never);
|
|
132
165
|
const toolDetails = collectToolDetails(pi.getActiveTools?.(), pi.getAllTools?.());
|
|
133
166
|
app.sessionStart(
|
|
@@ -180,8 +213,19 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
180
213
|
tuiSession = false;
|
|
181
214
|
terminalInputUnsubscribe?.();
|
|
182
215
|
terminalInputUnsubscribe = undefined;
|
|
216
|
+
resetBatchRegistry();
|
|
217
|
+
resetGrepRegistry();
|
|
218
|
+
resetBashTreeRegistry();
|
|
183
219
|
app.sessionShutdown();
|
|
184
|
-
|
|
220
|
+
// Tier C prototype patches stay installed across session switches. Pi renders
|
|
221
|
+
// the restored chat (renderBeforeBind) AFTER session_shutdown but BEFORE the
|
|
222
|
+
// next session_start, so disposing here would rebuild the resumed tool and
|
|
223
|
+
// special-block surfaces with native prototypes and they would never be
|
|
224
|
+
// re-decorated (their boxed output is derived once at updateDisplay time and
|
|
225
|
+
// cached; a later frame render does not re-invoke the renderer selectors).
|
|
226
|
+
// The next start() disposes this report (restoring the native identities)
|
|
227
|
+
// and reinstalls before any new render. On process exit (reason "quit") the
|
|
228
|
+
// terminal is torn down immediately after, so retained patches are harmless.
|
|
185
229
|
},
|
|
186
230
|
};
|
|
187
231
|
}
|
|
@@ -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;
|
|
@@ -45,6 +47,14 @@ export interface BoxedRenderOptions {
|
|
|
45
47
|
state?: Record<string, unknown>;
|
|
46
48
|
/** Wall-clock elapsed override (used when metrics are not in result.details). */
|
|
47
49
|
elapsedMs?: number;
|
|
50
|
+
/** Width-dependent content lines rendered between the top border and the
|
|
51
|
+
* footer border of a compact box (replaces the blank breathing line).
|
|
52
|
+
* Each returned line is truncated to the box inner width by the renderer.
|
|
53
|
+
* Returns an empty array for no body (blank line preserved). */
|
|
54
|
+
bodyLines?: (contentWidth: number) => string[];
|
|
55
|
+
/** Right-side label embedded in the compact box bottom border before the
|
|
56
|
+
* corner (e.g. an expand hint such as `Ctrl+O for more`). */
|
|
57
|
+
bottomRightLabel?: string;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export function isExpanded(options: { expanded?: boolean } | undefined): boolean {
|
|
@@ -131,7 +141,7 @@ export function countWords(text: string): number {
|
|
|
131
141
|
return count;
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
function formatCompactCount(value: number): string {
|
|
144
|
+
export function formatCompactCount(value: number): string {
|
|
135
145
|
if (value < 1000) return `${Math.round(value)}`;
|
|
136
146
|
if (value < 10000) return `${(value / 1000).toFixed(1)}k`;
|
|
137
147
|
if (value < 1000000) return `${Math.round(value / 1000)}k`;
|
|
@@ -140,7 +150,7 @@ function formatCompactCount(value: number): string {
|
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
export function formatBoxedWords(text: string): string {
|
|
143
|
-
return
|
|
153
|
+
return `~${formatCompactCount(countWords(text))} words`;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
export function badge(theme: BoxTheme, label: string): string {
|
|
@@ -172,11 +182,15 @@ const BOX_HORIZONTAL = "─";
|
|
|
172
182
|
const BOX_VERTICAL = "│";
|
|
173
183
|
const BOX_SIDE_PADDING = 2;
|
|
174
184
|
const BOX_MIN_WIDTH = 12;
|
|
185
|
+
const BOX_ROUND_TOP_LEFT = "╭";
|
|
186
|
+
const BOX_ROUND_TOP_RIGHT = "╮";
|
|
187
|
+
const BOX_ROUND_BOTTOM_LEFT = "╰";
|
|
188
|
+
const BOX_ROUND_BOTTOM_RIGHT = "╯";
|
|
189
|
+
const BOX_DIVIDER_LEFT = "├";
|
|
190
|
+
const BOX_DIVIDER_RIGHT = "┤";
|
|
191
|
+
/** Dash run before the right corner when a right-side border label is present. */
|
|
192
|
+
const BOX_LABELED_RIGHT_DASH_MIN = 3;
|
|
175
193
|
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
194
|
|
|
181
195
|
export function boxWidth(width: number): number {
|
|
182
196
|
return Math.max(BOX_MIN_WIDTH, width);
|
|
@@ -317,19 +331,22 @@ function formatBoxedStatusIcon(theme: BoxTheme, isError?: boolean): string {
|
|
|
317
331
|
return theme.fg(isError ? "error" : "success", icon);
|
|
318
332
|
}
|
|
319
333
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Colored `➔ Name` prefix for tool titles (identity color). The status glyph
|
|
336
|
+
* (✓/✗) is appended separately by formatBoxedToolTitle.
|
|
337
|
+
*/
|
|
338
|
+
export function formatToolTitlePrefix(theme: BoxTheme, name: string): string {
|
|
339
|
+
return colorFromExtra(theme, "bashPromptColor", "bashMode", `➔ ${name}`);
|
|
325
340
|
}
|
|
326
341
|
|
|
327
|
-
function
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
342
|
+
export function formatBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
|
|
343
|
+
// On failure the whole title turns error-colored (not just the ✗) so a failed
|
|
344
|
+
// tool reads instantly; on success the tool keeps its identity color and only
|
|
345
|
+
// the ✓ carries the success color.
|
|
346
|
+
const coloredTitle = isError
|
|
347
|
+
? theme.fg("error", `➔ ${name} ✗`)
|
|
348
|
+
: `${formatToolTitlePrefix(theme, name)} ${formatBoxedStatusIcon(theme, false)}`;
|
|
349
|
+
return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
function boxText(theme: BoxTheme, text: string): string {
|
|
@@ -349,8 +366,92 @@ export function boxBorder(theme: BoxTheme, left: string, right: string, width: n
|
|
|
349
366
|
return boxFrameText(theme, `${left}${BOX_HORIZONTAL.repeat(innerWidth)}${right}`);
|
|
350
367
|
}
|
|
351
368
|
|
|
352
|
-
|
|
353
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Border line with an optional label embedded after the left corner and an
|
|
371
|
+
* optional right-side label before the right corner, e.g.:
|
|
372
|
+
*
|
|
373
|
+
* ╭─ ➔ Bash ✓ ────────────╮
|
|
374
|
+
* ├─ Response ────────────┤
|
|
375
|
+
* ╰─ 0.00s · ~45 words ──── Ctrl+O for more ───╯
|
|
376
|
+
*/
|
|
377
|
+
export function boxLabeledBorder(
|
|
378
|
+
theme: BoxTheme,
|
|
379
|
+
start: string,
|
|
380
|
+
end: string,
|
|
381
|
+
leftLabel: string,
|
|
382
|
+
rightLabel: string | undefined,
|
|
383
|
+
width: number,
|
|
384
|
+
): string {
|
|
385
|
+
const renderedWidth = boxWidth(width);
|
|
386
|
+
let left = leftLabel ?? "";
|
|
387
|
+
const right = rightLabel ?? "";
|
|
388
|
+
let leftWidth = safeVisibleWidth(left);
|
|
389
|
+
const rightWidth = safeVisibleWidth(right);
|
|
390
|
+
const leftOverhead = left ? 3 : 0; // "─ " prefix + " " suffix
|
|
391
|
+
const rightOverhead = right ? 2 : 0; // " " prefix + " " suffix
|
|
392
|
+
let rightFill = right ? BOX_LABELED_RIGHT_DASH_MIN : 0;
|
|
393
|
+
let fill =
|
|
394
|
+
renderedWidth - start.length - end.length - leftOverhead - leftWidth - rightOverhead - rightWidth - rightFill;
|
|
395
|
+
|
|
396
|
+
if (right && fill < 0) {
|
|
397
|
+
rightFill = 1;
|
|
398
|
+
fill =
|
|
399
|
+
renderedWidth - start.length - end.length - leftOverhead - leftWidth - rightOverhead - rightWidth - rightFill;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (fill < 0) {
|
|
403
|
+
// Too narrow for the labels: truncate the left label, keeping at least one
|
|
404
|
+
// filler dash so the border stays closed.
|
|
405
|
+
const reserved =
|
|
406
|
+
start.length + end.length + leftOverhead + (right ? rightOverhead + rightWidth + rightFill : 0) + 1;
|
|
407
|
+
const maxLeft = renderedWidth - reserved;
|
|
408
|
+
left = maxLeft > 0 ? safeTruncateToWidth(left, maxLeft, "…") : "";
|
|
409
|
+
leftWidth = safeVisibleWidth(left);
|
|
410
|
+
fill =
|
|
411
|
+
renderedWidth -
|
|
412
|
+
start.length -
|
|
413
|
+
end.length -
|
|
414
|
+
(left ? leftWidth + leftOverhead : 0) -
|
|
415
|
+
(right ? rightOverhead + rightWidth + rightFill : 0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Style each border segment separately. Embedded labels carry their own
|
|
419
|
+
// foreground escapes that end in \x1b[39m (reset to the terminal default);
|
|
420
|
+
// applying the border color to the whole line in one wrap would leave every
|
|
421
|
+
// dash after a label in the default color, making one border render with
|
|
422
|
+
// mixed brightness.
|
|
423
|
+
const parts: string[] = [boxFrameText(theme, `${start}${left ? "─ " : ""}`)];
|
|
424
|
+
if (left) parts.push(left);
|
|
425
|
+
parts.push(boxFrameText(theme, `${left ? " " : ""}${BOX_HORIZONTAL.repeat(Math.max(0, fill))}`));
|
|
426
|
+
if (right) {
|
|
427
|
+
parts.push(boxFrameText(theme, " "), right, boxFrameText(theme, ` ${BOX_HORIZONTAL.repeat(rightFill)}`));
|
|
428
|
+
}
|
|
429
|
+
parts.push(boxFrameText(theme, end));
|
|
430
|
+
return parts.join("");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Empty content line used for breathing room inside a box. */
|
|
434
|
+
export function boxBlankLine(theme: BoxTheme, width: number): string {
|
|
435
|
+
const renderedWidth = boxWidth(width);
|
|
436
|
+
const contentWidth = boxInnerWidth(renderedWidth);
|
|
437
|
+
const sidePad = " ".repeat(BOX_SIDE_PADDING);
|
|
438
|
+
return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${" ".repeat(contentWidth)}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function boxLineAligned(theme: BoxTheme, left: string, right: string, width: number): string {
|
|
442
|
+
const renderedWidth = boxWidth(width);
|
|
443
|
+
const contentWidth = boxInnerWidth(renderedWidth);
|
|
444
|
+
const rightWidth = safeVisibleWidth(right);
|
|
445
|
+
const sidePad = " ".repeat(BOX_SIDE_PADDING);
|
|
446
|
+
|
|
447
|
+
if (!right || rightWidth >= contentWidth) {
|
|
448
|
+
return boxLine(theme, right || left, renderedWidth);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const maxLeftWidth = Math.max(1, contentWidth - rightWidth - 1);
|
|
452
|
+
const truncatedLeft = safeTruncateToWidth(left, maxLeftWidth, "…");
|
|
453
|
+
const gap = " ".repeat(Math.max(1, contentWidth - safeVisibleWidth(truncatedLeft) - rightWidth));
|
|
454
|
+
return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${truncatedLeft}${gap}${right}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
|
|
354
455
|
}
|
|
355
456
|
|
|
356
457
|
export function boxLineWithRight(theme: BoxTheme, left: string, right: string, width: number): string {
|
|
@@ -471,20 +572,30 @@ export function renderBoxedToolCall(
|
|
|
471
572
|
render(width: number): string[] {
|
|
472
573
|
if (cache?.width === width) return cache.lines;
|
|
473
574
|
const title = formatBoxedToolTitle(theme, toolName, options.isError);
|
|
575
|
+
const headerLabel = options.headerDetail ? `${title} · ${options.headerDetail}` : title;
|
|
474
576
|
const renderedWidth = boxWidth(width);
|
|
475
577
|
const lines = [
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
boxInsetDivider(theme, renderedWidth),
|
|
578
|
+
boxLabeledBorder(theme, BOX_ROUND_TOP_LEFT, BOX_ROUND_TOP_RIGHT, headerLabel, undefined, renderedWidth),
|
|
579
|
+
boxBlankLine(theme, renderedWidth),
|
|
479
580
|
...detailLines.flatMap((line) => boxedWrappedLines(theme, line, renderedWidth)),
|
|
480
581
|
];
|
|
481
582
|
if (options.isPending) {
|
|
482
583
|
const pendingText = options.pendingText ?? "Waiting for output…";
|
|
483
584
|
lines.push(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
585
|
+
boxBlankLine(theme, renderedWidth),
|
|
586
|
+
boxLabeledBorder(
|
|
587
|
+
theme,
|
|
588
|
+
BOX_ROUND_BOTTOM_LEFT,
|
|
589
|
+
BOX_ROUND_BOTTOM_RIGHT,
|
|
590
|
+
theme.fg("dim", `… ${pendingText}`),
|
|
591
|
+
undefined,
|
|
592
|
+
renderedWidth,
|
|
593
|
+
),
|
|
487
594
|
);
|
|
595
|
+
} else {
|
|
596
|
+
// Leave the box open with trailing breathing room; the result renderer
|
|
597
|
+
// continues it with the Response divider.
|
|
598
|
+
lines.push(boxBlankLine(theme, renderedWidth));
|
|
488
599
|
}
|
|
489
600
|
cache = { width, lines };
|
|
490
601
|
return lines;
|
|
@@ -513,27 +624,45 @@ export function renderCompactBoxedToolCall(
|
|
|
513
624
|
invalidate() {},
|
|
514
625
|
render(width: number): string[] {
|
|
515
626
|
const renderedWidth = boxWidth(width);
|
|
516
|
-
const title =
|
|
627
|
+
const title = formatBoxedToolTitle(theme, toolName, options.isError);
|
|
628
|
+
const headerLabel = detailLine ? `${title} · ${detailLine}` : title;
|
|
517
629
|
const compactFooter =
|
|
518
630
|
typeof options.state?.[COMPACT_FOOTER_KEY] === "string" ? options.state[COMPACT_FOOTER_KEY] : "";
|
|
519
631
|
const _footerIsError = Boolean(options.state?.[COMPACT_FOOTER_ERROR_KEY]);
|
|
520
632
|
const _footerIsPartial = Boolean(options.state?.[COMPACT_FOOTER_PARTIAL_KEY]);
|
|
633
|
+
const bodyLines = options.bodyLines ? options.bodyLines(boxInnerWidth(renderedWidth)) : [];
|
|
634
|
+
const lines = [
|
|
635
|
+
boxLabeledBorder(theme, BOX_ROUND_TOP_LEFT, BOX_ROUND_TOP_RIGHT, headerLabel, undefined, renderedWidth),
|
|
636
|
+
...(bodyLines.length > 0
|
|
637
|
+
? bodyLines.map((line) => boxLine(theme, line, renderedWidth))
|
|
638
|
+
: [boxBlankLine(theme, renderedWidth)]),
|
|
639
|
+
];
|
|
521
640
|
if (compactFooter) {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
641
|
+
lines.push(
|
|
642
|
+
boxLabeledBorder(
|
|
643
|
+
theme,
|
|
644
|
+
BOX_ROUND_BOTTOM_LEFT,
|
|
645
|
+
BOX_ROUND_BOTTOM_RIGHT,
|
|
646
|
+
compactFooter,
|
|
647
|
+
options.bottomRightLabel,
|
|
648
|
+
renderedWidth,
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
} else if (options.isPending) {
|
|
531
652
|
const pendingText = options.pendingText ?? "Waiting for output…";
|
|
532
653
|
lines.push(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
654
|
+
boxLabeledBorder(
|
|
655
|
+
theme,
|
|
656
|
+
BOX_ROUND_BOTTOM_LEFT,
|
|
657
|
+
BOX_ROUND_BOTTOM_RIGHT,
|
|
658
|
+
theme.fg("dim", `… ${pendingText}`),
|
|
659
|
+
options.bottomRightLabel,
|
|
660
|
+
renderedWidth,
|
|
661
|
+
),
|
|
536
662
|
);
|
|
663
|
+
} else {
|
|
664
|
+
// No footer yet (transient, or the result opens the Response divider):
|
|
665
|
+
// leave the box open so the result renderer continues the same box.
|
|
537
666
|
}
|
|
538
667
|
return lines;
|
|
539
668
|
},
|
|
@@ -552,6 +681,12 @@ export function renderBoxedToolResult(
|
|
|
552
681
|
widthKey?: string;
|
|
553
682
|
referenceLines?: string[];
|
|
554
683
|
renderLineBudget?: number;
|
|
684
|
+
/** 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). */
|
|
685
|
+
dividerLabel?: string | ((width: number) => string);
|
|
686
|
+
/** Right-side label embedded in the divider between the call and the result. */
|
|
687
|
+
dividerRightLabel?: string;
|
|
688
|
+
/** Right-side label embedded in the bottom border (e.g. the expand hint). */
|
|
689
|
+
expandHint?: string;
|
|
555
690
|
isError?: boolean;
|
|
556
691
|
isPartial?: boolean;
|
|
557
692
|
} = {},
|
|
@@ -572,16 +707,31 @@ export function renderBoxedToolResult(
|
|
|
572
707
|
bodyLines.length > 0
|
|
573
708
|
? [...errorPrefix, ...bodyLines]
|
|
574
709
|
: [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
|
|
575
|
-
const
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
?
|
|
579
|
-
:
|
|
710
|
+
const footerText = (options.footerLines ?? []).join(" · ");
|
|
711
|
+
const dividerText =
|
|
712
|
+
typeof options.dividerLabel === "function"
|
|
713
|
+
? options.dividerLabel(renderedWidth)
|
|
714
|
+
: (options.dividerLabel ?? "Response");
|
|
580
715
|
const rendered = [
|
|
581
|
-
|
|
716
|
+
boxLabeledBorder(
|
|
717
|
+
theme,
|
|
718
|
+
BOX_DIVIDER_LEFT,
|
|
719
|
+
BOX_DIVIDER_RIGHT,
|
|
720
|
+
theme.fg("dim", dividerText),
|
|
721
|
+
options.dividerRightLabel ? theme.fg("dim", options.dividerRightLabel) : undefined,
|
|
722
|
+
renderedWidth,
|
|
723
|
+
),
|
|
724
|
+
boxBlankLine(theme, renderedWidth),
|
|
582
725
|
...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
|
|
583
|
-
|
|
584
|
-
|
|
726
|
+
boxBlankLine(theme, renderedWidth),
|
|
727
|
+
boxLabeledBorder(
|
|
728
|
+
theme,
|
|
729
|
+
BOX_ROUND_BOTTOM_LEFT,
|
|
730
|
+
BOX_ROUND_BOTTOM_RIGHT,
|
|
731
|
+
footerText,
|
|
732
|
+
options.expandHint ? theme.fg("dim", options.expandHint) : undefined,
|
|
733
|
+
renderedWidth,
|
|
734
|
+
),
|
|
585
735
|
];
|
|
586
736
|
cache = { width, lines: rendered };
|
|
587
737
|
return rendered;
|
|
@@ -600,36 +750,21 @@ export function formatBoxedFooterFromValues(
|
|
|
600
750
|
elapsedMs: number | undefined,
|
|
601
751
|
output: string,
|
|
602
752
|
extraParts: string[] = [],
|
|
603
|
-
fixedColumns = false,
|
|
604
753
|
): string {
|
|
605
754
|
const wall = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
|
|
606
|
-
const elapsedPart =
|
|
755
|
+
const elapsedPart = theme.fg("text", wall);
|
|
607
756
|
const extraPartList = extraParts.filter(Boolean).map((part) => theme.fg("dim", part));
|
|
608
757
|
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", " · "));
|
|
758
|
+
return [elapsedPart, ...extraPartList, wordsPart].join(theme.fg("dim", " · "));
|
|
617
759
|
}
|
|
618
760
|
|
|
619
761
|
function formatBoxedFooterParts(
|
|
620
762
|
theme: BoxTheme,
|
|
621
763
|
result: MetricResultLike | undefined,
|
|
622
764
|
extraParts: string[] = [],
|
|
623
|
-
fixedColumns = false,
|
|
624
765
|
elapsedMs?: number,
|
|
625
766
|
): string {
|
|
626
|
-
return formatBoxedFooterFromValues(
|
|
627
|
-
theme,
|
|
628
|
-
elapsedMs ?? getElapsedMs(result),
|
|
629
|
-
getTextOutput(result),
|
|
630
|
-
extraParts,
|
|
631
|
-
fixedColumns,
|
|
632
|
-
);
|
|
767
|
+
return formatBoxedFooterFromValues(theme, elapsedMs ?? getElapsedMs(result), getTextOutput(result), extraParts);
|
|
633
768
|
}
|
|
634
769
|
|
|
635
770
|
export function formatBoxedFooter(
|
|
@@ -638,7 +773,7 @@ export function formatBoxedFooter(
|
|
|
638
773
|
extraParts: string[] = [],
|
|
639
774
|
elapsedMs?: number,
|
|
640
775
|
): string {
|
|
641
|
-
return formatBoxedFooterParts(theme, result, extraParts,
|
|
776
|
+
return formatBoxedFooterParts(theme, result, extraParts, elapsedMs);
|
|
642
777
|
}
|
|
643
778
|
|
|
644
779
|
export function renderCompactBoxedFooter(
|
|
@@ -647,7 +782,7 @@ export function renderCompactBoxedFooter(
|
|
|
647
782
|
options: BoxedRenderOptions = {},
|
|
648
783
|
): Component {
|
|
649
784
|
if (options.state && typeof options.state === "object") {
|
|
650
|
-
options.state[COMPACT_FOOTER_KEY] = formatBoxedFooterParts(theme, result, [],
|
|
785
|
+
options.state[COMPACT_FOOTER_KEY] = formatBoxedFooterParts(theme, result, [], options.elapsedMs);
|
|
651
786
|
options.state[COMPACT_FOOTER_ERROR_KEY] = Boolean(options.isError);
|
|
652
787
|
options.state[COMPACT_FOOTER_PARTIAL_KEY] = Boolean(options.isPartial);
|
|
653
788
|
return { invalidate() {}, render: () => [] };
|
|
@@ -658,8 +793,14 @@ export function renderCompactBoxedFooter(
|
|
|
658
793
|
render(width: number): string[] {
|
|
659
794
|
const renderedWidth = boxWidth(width);
|
|
660
795
|
return [
|
|
661
|
-
|
|
662
|
-
|
|
796
|
+
boxLabeledBorder(
|
|
797
|
+
theme,
|
|
798
|
+
BOX_ROUND_BOTTOM_LEFT,
|
|
799
|
+
BOX_ROUND_BOTTOM_RIGHT,
|
|
800
|
+
formatBoxedFooterParts(theme, result, [], options.elapsedMs),
|
|
801
|
+
undefined,
|
|
802
|
+
renderedWidth,
|
|
803
|
+
),
|
|
663
804
|
];
|
|
664
805
|
},
|
|
665
806
|
};
|
|
@@ -731,7 +872,7 @@ export function formatToolOutputLine(
|
|
|
731
872
|
return theme.fg(color, line);
|
|
732
873
|
}
|
|
733
874
|
|
|
734
|
-
function selectRenderLines(text: string, maxLines: number, tail = false): { lines: string[]; omitted: number } {
|
|
875
|
+
export function selectRenderLines(text: string, maxLines: number, tail = false): { lines: string[]; omitted: number } {
|
|
735
876
|
const source = text ?? "";
|
|
736
877
|
if (!source) return { lines: [], omitted: 0 };
|
|
737
878
|
const limit = Math.max(0, maxLines);
|