pi-open-tui 0.3.13 → 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.
- package/README.md +197 -170
- package/README.zh-CN.md +197 -170
- package/extensions/open-tui/config.ts +17 -0
- package/extensions/open-tui/editor.ts +98 -16
- package/extensions/open-tui/icons.ts +9 -0
- package/extensions/open-tui/index.ts +30 -22
- package/extensions/open-tui/settings-command.ts +12 -0
- package/extensions/open-tui/tool-view.ts +348 -0
- package/package.json +8 -8
|
@@ -8,6 +8,7 @@ import { readRuntimeInfo } from "./runtime.ts";
|
|
|
8
8
|
import { SessionLifecycle } from "./session-lifecycle.ts";
|
|
9
9
|
import { registerSettingsCommand } from "./settings-command.ts";
|
|
10
10
|
import { formatTurnTelemetry, TurnTelemetryTracker } from "./telemetry.ts";
|
|
11
|
+
import { registerToolView } from "./tool-view.ts";
|
|
11
12
|
import {
|
|
12
13
|
createInitialState,
|
|
13
14
|
getModelMeta,
|
|
@@ -24,9 +25,6 @@ import {
|
|
|
24
25
|
} from "./peek.ts";
|
|
25
26
|
|
|
26
27
|
type PendingUiChange = "install" | "uninstall";
|
|
27
|
-
const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
|
|
28
|
-
// Pi's fullscreen transcript can reserve one more column for its scrollbar.
|
|
29
|
-
const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
|
|
30
28
|
|
|
31
29
|
export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
|
|
32
30
|
if (enabled === active) return undefined;
|
|
@@ -48,6 +46,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
48
46
|
const turnTelemetry = new TurnTelemetryTracker();
|
|
49
47
|
|
|
50
48
|
let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
|
|
49
|
+
let configLoaded = false;
|
|
51
50
|
let active = false;
|
|
52
51
|
let lastCtx: ExtensionContext | undefined;
|
|
53
52
|
let requestFooterRender: (() => void) | undefined;
|
|
@@ -78,29 +77,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
77
|
/** Pi decides whether this label is visible; our toggle only controls updates. */
|
|
79
78
|
const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
|
|
80
79
|
|
|
81
|
-
/** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
|
|
82
|
-
const hiddenThinkingLabelWidth = (): number => {
|
|
83
|
-
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
84
|
-
return Math.max(
|
|
85
|
-
1,
|
|
86
|
-
editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
|
|
87
|
-
);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
80
|
const setPeekLabel = (ctx?: ExtensionContext): void => {
|
|
91
81
|
if (!isPeekEnabled() || peek.phase === "idle") return;
|
|
92
82
|
const target = ctx ?? lastCtx;
|
|
93
83
|
if (!target || !isTuiContext(target)) return;
|
|
94
84
|
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
hiddenThinkingLabelWidth(),
|
|
101
|
-
config.thinkingPeek.lines,
|
|
102
|
-
),
|
|
103
|
-
);
|
|
85
|
+
const snapshot = { ...peek };
|
|
86
|
+
const frame = peekFrame;
|
|
87
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
88
|
+
const lines = config.thinkingPeek.lines;
|
|
89
|
+
editor.setLatestHiddenThinkingLabel((width) => buildPeekLabel(snapshot, frame, glyphs, width, lines));
|
|
104
90
|
peekLabelActive = true;
|
|
105
91
|
};
|
|
106
92
|
|
|
@@ -130,7 +116,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
130
116
|
return;
|
|
131
117
|
}
|
|
132
118
|
if (!active) {
|
|
133
|
-
|
|
119
|
+
// Leave the header slot alone when disabled so another extension's header survives.
|
|
120
|
+
if (config.header) cleanupHeader = installHeader(pi, ctx);
|
|
134
121
|
const footer = installFooter(
|
|
135
122
|
ctx,
|
|
136
123
|
() => state,
|
|
@@ -244,6 +231,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
231
|
|
|
245
232
|
ensureConfigExists();
|
|
246
233
|
config = loadConfig((msg, level) => ctx.ui.notify(msg, level));
|
|
234
|
+
configLoaded = true;
|
|
247
235
|
clearPeekLabel(ctx);
|
|
248
236
|
|
|
249
237
|
applyUi(ctx);
|
|
@@ -382,10 +370,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
370
|
refreshInteractiveState(ctx);
|
|
383
371
|
});
|
|
384
372
|
|
|
373
|
+
if (typeof pi.registerToolRenderer === "function") {
|
|
374
|
+
registerToolView(
|
|
375
|
+
pi,
|
|
376
|
+
() => {
|
|
377
|
+
// Resumed sessions rebuild their rows before session_start fires, so read the file once here.
|
|
378
|
+
if (!configLoaded) {
|
|
379
|
+
config = loadConfig();
|
|
380
|
+
configLoaded = true;
|
|
381
|
+
}
|
|
382
|
+
return config.enabled ? config.toolView.mode : "native";
|
|
383
|
+
},
|
|
384
|
+
() => resolveGlyphs(config.icons.mode),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
385
388
|
registerSettingsCommand(pi, {
|
|
386
389
|
getConfig: () => config,
|
|
387
390
|
onConfigChanged: (newConfig) => {
|
|
388
391
|
const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
|
|
392
|
+
const headerChanged = config.header !== newConfig.header;
|
|
389
393
|
const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
|
|
390
394
|
saveConfig(newConfig);
|
|
391
395
|
config = newConfig;
|
|
@@ -398,6 +402,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
402
|
if (cursorStyleChanged && active && editor) {
|
|
399
403
|
editor.setCursorStyle(newConfig.cursorStyle);
|
|
400
404
|
}
|
|
405
|
+
if (headerChanged && active && lastCtx) {
|
|
406
|
+
cleanupHeader?.();
|
|
407
|
+
cleanupHeader = newConfig.header ? installHeader(pi, lastCtx) : undefined;
|
|
408
|
+
}
|
|
401
409
|
if (lastCtx) {
|
|
402
410
|
pendingUiChange = getPendingUiChange(newConfig.enabled, active);
|
|
403
411
|
}
|
|
@@ -28,8 +28,10 @@ const COPY = {
|
|
|
28
28
|
hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
|
|
29
29
|
labels: {
|
|
30
30
|
enabled: "Enabled",
|
|
31
|
+
header: "Override header",
|
|
31
32
|
inlineFooter: "Footer in editor border",
|
|
32
33
|
thinkingPeek: "Thinking peek",
|
|
34
|
+
toolView: "Tool view",
|
|
33
35
|
language: "Language",
|
|
34
36
|
cursorStyle: "Cursor style",
|
|
35
37
|
iconMode: "Icon mode",
|
|
@@ -56,6 +58,7 @@ const COPY = {
|
|
|
56
58
|
on: "On",
|
|
57
59
|
off: "Off",
|
|
58
60
|
thinkingPeek: { off: "Off", one: "1 line", two: "2 lines" },
|
|
61
|
+
toolView: { native: "Native", compact: "Compact" },
|
|
59
62
|
languages: { en: "English", zh: "简体中文" },
|
|
60
63
|
cursorStyles: { block: "Block", bar: "Bar", underline: "Underline" },
|
|
61
64
|
icons: { auto: "Auto", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
|
|
@@ -67,8 +70,10 @@ const COPY = {
|
|
|
67
70
|
hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
|
|
68
71
|
labels: {
|
|
69
72
|
enabled: "启用",
|
|
73
|
+
header: "覆盖顶部信息栏",
|
|
70
74
|
inlineFooter: "底栏嵌入输入框",
|
|
71
75
|
thinkingPeek: "思考预览",
|
|
76
|
+
toolView: "工具视图",
|
|
72
77
|
language: "语言",
|
|
73
78
|
cursorStyle: "光标样式",
|
|
74
79
|
iconMode: "图标模式",
|
|
@@ -95,6 +100,7 @@ const COPY = {
|
|
|
95
100
|
on: "开启",
|
|
96
101
|
off: "关闭",
|
|
97
102
|
thinkingPeek: { off: "关闭", one: "单行", two: "双行" },
|
|
103
|
+
toolView: { native: "原生", compact: "紧凑" },
|
|
98
104
|
languages: { en: "English", zh: "简体中文" },
|
|
99
105
|
cursorStyles: { block: "块", bar: "竖线", underline: "下划线" },
|
|
100
106
|
icons: { auto: "自动", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
|
|
@@ -160,9 +166,11 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
160
166
|
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
161
167
|
return [
|
|
162
168
|
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
169
|
+
{ id: "header", label: copy.labels.header, currentValue: flag(config.header) },
|
|
163
170
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
164
171
|
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
165
172
|
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
173
|
+
{ id: "toolView", label: copy.labels.toolView, currentValue: copy.values.toolView[config.toolView.mode] },
|
|
166
174
|
];
|
|
167
175
|
}
|
|
168
176
|
|
|
@@ -225,9 +233,13 @@ function handleSettingChange(
|
|
|
225
233
|
): OpenTuiConfig {
|
|
226
234
|
if (tab === "features") {
|
|
227
235
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
236
|
+
if (itemId === "header") return { ...config, header: !config.header };
|
|
228
237
|
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
229
238
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
230
239
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
240
|
+
if (itemId === "toolView") {
|
|
241
|
+
return { ...config, toolView: { mode: config.toolView.mode === "native" ? "compact" : "native" } };
|
|
242
|
+
}
|
|
231
243
|
}
|
|
232
244
|
if (tab === "icons") {
|
|
233
245
|
if (itemId === "mode") return cycleIconMode(config);
|
|
@@ -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
|
+
"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.
|
|
39
|
-
"@earendil-works/pi-ai": ">=1.
|
|
40
|
-
"@earendil-works/pi-tui": ">=1.
|
|
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.
|
|
55
|
-
"@earendil-works/pi-ai": "^1.
|
|
56
|
-
"@earendil-works/pi-tui": "^1.
|
|
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
|
},
|