pi-pretty-tui 0.2.8 → 0.3.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.
@@ -53,11 +53,24 @@ const compactArgs = (value: string): string => {
53
53
  export class NestedTools {
54
54
  private calls = new Map<string, NestedCall>();
55
55
  private incomplete = new Set<string>();
56
+ private renderCache = new Map<string, {
57
+ width: number; theme: any; palette: string; args: any; result: any;
58
+ expanded: boolean; partial: boolean; label: string; rootDetails: boolean;
59
+ projection: { lines: string[]; actions: Map<number, string> };
60
+ }>();
61
+ private countCache = new Map<string, number>();
62
+ private invalidateRender(rootId?: string) {
63
+ if (rootId) this.renderCache.delete(rootId);
64
+ else this.renderCache.clear();
65
+ this.countCache.clear();
66
+ }
67
+
56
68
  // Local overrides can close one disclosure even after global expansion.
57
69
  private expanded = new Map<string, boolean>();
58
- clearExpansion() { this.expanded.clear(); }
70
+ clearExpansion() { this.expanded.clear(); this.invalidateRender(); }
59
71
  clear() { this.calls.clear(); this.incomplete.clear(); this.clearExpansion(); }
60
72
  toggle(key: string, globallyExpanded = false) {
73
+ this.invalidateRender(this.calls.has(key) ? this.root(key) : key.replace(/:(?:script|output)$/, ""));
61
74
  this.expanded.set(key, !(this.expanded.get(key) ?? globallyExpanded));
62
75
  }
63
76
  private isExpanded(key: string, globallyExpanded: boolean): boolean {
@@ -74,15 +87,21 @@ export class NestedTools {
74
87
  return [...this.calls.values()].filter((call) => call.parentId === parentId);
75
88
  }
76
89
  count(rootId: string): number {
77
- return [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
90
+ const cached = this.countCache.get(rootId);
91
+ if (cached !== undefined) return cached;
92
+ const count = [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
93
+ this.countCache.set(rootId, count);
94
+ return count;
78
95
  }
79
96
  start(event: any) {
80
97
  if (!event.parentToolCallId || this.calls.has(event.toolCallId)) return;
81
98
  const rootId = this.root(event.parentToolCallId);
99
+ this.invalidateRender(rootId);
82
100
  if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); return; }
83
101
  const args = argsText(event.args);
84
102
  const argsTruncated = args.length >= NESTED_LIMITS.args;
85
103
  if (argsTruncated) this.incomplete.add(rootId);
104
+ this.countCache.clear();
86
105
  this.calls.set(event.toolCallId, {
87
106
  id: event.toolCallId, parentId: event.parentToolCallId, name: bounded(String(event.toolName ?? "tool"), 256),
88
107
  args, argsTruncated, status: "running", startedAt: Date.now(),
@@ -91,6 +110,7 @@ export class NestedTools {
91
110
  update(event: any, finished = false) {
92
111
  const call = this.calls.get(event.toolCallId);
93
112
  if (!call) return;
113
+ this.invalidateRender(this.root(call.id));
94
114
  const text = resultText(finished ? event.result : event.partialResult);
95
115
  const rootId = this.root(call.id);
96
116
  const otherSize = [...this.calls.values()].filter((other) => other.id !== call.id && this.root(other.id) === rootId)
@@ -105,6 +125,7 @@ export class NestedTools {
105
125
  }
106
126
  }
107
127
  finish(rootId: string): boolean {
128
+ this.invalidateRender(rootId);
108
129
  let changed = false;
109
130
  for (const call of this.calls.values()) {
110
131
  if (this.root(call.id) === rootId && call.status === "running") {
@@ -124,6 +145,7 @@ export class NestedTools {
124
145
  records = records.calls;
125
146
  }
126
147
  if (!Array.isArray(records)) return;
148
+ this.invalidateRender(rootId);
127
149
  for (const record of records) {
128
150
  if (typeof record?.id !== "string" || typeof record?.name !== "string" || record.id.endsWith("/?")) continue;
129
151
  const existing = this.calls.get(record.id);
@@ -137,6 +159,7 @@ export class NestedTools {
137
159
  const slash = record.id.lastIndexOf("/");
138
160
  const candidate = record.id.slice(0, slash);
139
161
  const parentId = this.calls.has(candidate) ? candidate : rootId;
162
+ this.countCache.clear();
140
163
  this.calls.set(record.id, {
141
164
  id: record.id, parentId, name: bounded(record.name, 256),
142
165
  args: typeof record.args === "string" ? recordedArgs(record.args) : record.argumentsBytes !== undefined ? "[Arguments omitted by Pi]" : argsText(record.arguments),
@@ -172,6 +195,7 @@ export class NestedTools {
172
195
  }
173
196
  restore(data: any) {
174
197
  if (data?.version !== 1 || typeof data.rootId !== "string" || !Array.isArray(data.calls)) return;
198
+ this.invalidateRender(data.rootId);
175
199
  // Snapshot entries originate locally; still validate and bound every field.
176
200
  let budget = NESTED_LIMITS.total;
177
201
  for (const call of data.calls.slice(0, NESTED_LIMITS.calls)) {
@@ -186,13 +210,24 @@ export class NestedTools {
186
210
  }
187
211
  if (data.incomplete) this.incomplete.add(data.rootId);
188
212
  }
189
- render(owner: any, width: number, theme: any): { lines: string[]; actions: Map<number, string> } {
213
+ render(owner: any, width: number, theme: any, options: { rootDetails?: boolean } = {}): { lines: string[]; actions: Map<number, string> } {
190
214
  const rootId = owner.toolCallId;
215
+ // Timer ticks and scrolling do not change tool content. Reuse wrapped
216
+ // detail rows and hit regions until data, layout, expansion or colors change.
217
+ const palette = ["dim", "muted", "text", "success", "error", "warning", "mdLink", "toolOutput"]
218
+ .map((role) => theme.fg(role, "x")).join("|") + theme.bold("x");
219
+ const expanded = Boolean(owner.expanded);
220
+ const partial = Boolean(owner.isPartial);
221
+ const rootDetails = options.rootDetails !== false;
222
+ const label = owner.toolDefinition?.label ?? owner.toolName;
223
+ const cached = this.renderCache.get(rootId);
224
+ if (cached && cached.width === width && cached.theme === theme && cached.palette === palette &&
225
+ cached.args === owner.args && cached.result === owner.result && cached.expanded === expanded &&
226
+ cached.partial === partial && cached.label === label && cached.rootDetails === rootDetails) return cached.projection;
191
227
  const actions = new Map<number, string>();
192
228
  const count = this.count(rootId);
193
229
  const failed = [...this.calls.values()].filter((call) => this.root(call.id) === rootId && call.status === "error").length;
194
230
  const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "dim";
195
- const label = owner.toolDefinition?.label ?? owner.toolName;
196
231
  const lines = [theme.fg(color, "● ") + theme.fg("text", theme.bold(label)) +
197
232
  theme.fg("muted", `(${count} nested ${count === 1 ? "call" : "calls"}${failed ? ` · ${failed} failed` : ""})`)];
198
233
  const detail = (text: string, prefix: string, action?: string) => {
@@ -210,7 +245,13 @@ export class NestedTools {
210
245
  }
211
246
  if (safeText(text).length > NESTED_LIMITS.output) push(theme.fg("dim", prefix + "[Preview truncated]"));
212
247
  };
213
- const items = this.children(rootId);
248
+ const childLists = new Map<string, NestedCall[]>();
249
+ for (const call of this.calls.values()) {
250
+ const siblings = childLists.get(call.parentId) ?? [];
251
+ siblings.push(call);
252
+ childLists.set(call.parentId, siblings);
253
+ }
254
+ const items = childLists.get(rootId) ?? [];
214
255
  const visit = (call: NestedCall, indent: string, last: boolean, depth: number) => {
215
256
  const prefix = indent + (last ? "└─ " : "├─ ");
216
257
  const continuation = indent + (last ? " " : "│ ");
@@ -218,7 +259,7 @@ export class NestedTools {
218
259
  const callColor = call.status === "ok" ? "success" : call.status === "error" ? "error" : call.status === "running" ? "dim" : "muted";
219
260
  actions.set(lines.length, call.id);
220
261
  const duration = call.durationMs === undefined ? "" : ` · ${call.durationMs < 1000 ? `${Math.round(call.durationMs)}ms` : `${(call.durationMs / 1000).toFixed(1)}s`}`;
221
- const descendants = this.children(call.id);
262
+ const descendants = childLists.get(call.id) ?? [];
222
263
  lines.push(theme.fg("dim", prefix) + theme.fg(callColor, icon + " ") + theme.fg("text", theme.bold(call.name)) + theme.fg("muted", `(${compactArgs(call.args)})${duration}`));
223
264
  const expanded = this.isExpanded(call.id, Boolean(owner.expanded));
224
265
  const first = call.output?.split("\n").find((line) => line.trim());
@@ -235,17 +276,22 @@ export class NestedTools {
235
276
  else if (descendants.length) detail("[Deeper calls omitted]", continuation);
236
277
  };
237
278
  const code = typeof owner.args?.code === "string" ? owner.args.code : undefined;
238
- if (code) {
279
+ if (code && options.rootDetails !== false) {
239
280
  const key = `${rootId}:script`;
240
281
  actions.set(lines.length, key); lines.push(theme.fg("dim", " ├─ ") + theme.fg("mdLink", "[Script]"));
241
282
  if (this.isExpanded(key, Boolean(owner.expanded))) detail(code, " │ ", key);
242
283
  }
243
- items.forEach((call) => visit(call, " ", false, 0));
244
- const key = `${rootId}:output`;
245
- actions.set(lines.length, key);
246
- lines.push(theme.fg("dim", " └─ ") + theme.fg("mdLink", code ? "[Script output]" : "[Output]"));
247
- if (this.isExpanded(key, Boolean(owner.expanded))) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
284
+ items.forEach((call, index) => visit(call, " ", options.rootDetails === false && index === items.length - 1, 0));
285
+ if (options.rootDetails !== false) {
286
+ const key = `${rootId}:output`;
287
+ actions.set(lines.length, key);
288
+ lines.push(theme.fg("dim", " └─ ") + theme.fg("mdLink", code ? "[Script output]" : "[Output]"));
289
+ if (this.isExpanded(key, Boolean(owner.expanded))) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
290
+ }
248
291
  if (this.incomplete.has(rootId)) lines.push(theme.fg("warning", " [Nested call record is incomplete or truncated]"));
249
- return { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
292
+ const projection = { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
293
+ this.renderCache.set(rootId, { width, theme, palette, args: owner.args, result: owner.result,
294
+ expanded, partial, label, rootDetails, projection });
295
+ return projection;
250
296
  }
251
297
  }
@@ -0,0 +1,36 @@
1
+ import { highlightCode } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+
4
+ /** Shell highlighting only changes ANSI styling, never the command text. */
5
+ export function highlightShellCommand(command: string, theme: any, state: any): string {
6
+ const palette = ["syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
7
+ "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation"]
8
+ .map((role) => theme.fg(role, "x")).join("|");
9
+ const cached = state.prettyShellHighlight;
10
+ if (cached?.command === command && cached.palette === palette) return cached.text;
11
+ const highlighted = highlightCode(command, "bash").join("\n");
12
+ const text = stripTerminalSequences(highlighted) === command ? highlighted : command;
13
+ state.prettyShellHighlight = { command, palette, text };
14
+ return text;
15
+ }
16
+
17
+ /** Style Pi's native shell Text through its component contract, not instanceof:
18
+ * bundled/global/local Pi runtimes can carry distinct Text constructors. */
19
+ export function highlightNativeShellCall(owner: any, theme: any): void {
20
+ if (owner.toolName !== "bash") return;
21
+ const command = owner.args?.command;
22
+ const component = owner.callRendererComponent;
23
+ if (typeof command !== "string" || !command || typeof component?.text !== "string" ||
24
+ typeof component?.setText !== "function") return;
25
+ const state = owner.rendererState ??= {};
26
+ const previous = state.prettyShellCall;
27
+ const original = previous?.component === component && previous.command === command &&
28
+ previous.styled === component.text ? previous.original : component.text;
29
+ const promptStart = original.indexOf("$ " + command);
30
+ if (promptStart < 0) return;
31
+ const start = promptStart + 2;
32
+ const highlighted = highlightShellCommand(command, theme, state);
33
+ const text = original.slice(0, start) + highlighted + original.slice(start + command.length);
34
+ state.prettyShellCall = { component, command, original, styled: text };
35
+ if (text !== component.text) component.setText(text);
36
+ }
@@ -0,0 +1,38 @@
1
+ import { Markdown, truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ /** Thought content is independent of activity grouping and mode defaults. */
4
+ export class ThoughtPresentation {
5
+ private cache = new WeakMap<object, {
6
+ text: string; theme: any; palette: string; markdownTheme: any; component: Markdown;
7
+ width?: number; lines?: string[];
8
+ }>();
9
+
10
+ header(label: string, width: number, theme: any): string {
11
+ return truncateToWidth(
12
+ theme.fg("mdLink", "● ") + theme.fg("text", theme.bold(label)),
13
+ Math.max(1, width), "…",
14
+ );
15
+ }
16
+
17
+ details(owner: any, text: string, theme: any, contentWidth: number): string[] {
18
+ const palette = ["dim", "muted", "mdHeading", "mdCode", "mdCodeBlock", "mdLink"]
19
+ .map((role) => theme.fg(role, "x")).join("|");
20
+ let detail = this.cache.get(owner);
21
+ if (!detail || detail.text !== text || detail.theme !== theme || detail.palette !== palette || detail.markdownTheme !== owner.markdownTheme) {
22
+ detail = {
23
+ text, theme, palette, markdownTheme: owner.markdownTheme,
24
+ component: new Markdown(text, 0, 0, owner.markdownTheme, {
25
+ color: (line: string) => theme.fg("muted", line), italic: true,
26
+ }),
27
+ };
28
+ this.cache.set(owner, detail);
29
+ }
30
+ if (detail.width === contentWidth && detail.lines) return detail.lines;
31
+ const lines = detail.component.render(Math.max(1, contentWidth));
32
+ detail.width = contentWidth;
33
+ detail.lines = lines.map((line, index) =>
34
+ theme.fg("dim", index === lines.length - 1 ? " └ " : " │ ") + line,
35
+ );
36
+ return detail.lines;
37
+ }
38
+ }
@@ -0,0 +1,286 @@
1
+ import { highlightNativeShellCall } from "./shell-highlight.js";
2
+ import { isNativeImageRow, stripAnsiBackgrounds } from "./ansi-style.js";
3
+ import { cardMargin, frameNativeRows, frameNativeToolRows, nativeFrameWidth } from "./full-frame.js";
4
+ import { Spacer, Text, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { NestedTools } from "./nested-tools.js";
6
+
7
+ // Tool content and hit regions are independent of activity grouping. The host
8
+ // decides whether to decorate this projection with a clean activity tree.
9
+ export const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
10
+ export const isThirdPartyTool = (owner: any): boolean =>
11
+ !SPECIALIZED_TOOL_NAMES.has(owner.toolName) || owner.toolDefinition?.renderShell !== "self";
12
+
13
+ export const toolLabel = (toolName: string, label?: string): string => label || toolName;
14
+
15
+ const conciseThirdPartyArgs = (args: any): string => {
16
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
17
+ const sensitive = /(?:token|secret|password|authorization|credential|api[_-]?key)/iu;
18
+ const preferred = ["path", "query", "url", "offset", "id"];
19
+ const entries = Object.entries(args)
20
+ .filter(([key, value]) => !sensitive.test(key) && ["string", "number", "boolean"].includes(typeof value))
21
+ .sort(([left], [right]) => {
22
+ const leftRank = preferred.indexOf(left);
23
+ const rightRank = preferred.indexOf(right);
24
+ return (leftRank < 0 ? preferred.length : leftRank) -
25
+ (rightRank < 0 ? preferred.length : rightRank);
26
+ })
27
+ .slice(0, 2)
28
+ .map(([key, value]) => {
29
+ const safeValue = String(value)
30
+ .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu, "$1…:…@")
31
+ .replace(/([?&](?:token|secret|password|api[_-]?key)=)[^&\s]+/giu, "$1…");
32
+ const rendered = truncateToWidth(safeValue, 36, "…");
33
+ return `${key}=${rendered}`;
34
+ });
35
+ return entries.join(" · ");
36
+ };
37
+
38
+ const thirdPartyResultSummary = (component: any): string => {
39
+ const detailsError = component.result?.details?.error;
40
+ const firstText = component.result?.content?.find(
41
+ (item: any) => item?.type === "text" && typeof item.text === "string" && item.text.length > 0,
42
+ )?.text;
43
+ const text = String(typeof detailsError === "string" ? detailsError : firstText ?? "");
44
+ const boundedText = text.slice(0, 512);
45
+ const newline = boundedText.indexOf("\n");
46
+ const boundedFirstLine = boundedText.slice(0, newline < 0 ? boundedText.length : newline);
47
+ const firstLine = stripTerminalSequences(boundedFirstLine).trim();
48
+ if (firstLine) return truncateToWidth(firstLine, 72, "…");
49
+ if (component.result) return component.result.isError ? "Failed" : "Done";
50
+ return component.executionStarted ? "Running…" : "Pending";
51
+ };
52
+
53
+ export const renderThirdPartyCompact = (component: any, width: number, theme: any): string[] => {
54
+ const label = toolLabel(component.toolName, component.toolDefinition?.label);
55
+ const args = conciseThirdPartyArgs(component.args);
56
+ const dotColor = component.result?.isError
57
+ ? "error"
58
+ : component.result && !component.isPartial
59
+ ? "success"
60
+ : "dim";
61
+ const title = theme.fg(dotColor, "● ") +
62
+ theme.fg("text", theme.bold(label)) +
63
+ (args ? theme.fg("muted", `(${args})`) : "");
64
+ const result = thirdPartyResultSummary(component);
65
+ const resultColor = component.result?.isError ? "error" : "muted";
66
+ return [
67
+ truncateToWidth(title, Math.max(1, width), "…"),
68
+ theme.fg(resultColor, truncateToWidth(` └ ${result}`, Math.max(1, width), "…")),
69
+ ];
70
+ };
71
+
72
+ export type ToolProjection = {
73
+ lines: string[];
74
+ actions: Map<number, string>;
75
+ toggleRows: Set<number>;
76
+ // Coordinates of the original renderer inside the projection. sourceY
77
+ // accounts for Pi's spacer, removed only by compact/clean presentation.
78
+ native?: { x: number; y: number; width: number; height: number; sourceY: number };
79
+ sourceRows?: Map<number, number>;
80
+ };
81
+
82
+ // Native shell/codemode timers are distinct final renderer components. Locate
83
+ // that component, never a matching line in user output, before moving it.
84
+ const nativeTimerRegion = (owner: any, resultStart: number, contentWidth: number, width: number, rows: string[]) => {
85
+ if (!["bash", "powershell", "codemode"].includes(owner.toolName)) return undefined;
86
+ const state = owner.rendererState?.nativeFull?.state ?? owner.rendererState;
87
+ if (typeof state?.startedAt !== "number" && typeof state?.endedAt !== "number") return undefined;
88
+ const children: any[] | undefined = owner.resultRendererComponent?.children;
89
+ if (!children?.length) return undefined;
90
+ const tail = children[children.length - 1];
91
+ if (!(tail instanceof Text) || typeof (tail as any).text !== "string") return undefined;
92
+ const label = stripTerminalSequences((tail as any).text).trim();
93
+ if (!/^(?:Took|Elapsed) \d+(?:\.\d+)?[smh](?: \d+(?:\.\d+)?[smh])*$/.test(label) || visibleWidth(label) + 6 > width) return undefined;
94
+ let first = children.length - 1;
95
+ if (children[first - 1] instanceof Spacer) first--;
96
+ const height = (items: any[]) => items.reduce((sum, item) => sum + item.render(contentWidth).length, 0);
97
+ const start = resultStart + height(children.slice(0, first));
98
+ const end = resultStart + height(children);
99
+ if (rows.slice(start, end).map(stripTerminalSequences).join("\n").trim() !== label) return undefined;
100
+ return { label, start, end };
101
+ };
102
+
103
+ function renderToolContent(
104
+ owner: any,
105
+ width: number,
106
+ theme: any,
107
+ options: {
108
+ mode: "full" | "compact";
109
+ nested: NestedTools;
110
+ nestedExpanded: boolean;
111
+ panels?: boolean;
112
+ cardTitle?: string;
113
+ renderNative: (width: number) => string[];
114
+ },
115
+ ): ToolProjection | undefined {
116
+ const actions = new Map<number, string>();
117
+ const toggleRows = new Set<number>();
118
+ if (options.mode === "full") {
119
+ const innerWidth = nativeFrameWidth(width);
120
+ highlightNativeShellCall(owner, theme);
121
+ const nativeLines = options.renderNative(innerWidth);
122
+ if (!nativeLines.length) return { lines: [], actions, toggleRows };
123
+ const self = owner.toolDefinition?.renderShell === "self";
124
+ const shell = self ? owner.selfRenderContainer : owner.contentBox;
125
+ const call = shell?.children?.[0];
126
+ const callWidth = self ? innerWidth : Math.max(1, innerWidth - (owner.outputPad ?? 1) * 2);
127
+ const callStart = self ? 1 : 2;
128
+ const callHeight = call?.render(callWidth).length ?? 0;
129
+ const resultBoundaries: number[] = [];
130
+ if (!owner.toolDefinition && typeof owner.formatToolExecution === "function") {
131
+ // Unknown historical tools use Pi's generic Text component. Measure the
132
+ // native prefix rather than reconstructing or parsing the displayed args.
133
+ const output = owner.getTextOutput();
134
+ const formatted = owner.formatToolExecution();
135
+ if (output && formatted.endsWith("\n" + output)) {
136
+ const prefix = formatted.slice(0, -output.length - 1);
137
+ const resultY = new Text(prefix, owner.outputPad ?? 1, 1).render(innerWidth).length;
138
+ resultBoundaries.push(resultY);
139
+ } else if (owner.imageComponents?.length && owner.contentText) {
140
+ // With image display enabled, the generic text output may be empty;
141
+ // the native image spacers and payload follow the generic text region.
142
+ resultBoundaries.push(1 + owner.contentText.render(innerWidth).length);
143
+ }
144
+ }
145
+ // Native edit puts its authoritative diff in the call component so a
146
+ // pre-execution preview can update in place. Split the displayed rows at
147
+ // that region without rebuilding or duplicating its content.
148
+ const preview = owner.callRendererComponent;
149
+ if ((typeof preview?.preview?.diff === "string" || typeof preview?.preview?.error === "string") && preview.children?.length >= 3) {
150
+ const previewWidth = Math.max(1, callWidth - (preview.paddingX ?? 0) * 2);
151
+ const diffStart = callStart + (preview.paddingY ?? 0) +
152
+ preview.children.slice(0, 2).reduce((sum: number, child: any) => sum + child.render(previewWidth).length, 0);
153
+ resultBoundaries.push(diffStart);
154
+ }
155
+ if (owner.result && shell?.children?.length > 1) {
156
+ const resultStart = callStart + callHeight;
157
+ if (resultStart < nativeLines.length) resultBoundaries.push(resultStart);
158
+ }
159
+ // Only split a real result. Native edit previews before execution stay in
160
+ // the call card; empty/collapsed results must not create an empty card.
161
+ const timer = nativeTimerRegion(owner, callStart + callHeight, callWidth, width - cardMargin(width) * 2, nativeLines);
162
+ const sourceIndexes = nativeLines.map((_, y) => y).filter((y) => !timer || y < timer.start || y >= timer.end);
163
+ const displayed = sourceIndexes.map((y) => nativeLines[y]);
164
+ const candidates = owner.result ? resultBoundaries.sort((a, b) => a - b)
165
+ .map((start) => sourceIndexes.findIndex((y) => y >= start)) : [];
166
+ const resultStart = candidates.find((start) => start > 0 && start < displayed.length &&
167
+ displayed.slice(start).some((line) => stripTerminalSequences(line).trim() ||
168
+ line.includes("\x1b_G") || line.includes("\x1b]1337;File=")));
169
+ const frame = frameNativeToolRows(
170
+ options.cardTitle ?? toolLabel(owner.toolName, owner.toolDefinition?.label), displayed, width, theme,
171
+ resultStart, owner.result?.isError ? "error" : owner.isPartial ? "muted" : "success", owner, timer?.label,
172
+ );
173
+ const sourceRows = new Map([...frame.sourceRows].map(([y, original]) => [y, sourceIndexes[original]]));
174
+ if (timer) sourceRows.set(frame.lines.length - 1, timer.start);
175
+ return {
176
+ lines: frame.lines, actions, toggleRows, sourceRows,
177
+ native: { x: frame.inset, y: 0, width: innerWidth, height: nativeLines.length, sourceY: 0 },
178
+ };
179
+ }
180
+ if (options.nested.count(owner.toolCallId) > 0) {
181
+ return { ...options.nested.render({ ...owner, expanded: options.nestedExpanded }, width, theme), toggleRows };
182
+ }
183
+ if (!isThirdPartyTool(owner)) return undefined;
184
+ const summary = renderThirdPartyCompact(owner, width, theme);
185
+ toggleRows.add(0);
186
+ if (!owner.expanded) {
187
+ toggleRows.add(1);
188
+ return { lines: summary, actions, toggleRows };
189
+ }
190
+ const prefix = " │ ";
191
+ const nativeWidth = Math.max(1, width - visibleWidth(prefix));
192
+ const nativeLines = options.renderNative(nativeWidth);
193
+ const sourceY = nativeLines[0] === "" ? 1 : 0;
194
+ const content = nativeLines.slice(sourceY);
195
+ return {
196
+ lines: [summary[0], ...content.map((line, i) => isNativeImageRow(line) ? line :
197
+ truncateToWidth(
198
+ theme.fg("dim", i === content.length - 1 ? " └ " : prefix) + stripAnsiBackgrounds(line),
199
+ Math.max(1, width), "",
200
+ ))],
201
+ actions, toggleRows,
202
+ native: { x: visibleWidth(prefix), y: 1, width: nativeWidth, height: content.length, sourceY },
203
+ };
204
+ }
205
+
206
+ /** Compact shares panel geometry with full, retaining its concise projection. */
207
+ export function renderToolProjection(
208
+ owner: any, width: number, theme: any,
209
+ options: Parameters<typeof renderToolContent>[3],
210
+ ): ToolProjection | undefined {
211
+ if (options.mode === "full" || options.panels === false) return renderToolContent(owner, width, theme, options);
212
+ if (owner.expanded && !options.nested.count(owner.toolCallId)) {
213
+ const projection = renderToolContent(owner, width, theme, { ...options, mode: "full", cardTitle: `${toolLabel(owner.toolName, owner.toolDefinition?.label)} ▾` });
214
+ if (projection) {
215
+ projection.toggleRows.add(0);
216
+ if (width < 8) projection.lines = projection.lines.map((line) => isNativeImageRow(line) ? line : truncateToWidth(line, Math.max(1, width), ""));
217
+ }
218
+ return projection;
219
+ }
220
+ const innerWidth = nativeFrameWidth(width);
221
+ let content = renderToolContent(owner, innerWidth, theme, options);
222
+ if (!content) {
223
+ const raw = options.renderNative(innerWidth);
224
+ if (!raw.length) return { lines: [], actions: new Map(), toggleRows: new Set() };
225
+ const sourceY = raw[0] === "" ? 1 : 0;
226
+ content = { lines: raw.slice(sourceY), actions: new Map(), toggleRows: new Set(),
227
+ native: { x: 0, y: 0, width: innerWidth, height: raw.length - sourceY, sourceY } };
228
+ }
229
+ const sections = new Map<number, { label: string; color: string }>();
230
+ const native = content.native;
231
+ if (native && owner.result && owner.expanded) {
232
+ const self = owner.toolDefinition?.renderShell === "self";
233
+ const call = (self ? owner.selfRenderContainer : owner.contentBox)?.children?.[0];
234
+ if (call) {
235
+ const callWidth = self ? native.width : Math.max(1, native.width - (owner.outputPad ?? 1) * 2);
236
+ const start = native.y + (self ? 1 : 2) + call.render(callWidth).length - native.sourceY;
237
+ if (start < content.lines.length) sections.set(start, { label: "Output", color: owner.result.isError ? "error" : "success" });
238
+ }
239
+ }
240
+ const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "accent";
241
+ const duration = !owner.isPartial && Number.isFinite(owner.result?.durationMs) ? owner.result.durationMs : undefined;
242
+ const seconds = duration === undefined ? undefined : Math.max(0, duration) / 1000;
243
+ const footer = seconds === undefined ? "" : seconds < 60 ? `Took ${seconds.toFixed(1)}s`
244
+ : seconds < 3600 ? `Took ${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`
245
+ : `Took ${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m ${Math.floor(seconds % 60)}s`;
246
+ const body = content.lines.slice();
247
+ if (!owner.expanded && body.length) {
248
+ const label = toolLabel(owner.toolName, owner.toolDefinition?.label);
249
+ const plain = stripTerminalSequences(body[0]);
250
+ const prefix = `● ${label}(`;
251
+ if (plain.startsWith(prefix) && plain.endsWith(")")) {
252
+ const start = visibleWidth(prefix);
253
+ body[0] = (owner.toolName === "bash" ? theme.fg("toolTitle", "$ ") : "") +
254
+ sliceByColumn(body[0], start, Math.max(0, visibleWidth(plain) - start - 1));
255
+ } else if (plain === `● ${label}`) {
256
+ body[0] = theme.fg("muted", "No arguments");
257
+ }
258
+ for (let row = 1; row < body.length; row++) {
259
+ const result = /^(\s*└\s*)/.exec(stripTerminalSequences(body[row]));
260
+ if (result) {
261
+ const prefixWidth = visibleWidth(result[1]);
262
+ const status = owner.result?.isError ? "✕ " : owner.result && !owner.isPartial ? "✓ " : "· ";
263
+ body[row] = theme.fg(color === "accent" ? "dim" : color, status) +
264
+ sliceByColumn(body[row], prefixWidth, Math.max(0, visibleWidth(body[row]) - prefixWidth));
265
+ break;
266
+ }
267
+ }
268
+ }
269
+ const collapsible = !options.nested.count(owner.toolCallId);
270
+ const title = toolLabel(owner.toolName, owner.toolDefinition?.label) + (collapsible ? ` ${owner.expanded ? "▾" : "▸"}` : "");
271
+ const frame = frameNativeRows(title, body, width, theme,
272
+ sections, owner, color, footer, color, color === "error" ? "toolErrorBg" : "toolPendingBg");
273
+ const actions = new Map<number, string>();
274
+ const toggleRows = new Set<number>();
275
+ const sourceRows = new Map<number, number>();
276
+ for (const [y, source] of frame.sourceRows) {
277
+ const action = content.actions.get(source);
278
+ if (action) actions.set(y, action);
279
+ if (content.toggleRows.has(source)) toggleRows.add(y);
280
+ if (native && source >= native.y && source < native.y + native.height) sourceRows.set(y, source - native.y + native.sourceY);
281
+ }
282
+ if (!options.nested.count(owner.toolCallId)) toggleRows.add(0);
283
+ const lines = width < 8 ? frame.lines.map((line) => isNativeImageRow(line) ? line : truncateToWidth(line, Math.max(1, width), "")) : frame.lines;
284
+ return { lines, actions, toggleRows, sourceRows: native ? sourceRows : undefined,
285
+ native: native ? { ...native, x: frame.inset + native.x, y: 0 } : undefined };
286
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "A beautiful, polished TUI for the Pi coding agent",
5
5
  "license": "MIT",
6
6
  "author": "Kainan Yang <ykn0309@whu.edu.cn>",
@@ -39,8 +39,8 @@
39
39
  "@earendil-works/pi-tui": "*"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-coding-agent": "1.1.0",
43
- "@earendil-works/pi-tui": "1.1.0",
42
+ "@earendil-works/pi-coding-agent": "1.3.0",
43
+ "@earendil-works/pi-tui": "1.3.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "jiti": "^2.7.0",
46
46
  "typescript": "^5.9.0"