@anthodev/pi-tools-style 0.1.3 → 0.2.0

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.
@@ -0,0 +1,261 @@
1
+ import { getSettingsListTheme, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, SettingsList, Text, type SettingItem, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
+
4
+ import { getSettings, isShellEnabledByConfig, isToolEnabledByConfig } from "./settings.js";
5
+ import type { IconMode } from "./tool-icon.js";
6
+ import { getToolPresentationCatalog, isToolRendererEnabled, type ToolPresentationCatalogEntry } from "./tool-renderer.js";
7
+
8
+ /**
9
+ * Change emitted by the config menu. The caller owns persistence and runtime effects; the menu only
10
+ * reports the row the user changed.
11
+ */
12
+ export type ToolsStyleConfigChange =
13
+ | { readonly kind: "enabled"; readonly enabled: boolean }
14
+ | { readonly kind: "iconMode"; readonly iconMode: IconMode }
15
+ | { readonly kind: "shellEnabled"; readonly enabled: boolean }
16
+ | { readonly kind: "tool"; readonly name: string; readonly enabled: boolean };
17
+
18
+ const ENABLED_ID = "global.enabled";
19
+ const ICON_MODE_ID = "global.iconMode";
20
+ const SHELL_ENABLED_ID = "global.shellEnabled";
21
+ const TOOL_ID_PREFIX = "tool:";
22
+
23
+ const ON = "on";
24
+ const OFF = "off";
25
+ const ICON_MODES = ["ascii", "nerd-font", "off"] as const;
26
+ const NATIVE_RANK: Record<string, number> = {
27
+ bash: 0,
28
+ powershell: 1,
29
+ read: 2,
30
+ edit: 3,
31
+ write: 4,
32
+ find: 5,
33
+ grep: 6,
34
+ ls: 7,
35
+ };
36
+ const NATIVE_COUNT = Object.keys(NATIVE_RANK).length;
37
+
38
+ const ENABLED_DESCRIPTION = "Renders tool calls with the tools-style presentation for this session.";
39
+ const ICON_MODE_DESCRIPTION = "Icon set used in tool call titles.";
40
+ const SHELL_ENABLED_DESCRIPTION = "Applies the tools-style presentation to direct shell command output.";
41
+ const EXPOSED_NOTE = "Currently exposed by this session.";
42
+ const OFFLINE_NOTE = "Not currently exposed; preference is retained for this exact name.";
43
+ const CONDITIONAL_NOTE = "Applies when this tool uses a supported renderer; third-party self-renderers remain unchanged.";
44
+ const EFFECTIVE_OFF_NOTE = "Global styling is off, so this preference takes effect only when global styling is on.";
45
+
46
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
47
+ const MAX_VISIBLE_ROWS = 15;
48
+ const HEADER = "Tools style configuration";
49
+ const HELPER = "Type to search. Enter or Space changes the selected value, Escape closes.";
50
+
51
+ function iconModeOf(value: string): IconMode | undefined {
52
+ switch (value) {
53
+ case "ascii":
54
+ case "nerd-font":
55
+ case "off":
56
+ return value;
57
+ default:
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Display-only sanitising: control characters would corrupt the terminal grid, and built-in search
64
+ * only reads the label. The row id and the emitted tool name always keep the exact identity.
65
+ */
66
+ function displayName(name: string): string {
67
+ return name.replace(CONTROL_CHARACTERS, "");
68
+ }
69
+
70
+ /** Own-property lookup: reserved-looking names must never resolve inherited values. */
71
+ function nativeRank(name: string): number | undefined {
72
+ return Object.hasOwn(NATIVE_RANK, name) ? NATIVE_RANK[name] : undefined;
73
+ }
74
+
75
+ /** Native tools first in their existing order, then every other exact name sorted deterministically. */
76
+ function orderEntries(entries: readonly ToolPresentationCatalogEntry[]): readonly ToolPresentationCatalogEntry[] {
77
+ return [...entries].sort((a, b) => {
78
+ const left = nativeRank(a.name);
79
+ const right = nativeRank(b.name);
80
+ if (left !== undefined || right !== undefined) {
81
+ return (left ?? NATIVE_COUNT) - (right ?? NATIVE_COUNT);
82
+ }
83
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
84
+ });
85
+ }
86
+
87
+ function toolDescription(entry: ToolPresentationCatalogEntry, globalOff: boolean): string {
88
+ const parts = [entry.available ? EXPOSED_NOTE : OFFLINE_NOTE];
89
+ // Availability and support are independent dimensions: an offline identity is still conditional
90
+ // unless owned-row evidence confirmed its renderer.
91
+ if (entry.support === "conditional") {
92
+ parts.push(CONDITIONAL_NOTE);
93
+ }
94
+ if (globalOff) {
95
+ parts.push(EFFECTIVE_OFF_NOTE);
96
+ }
97
+ return parts.join(" ");
98
+ }
99
+
100
+ function toolItem(entry: ToolPresentationCatalogEntry, globalOff: boolean): SettingItem {
101
+ return {
102
+ id: `${TOOL_ID_PREFIX}${entry.name}`,
103
+ label: displayName(entry.name),
104
+ description: toolDescription(entry, globalOff),
105
+ currentValue: isToolEnabledByConfig(entry.name) ? ON : OFF,
106
+ values: [ON, OFF],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Row ids are exact and collision-safe: the global rows are matched before the fixed `tool:` prefix,
112
+ * and only that fixed prefix is ever stripped, so names containing colons stay intact.
113
+ */
114
+ function changeOf(id: string, value: string): ToolsStyleConfigChange | undefined {
115
+ if (id === ENABLED_ID) {
116
+ return { kind: "enabled", enabled: value === ON };
117
+ }
118
+ if (id === SHELL_ENABLED_ID) {
119
+ return { kind: "shellEnabled", enabled: value === ON };
120
+ }
121
+ if (id === ICON_MODE_ID) {
122
+ const iconMode = iconModeOf(value);
123
+ return iconMode === undefined ? undefined : { kind: "iconMode", iconMode };
124
+ }
125
+ if (id.startsWith(TOOL_ID_PREFIX)) {
126
+ return { kind: "tool", name: id.slice(TOOL_ID_PREFIX.length), enabled: value === ON };
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ export function openToolsStyleConfig(
132
+ pi: Pick<ExtensionAPI, "getAllTools">,
133
+ context: ExtensionCommandContext,
134
+ onChange: (change: ToolsStyleConfigChange) => Promise<void>,
135
+ ): Promise<void> {
136
+ if (context.mode !== "tui") {
137
+ return Promise.resolve();
138
+ }
139
+ return context.ui.custom<void>((tui, theme, _keybindings, done) => {
140
+ // The catalog is evaluated once per opening, so a later opening sees newly discovered tools.
141
+ const entries = orderEntries(getToolPresentationCatalog(pi.getAllTools()));
142
+ const globalOff = !isToolRendererEnabled();
143
+ const toolRows = entries.map((entry) => ({ entry, item: toolItem(entry, globalOff) }));
144
+ const items: SettingItem[] = [
145
+ {
146
+ id: ENABLED_ID,
147
+ label: "Tools style rendering",
148
+ description: ENABLED_DESCRIPTION,
149
+ // Raw runtime state for this session, not the saved preference.
150
+ currentValue: isToolRendererEnabled() ? ON : OFF,
151
+ values: [ON, OFF],
152
+ },
153
+ {
154
+ id: ICON_MODE_ID,
155
+ label: "Icon mode",
156
+ description: ICON_MODE_DESCRIPTION,
157
+ currentValue: getSettings().iconMode,
158
+ values: [...ICON_MODES],
159
+ },
160
+ {
161
+ id: SHELL_ENABLED_ID,
162
+ label: "Shell commands (!/!!)",
163
+ description: SHELL_ENABLED_DESCRIPTION,
164
+ currentValue: isShellEnabledByConfig() ? ON : OFF,
165
+ values: [ON, OFF],
166
+ },
167
+ ...toolRows.map((row) => row.item),
168
+ ];
169
+
170
+ let closed = false;
171
+ const close = (): void => {
172
+ if (closed) {
173
+ return;
174
+ }
175
+ closed = true;
176
+ done(undefined);
177
+ };
178
+
179
+ const reportFailure = (): void => {
180
+ try {
181
+ context.ui.notify("Tools style: that change could not be saved", "warning");
182
+ } catch {
183
+ // Best-effort warning; the caller's promise already reports the failure.
184
+ }
185
+ };
186
+
187
+ /** Keeps every tool row's gate disclosure in step with the applied global choice. */
188
+ const refreshGateDisclosure = (enabled: boolean): void => {
189
+ const off = !enabled;
190
+ for (const row of toolRows) {
191
+ row.item.description = toolDescription(row.entry, off);
192
+ }
193
+ };
194
+
195
+ const emit = (id: string, newValue: string): void => {
196
+ const change = changeOf(id, newValue);
197
+ if (change === undefined) {
198
+ return;
199
+ }
200
+ if (change.kind === "enabled") {
201
+ // The row value and its disclosure move together, without rebuilding the menu.
202
+ refreshGateDisclosure(change.enabled);
203
+ }
204
+ let pending: Promise<void>;
205
+ try {
206
+ pending = onChange(change);
207
+ } catch {
208
+ reportFailure();
209
+ return;
210
+ }
211
+ void pending.then(undefined, reportFailure);
212
+ };
213
+
214
+ const settingsList = new SettingsList(
215
+ items,
216
+ Math.min(items.length + 2, MAX_VISIBLE_ROWS),
217
+ getSettingsListTheme(),
218
+ emit,
219
+ close,
220
+ { enableSearch: true },
221
+ );
222
+
223
+ const container = new Container();
224
+ container.addChild(new Text(theme.fg("accent", HEADER), 1, 0));
225
+ container.addChild(new Text(theme.fg("dim", HELPER), 1, 0));
226
+ container.addChild(settingsList);
227
+
228
+ return {
229
+ render(width: number): string[] {
230
+ return container.render(width);
231
+ },
232
+ invalidate(): void {
233
+ container.invalidate();
234
+ },
235
+ // Every key byte is delegated to the SDK component, which owns search and value cycling.
236
+ handleInput(data: string): void {
237
+ if (closed) {
238
+ return;
239
+ }
240
+ settingsList.handleInput(data);
241
+ if (!closed) {
242
+ tui.requestRender();
243
+ }
244
+ },
245
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
246
+ if (closed) {
247
+ return undefined;
248
+ }
249
+ const result = container.handleMouse(event);
250
+ if (!closed) {
251
+ tui.requestRender();
252
+ }
253
+ return result;
254
+ },
255
+ dispose(): void {
256
+ // Writes stay detached: settlements must not repaint or mutate this disposed component.
257
+ closed = true;
258
+ },
259
+ };
260
+ });
261
+ }
package/src/frame.ts CHANGED
@@ -1,120 +1,313 @@
1
+ import { keyHint, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
1
2
  import {
2
- stripTerminalSequences,
3
+ Container,
4
+ Text,
3
5
  truncateToWidth,
4
6
  visibleWidth,
7
+ wrapTextWithAnsi,
8
+ type Component,
5
9
  } from "@earendil-works/pi-tui";
10
+ import { getIconMode } from "./settings.js";
11
+ import { formatToolTitle } from "./tool-icon.js";
12
+ import { getToolSpinnerFrame, toolSpinnerFrame } from "./tool-spinner.js";
13
+ import type { EntryLinesComponent, ToolSection, ToolSnapshot, ToolView } from "./tool-presentation.js";
14
+ import type { ToolChildBounds, ToolLayout } from "./tool-renderer.js";
6
15
 
7
16
  const RESET = "\u001b[0m";
8
17
  const DEFAULT_PADDING = 1;
9
- const MINIMUM_FRAME_WIDTH = 5;
10
- const HORIZONTAL_RULE = /^[─━═╌╍┄┅┈┉-]+$/u;
11
18
  const TERMINAL_IMAGE_SEQUENCES = [
12
19
  "\u001b_G", // Kitty graphics protocol
13
20
  "\u001b]1337;File=", // iTerm2 inline images
14
- "\u001bPq", // Sixel device control string
15
- "\u0090q", // Eight-bit Sixel device control string
16
21
  ] as const;
22
+ const SIXEL_SEQUENCE = /(?:\u001bP|\u0090)[0-9;]*q/u;
23
+ // SDK wrapping can retain a grapheme wider than its width. These are the branches
24
+ // that can exceed the usual one/two-cell base width in Pi's graphemeWidth.
25
+ const DEFENSIVE_PADDED_ROW = /[\t\r\n\p{Mark}\uFF00-\uFFEF\u0E33\u0EB3]/u;
26
+ const TEXT_RENDER = Text.prototype.render;
27
+ const TEXT_INVALIDATE = Text.prototype.invalidate;
28
+ const CONTAINER_RENDER = Container.prototype.render;
29
+ const CONTAINER_INVALIDATE = Container.prototype.invalidate;
30
+ const CONTAINER_HANDLE_MOUSE = Container.prototype.handleMouse;
17
31
 
18
- export interface FrameOptions {
19
- horizontalPadding?: number;
20
- styleBorder?: (value: string) => string;
21
- title?: string;
22
- }
23
-
24
- export function frameContentWidth(
25
- width: number,
26
- horizontalPadding = DEFAULT_PADDING,
27
- ): number {
28
- const padding = normalizePadding(horizontalPadding);
29
- return Math.max(0, Math.floor(width) - 2 - padding * 2);
30
- }
31
-
32
- export function frameLines(
33
- sourceLines: readonly string[],
34
- width: number,
35
- options: FrameOptions = {},
36
- ): string[] {
37
- const lines = [...sourceLines];
38
- const outerWidth = Math.floor(width);
39
- const padding = normalizePadding(options.horizontalPadding);
40
-
41
- if (
42
- lines.length === 0 ||
43
- outerWidth < MINIMUM_FRAME_WIDTH ||
44
- containsTerminalImage(lines)
45
- ) {
46
- return lines;
47
- }
48
-
49
- const contentWidth = frameContentWidth(outerWidth, padding);
50
- if (contentWidth < 1) return lines;
51
-
52
- const styleBorder = options.styleBorder ?? identity;
53
- const horizontalPadding = " ".repeat(padding);
54
- const top = renderTopBorder(outerWidth, options.title, styleBorder);
55
- const bottom = styleBorder(`╰${"─".repeat(outerWidth - 2)}╯`);
56
- const body = lines.map((line) => {
57
- const content = truncateToWidth(line, contentWidth, "");
58
- const remaining = Math.max(0, contentWidth - visibleWidth(content));
59
-
60
- const reset = content.includes("\u001b") ? RESET : "";
61
- return `${styleBorder("│")}${horizontalPadding}${content}${reset}${" ".repeat(
62
- remaining,
63
- )}${horizontalPadding}${styleBorder("│")}`;
64
- });
65
-
66
- return [top, ...body, bottom];
67
- }
68
32
 
69
33
  export function containsTerminalImage(lines: readonly string[]): boolean {
70
34
  return lines.some((line) =>
71
- TERMINAL_IMAGE_SEQUENCES.some((sequence) => line.includes(sequence)),
35
+ TERMINAL_IMAGE_SEQUENCES.some((sequence) => line.includes(sequence))
36
+ || SIXEL_SEQUENCE.test(line),
72
37
  );
73
38
  }
74
39
 
75
- export function trimOuterRules(sourceLines: readonly string[]): string[] {
76
- const lines = [...sourceLines];
77
-
78
- const firstRuleIndex =
79
- lines.length > 1 &&
80
- stripTerminalSequences(lines[0] ?? "").trim() === "" &&
81
- isHorizontalRule(lines[1] ?? "")
82
- ? 1
83
- : 0;
84
- const lastRuleIndex = lines.length - 1;
40
+ /** Public Container handlers only forward; opaque or overridden handlers own interaction. */
41
+ export function isInteractiveToolComponent(component: Component): boolean {
42
+ if (typeof component.handleInput === "function") return true;
43
+ if (component instanceof Container && component.handleMouse === CONTAINER_HANDLE_MOUSE) {
44
+ return component.children.some(isInteractiveToolComponent);
45
+ }
46
+ return typeof component.handleMouse === "function";
47
+ }
85
48
 
86
- if (
87
- firstRuleIndex < lastRuleIndex &&
88
- isHorizontalRule(lines[firstRuleIndex] ?? "") &&
89
- isHorizontalRule(lines[lastRuleIndex] ?? "")
90
- ) {
91
- return lines.slice(firstRuleIndex + 1, lastRuleIndex);
49
+ /** Only public static bodies can skip a generic renderer's ordinary clock invalidation. */
50
+ export function hasStableToolBody(component: Component): boolean {
51
+ if (component instanceof Text) {
52
+ return component.render === TEXT_RENDER && component.invalidate === TEXT_INVALIDATE;
92
53
  }
54
+ if (component instanceof Container) {
55
+ return component.render === CONTAINER_RENDER && component.invalidate === CONTAINER_INVALIDATE
56
+ && component.children.every(hasStableToolBody);
57
+ }
58
+ return false;
59
+ }
93
60
 
94
- return lines;
61
+ interface RenderedSection {
62
+ section: ToolSection;
63
+ rows: readonly string[];
95
64
  }
96
65
 
97
- function renderTopBorder(
66
+ /**
67
+ * Compose a single semantic card and assign intact sections to the two Pi slots.
68
+ * Interaction routing belongs to ToolPresentation; bounds retain the real children.
69
+ */
70
+ export function layoutToolView(
71
+ view: ToolView,
72
+ snapshot: ToolSnapshot,
73
+ theme: Theme,
98
74
  width: number,
99
- rawTitle: string | undefined,
100
- styleBorder: (value: string) => string,
101
- ): string {
102
- const title = normalizeTitle(rawTitle);
103
- const maxTitleWidth = Math.max(0, width - 6);
104
-
105
- if (!title || maxTitleWidth < 1) {
106
- return styleBorder(`╭${"─".repeat(width - 2)}╮`);
75
+ spinnerFrameIndex?: number,
76
+ ): ToolLayout {
77
+ const outerWidth = Math.max(1, Math.floor(width));
78
+ const framed = view.layout === "framed";
79
+ const padding = framed
80
+ ? normalizePadding(snapshot.context.outputPad)
81
+ : Math.min(normalizePadding(snapshot.context.outputPad), Math.floor((outerWidth - 1) / 2));
82
+ const childWidth = Math.max(1, outerWidth - (framed ? 2 : 0) - 2 * padding);
83
+ const rendered = new Map<Component, readonly string[]>();
84
+ const sections: RenderedSection[] = [];
85
+ // Rendering is done before decoration or previews: an image escapes the entire card.
86
+ for (const section of view.sections) {
87
+ let childRows = rendered.get(section.component);
88
+ if (!childRows) {
89
+ childRows = section.component.render(childWidth);
90
+ rendered.set(section.component, childRows);
91
+ }
92
+ sections.push({ section, rows: childRows });
93
+ }
94
+ const layout: ToolLayout = {
95
+ callRows: [],
96
+ resultRows: [],
97
+ callChildBounds: [],
98
+ resultChildBounds: [],
99
+ callOffset: 0,
100
+ resultOffset: 0,
101
+ };
102
+ const callBounds: ToolChildBounds[] = [];
103
+ const resultBounds: ToolChildBounds[] = [];
104
+ layout.callChildBounds = callBounds;
105
+ layout.resultChildBounds = resultBounds;
106
+ const hasResult = snapshot.result !== undefined;
107
+ if (sections.some(({ rows }) => containsTerminalImage(rows))) {
108
+ for (const slot of ["call", "result"] as const) {
109
+ const output = slot === "call" || !hasResult ? layout.callRows : layout.resultRows;
110
+ const bounds = slot === "call" || !hasResult ? callBounds : resultBounds;
111
+ for (const entry of sections) {
112
+ if (entry.section.slot !== slot || entry.rows.length === 0) continue;
113
+ bounds.push({ component: entry.section.component, x: 0, y: output.length, width: childWidth, height: entry.rows.length });
114
+ output.push(...entry.rows);
115
+ }
116
+ }
117
+ layout.resultOffset = layout.callRows.length;
118
+ return layout;
107
119
  }
108
120
 
109
- const visibleTitle = truncateToWidth(title, maxTitleWidth, "");
110
- const prefix = `╭─ ${visibleTitle} `;
111
- const fill = "─".repeat(Math.max(1, width - visibleWidth(prefix) - 1));
112
- return `${styleBorder(prefix)}${styleBorder(fill)}${styleBorder("╮")}`;
121
+ const borderColor: ThemeColor = view.tone === "error" || view.head.status === "error"
122
+ ? "error"
123
+ : view.tone === "warning" || view.head.status === "cancelled"
124
+ ? "warning"
125
+ : view.head.status === "pending" || view.head.status === "running"
126
+ ? "accent"
127
+ : "borderMuted";
128
+ const background = theme.getBgAnsi(borderColor === "error" ? "toolErrorBg"
129
+ : view.head.status === "pending" || view.head.status === "running" ? "toolPendingBg" : "toolSuccessBg");
130
+ const paint = (line: string) => {
131
+ if (!background) return line;
132
+ // Reapply the card background after any SGR resetting it, including compound SGRs.
133
+ const stabilized = line.replace(/\u001b\[([0-9;]*)m/gu, (sequence: string, parameters: string) => {
134
+ const codes = parameters.split(";");
135
+ for (let index = 0; index < codes.length; index++) {
136
+ const code = codes[index];
137
+ if (code === "" || code === "0" || code === "49") return sequence + background;
138
+ // RGB/palette channels may contain 0 or 49 without being SGR resets.
139
+ if (code === "38" || code === "48" || code === "58") {
140
+ index += codes[index + 1] === "2" ? 4 : codes[index + 1] === "5" ? 2 : 0;
141
+ }
142
+ }
143
+ return sequence;
144
+ });
145
+ return background + stabilized + "\u001b[49m";
146
+ };
147
+ const border = (text: string) => theme.fg(borderColor, text);
148
+ const capWidth = Math.min(3, Math.max(0, outerWidth - 2));
149
+ const cap = "─".repeat(capWidth);
150
+ const barBudget = Math.max(0, outerWidth - capWidth - 4);
151
+ const bar = (left: string, right: string, label: string) => {
152
+ const text = barBudget > 0 && label ? ` ${truncateToWidth(label, barBudget, "…")} ` : "";
153
+ const fill = "─".repeat(Math.max(0, outerWidth - 2 - capWidth - visibleWidth(text)));
154
+ return paint(border(left + cap) + text + border(fill + right));
155
+ };
156
+ const pad = " ".repeat(padding);
157
+ const body = (line: string, padded = false) => {
158
+ // Children own their ANSI text. Close styles/links only outside their content.
159
+ const reset = line.includes("\u001b") ? "\u001b]8;;\u001b\\" + RESET : "";
160
+ const fill = padded ? "" : " ".repeat(Math.max(0, childWidth - visibleWidth(line)));
161
+ return paint(framed
162
+ ? border("│") + pad + line + reset + fill + pad + border("│")
163
+ : pad + line + reset + fill + pad);
164
+ };
165
+ const headerWidth = Math.max(0, outerWidth - (framed ? Math.min(3, outerWidth - 2) + 4 : 2 * padding));
166
+ const header = semanticHeader(view, snapshot, theme, borderColor, headerWidth, spinnerFrameIndex);
167
+ let headerText = header.text;
168
+ let serializeHeader: (text: string) => string;
169
+ if (framed) {
170
+ headerText = barBudget > 0 ? truncateToWidth(headerText, barBudget, "…") : "";
171
+ const gap = headerText ? " " : "";
172
+ const fill = "─".repeat(Math.max(0, outerWidth - 2 - capWidth - header.width - 2 * gap.length));
173
+ const left = border("╭" + cap) + gap;
174
+ const right = gap + border(fill + "╮");
175
+ serializeHeader = (text) => paint(left + text + right);
176
+ } else {
177
+ const reset = headerText.includes("\u001b") ? "\u001b]8;;\u001b\\" + RESET : "";
178
+ const right = reset + " ".repeat(Math.max(0, childWidth - header.width)) + pad;
179
+ serializeHeader = (text) => paint(pad + text + right);
180
+ }
181
+ const preparedHeader = serializeHeader(headerText);
182
+ layout.callRows.push(preparedHeader);
183
+ if (!view.sections.some(({ component }) => isInteractiveToolComponent(component)
184
+ || (snapshot.presentation === "generic" && !hasStableToolBody(component)))) {
185
+ const refreshText = header.refreshText;
186
+ layout.refreshHeader = refreshText
187
+ ? (frameIndex) => { layout.callRows[0] = serializeHeader(refreshText(frameIndex)); }
188
+ : () => { layout.callRows[0] = preparedHeader; };
189
+ }
190
+ for (const slot of ["call", "result"] as const) {
191
+ const output = slot === "call" || !hasResult ? layout.callRows : layout.resultRows;
192
+ const bounds = slot === "call" || !hasResult ? callBounds : resultBounds;
193
+ for (const entry of sections) {
194
+ if (entry.section.slot !== slot || entry.rows.length === 0) continue;
195
+ const interactive = isInteractiveToolComponent(entry.section.component);
196
+ let content: readonly string[] = entry.rows;
197
+ let padded = entry.section.renderedWidth === "padded" && !interactive;
198
+ let hidden = 0;
199
+ const preview = !view.expanded && entry.section.preview !== false && !interactive
200
+ ? entry.section.previewPolicy ?? view.preview : undefined;
201
+ if (preview?.unit === "entries") {
202
+ padded = false;
203
+ const entries = (entry.section.component as Partial<EntryLinesComponent>).entryLines;
204
+ if (!entries) throw new Error("Entry previews require logical entryLines");
205
+ const count = Math.max(0, Math.floor(preview.count));
206
+ hidden = Math.max(0, entries.length - count);
207
+ const selected = preview.edge === "tail" ? entries.slice(hidden) : entries.slice(0, count);
208
+ content = selected.flatMap((line) => wrapTextWithAnsi(line, childWidth));
209
+ } else if (!interactive) {
210
+ if (padded) {
211
+ let wrapped: string[] | undefined;
212
+ for (let index = 0; index < entry.rows.length; index++) {
213
+ const line = entry.rows[index]!;
214
+ if ((childWidth < 2 || DEFENSIVE_PADDED_ROW.test(line))
215
+ && (visibleWidth(line) > childWidth || line.includes("\n"))) {
216
+ wrapped ??= entry.rows.slice(0, index);
217
+ wrapped.push(...wrapTextWithAnsi(line, childWidth));
218
+ } else {
219
+ wrapped?.push(line);
220
+ }
221
+ }
222
+ if (wrapped) {
223
+ content = wrapped;
224
+ padded = false;
225
+ }
226
+ } else {
227
+ content = entry.rows.flatMap((line) => visibleWidth(line) > childWidth || line.includes("\n")
228
+ ? wrapTextWithAnsi(line, childWidth) : [line]);
229
+ }
230
+ if (preview) {
231
+ const count = Math.max(0, Math.floor(preview.count));
232
+ hidden = Math.max(0, content.length - count);
233
+ content = preview.edge === "tail" ? content.slice(hidden) : content.slice(0, count);
234
+ }
235
+ }
236
+ if (content.length === 0 && hidden === 0) continue;
237
+ // The semantic Input/Command label stays on the call section for producers and tests;
238
+ // the rendered card shows the supplied body directly, with no Input or Command divider.
239
+ const labelText = slot === "call" && (entry.section.label === "Input" || entry.section.label === "Command")
240
+ ? undefined
241
+ : entry.section.label;
242
+ if (labelText) {
243
+ const label = theme.fg("muted", labelText);
244
+ output.push(framed ? bar("├", "┤", label) : body(truncateToWidth(label, childWidth, "…")));
245
+ }
246
+ if (content.length > 0) {
247
+ bounds.push({ component: entry.section.component, x: padding + (framed ? 1 : 0), y: output.length, width: childWidth, height: content.length });
248
+ for (const line of content) output.push(body(line, padded));
249
+ }
250
+ if (hidden > 0) {
251
+ const unit = preview?.unit === "entries" ? "entries" : "lines";
252
+ const hint = theme.fg("dim", `… ${hidden} more ${unit} ${keyHint("app.tools.expand", "to expand")}`);
253
+ for (const line of wrapTextWithAnsi(hint, childWidth)) output.push(body(line));
254
+ }
255
+ }
256
+ }
257
+ if (framed) {
258
+ (hasResult ? layout.resultRows : layout.callRows).push(paint(border(`╰${"─".repeat(Math.max(0, outerWidth - 2))}╯`)));
259
+ }
260
+ layout.resultOffset = layout.callRows.length;
261
+ return layout;
113
262
  }
114
263
 
115
- function isHorizontalRule(line: string): boolean {
116
- const visible = stripTerminalSequences(line).trim();
117
- return visible.length > 0 && HORIZONTAL_RULE.test(visible);
264
+ function semanticHeader(view: ToolView, snapshot: ToolSnapshot, theme: Theme, color: ThemeColor, width: number, spinnerFrameIndex?: number) {
265
+ const mode = getIconMode();
266
+ const status = view.head.status;
267
+ // The shell adapter supplies its native-loader clock; public tools isolate per render-context state.
268
+ const runningSymbol = (frameIndex?: number) => frameIndex === undefined
269
+ ? toolSpinnerFrame(snapshot.context.state, mode) : getToolSpinnerFrame(mode, frameIndex);
270
+ const symbol = mode === "off" ? status
271
+ : status === "running" ? runningSymbol(spinnerFrameIndex)
272
+ : status === "pending" ? mode === "nerd-font" ? "○" : "?"
273
+ : status === "done" ? mode === "nerd-font" ? "✓" : "ok"
274
+ : status === "error" ? mode === "nerd-font" ? "✗" : "!"
275
+ : mode === "nerd-font" ? "⊘" : "-";
276
+ const duration = view.head.durationMs;
277
+ const final = status === "done" || status === "error" || status === "cancelled";
278
+ const state = theme.fg(color, symbol);
279
+ const timing = final && duration !== undefined && Number.isFinite(duration) && duration >= 0
280
+ ? theme.fg("dim", duration < 1000 ? `${Math.round(duration)}ms` : `${(duration / 1000).toFixed(1)}s`) : "";
281
+ const ending = [state, timing].filter(Boolean).join(" · ");
282
+ const endingWidth = visibleWidth(ending);
283
+ const target = view.head.target;
284
+ // Reserve room for the callable identity even when title or metadata competes with it.
285
+ const targetReserve = target ? Math.min(16, Math.floor(width / 3)) : 0;
286
+ const titleBudget = Math.max(0, width - endingWidth - 3 - targetReserve - (targetReserve ? 1 : 0));
287
+ const title = theme.bold(theme.fg("toolTitle", truncateToWidth(formatToolTitle(view.head.title, mode), titleBudget, "…")));
288
+ const metaBudget = Math.max(0, width - visibleWidth(title) - endingWidth - 6 - targetReserve - (targetReserve ? 1 : 0));
289
+ const rawMeta = view.head.meta.filter(Boolean).join(" · ");
290
+ const meta = metaBudget > 0 && rawMeta ? theme.fg("dim", truncateToWidth(rawMeta, metaBudget, "…")) : "";
291
+ const fixed = [title, meta, ending].filter(Boolean).join(" · ");
292
+ const targetBudget = Math.max(0, width - visibleWidth(fixed) - 1);
293
+ const multiline = target.includes("\n") || target.includes("\r");
294
+ const summary = target.split(/\r?\n/u)[0] ?? "";
295
+ const visibleTarget = targetBudget > 0 && target
296
+ ? theme.fg("toolOutput", truncateToWidth(summary + (multiline ? " …" : ""), targetBudget, "…")) : "";
297
+ const first = [title, visibleTarget].filter(Boolean).join(" ");
298
+ const prefix = [first, meta].filter(Boolean).join(" · ");
299
+ const beforeState = prefix ? prefix + " · " : "";
300
+ const rawText = beforeState + ending;
301
+ const text = truncateToWidth(rawText, width, "…");
302
+ return {
303
+ text,
304
+ width: visibleWidth(text),
305
+ // A running glyph is the last visible cell. If truncation removed it, the
306
+ // prepared header is static; otherwise its one-cell budget never changes.
307
+ ...(status === "running" && mode !== "off" && visibleWidth(rawText) <= width
308
+ ? { refreshText: (frameIndex?: number) => beforeState + theme.fg(color, runningSymbol(frameIndex)) }
309
+ : {}),
310
+ };
118
311
  }
119
312
 
120
313
  function normalizePadding(value: number | undefined): number {
@@ -122,11 +315,3 @@ function normalizePadding(value: number | undefined): number {
122
315
  return Math.max(0, Math.floor(value));
123
316
  }
124
317
 
125
- function normalizeTitle(value: string | undefined): string {
126
- if (!value) return "";
127
- return stripTerminalSequences(value).replace(/\s+/gu, " ").trim();
128
- }
129
-
130
- function identity(value: string): string {
131
- return value;
132
- }