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.
@@ -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
- editor.setLatestHiddenThinkingLabel(
96
- buildPeekLabel(
97
- peek,
98
- peekFrame,
99
- resolveGlyphs(config.icons.mode),
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
- cleanupHeader = installHeader(pi, ctx);
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.13",
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
  },