pi-pretty-tui 0.2.4 → 0.2.6

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,251 @@
1
+ import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ export const NESTED_SNAPSHOT_ENTRY = "pretty-tui-nested-results";
4
+ export const NESTED_LIMITS = { calls: 256, args: 4096, output: 8192, total: 131072 };
5
+ type Status = "running" | "ok" | "error" | "cancelled";
6
+ export type NestedCall = {
7
+ id: string; parentId: string; name: string; args: string; status: Status;
8
+ startedAt?: number; durationMs?: number; output?: string; truncated?: boolean; argsTruncated?: boolean;
9
+ };
10
+ export type NestedSnapshot = { version: 1; rootId: string; calls: NestedCall[]; incomplete: boolean };
11
+ const safeText = (value: string) => stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/gu, "");
12
+ const bounded = (text: string, limit: number) => safeText(text).slice(0, limit);
13
+ const argsText = (args: any): string => {
14
+ try {
15
+ return bounded(JSON.stringify(args ?? {}, (key, value) =>
16
+ /token|secret|password|credential|authorization|api[_-]?key/iu.test(key) ? "[redacted]" : value,
17
+ ), NESTED_LIMITS.args);
18
+ } catch { return "[arguments unavailable]"; }
19
+ };
20
+ const recordedArgs = (value: string): string => {
21
+ try { return argsText(JSON.parse(value)); } catch {
22
+ return bounded(value.replace(/("[^"\n]*(?:token|secret|password|credential|authorization|api[_-]?key)[^"\n]*"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\n]*)/giu, '$1"[redacted]"'), NESTED_LIMITS.args);
23
+ }
24
+ };
25
+ const resultText = (result: any): string => {
26
+ const text = (Array.isArray(result?.content) ? result.content : [])
27
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
28
+ .map((block: any) => block.text).join("\n");
29
+ // Edit renderers carry their useful diff separately from the success message.
30
+ const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
31
+ let structured = "";
32
+ if (!text && result?.structuredContent !== undefined) {
33
+ try { structured = JSON.stringify(result.structuredContent, null, 2); } catch { /* non-JSON result */ }
34
+ }
35
+ const media = (result?.content ?? []).some((block: any) => block?.type === "image")
36
+ ? "[Image result omitted from nested text preview]" : "";
37
+ const error = !text && typeof result?.details?.error === "string" ? result.details.error : "";
38
+ return safeText([text, diff, structured, media, error].filter(Boolean).join("\n"));
39
+ };
40
+
41
+ const compactArgs = (value: string): string => {
42
+ try {
43
+ const args = JSON.parse(value);
44
+ if (!args || typeof args !== "object" || Array.isArray(args)) return value;
45
+ return Object.entries(args)
46
+ .filter(([key]) => !/token|secret|password|credential|authorization|api[_-]?key/iu.test(key))
47
+ .slice(0, 3).map(([key, entry]) => `${key}=${typeof entry === "string" ? entry : JSON.stringify(entry)}`)
48
+ .join(" · ").replace(/\s+/gu, " ");
49
+ } catch { return value.replace(/\s+/gu, " "); }
50
+ };
51
+
52
+ /** UI-only nested execution state. Never executes tools or alters model results. */
53
+ export class NestedTools {
54
+ private calls = new Map<string, NestedCall>();
55
+ private incomplete = new Set<string>();
56
+ // Local overrides can close one disclosure even after global expansion.
57
+ private expanded = new Map<string, boolean>();
58
+ clearExpansion() { this.expanded.clear(); }
59
+ clear() { this.calls.clear(); this.incomplete.clear(); this.clearExpansion(); }
60
+ toggle(key: string, globallyExpanded = false) {
61
+ this.expanded.set(key, !(this.expanded.get(key) ?? globallyExpanded));
62
+ }
63
+ private isExpanded(key: string, globallyExpanded: boolean): boolean {
64
+ return this.expanded.get(key) ?? globallyExpanded;
65
+ }
66
+ root(id: string): string {
67
+ const seen = new Set<string>();
68
+ while (this.calls.has(id) && !seen.has(id)) {
69
+ seen.add(id); id = this.calls.get(id)!.parentId;
70
+ }
71
+ return id;
72
+ }
73
+ children(parentId: string): NestedCall[] {
74
+ return [...this.calls.values()].filter((call) => call.parentId === parentId);
75
+ }
76
+ count(rootId: string): number {
77
+ return [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
78
+ }
79
+ start(event: any) {
80
+ if (!event.parentToolCallId || this.calls.has(event.toolCallId)) return;
81
+ const rootId = this.root(event.parentToolCallId);
82
+ if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); return; }
83
+ const args = argsText(event.args);
84
+ const argsTruncated = args.length >= NESTED_LIMITS.args;
85
+ if (argsTruncated) this.incomplete.add(rootId);
86
+ this.calls.set(event.toolCallId, {
87
+ id: event.toolCallId, parentId: event.parentToolCallId, name: bounded(String(event.toolName ?? "tool"), 256),
88
+ args, argsTruncated, status: "running", startedAt: Date.now(),
89
+ });
90
+ }
91
+ update(event: any, finished = false) {
92
+ const call = this.calls.get(event.toolCallId);
93
+ if (!call) return;
94
+ const text = resultText(finished ? event.result : event.partialResult);
95
+ const rootId = this.root(call.id);
96
+ const otherSize = [...this.calls.values()].filter((other) => other.id !== call.id && this.root(other.id) === rootId)
97
+ .reduce((size, other) => size + other.args.length + (other.output?.length ?? 0), 0);
98
+ const limit = Math.max(0, Math.min(NESTED_LIMITS.output, NESTED_LIMITS.total - otherSize - call.args.length));
99
+ call.output = text.slice(0, limit);
100
+ call.truncated = text.length > limit;
101
+ if (call.truncated) this.incomplete.add(rootId);
102
+ if (finished) {
103
+ call.status = event.isError ? "error" : "ok";
104
+ call.durationMs = Math.max(0, Date.now() - (call.startedAt ?? Date.now()));
105
+ }
106
+ }
107
+ finish(rootId: string): boolean {
108
+ let changed = false;
109
+ for (const call of this.calls.values()) {
110
+ if (this.root(call.id) === rootId && call.status === "running") {
111
+ call.status = "cancelled"; changed = true;
112
+ }
113
+ }
114
+ return changed;
115
+ }
116
+ finishAll(): string[] {
117
+ const roots = new Set([...this.calls.values()].map((call) => this.root(call.id)));
118
+ return [...roots].filter((rootId) => this.finish(rootId));
119
+ }
120
+ /** Persisted host metadata is a fallback, not a substitute for actual results. */
121
+ absorb(rootId: string, records: any, complete = true) {
122
+ if (records && !Array.isArray(records) && Array.isArray(records.calls)) {
123
+ complete &&= records.complete !== false;
124
+ records = records.calls;
125
+ }
126
+ if (!Array.isArray(records)) return;
127
+ for (const record of records) {
128
+ if (typeof record?.id !== "string" || typeof record?.name !== "string" || record.id.endsWith("/?")) continue;
129
+ const existing = this.calls.get(record.id);
130
+ if (existing) {
131
+ if (record.status === "cancelled" || record.status === "unfinished") existing.status = "cancelled";
132
+ else if (record.status === "error" || record.status === "ok") existing.status = record.status;
133
+ if (typeof record.durationMs === "number") existing.durationMs = record.durationMs;
134
+ continue;
135
+ }
136
+ if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); break; }
137
+ const slash = record.id.lastIndexOf("/");
138
+ const candidate = record.id.slice(0, slash);
139
+ const parentId = this.calls.has(candidate) ? candidate : rootId;
140
+ this.calls.set(record.id, {
141
+ id: record.id, parentId, name: bounded(record.name, 256),
142
+ args: typeof record.args === "string" ? recordedArgs(record.args) : record.argumentsBytes !== undefined ? "[Arguments omitted by Pi]" : argsText(record.arguments),
143
+ argsTruncated: record.argumentsBytes !== undefined,
144
+ status: record.status === "error" ? "error" : record.status === "cancelled" || record.status === "unfinished" ? "cancelled" : record.status === "running" ? "running" : "ok",
145
+ durationMs: typeof record.durationMs === "number" ? record.durationMs : undefined,
146
+ output: typeof record.error === "string" ? bounded(record.error, NESTED_LIMITS.output) : undefined,
147
+ });
148
+ }
149
+ if (!complete) this.incomplete.add(rootId);
150
+ }
151
+ snapshot(rootId: string): NestedSnapshot | undefined {
152
+ const calls: NestedCall[] = [];
153
+ let budget = NESTED_LIMITS.total;
154
+ let incomplete = this.incomplete.has(rootId);
155
+ for (const call of this.calls.values()) {
156
+ if (this.root(call.id) !== rootId) continue;
157
+ const { startedAt: _startedAt, ...copy } = call;
158
+ copy.args = copy.args.slice(0, Math.max(0, budget));
159
+ budget -= copy.args.length;
160
+ if (copy.output !== undefined) {
161
+ copy.output = copy.output.slice(0, Math.max(0, budget));
162
+ budget -= copy.output.length;
163
+ copy.truncated ||= copy.output.length < (call.output?.length ?? 0);
164
+ }
165
+ copy.argsTruncated ||= copy.args.length < call.args.length;
166
+ incomplete ||= Boolean(copy.argsTruncated || copy.truncated);
167
+ if (copy.status === "running") copy.status = "cancelled";
168
+ calls.push(copy);
169
+ }
170
+ if (!calls.length) return undefined;
171
+ return { version: 1, rootId, calls, incomplete };
172
+ }
173
+ restore(data: any) {
174
+ if (data?.version !== 1 || typeof data.rootId !== "string" || !Array.isArray(data.calls)) return;
175
+ // Snapshot entries originate locally; still validate and bound every field.
176
+ let budget = NESTED_LIMITS.total;
177
+ for (const call of data.calls.slice(0, NESTED_LIMITS.calls)) {
178
+ if (typeof call?.id !== "string" || typeof call.parentId !== "string" || typeof call.name !== "string") continue;
179
+ const args = bounded(String(call.args ?? ""), Math.min(NESTED_LIMITS.args, budget));
180
+ budget -= args.length;
181
+ const output = typeof call.output === "string" ? bounded(call.output, Math.min(NESTED_LIMITS.output, budget)) : undefined;
182
+ budget -= output?.length ?? 0;
183
+ this.calls.set(call.id, { id: call.id, parentId: call.parentId, name: bounded(call.name, 256), args, output,
184
+ status: call.status === "error" ? "error" : call.status === "cancelled" || call.status === "running" ? "cancelled" : "ok",
185
+ durationMs: typeof call.durationMs === "number" ? call.durationMs : undefined, truncated: Boolean(call.truncated), argsTruncated: Boolean(call.argsTruncated) });
186
+ }
187
+ if (data.incomplete) this.incomplete.add(data.rootId);
188
+ }
189
+ render(owner: any, width: number, theme: any): { lines: string[]; actions: Map<number, string> } {
190
+ const rootId = owner.toolCallId;
191
+ const actions = new Map<number, string>();
192
+ const count = this.count(rootId);
193
+ const failed = [...this.calls.values()].filter((call) => this.root(call.id) === rootId && call.status === "error").length;
194
+ const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "dim";
195
+ const label = owner.toolDefinition?.label ?? owner.toolName;
196
+ const lines = [theme.fg(color, "● ") + theme.fg("text", theme.bold(label)) +
197
+ theme.fg("muted", `(${count} nested ${count === 1 ? "call" : "calls"}${failed ? ` · ${failed} failed` : ""})`)];
198
+ const detail = (text: string, prefix: string, action?: string) => {
199
+ const push = (line: string) => {
200
+ if (action) actions.set(lines.length, action);
201
+ lines.push(line);
202
+ };
203
+ const available = Math.max(1, width - visibleWidth(prefix));
204
+ let rows = 0;
205
+ for (const line of bounded(text, NESTED_LIMITS.output).split("\n")) {
206
+ for (const wrapped of wrapTextWithAnsi(line || " ", available)) {
207
+ if (++rows > 200) { push(theme.fg("dim", prefix + "[Preview truncated]")); return; }
208
+ push(theme.fg("dim", prefix) + theme.fg("toolOutput", wrapped));
209
+ }
210
+ }
211
+ if (safeText(text).length > NESTED_LIMITS.output) push(theme.fg("dim", prefix + "[Preview truncated]"));
212
+ };
213
+ const items = this.children(rootId);
214
+ const visit = (call: NestedCall, indent: string, last: boolean, depth: number) => {
215
+ const prefix = indent + (last ? "└─ " : "├─ ");
216
+ const continuation = indent + (last ? " " : "│ ");
217
+ const icon = call.status === "cancelled" ? "⊘" : "●";
218
+ const callColor = call.status === "ok" ? "success" : call.status === "error" ? "error" : call.status === "running" ? "dim" : "muted";
219
+ actions.set(lines.length, call.id);
220
+ 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);
222
+ 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
+ const expanded = this.isExpanded(call.id, Boolean(owner.expanded));
224
+ const first = call.output?.split("\n").find((line) => line.trim());
225
+ const summary = first ?? (call.status === "running" ? "Running…" : call.truncated ? "Output omitted by UI size limit" : call.output === undefined ? "Only call metadata retained" : "No text output");
226
+ actions.set(lines.length, call.id);
227
+ lines.push(theme.fg("dim", continuation + (descendants.length ? "├ " : "└ ")) + theme.fg(call.status === "error" ? "error" : "muted", summary));
228
+ if (expanded) {
229
+ detail(`Arguments: ${call.args}`, continuation + " ", call.id);
230
+ if (call.output) detail(call.output, continuation + " ", call.id);
231
+ if (call.argsTruncated) detail("[Arguments truncated in UI snapshot]", continuation + " ", call.id);
232
+ if (call.truncated) detail("[Output truncated in UI snapshot]", continuation + " ", call.id);
233
+ }
234
+ if (depth < 7) descendants.forEach((child, index, children) => visit(child, continuation, index === children.length - 1, depth + 1));
235
+ else if (descendants.length) detail("[Deeper calls omitted]", continuation);
236
+ };
237
+ const code = typeof owner.args?.code === "string" ? owner.args.code : undefined;
238
+ if (code) {
239
+ const key = `${rootId}:script`;
240
+ actions.set(lines.length, key); lines.push(theme.fg("dim", " ├─ ") + theme.fg("mdLink", "[Script]"));
241
+ if (this.isExpanded(key, Boolean(owner.expanded))) detail(code, " │ ", key);
242
+ }
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);
248
+ 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 };
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": "0.85.1",
43
- "@earendil-works/pi-tui": "0.85.1",
42
+ "@earendil-works/pi-coding-agent": "1.0.1",
43
+ "@earendil-works/pi-tui": "1.0.1",
44
44
  "@types/node": "^24.0.0",
45
45
  "jiti": "^2.7.0",
46
46
  "typescript": "^5.9.0"