@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5881 -4929
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -2
  6. package/extension-src/pi-style/app/index.ts +0 -1
  7. package/extension-src/pi-style/domain/config-authorization.ts +1 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -3
  9. package/extension-src/pi-style/domain/config-types.ts +2 -2
  10. package/extension-src/pi-style/domain/theme.ts +3 -0
  11. package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
  12. package/extension-src/pi-style/features/messages/index.ts +97 -40
  13. package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
  14. package/extension-src/pi-style/features/startup/index.ts +4 -4
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  21. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  22. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  27. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  28. package/extension-src/pi-style/features/tools/index.ts +14 -0
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  32. package/extension-src/pi-style/pi/config-session.ts +0 -2
  33. package/extension-src/pi-style/pi/index.ts +35 -1
  34. package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
  35. package/extension-src/pi-style/shared/ansi.ts +3 -0
  36. package/extension-src/pi-style/shared/box.ts +210 -69
  37. package/extension-src/pi-style/shared/split-diff.ts +395 -86
  38. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  39. 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 { setToolsRenderConfig } from "../features/tools/boxed/session-config.js";
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
- setToolsRenderConfig(app.config.tools);
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
- compatibility.dispose();
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 `✎ ~${formatCompactCount(countWords(text))} words`;
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
- 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, "|")}`;
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 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, "|")}`;
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
- function padVisibleRight(text: string, width: number): string {
353
- return `${text}${" ".repeat(Math.max(0, width - safeVisibleWidth(text)))}`;
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
- boxBorder(theme, "┌", "┐", renderedWidth),
477
- boxLine(theme, title, renderedWidth),
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
- boxInsetDivider(theme, renderedWidth),
485
- ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
486
- boxBorder(theme, "└", "┘", renderedWidth),
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 = `${formatCompactBoxedToolTitle(theme, toolName, options.isError)} ${detailLine}`;
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
- 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) {
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
- boxInsetDivider(theme, renderedWidth),
534
- ...boxedWrappedLines(theme, `${theme.fg("muted", "…")} ${theme.fg("dim", pendingText)}`, renderedWidth),
535
- boxBorder(theme, "└", "┘", renderedWidth),
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 footerLines = options.footerLines ?? [];
576
- const renderedFooterLines =
577
- footerLines.length > 0
578
- ? [boxInsetDivider(theme, renderedWidth), ...footerLines.map((line) => boxLine(theme, line, renderedWidth))]
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
- boxInsetDivider(theme, renderedWidth),
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
- ...renderedFooterLines,
584
- boxBorder(theme, "└", "┘", renderedWidth),
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 = `${theme.fg("text", "◷")} ${theme.fg("dim", wall)}`;
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
- 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", " · "));
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, false, elapsedMs);
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, [], true, options.elapsedMs);
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
- boxLine(theme, formatBoxedFooterParts(theme, result, [], false, options.elapsedMs), renderedWidth),
662
- boxBorder(theme, "└", "┘", renderedWidth),
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);