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.
- package/README.md +37 -13
- package/README.zh-CN.md +37 -13
- package/extensions/ansi-style.ts +29 -0
- package/extensions/card-surface.ts +38 -0
- package/extensions/full-frame.ts +134 -0
- package/extensions/index.ts +414 -238
- package/extensions/nested-tools.ts +59 -13
- package/extensions/shell-highlight.ts +36 -0
- package/extensions/thought-presentation.ts +38 -0
- package/extensions/tool-presentation.ts +286 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@earendil-works/pi-tui": "1.
|
|
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"
|