pi-open-tui 0.3.14 → 0.4.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,348 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, relative, sep } from "node:path";
3
+ import type { ExtensionAPI, Theme, ThemeColor, ToolRenderers } from "@earendil-works/pi-coding-agent";
4
+ import { Box, type Component, Spacer, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import type { ToolViewMode } from "./config.ts";
6
+ import type { IconGlyphs } from "./icons.ts";
7
+
8
+ type RenderCall = NonNullable<ToolRenderers["renderCall"]>;
9
+ type RenderResult = NonNullable<ToolRenderers["renderResult"]>;
10
+ type RenderContext = Parameters<RenderCall>[2];
11
+ type ToolResult = Parameters<RenderResult>[0];
12
+ type Args = Record<string, unknown>;
13
+
14
+ const TOOL_LABELS: Record<string, string> = {
15
+ read: "Read",
16
+ bash: "Bash",
17
+ powershell: "PowerShell",
18
+ edit: "Edit",
19
+ write: "Write",
20
+ grep: "Grep",
21
+ find: "Find",
22
+ ls: "LS",
23
+ };
24
+ const LIVE_TAIL_LINES = 3;
25
+
26
+ /** Per-row slots: Pi's native renderers keep their own state and components here. */
27
+ interface RowState {
28
+ nativeState: Record<string, unknown>;
29
+ nativeCall?: Component;
30
+ nativeResult?: Component;
31
+ callCard?: Box;
32
+ callCardBottom?: Spacer;
33
+ resultCard?: Box;
34
+ startedAt?: number;
35
+ }
36
+
37
+ const str = (value: unknown): string => (typeof value === "string" ? value : "");
38
+
39
+ function plural(count: number, one: string, many = `${one}s`): string {
40
+ return `${count} ${count === 1 ? one : many}`;
41
+ }
42
+
43
+ /**
44
+ * Streaming shell output arrives raw: drop escape sequences and control characters as Pi does,
45
+ * and keep only the text after a lone `\r` so progress bars show their latest frame.
46
+ */
47
+ function cleanOutput(text: string): string {
48
+ return stripTerminalSequences(text)
49
+ .replace(/\r\n/g, "\n")
50
+ .split("\n")
51
+ .map((line) => {
52
+ const frames = line.replace(/\r+$/, "");
53
+ return frames.slice(frames.lastIndexOf("\r") + 1);
54
+ })
55
+ .join("\n")
56
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "");
57
+ }
58
+
59
+ export function textOutput(result: ToolResult): string {
60
+ return cleanOutput(result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"));
61
+ }
62
+
63
+ /** Drops Pi's trailing `[...]` notice (truncation, limits, continuation hints) and the blank line before it. */
64
+ export function stripNotice(text: string): string {
65
+ return text.replace(/\n\n\[[^\n]*\]\s*$/, "").replace(/\n+$/, "");
66
+ }
67
+
68
+ function countLines(text: string): number {
69
+ return text ? text.split("\n").length : 0;
70
+ }
71
+
72
+ export function diffStats(diff: string): { added: number; removed: number } {
73
+ let added = 0;
74
+ let removed = 0;
75
+ for (const line of diff.split("\n")) {
76
+ if (line.startsWith("+")) added++;
77
+ else if (line.startsWith("-")) removed++;
78
+ }
79
+ return { added, removed };
80
+ }
81
+
82
+ /** First command line, with a `… (+N lines)` hint for multi-line commands. */
83
+ export function firstLine(text: string): string {
84
+ const lines = text.replace(/\r\n?/g, "\n").trim().split("\n");
85
+ const head = lines[0] ?? "";
86
+ return lines.length > 1 ? `${head} … (+${lines.length - 1} lines)` : head;
87
+ }
88
+
89
+ export function tailLines(text: string, count: number): string[] {
90
+ const lines = cleanOutput(text).split("\n").filter((line) => line.trim() !== "");
91
+ return lines.slice(-count);
92
+ }
93
+
94
+ /** `rel` when `path` is `base` or inside it; undefined for siblings such as `/home/sam-old`. */
95
+ function inside(base: string, path: string): string | undefined {
96
+ const rel = relative(base, path);
97
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel.split(sep).join("/") : undefined;
98
+ }
99
+
100
+ export function displayPath(path: string, cwd: string, home = homedir()): string {
101
+ if (!path || !isAbsolute(path)) return path;
102
+ const inCwd = inside(cwd, path);
103
+ if (inCwd) return inCwd;
104
+ const inHome = inside(home, path);
105
+ return inHome === undefined ? path : inHome ? `~/${inHome}` : "~";
106
+ }
107
+
108
+ function lineRange(offset: unknown, limit: unknown): string {
109
+ if (typeof offset !== "number" && typeof limit !== "number") return "";
110
+ const start = typeof offset === "number" ? offset : 1;
111
+ return typeof limit === "number" ? `:${start}-${start + limit - 1}` : `:${start}`;
112
+ }
113
+
114
+ /** The single key argument shown inside `Tool(...)`. */
115
+ export function callDetail(toolName: string, args: Args, cwd: string): string {
116
+ const path = displayPath(str(args.file_path ?? args.path), cwd);
117
+ switch (toolName) {
118
+ case "read":
119
+ return path + lineRange(args.offset, args.limit);
120
+ case "edit":
121
+ case "write":
122
+ return path;
123
+ case "bash":
124
+ case "powershell":
125
+ return firstLine(str(args.command));
126
+ case "grep": {
127
+ const pattern = str(args.pattern);
128
+ return pattern ? `/${pattern}/${path ? ` in ${path}` : ""}` : path;
129
+ }
130
+ case "find": {
131
+ const pattern = str(args.pattern);
132
+ return pattern && path ? `${pattern} in ${path}` : pattern || path;
133
+ }
134
+ case "ls":
135
+ return path || ".";
136
+ default:
137
+ return "";
138
+ }
139
+ }
140
+
141
+ function details(result: ToolResult): Record<string, unknown> {
142
+ const value: unknown = result.details;
143
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
144
+ }
145
+
146
+ function truncation(result: ToolResult): { truncated: boolean; totalLines?: number } {
147
+ const value = details(result).truncation;
148
+ if (typeof value !== "object" || value === null) return { truncated: false };
149
+ const { truncated, totalLines } = value as { truncated?: unknown; totalLines?: unknown };
150
+ return { truncated: truncated === true, totalLines: typeof totalLines === "number" ? totalLines : undefined };
151
+ }
152
+
153
+ /** Keeps the hint that a result is incomplete, which stripNotice drops from the text. */
154
+ function incomplete(result: ToolResult): string {
155
+ const info = details(result);
156
+ const limit = ["matchLimitReached", "resultLimitReached", "entryLimitReached"].some((key) => info[key] !== undefined);
157
+ return (limit ? " · limit reached" : "") + (truncation(result).truncated ? " · truncated" : "");
158
+ }
159
+
160
+ /** Line count of shell output plus any incomplete-result hint. */
161
+ function shellLines(output: string, result: ToolResult): string {
162
+ const text = output.trim();
163
+ const lines = text === "(no output)" ? 0 : truncation(result).totalLines ?? countLines(text);
164
+ return (lines === 0 ? "no output" : plural(lines, "line")) + incomplete(result);
165
+ }
166
+
167
+ /** One-line result summary for a finished, successful call. */
168
+ export function summarizeResult(toolName: string, result: ToolResult, args: Args): string {
169
+ const raw = textOutput(result);
170
+ const text = stripNotice(raw);
171
+ switch (toolName) {
172
+ case "read": {
173
+ if (result.content.some((block) => block.type === "image")) return "image";
174
+ // An explicit `limit` that stops early adds this notice without setting details.truncation.
175
+ const more = /\n\n\[\d+ more lines in file\.[^\n]*\]\s*$/.test(raw) ? " · more" : "";
176
+ return plural(countLines(text), "line") + incomplete(result) + more;
177
+ }
178
+ case "bash":
179
+ case "powershell":
180
+ return `Done · ${shellLines(text, result)}`;
181
+ case "edit": {
182
+ const diff = details(result).diff;
183
+ if (typeof diff !== "string") return "Edited";
184
+ const { added, removed } = diffStats(diff);
185
+ return `+${added} −${removed}`;
186
+ }
187
+ case "write":
188
+ return `${plural(countLines(str(args.content).replace(/\n$/, "")), "line")} written`;
189
+ case "grep": {
190
+ if (text === "No matches found") return "No matches";
191
+ const files = new Set<string>();
192
+ let matches = 0;
193
+ for (const line of text.split("\n")) {
194
+ const match = /^(.+?):\d+: /.exec(line);
195
+ if (!match) continue;
196
+ matches++;
197
+ files.add(match[1]!);
198
+ }
199
+ return `${plural(matches, "match", "matches")} in ${plural(files.size, "file")}${incomplete(result)}`;
200
+ }
201
+ case "find":
202
+ return text === "No files found matching pattern" ? "No files" : plural(countLines(text), "file") + incomplete(result);
203
+ case "ls":
204
+ return text === "(empty directory)" ? "Empty" : plural(countLines(text), "entry", "entries") + incomplete(result);
205
+ default:
206
+ return "";
207
+ }
208
+ }
209
+
210
+ /**
211
+ * One-line summary for a failed call: the shell's closing status plus its last output line,
212
+ * otherwise the error's first line.
213
+ */
214
+ export function summarizeError(toolName: string, result: ToolResult, duration = ""): string {
215
+ const text = textOutput(result).trim();
216
+ // Pi ends failed shell output with "Command exited with code N", "Command aborted", etc.
217
+ const status = toolName === "bash" || toolName === "powershell" ? /(?:^|\n\n)Command ([^\n]+)$/.exec(text) : null;
218
+ if (status) {
219
+ const reason = status[1]!;
220
+ const output = stripNotice(text.slice(0, status.index));
221
+ // The last output line usually names the failure; it goes last so narrow rows cut it first.
222
+ const last = tailLines(output, 1)[0]?.trim();
223
+ const tail = last && last !== "(no output)" ? ` · ${last}` : "";
224
+ return `${reason[0]!.toUpperCase()}${reason.slice(1)} · ${shellLines(output, result)}${duration}${tail}`;
225
+ }
226
+ return text.split("\n").find((line) => line.trim())?.trim() ?? "Failed";
227
+ }
228
+
229
+ function formatElapsed(ms: number): string {
230
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
231
+ }
232
+
233
+ /** Lines truncated to the render width; rebuilt on every render call. */
234
+ function lines(build: (width: number) => string[]): Component {
235
+ return {
236
+ render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(1, width), "…")),
237
+ invalidate() {},
238
+ };
239
+ }
240
+
241
+ function rowState(context: RenderContext): RowState {
242
+ const state = context.state as Partial<RowState>;
243
+ state.nativeState ??= {};
244
+ return state as RowState;
245
+ }
246
+
247
+ function nativeContext(context: RenderContext, row: RowState, lastComponent: Component | undefined): RenderContext {
248
+ return { ...context, state: row.nativeState, lastComponent };
249
+ }
250
+
251
+ function statusBg(theme: Theme, context: RenderContext): (text: string) => string {
252
+ const bg = context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg";
253
+ return (text) => theme.bg(bg, text);
254
+ }
255
+
256
+ export function createCompactRenderers(
257
+ toolName: string,
258
+ native: ToolRenderers,
259
+ glyphs: () => IconGlyphs,
260
+ ): ToolRenderers {
261
+ const label = TOOL_LABELS[toolName] ?? toolName;
262
+ const isShell = toolName === "bash" || toolName === "powershell";
263
+ // Expanded rows hand off to Pi's own card so nothing is hidden.
264
+ const showNative = (context: RenderContext) => context.expanded;
265
+
266
+ return {
267
+ renderShell: "self",
268
+ renderCall(args, theme, context) {
269
+ const row = rowState(context);
270
+ // Always run the native renderer so its own state (timers, edit preview) stays current.
271
+ row.nativeCall = native.renderCall?.(args, theme, nativeContext(context, row, row.nativeCall));
272
+ if (context.executionStarted) row.startedAt ??= Date.now();
273
+ if (showNative(context) && row.nativeCall) {
274
+ if (native.renderShell === "self") return row.nativeCall;
275
+ // Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
276
+ row.callCard ??= new Box(context.outputPad, 0);
277
+ row.callCardBottom ??= new Spacer(1);
278
+ row.callCard.setBgFn(statusBg(theme, context));
279
+ row.callCard.setPaddingX(context.outputPad);
280
+ row.callCard.clear();
281
+ row.callCard.addChild(new Spacer(1));
282
+ row.callCard.addChild(row.nativeCall);
283
+ row.callCard.addChild(row.callCardBottom);
284
+ return row.callCard;
285
+ }
286
+ const color: ThemeColor = context.isError ? "error" : context.isPartial ? "warning" : "success";
287
+ const detail = callDetail(toolName, (args ?? {}) as Args, context.cwd);
288
+ const pad = " ".repeat(context.outputPad);
289
+ return lines(() => [
290
+ pad + theme.fg(color, glyphs().toolCall) + " " + theme.fg("toolTitle", theme.bold(label))
291
+ + (detail ? theme.fg("muted", `(${detail})`) : ""),
292
+ ]);
293
+ },
294
+
295
+ renderResult(result, options, theme, context) {
296
+ const row = rowState(context);
297
+ row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
298
+ if (showNative(context) && row.nativeResult) {
299
+ if (native.renderShell === "self") return row.nativeResult;
300
+ // The result now closes the card, so the call card drops its pending bottom padding.
301
+ if (row.callCard && row.callCardBottom) row.callCard.removeChild(row.callCardBottom);
302
+ row.resultCard ??= new Box(context.outputPad, 0);
303
+ row.resultCard.setBgFn(statusBg(theme, context));
304
+ row.resultCard.setPaddingX(context.outputPad);
305
+ row.resultCard.clear();
306
+ row.resultCard.addChild(row.nativeResult);
307
+ row.resultCard.addChild(new Spacer(1));
308
+ return row.resultCard;
309
+ }
310
+ const pad = " ".repeat(context.outputPad);
311
+ const args = (context.args ?? {}) as Args;
312
+ return lines(() => {
313
+ const { toolResult } = glyphs();
314
+ const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
315
+ const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
316
+ if (options.isPartial) {
317
+ if (!isShell) return [gutter + theme.fg("warning", "Running…")];
318
+ const tail = tailLines(textOutput(result), LIVE_TAIL_LINES).map((line) => theme.fg("toolOutput", line));
319
+ const elapsed = row.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - row.startedAt)}`;
320
+ return [...tail, theme.fg("warning", `Running…${elapsed}`)]
321
+ .map((line, index) => (index === 0 ? gutter : indent) + line);
322
+ }
323
+ const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
324
+ if (context.isError) return [gutter + theme.fg("error", summarizeError(toolName, result, duration))];
325
+ const summary = summarizeResult(toolName, result, args);
326
+ if (toolName === "edit" && /^\+\d+ −\d+$/.test(summary)) {
327
+ const [added, removed] = summary.split(" ");
328
+ return [gutter + theme.fg("toolDiffAdded", added!) + " " + theme.fg("toolDiffRemoved", removed!)];
329
+ }
330
+ return [gutter + theme.fg("muted", summary + duration)];
331
+ });
332
+ },
333
+ };
334
+ }
335
+
336
+ const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
337
+
338
+ /**
339
+ * Display-only: Pi's tools, execution, arguments and results are untouched. The mode is read
340
+ * when Pi builds a row, so switching it affects new rows only.
341
+ */
342
+ export function registerToolView(pi: ExtensionAPI, getMode: () => ToolViewMode, glyphs: () => IconGlyphs): void {
343
+ pi.registerToolRenderer((toolName, next) => {
344
+ const native = next();
345
+ if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
346
+ return createCompactRenderers(toolName, native, glyphs);
347
+ });
348
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.14",
3
+ "version": "0.4.0",
4
4
  "description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "scripts": {
28
- "test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts",
28
+ "test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts tests/tool-view.test.ts",
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "pi": {
@@ -35,9 +35,9 @@
35
35
  "image": "https://raw.githubusercontent.com/OldSuns/pi-open-tui/main/assets/preview_dashboard_1.png"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": ">=1.0.0",
39
- "@earendil-works/pi-ai": ">=1.0.0",
40
- "@earendil-works/pi-tui": ">=1.0.0"
38
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
39
+ "@earendil-works/pi-ai": ">=1.2.0",
40
+ "@earendil-works/pi-tui": ">=1.2.0"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@earendil-works/pi-coding-agent": {
@@ -51,9 +51,9 @@
51
51
  }
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "^1.0.0",
55
- "@earendil-works/pi-ai": "^1.0.0",
56
- "@earendil-works/pi-tui": "^1.0.0",
54
+ "@earendil-works/pi-coding-agent": "^1.2.0",
55
+ "@earendil-works/pi-ai": "^1.2.0",
56
+ "@earendil-works/pi-tui": "^1.2.0",
57
57
  "@types/node": "^22.0.0",
58
58
  "typescript": "^5.5.0"
59
59
  },