pi-open-tui 0.3.14 → 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 +72 -5
- package/README.zh-CN.md +72 -5
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +26 -2
- package/extensions/open-tui/copy-buttons.ts +363 -0
- package/extensions/open-tui/editor.ts +322 -122
- 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/icons.ts +9 -0
- package/extensions/open-tui/index.ts +45 -20
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +66 -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 +418 -0
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +11 -15
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { backgroundAnsi, rgbColor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { diffWordsWithSpace } from "diff";
|
|
4
|
+
import { stripAnsi } from "./utils.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_PREVIEW_LINES = 16;
|
|
7
|
+
const MAX_HIGHLIGHT_CHARS = 16_384;
|
|
8
|
+
const MAX_WORD_CHARS = 4_096;
|
|
9
|
+
const SGR = /\x1b\[([\d;]*)m/g;
|
|
10
|
+
|
|
11
|
+
type Span = { start: number; end: number };
|
|
12
|
+
type PreviewLine = {
|
|
13
|
+
kind: " " | "+" | "-" | "gap";
|
|
14
|
+
number: string;
|
|
15
|
+
text: string;
|
|
16
|
+
highlighted?: string;
|
|
17
|
+
spans?: Span[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Code keeps its indentation and blank lines; raw terminal controls never reach highlighting. */
|
|
21
|
+
export function codeLines(source: string): string[] {
|
|
22
|
+
if (!source) return [];
|
|
23
|
+
const clean = stripAnsi(source).replace(/\r\n?/g, "\n")
|
|
24
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFF9-\uFFFB]/g, "")
|
|
25
|
+
.replace(/\t/g, " ");
|
|
26
|
+
return clean.replace(/\n$/, "").split("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseDiff(source: string): PreviewLine[] {
|
|
30
|
+
const rows: PreviewLine[] = [];
|
|
31
|
+
for (const line of codeLines(source)) {
|
|
32
|
+
if (/^ +\.\.\.$/.test(line)) {
|
|
33
|
+
rows.push({ kind: "gap", number: "", text: "…" });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const match = /^([ +\-]) *(\d+) (.*)$/.exec(line);
|
|
37
|
+
// A malformed result gets no guessed preview.
|
|
38
|
+
if (!match) return [];
|
|
39
|
+
rows.push({ kind: match[1] as PreviewLine["kind"], number: match[2]!, text: match[3]! });
|
|
40
|
+
}
|
|
41
|
+
return rows.some((line) => line.kind === "+" || line.kind === "-") ? rows : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function changedWords(oldLine: PreviewLine, newLine: PreviewLine): void {
|
|
45
|
+
if (Math.max(oldLine.text.length, newLine.text.length) > MAX_WORD_CHARS) return;
|
|
46
|
+
const changes = diffWordsWithSpace(oldLine.text, newLine.text, { timeout: 5, maxEditLength: 256 });
|
|
47
|
+
if (!changes) return;
|
|
48
|
+
const shared = changes.filter((part) => !part.added && !part.removed)
|
|
49
|
+
.reduce((count, part) => count + part.value.replace(/\s/g, "").length, 0);
|
|
50
|
+
if (shared < 0.3 * Math.max(oldLine.text.trim().length, newLine.text.trim().length) || shared === 0) return;
|
|
51
|
+
let oldOffset = 0;
|
|
52
|
+
let newOffset = 0;
|
|
53
|
+
oldLine.spans = [];
|
|
54
|
+
newLine.spans = [];
|
|
55
|
+
for (const part of changes) {
|
|
56
|
+
if (part.removed) oldLine.spans.push({ start: oldOffset, end: oldOffset + part.value.length });
|
|
57
|
+
if (part.added) newLine.spans.push({ start: newOffset, end: newOffset + part.value.length });
|
|
58
|
+
if (!part.added) oldOffset += part.value.length;
|
|
59
|
+
if (!part.removed) newOffset += part.value.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pairChanges(rows: PreviewLine[], shown: number): void {
|
|
64
|
+
for (let i = 0; i < shown; i++) {
|
|
65
|
+
if (rows[i]!.kind !== "-") continue;
|
|
66
|
+
const start = i;
|
|
67
|
+
while (rows[i]?.kind === "-") i++;
|
|
68
|
+
const added = i;
|
|
69
|
+
while (rows[i]?.kind === "+") i++;
|
|
70
|
+
for (let n = 0; n < Math.min(added - start, i - added); n++) {
|
|
71
|
+
if (start + n >= shown && added + n >= shown) break;
|
|
72
|
+
changedWords(rows[start + n]!, rows[added + n]!);
|
|
73
|
+
}
|
|
74
|
+
i--;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function highlight(rows: PreviewLine[], path: string, theme: Theme): void {
|
|
79
|
+
const language = getLanguageFromPath(path);
|
|
80
|
+
for (let start = 0; start < rows.length;) {
|
|
81
|
+
let end = start;
|
|
82
|
+
while (end < rows.length && rows[end]!.kind !== "gap") end++;
|
|
83
|
+
const segment = rows.slice(start, end);
|
|
84
|
+
// Highlight the two file versions independently, never as one mixed diff document.
|
|
85
|
+
for (const side of ["old", "new"] as const) {
|
|
86
|
+
if (side === "old" && !segment.some((line) => line.kind === "-")) continue;
|
|
87
|
+
const selected = segment.filter((line) => line.kind !== (side === "old" ? "+" : "-"));
|
|
88
|
+
const source = selected.map((line) => line.text).join("\n");
|
|
89
|
+
// shortcut: oversized snippets use plain foreground; raise the budget only with bounded highlighting.
|
|
90
|
+
const colored = language && source.length <= MAX_HIGHLIGHT_CHARS ? highlightCode(source, language) : undefined;
|
|
91
|
+
const unsupported = colored?.every((line, index) => line === theme.fg("mdCodeBlock", selected[index]?.text ?? ""));
|
|
92
|
+
selected.forEach((line, index) => {
|
|
93
|
+
if (side === "new" || line.kind === "-") line.highlighted = !unsupported && colored?.[index] !== undefined
|
|
94
|
+
? colored[index]!
|
|
95
|
+
: theme.fg("toolOutput", line.text);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
start = end + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Insert word bands by UTF-16 source offsets, leaving syntax ANSI outside the offset count. */
|
|
103
|
+
function withBands(code: string, spans: Span[], base: string, word: string): string {
|
|
104
|
+
if (spans.length === 0) return base + code.replace(SGR, (sgr, values: string) =>
|
|
105
|
+
values === "" || values.split(";").some((value) => value === "0" || value === "49") ? sgr + base : sgr);
|
|
106
|
+
let output = base;
|
|
107
|
+
let offset = 0;
|
|
108
|
+
let spanIndex = 0;
|
|
109
|
+
let active = base;
|
|
110
|
+
let from = 0;
|
|
111
|
+
const append = (text: string) => {
|
|
112
|
+
for (const char of text) {
|
|
113
|
+
while (spans[spanIndex] && spans[spanIndex]!.end <= offset) spanIndex++;
|
|
114
|
+
const span = spans[spanIndex];
|
|
115
|
+
const next = span && span.start < offset + char.length && span.end > offset ? word : base;
|
|
116
|
+
if (next !== active) output += next;
|
|
117
|
+
active = next;
|
|
118
|
+
output += char;
|
|
119
|
+
offset += char.length;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
for (const match of code.matchAll(SGR)) {
|
|
123
|
+
append(code.slice(from, match.index));
|
|
124
|
+
output += match[0];
|
|
125
|
+
// Syntax resets must restore the current band, including a word band.
|
|
126
|
+
if (match[1] === "" || match[1]!.split(";").some((value) => value === "0" || value === "49")) output += active;
|
|
127
|
+
from = match.index + match[0].length;
|
|
128
|
+
}
|
|
129
|
+
append(code.slice(from));
|
|
130
|
+
return output;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function prepareToolPreview(kind: "edit" | "write", source: string, path: string, theme: Theme): (width: number, indent: string) => string[] {
|
|
134
|
+
const rows: PreviewLine[] = kind === "edit" ? parseDiff(source) : codeLines(source)
|
|
135
|
+
.map((text, index) => ({ kind: " ", number: String(index + 1), text }));
|
|
136
|
+
const shown = Math.min(rows.length, MAX_PREVIEW_LINES);
|
|
137
|
+
if (kind === "edit") pairChanges(rows, shown);
|
|
138
|
+
const preview = rows.slice(0, shown);
|
|
139
|
+
highlight(preview, path, theme);
|
|
140
|
+
const digits = preview.reduce((max, line) => Math.max(max, line.number.length), 1);
|
|
141
|
+
const palette = theme.appearance === "light"
|
|
142
|
+
? { "+": [[229, 245, 232], [174, 226, 185]], "-": [[255, 230, 230], [255, 188, 188]] }
|
|
143
|
+
: { "+": [[26, 55, 40], [32, 96, 59]], "-": [[70, 30, 32], [124, 42, 48]] };
|
|
144
|
+
const prepared = preview.map((line) => {
|
|
145
|
+
const marker = kind === "edit" ? (line.kind === "gap" ? " " : line.kind) : "";
|
|
146
|
+
const prefix = `${marker}${line.number.padStart(digits)} `;
|
|
147
|
+
const code = theme.fg("dim", prefix) + (line.highlighted ?? theme.fg("dim", line.text));
|
|
148
|
+
if (line.kind !== "+" && line.kind !== "-") return { code };
|
|
149
|
+
const [normal, strong] = palette[line.kind].map(([r, g, b]) => backgroundAnsi(rgbColor(r!, g!, b!), theme.getColorMode()));
|
|
150
|
+
const spans = (line.spans ?? []).map(({ start, end }) => ({ start: start + prefix.length, end: end + prefix.length }));
|
|
151
|
+
return { code: withBands(code, spans, normal!, strong!), band: normal };
|
|
152
|
+
});
|
|
153
|
+
const hidden = rows.length - shown;
|
|
154
|
+
return (width, indent) => {
|
|
155
|
+
const pad = truncateToWidth(indent, Math.max(0, width), "");
|
|
156
|
+
const available = Math.max(0, width - visibleWidth(pad));
|
|
157
|
+
const result = prepared.map(({ code, band }) => {
|
|
158
|
+
const columns = Math.max(0, available - (band ? 2 : 0));
|
|
159
|
+
const clipped = truncateToWidth(code, columns, band ? `${band}…` : "…");
|
|
160
|
+
return pad + (band && columns > 0
|
|
161
|
+
? clipped + band + " ".repeat(Math.max(0, columns - visibleWidth(clipped))) + "\x1b[49m"
|
|
162
|
+
: clipped);
|
|
163
|
+
});
|
|
164
|
+
if (hidden > 0) result.push(pad + theme.fg("dim", truncateToWidth(`… +${hidden} lines (ctrl+o to expand)`, available, "…")));
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
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, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { ToolViewMode } from "./config.ts";
|
|
6
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
7
|
+
import { codeLines, prepareToolPreview } from "./tool-preview.ts";
|
|
8
|
+
import { sanitizeStatus, stripAnsi } from "./utils.ts";
|
|
9
|
+
|
|
10
|
+
type RenderCall = NonNullable<ToolRenderers["renderCall"]>;
|
|
11
|
+
type RenderResult = NonNullable<ToolRenderers["renderResult"]>;
|
|
12
|
+
type RenderContext = Parameters<RenderCall>[2];
|
|
13
|
+
type ToolResult = Parameters<RenderResult>[0];
|
|
14
|
+
type Args = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
17
|
+
read: "Read",
|
|
18
|
+
bash: "Bash",
|
|
19
|
+
powershell: "PowerShell",
|
|
20
|
+
edit: "Edit",
|
|
21
|
+
write: "Write",
|
|
22
|
+
grep: "Grep",
|
|
23
|
+
find: "Find",
|
|
24
|
+
ls: "LS",
|
|
25
|
+
};
|
|
26
|
+
const LIVE_TAIL_LINES = 3;
|
|
27
|
+
|
|
28
|
+
/** Per-row slots: Pi's native renderers keep their own state and components here. */
|
|
29
|
+
interface RowState {
|
|
30
|
+
nativeState: Record<string, unknown>;
|
|
31
|
+
nativeCall?: Component;
|
|
32
|
+
nativeResult?: Component;
|
|
33
|
+
callCard?: Box;
|
|
34
|
+
callCardBottom?: Spacer;
|
|
35
|
+
resultCard?: Box;
|
|
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
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
48
|
+
|
|
49
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
50
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Streaming shell output arrives raw: drop escape sequences and control characters as Pi does,
|
|
55
|
+
* and keep only the text after a lone `\r` so progress bars show their latest frame.
|
|
56
|
+
*/
|
|
57
|
+
function cleanOutput(text: string): string {
|
|
58
|
+
return stripAnsi(text)
|
|
59
|
+
.replace(/\r\n/g, "\n")
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((line) => {
|
|
62
|
+
const frames = line.replace(/\r+$/, "");
|
|
63
|
+
return frames.slice(frames.lastIndexOf("\r") + 1);
|
|
64
|
+
})
|
|
65
|
+
.join("\n")
|
|
66
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function textOutput(result: ToolResult): string {
|
|
70
|
+
return cleanOutput(result.content.map((block) => (block.type === "text" ? block.text : "")).join("\n"));
|
|
71
|
+
}
|
|
72
|
+
|
|
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+$/, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function countLines(text: string): number {
|
|
96
|
+
return text ? text.split("\n").length : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function diffStats(diff: string): { added: number; removed: number } {
|
|
100
|
+
let added = 0;
|
|
101
|
+
let removed = 0;
|
|
102
|
+
for (const line of diff.split("\n")) {
|
|
103
|
+
if (line.startsWith("+")) added++;
|
|
104
|
+
else if (line.startsWith("-")) removed++;
|
|
105
|
+
}
|
|
106
|
+
return { added, removed };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** First command line, with a `… (+N lines)` hint for multi-line commands. */
|
|
110
|
+
export function firstLine(text: string): string {
|
|
111
|
+
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n");
|
|
112
|
+
const head = lines[0] ?? "";
|
|
113
|
+
return lines.length > 1 ? `${head} … (+${lines.length - 1} lines)` : head;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function tailLines(text: string, count: number): string[] {
|
|
117
|
+
const lines = cleanOutput(text).split("\n").filter((line) => line.trim() !== "");
|
|
118
|
+
return lines.slice(-count);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `rel` when `path` is `base` or inside it; undefined for siblings such as `/home/sam-old`. */
|
|
122
|
+
function inside(base: string, path: string): string | undefined {
|
|
123
|
+
const rel = relative(base, path);
|
|
124
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel.split(sep).join("/") : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function displayPath(path: string, cwd: string, home = homedir()): string {
|
|
128
|
+
if (!path || !isAbsolute(path)) return path;
|
|
129
|
+
const inCwd = inside(cwd, path);
|
|
130
|
+
if (inCwd) return inCwd;
|
|
131
|
+
const inHome = inside(home, path);
|
|
132
|
+
return inHome === undefined ? path : inHome ? `~/${inHome}` : "~";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function lineRange(offset: unknown, limit: unknown): string {
|
|
136
|
+
if (typeof offset !== "number" && typeof limit !== "number") return "";
|
|
137
|
+
const start = typeof offset === "number" ? offset : 1;
|
|
138
|
+
return typeof limit === "number" ? `:${start}-${start + limit - 1}` : `:${start}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The single key argument shown inside `Tool(...)`. */
|
|
142
|
+
export function callDetail(toolName: string, args: Args, cwd: string): string {
|
|
143
|
+
const path = displayPath(str(args.file_path ?? args.path), cwd);
|
|
144
|
+
switch (toolName) {
|
|
145
|
+
case "read":
|
|
146
|
+
return path + lineRange(args.offset, args.limit);
|
|
147
|
+
case "edit":
|
|
148
|
+
case "write":
|
|
149
|
+
return path;
|
|
150
|
+
case "bash":
|
|
151
|
+
case "powershell":
|
|
152
|
+
return firstLine(str(args.command));
|
|
153
|
+
case "grep": {
|
|
154
|
+
const pattern = str(args.pattern);
|
|
155
|
+
return pattern ? `/${pattern}/${path ? ` in ${path}` : ""}` : path;
|
|
156
|
+
}
|
|
157
|
+
case "find": {
|
|
158
|
+
const pattern = str(args.pattern);
|
|
159
|
+
return pattern && path ? `${pattern} in ${path}` : pattern || path;
|
|
160
|
+
}
|
|
161
|
+
case "ls":
|
|
162
|
+
return path || ".";
|
|
163
|
+
default:
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function details(result: ToolResult): Record<string, unknown> {
|
|
169
|
+
const value: unknown = result.details;
|
|
170
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function truncation(result: ToolResult): { truncated: boolean; totalLines?: number } {
|
|
174
|
+
const value = details(result).truncation;
|
|
175
|
+
if (typeof value !== "object" || value === null) return { truncated: false };
|
|
176
|
+
const { truncated, totalLines } = value as { truncated?: unknown; totalLines?: unknown };
|
|
177
|
+
return { truncated: truncated === true, totalLines: typeof totalLines === "number" ? totalLines : undefined };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Keeps the hint that a result is incomplete, which stripNotice drops from the text. */
|
|
181
|
+
function incomplete(result: ToolResult): string {
|
|
182
|
+
const info = details(result);
|
|
183
|
+
const limit = ["matchLimitReached", "resultLimitReached", "entryLimitReached"].some((key) => info[key] !== undefined);
|
|
184
|
+
return (limit ? " · limit reached" : "")
|
|
185
|
+
+ (truncation(result).truncated || info.linesTruncated === true ? " · truncated" : "");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Line count of shell output plus any incomplete-result hint. */
|
|
189
|
+
function shellLines(output: string, result: ToolResult): string {
|
|
190
|
+
const text = output.trim();
|
|
191
|
+
const lines = text === "(no output)" ? 0 : truncation(result).totalLines ?? countLines(text);
|
|
192
|
+
return (lines === 0 ? "no output" : plural(lines, "line")) + incomplete(result);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One-line result summary for a finished, successful call. */
|
|
196
|
+
export function summarizeResult(toolName: string, result: ToolResult, args: Args): string {
|
|
197
|
+
const raw = textOutput(result);
|
|
198
|
+
const text = stripNotice(toolName, raw);
|
|
199
|
+
switch (toolName) {
|
|
200
|
+
case "read": {
|
|
201
|
+
if (result.content.some((block) => block.type === "image")) return "image";
|
|
202
|
+
// An explicit `limit` that stops early adds this notice without setting details.truncation.
|
|
203
|
+
const more = /\n\n\[\d+ more lines in file\. Use offset=\d+ to continue\.\]\s*$/.test(raw) ? " · more" : "";
|
|
204
|
+
return plural(countLines(text), "line") + incomplete(result) + more;
|
|
205
|
+
}
|
|
206
|
+
case "bash":
|
|
207
|
+
case "powershell":
|
|
208
|
+
return `Done · ${shellLines(text, result)}`;
|
|
209
|
+
case "edit": {
|
|
210
|
+
const diff = details(result).diff;
|
|
211
|
+
if (typeof diff !== "string") return "Edited";
|
|
212
|
+
const { added, removed } = diffStats(diff);
|
|
213
|
+
return `+${added} −${removed}`;
|
|
214
|
+
}
|
|
215
|
+
case "write":
|
|
216
|
+
return `${plural(codeLines(str(args.content)).length, "line")} written`;
|
|
217
|
+
case "grep": {
|
|
218
|
+
if (text === "No matches found") return "No matches";
|
|
219
|
+
const files = new Set<string>();
|
|
220
|
+
let matches = 0;
|
|
221
|
+
for (const line of text.split("\n")) {
|
|
222
|
+
const match = /^(.+?):\d+: /.exec(line);
|
|
223
|
+
if (!match) continue;
|
|
224
|
+
matches++;
|
|
225
|
+
files.add(match[1]!);
|
|
226
|
+
}
|
|
227
|
+
return `${plural(matches, "match", "matches")} in ${plural(files.size, "file")}${incomplete(result)}`;
|
|
228
|
+
}
|
|
229
|
+
case "find":
|
|
230
|
+
return text === "No files found matching pattern" ? "No files" : plural(countLines(text), "file") + incomplete(result);
|
|
231
|
+
case "ls":
|
|
232
|
+
return text === "(empty directory)" ? "Empty" : plural(countLines(text), "entry", "entries") + incomplete(result);
|
|
233
|
+
default:
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One-line summary for a failed call: the shell's closing status plus its last output line,
|
|
240
|
+
* otherwise the error's first line.
|
|
241
|
+
*/
|
|
242
|
+
export function summarizeError(toolName: string, result: ToolResult, duration = ""): string {
|
|
243
|
+
const text = textOutput(result).trim();
|
|
244
|
+
// Pi ends failed shell output with "Command exited with code N", "Command aborted", etc.
|
|
245
|
+
const status = toolName === "bash" || toolName === "powershell" ? /(?:^|\n\n)Command ([^\n]+)$/.exec(text) : null;
|
|
246
|
+
if (status) {
|
|
247
|
+
const reason = status[1]!;
|
|
248
|
+
const output = stripNotice(toolName, text.slice(0, status.index));
|
|
249
|
+
// The last output line usually names the failure; it goes last so narrow rows cut it first.
|
|
250
|
+
const last = tailLines(output, 1)[0]?.trim();
|
|
251
|
+
const tail = last && last !== "(no output)" ? ` · ${last}` : "";
|
|
252
|
+
return `${reason[0]!.toUpperCase()}${reason.slice(1)} · ${shellLines(output, result)}${duration}${tail}`;
|
|
253
|
+
}
|
|
254
|
+
return text.split("\n").find((line) => line.trim())?.trim() ?? "Failed";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function formatElapsed(ms: number): string {
|
|
258
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Lines truncated to the render width; rebuilt on every render call. */
|
|
262
|
+
function lines(build: (width: number) => string[]): Component {
|
|
263
|
+
return {
|
|
264
|
+
render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(0, width), "…")),
|
|
265
|
+
invalidate() {},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rowState(context: RenderContext): RowState {
|
|
270
|
+
const state = context.state as Partial<RowState>;
|
|
271
|
+
state.nativeState ??= {};
|
|
272
|
+
return state as RowState;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function nativeContext(context: RenderContext, row: RowState, lastComponent: Component | undefined): RenderContext {
|
|
276
|
+
return { ...context, state: row.nativeState, lastComponent };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function statusBg(theme: Theme, context: RenderContext): (text: string) => string {
|
|
280
|
+
const bg = context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg";
|
|
281
|
+
return (text) => theme.bg(bg, text);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function stopPulse(row: RowState): void {
|
|
285
|
+
if (row.pulse) clearInterval(row.pulse);
|
|
286
|
+
row.pulse = undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function createCompactRenderers(
|
|
290
|
+
toolName: string,
|
|
291
|
+
native: ToolRenderers,
|
|
292
|
+
glyphs: () => IconGlyphs,
|
|
293
|
+
running?: Map<string, RowState | undefined>,
|
|
294
|
+
): ToolRenderers {
|
|
295
|
+
const label = TOOL_LABELS[toolName] ?? toolName;
|
|
296
|
+
const isShell = toolName === "bash" || toolName === "powershell";
|
|
297
|
+
// Expanded rows hand off to Pi's own card so nothing is hidden.
|
|
298
|
+
const showNative = (context: RenderContext) => context.expanded;
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
renderShell: "self",
|
|
302
|
+
renderCall(args, theme, context) {
|
|
303
|
+
const row = rowState(context);
|
|
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
|
+
}
|
|
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
|
+
}
|
|
324
|
+
if (showNative(context) && row.nativeCall) {
|
|
325
|
+
if (native.renderShell === "self") return row.nativeCall;
|
|
326
|
+
// Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
|
|
327
|
+
row.callCard ??= new Box(context.outputPad, 0);
|
|
328
|
+
row.callCardBottom ??= new Spacer(1);
|
|
329
|
+
row.callCard.setBgFn(statusBg(theme, context));
|
|
330
|
+
row.callCard.setPaddingX(context.outputPad);
|
|
331
|
+
row.callCard.clear();
|
|
332
|
+
row.callCard.addChild(new Spacer(1));
|
|
333
|
+
row.callCard.addChild(row.nativeCall);
|
|
334
|
+
row.callCard.addChild(row.callCardBottom);
|
|
335
|
+
return row.callCard;
|
|
336
|
+
}
|
|
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));
|
|
342
|
+
const pad = " ".repeat(context.outputPad);
|
|
343
|
+
return lines(() => [
|
|
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})`) : ""),
|
|
346
|
+
]);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
renderResult(result, options, theme, context) {
|
|
350
|
+
const row = rowState(context);
|
|
351
|
+
if (!options.isPartial || context.isError) stopPulse(row);
|
|
352
|
+
row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
|
|
353
|
+
if (showNative(context) && row.nativeResult) {
|
|
354
|
+
if (native.renderShell === "self") return row.nativeResult;
|
|
355
|
+
// The result now closes the card, so the call card drops its pending bottom padding.
|
|
356
|
+
if (row.callCard && row.callCardBottom) row.callCard.removeChild(row.callCardBottom);
|
|
357
|
+
row.resultCard ??= new Box(context.outputPad, 0);
|
|
358
|
+
row.resultCard.setBgFn(statusBg(theme, context));
|
|
359
|
+
row.resultCard.setPaddingX(context.outputPad);
|
|
360
|
+
row.resultCard.clear();
|
|
361
|
+
row.resultCard.addChild(row.nativeResult);
|
|
362
|
+
row.resultCard.addChild(new Spacer(1));
|
|
363
|
+
return row.resultCard;
|
|
364
|
+
}
|
|
365
|
+
const pad = " ".repeat(context.outputPad);
|
|
366
|
+
const args = (context.args ?? {}) as Args;
|
|
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) => {
|
|
375
|
+
const { toolResult } = glyphs();
|
|
376
|
+
const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
|
|
377
|
+
const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
|
|
378
|
+
if (options.isPartial) {
|
|
379
|
+
if (!isShell) return [gutter + theme.fg("warning", "Running…")];
|
|
380
|
+
const tail = tailLines(textOutput(result), LIVE_TAIL_LINES).map((line) => theme.fg("toolOutput", line));
|
|
381
|
+
const elapsed = row.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - row.startedAt)}`;
|
|
382
|
+
return [...tail, theme.fg("warning", `Running…${elapsed}`)]
|
|
383
|
+
.map((line, index) => (index === 0 ? gutter : indent) + line);
|
|
384
|
+
}
|
|
385
|
+
const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
|
|
386
|
+
if (context.isError) return [gutter + theme.fg("error", summarizeError(toolName, result, duration))];
|
|
387
|
+
const summary = summarizeResult(toolName, result, args);
|
|
388
|
+
return [gutter + theme.fg("dim", summary + duration), ...(row.preview?.render(width, indent) ?? [])];
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Display-only: Pi's tools, execution, arguments and results are untouched. The mode is read
|
|
398
|
+
* when Pi builds a row, so switching it affects new rows only.
|
|
399
|
+
*/
|
|
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
|
+
});
|
|
412
|
+
pi.registerToolRenderer((toolName, next) => {
|
|
413
|
+
const native = next();
|
|
414
|
+
if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
|
|
415
|
+
if (pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo?.source !== "builtin") return native;
|
|
416
|
+
return createCompactRenderers(toolName, native, glyphs, running);
|
|
417
|
+
});
|
|
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
|
-
"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",
|
|
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,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-
|
|
39
|
-
"@earendil-works/pi-
|
|
40
|
-
"@earendil-works/pi-tui": ">=1.
|
|
38
|
+
"@earendil-works/pi-ai": ">=1.2.0",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
40
|
+
"@earendil-works/pi-tui": ">=1.2.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
43
43
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -51,17 +51,13 @@
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-
|
|
55
|
-
"@earendil-works/pi-
|
|
56
|
-
"@earendil-works/pi-tui": "^1.
|
|
54
|
+
"@earendil-works/pi-ai": "^1.2.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^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
|
},
|
|
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
|
}
|