pi-open-tui 0.4.0 → 0.4.1
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 +237 -197
- package/README.zh-CN.md +237 -197
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +9 -2
- package/extensions/open-tui/copy-buttons.ts +363 -0
- package/extensions/open-tui/editor.ts +311 -119
- package/extensions/open-tui/footer.ts +13 -11
- package/extensions/open-tui/git.ts +10 -32
- package/extensions/open-tui/header.ts +133 -244
- package/extensions/open-tui/index.ts +34 -33
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +54 -45
- package/extensions/open-tui/state.ts +11 -14
- package/extensions/open-tui/tool-preview.ts +167 -0
- package/extensions/open-tui/tool-view.ts +94 -24
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +7 -11
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { isAbsolute, relative, sep } from "node:path";
|
|
3
3
|
import type { ExtensionAPI, Theme, ThemeColor, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Box, type Component, Spacer,
|
|
4
|
+
import { Box, type Component, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { ToolViewMode } from "./config.ts";
|
|
6
6
|
import type { IconGlyphs } from "./icons.ts";
|
|
7
|
+
import { codeLines, prepareToolPreview } from "./tool-preview.ts";
|
|
8
|
+
import { sanitizeStatus, stripAnsi } from "./utils.ts";
|
|
7
9
|
|
|
8
10
|
type RenderCall = NonNullable<ToolRenderers["renderCall"]>;
|
|
9
11
|
type RenderResult = NonNullable<ToolRenderers["renderResult"]>;
|
|
@@ -32,6 +34,14 @@ interface RowState {
|
|
|
32
34
|
callCardBottom?: Spacer;
|
|
33
35
|
resultCard?: Box;
|
|
34
36
|
startedAt?: number;
|
|
37
|
+
pulse?: ReturnType<typeof setInterval>;
|
|
38
|
+
pulseBright?: boolean;
|
|
39
|
+
preview?: {
|
|
40
|
+
source: string;
|
|
41
|
+
path: string;
|
|
42
|
+
colors: Theme["colors"];
|
|
43
|
+
render: ReturnType<typeof prepareToolPreview>;
|
|
44
|
+
};
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
@@ -45,7 +55,7 @@ function plural(count: number, one: string, many = `${one}s`): string {
|
|
|
45
55
|
* and keep only the text after a lone `\r` so progress bars show their latest frame.
|
|
46
56
|
*/
|
|
47
57
|
function cleanOutput(text: string): string {
|
|
48
|
-
return
|
|
58
|
+
return stripAnsi(text)
|
|
49
59
|
.replace(/\r\n/g, "\n")
|
|
50
60
|
.split("\n")
|
|
51
61
|
.map((line) => {
|
|
@@ -60,9 +70,26 @@ export function textOutput(result: ToolResult): string {
|
|
|
60
70
|
return cleanOutput(result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"));
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const NOTICE_SIZE = String.raw`\d+(?:\.\d+)?(?:B|KB|MB)`;
|
|
74
|
+
const BYTE_LIMIT_NOTICE = `${NOTICE_SIZE} limit reached`;
|
|
75
|
+
const GREP_LINES_NOTICE = String.raw`Some lines truncated to \d+ chars\. Use read tool to see full lines`;
|
|
76
|
+
const SHOWN_LINES_NOTICE = String.raw`Showing lines \d+-\d+ of \d+(?: \(${NOTICE_SIZE} limit\))?`;
|
|
77
|
+
// Pi 1.2–1.4 appends these tool-specific notices, not arbitrary bracketed output.
|
|
78
|
+
const TOOL_NOTICES: Record<string, RegExp> = {
|
|
79
|
+
read: new RegExp(String.raw`^(?:${SHOWN_LINES_NOTICE}\. Use offset=\d+ to continue\.|\d+ more lines in file\. Use offset=\d+ to continue\.)$`),
|
|
80
|
+
bash: new RegExp(String.raw`^(?:${SHOWN_LINES_NOTICE}|Showing last ${NOTICE_SIZE} of line \d+ \(line is ${NOTICE_SIZE}\))\. Full output: [^\n]+$`),
|
|
81
|
+
grep: new RegExp(String.raw`^(?:\d+ matches limit reached\. Use limit=\d+ for more, or refine pattern(?:\. ${BYTE_LIMIT_NOTICE})?(?:\. ${GREP_LINES_NOTICE})?|${BYTE_LIMIT_NOTICE}(?:\. ${GREP_LINES_NOTICE})?|${GREP_LINES_NOTICE})$`),
|
|
82
|
+
find: new RegExp(String.raw`^(?:\d+ results limit reached(?:\. Use limit=\d+ for more, or refine pattern)?(?:\. ${BYTE_LIMIT_NOTICE})?|${BYTE_LIMIT_NOTICE})$`),
|
|
83
|
+
ls: new RegExp(String.raw`^(?:\d+ entries limit reached\. Use limit=\d+ for more(?:\. ${BYTE_LIMIT_NOTICE})?|${BYTE_LIMIT_NOTICE})$`),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Drop only a recognized notice for this tool, preserving bracketed content and errors. */
|
|
87
|
+
export function stripNotice(toolName: string, text: string): string {
|
|
88
|
+
const notice = /\n\n\[([^\n]+)\]\s*$/.exec(text);
|
|
89
|
+
if (notice && TOOL_NOTICES[toolName === "powershell" ? "bash" : toolName]?.test(notice[1]!)) {
|
|
90
|
+
text = text.slice(0, notice.index);
|
|
91
|
+
}
|
|
92
|
+
return text.replace(/\n+$/, "");
|
|
66
93
|
}
|
|
67
94
|
|
|
68
95
|
function countLines(text: string): number {
|
|
@@ -154,7 +181,8 @@ function truncation(result: ToolResult): { truncated: boolean; totalLines?: numb
|
|
|
154
181
|
function incomplete(result: ToolResult): string {
|
|
155
182
|
const info = details(result);
|
|
156
183
|
const limit = ["matchLimitReached", "resultLimitReached", "entryLimitReached"].some((key) => info[key] !== undefined);
|
|
157
|
-
return (limit ? " · limit reached" : "")
|
|
184
|
+
return (limit ? " · limit reached" : "")
|
|
185
|
+
+ (truncation(result).truncated || info.linesTruncated === true ? " · truncated" : "");
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
/** Line count of shell output plus any incomplete-result hint. */
|
|
@@ -167,12 +195,12 @@ function shellLines(output: string, result: ToolResult): string {
|
|
|
167
195
|
/** One-line result summary for a finished, successful call. */
|
|
168
196
|
export function summarizeResult(toolName: string, result: ToolResult, args: Args): string {
|
|
169
197
|
const raw = textOutput(result);
|
|
170
|
-
const text = stripNotice(raw);
|
|
198
|
+
const text = stripNotice(toolName, raw);
|
|
171
199
|
switch (toolName) {
|
|
172
200
|
case "read": {
|
|
173
201
|
if (result.content.some((block) => block.type === "image")) return "image";
|
|
174
202
|
// An explicit `limit` that stops early adds this notice without setting details.truncation.
|
|
175
|
-
const more = /\n\n\[\d+ more lines in file\.
|
|
203
|
+
const more = /\n\n\[\d+ more lines in file\. Use offset=\d+ to continue\.\]\s*$/.test(raw) ? " · more" : "";
|
|
176
204
|
return plural(countLines(text), "line") + incomplete(result) + more;
|
|
177
205
|
}
|
|
178
206
|
case "bash":
|
|
@@ -185,7 +213,7 @@ export function summarizeResult(toolName: string, result: ToolResult, args: Args
|
|
|
185
213
|
return `+${added} −${removed}`;
|
|
186
214
|
}
|
|
187
215
|
case "write":
|
|
188
|
-
return `${plural(
|
|
216
|
+
return `${plural(codeLines(str(args.content)).length, "line")} written`;
|
|
189
217
|
case "grep": {
|
|
190
218
|
if (text === "No matches found") return "No matches";
|
|
191
219
|
const files = new Set<string>();
|
|
@@ -217,7 +245,7 @@ export function summarizeError(toolName: string, result: ToolResult, duration =
|
|
|
217
245
|
const status = toolName === "bash" || toolName === "powershell" ? /(?:^|\n\n)Command ([^\n]+)$/.exec(text) : null;
|
|
218
246
|
if (status) {
|
|
219
247
|
const reason = status[1]!;
|
|
220
|
-
const output = stripNotice(text.slice(0, status.index));
|
|
248
|
+
const output = stripNotice(toolName, text.slice(0, status.index));
|
|
221
249
|
// The last output line usually names the failure; it goes last so narrow rows cut it first.
|
|
222
250
|
const last = tailLines(output, 1)[0]?.trim();
|
|
223
251
|
const tail = last && last !== "(no output)" ? ` · ${last}` : "";
|
|
@@ -233,7 +261,7 @@ function formatElapsed(ms: number): string {
|
|
|
233
261
|
/** Lines truncated to the render width; rebuilt on every render call. */
|
|
234
262
|
function lines(build: (width: number) => string[]): Component {
|
|
235
263
|
return {
|
|
236
|
-
render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(
|
|
264
|
+
render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(0, width), "…")),
|
|
237
265
|
invalidate() {},
|
|
238
266
|
};
|
|
239
267
|
}
|
|
@@ -253,10 +281,16 @@ function statusBg(theme: Theme, context: RenderContext): (text: string) => strin
|
|
|
253
281
|
return (text) => theme.bg(bg, text);
|
|
254
282
|
}
|
|
255
283
|
|
|
284
|
+
function stopPulse(row: RowState): void {
|
|
285
|
+
if (row.pulse) clearInterval(row.pulse);
|
|
286
|
+
row.pulse = undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
export function createCompactRenderers(
|
|
257
290
|
toolName: string,
|
|
258
291
|
native: ToolRenderers,
|
|
259
292
|
glyphs: () => IconGlyphs,
|
|
293
|
+
running?: Map<string, RowState | undefined>,
|
|
260
294
|
): ToolRenderers {
|
|
261
295
|
const label = TOOL_LABELS[toolName] ?? toolName;
|
|
262
296
|
const isShell = toolName === "bash" || toolName === "powershell";
|
|
@@ -267,9 +301,26 @@ export function createCompactRenderers(
|
|
|
267
301
|
renderShell: "self",
|
|
268
302
|
renderCall(args, theme, context) {
|
|
269
303
|
const row = rowState(context);
|
|
270
|
-
//
|
|
271
|
-
|
|
304
|
+
// Keep native timers/edit state current; write rebuilds its full highlight cache only on expansion.
|
|
305
|
+
if (toolName !== "write" || context.expanded) {
|
|
306
|
+
row.nativeCall = native.renderCall?.(args, theme, nativeContext(context, row, row.nativeCall));
|
|
307
|
+
}
|
|
272
308
|
if (context.executionStarted) row.startedAt ??= Date.now();
|
|
309
|
+
const live = context.executionStarted && context.isPartial && !context.isError;
|
|
310
|
+
// Actual execution events exclude history rebuilding and HTML export's partial call context.
|
|
311
|
+
if (live && !context.expanded && running?.has(context.toolCallId)) {
|
|
312
|
+
running.set(context.toolCallId, row);
|
|
313
|
+
if (!row.pulse) {
|
|
314
|
+
row.pulseBright = true;
|
|
315
|
+
row.pulse = setInterval(() => {
|
|
316
|
+
row.pulseBright = !row.pulseBright;
|
|
317
|
+
context.invalidate();
|
|
318
|
+
}, 1000);
|
|
319
|
+
row.pulse.unref();
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
stopPulse(row);
|
|
323
|
+
}
|
|
273
324
|
if (showNative(context) && row.nativeCall) {
|
|
274
325
|
if (native.renderShell === "self") return row.nativeCall;
|
|
275
326
|
// Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
|
|
@@ -283,17 +334,21 @@ export function createCompactRenderers(
|
|
|
283
334
|
row.callCard.addChild(row.callCardBottom);
|
|
284
335
|
return row.callCard;
|
|
285
336
|
}
|
|
286
|
-
const color: ThemeColor = context.isError ? "error" : context.isPartial
|
|
287
|
-
|
|
337
|
+
const color: ThemeColor = context.isError ? "error" : context.isPartial
|
|
338
|
+
? live && row.pulseBright !== false ? "accent" : "dim"
|
|
339
|
+
: "success";
|
|
340
|
+
const titleColor = context.isPartial || context.isError ? "toolTitle" : "dim";
|
|
341
|
+
const detail = sanitizeStatus(callDetail(toolName, (args ?? {}) as Args, context.cwd));
|
|
288
342
|
const pad = " ".repeat(context.outputPad);
|
|
289
343
|
return lines(() => [
|
|
290
|
-
pad + theme.fg(color, glyphs().toolCall) + " " + theme.fg("
|
|
291
|
-
+ (detail ? theme.fg("muted", `(${detail})`) : ""),
|
|
344
|
+
pad + theme.fg(color, glyphs().toolCall) + " " + theme.fg(titleColor, titleColor === "dim" ? label : theme.bold(label))
|
|
345
|
+
+ (detail ? theme.fg(context.isPartial || context.isError ? "muted" : "dim", `(${detail})`) : ""),
|
|
292
346
|
]);
|
|
293
347
|
},
|
|
294
348
|
|
|
295
349
|
renderResult(result, options, theme, context) {
|
|
296
350
|
const row = rowState(context);
|
|
351
|
+
if (!options.isPartial || context.isError) stopPulse(row);
|
|
297
352
|
row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
|
|
298
353
|
if (showNative(context) && row.nativeResult) {
|
|
299
354
|
if (native.renderShell === "self") return row.nativeResult;
|
|
@@ -309,7 +364,14 @@ export function createCompactRenderers(
|
|
|
309
364
|
}
|
|
310
365
|
const pad = " ".repeat(context.outputPad);
|
|
311
366
|
const args = (context.args ?? {}) as Args;
|
|
312
|
-
|
|
367
|
+
if (!options.isPartial && !context.isError && (toolName === "edit" || toolName === "write")) {
|
|
368
|
+
const source = toolName === "edit" ? str(details(result).diff) : str(args.content);
|
|
369
|
+
const path = str(args.file_path ?? args.path);
|
|
370
|
+
if (!row.preview || row.preview.source !== source || row.preview.path !== path || row.preview.colors !== theme.colors) {
|
|
371
|
+
row.preview = { source, path, colors: theme.colors, render: prepareToolPreview(toolName, source, path, theme) };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return lines((width) => {
|
|
313
375
|
const { toolResult } = glyphs();
|
|
314
376
|
const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
|
|
315
377
|
const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
|
|
@@ -323,11 +385,7 @@ export function createCompactRenderers(
|
|
|
323
385
|
const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
|
|
324
386
|
if (context.isError) return [gutter + theme.fg("error", summarizeError(toolName, result, duration))];
|
|
325
387
|
const summary = summarizeResult(toolName, result, args);
|
|
326
|
-
|
|
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)];
|
|
388
|
+
return [gutter + theme.fg("dim", summary + duration), ...(row.preview?.render(width, indent) ?? [])];
|
|
331
389
|
});
|
|
332
390
|
},
|
|
333
391
|
};
|
|
@@ -340,9 +398,21 @@ const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
|
|
|
340
398
|
* when Pi builds a row, so switching it affects new rows only.
|
|
341
399
|
*/
|
|
342
400
|
export function registerToolView(pi: ExtensionAPI, getMode: () => ToolViewMode, glyphs: () => IconGlyphs): void {
|
|
401
|
+
const running = new Map<string, RowState | undefined>();
|
|
402
|
+
pi.on("tool_execution_start", (event) => { running.set(event.toolCallId, undefined); });
|
|
403
|
+
pi.on("tool_execution_end", (event) => {
|
|
404
|
+
const row = running.get(event.toolCallId);
|
|
405
|
+
if (row) stopPulse(row);
|
|
406
|
+
running.delete(event.toolCallId);
|
|
407
|
+
});
|
|
408
|
+
pi.on("session_shutdown", () => {
|
|
409
|
+
for (const row of running.values()) if (row) stopPulse(row);
|
|
410
|
+
running.clear();
|
|
411
|
+
});
|
|
343
412
|
pi.registerToolRenderer((toolName, next) => {
|
|
344
413
|
const native = next();
|
|
345
414
|
if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
|
|
346
|
-
|
|
415
|
+
if (pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo?.source !== "builtin") return native;
|
|
416
|
+
return createCompactRenderers(toolName, native, glyphs, running);
|
|
347
417
|
});
|
|
348
418
|
}
|
|
@@ -135,8 +135,8 @@ export type PrioritizedSegment = {
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* Pack segments into maxWidth: compact segments first, then shrink/drop the
|
|
138
|
-
* lowest-priority segments (higher priority = survives longer). Returns
|
|
139
|
-
*
|
|
138
|
+
* lowest-priority segments (higher priority = survives longer). Returns one
|
|
139
|
+
* text per input segment, preserving positions with empty strings for dropped items.
|
|
140
140
|
*/
|
|
141
141
|
export function fitSegmentsByPriority(
|
|
142
142
|
segs: readonly PrioritizedSegment[],
|
|
@@ -205,7 +205,7 @@ export function fitSegmentsByPriority(
|
|
|
205
205
|
break;
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
-
return items.
|
|
208
|
+
return items.map((it) => it.text);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "A polished TUI for Pi coding agent:
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "A polished TUI for Pi coding agent: branded header, responsive footer, framed editor, thinking preview, and compact tool views.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -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 tests/tool-view.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 tests/copy-buttons.test.ts",
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"pi": {
|
|
@@ -35,8 +35,8 @@
|
|
|
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.2.0",
|
|
39
38
|
"@earendil-works/pi-ai": ">=1.2.0",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
40
40
|
"@earendil-works/pi-tui": ">=1.2.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
@@ -51,17 +51,13 @@
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
55
54
|
"@earendil-works/pi-ai": "^1.2.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
56
56
|
"@earendil-works/pi-tui": "^1.2.0",
|
|
57
57
|
"@types/node": "^22.0.0",
|
|
58
58
|
"typescript": "^5.5.0"
|
|
59
59
|
},
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"protobufjs@7.6.4": true,
|
|
63
|
-
"@google/genai@1.52.0": true,
|
|
64
|
-
"koffi@2.16.2": true,
|
|
65
|
-
"tree-sitter-bash@0.25.1": true
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"diff": "^9.0.0"
|
|
66
62
|
}
|
|
67
63
|
}
|