pi-open-tui 0.4.0 → 0.4.2

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.
@@ -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, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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 stripTerminalSequences(text)
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
- /** 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+$/, "");
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" : "") + (truncation(result).truncated ? " · truncated" : "");
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\.[^\n]*\]\s*$/.test(raw) ? " · more" : "";
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(countLines(str(args.content).replace(/\n$/, "")), "line")} written`;
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}` : "";
@@ -230,11 +258,25 @@ function formatElapsed(ms: number): string {
230
258
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
231
259
  }
232
260
 
233
- /** Lines truncated to the render width; rebuilt on every render call. */
234
- function lines(build: (width: number) => string[]): Component {
261
+ /** Stable rows reuse their final lines until width, theme, icons, or invalidation changes them. */
262
+ function lines(
263
+ build: (width: number, icons: IconGlyphs) => string[],
264
+ theme: Theme,
265
+ glyphs: () => IconGlyphs,
266
+ cacheable = true,
267
+ ): Component {
268
+ let cache: { width: number; colors: Theme["colors"]; toolCall: string; toolResult: string; lines: string[] } | undefined;
235
269
  return {
236
- render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(1, width), "…")),
237
- invalidate() {},
270
+ render(width) {
271
+ const icons = glyphs();
272
+ const colors = theme.colors;
273
+ if (cacheable && cache && cache.width === width && cache.colors === colors
274
+ && cache.toolCall === icons.toolCall && cache.toolResult === icons.toolResult) return cache.lines;
275
+ const rendered = build(width, icons).map((line) => truncateToWidth(line, Math.max(0, width), "…"));
276
+ if (cacheable) cache = { width, colors, toolCall: icons.toolCall, toolResult: icons.toolResult, lines: rendered };
277
+ return rendered;
278
+ },
279
+ invalidate() { cache = undefined; },
238
280
  };
239
281
  }
240
282
 
@@ -253,10 +295,16 @@ function statusBg(theme: Theme, context: RenderContext): (text: string) => strin
253
295
  return (text) => theme.bg(bg, text);
254
296
  }
255
297
 
298
+ function stopPulse(row: RowState): void {
299
+ if (row.pulse) clearInterval(row.pulse);
300
+ row.pulse = undefined;
301
+ }
302
+
256
303
  export function createCompactRenderers(
257
304
  toolName: string,
258
305
  native: ToolRenderers,
259
306
  glyphs: () => IconGlyphs,
307
+ running?: Map<string, RowState | undefined>,
260
308
  ): ToolRenderers {
261
309
  const label = TOOL_LABELS[toolName] ?? toolName;
262
310
  const isShell = toolName === "bash" || toolName === "powershell";
@@ -267,9 +315,26 @@ export function createCompactRenderers(
267
315
  renderShell: "self",
268
316
  renderCall(args, theme, context) {
269
317
  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));
318
+ // Keep native timers/edit state current; write rebuilds its full highlight cache only on expansion.
319
+ if (toolName !== "write" || context.expanded) {
320
+ row.nativeCall = native.renderCall?.(args, theme, nativeContext(context, row, row.nativeCall));
321
+ }
272
322
  if (context.executionStarted) row.startedAt ??= Date.now();
323
+ const live = context.executionStarted && context.isPartial && !context.isError;
324
+ // Actual execution events exclude history rebuilding and HTML export's partial call context.
325
+ if (live && !context.expanded && running?.has(context.toolCallId)) {
326
+ running.set(context.toolCallId, row);
327
+ if (!row.pulse) {
328
+ row.pulseBright = true;
329
+ row.pulse = setInterval(() => {
330
+ row.pulseBright = !row.pulseBright;
331
+ context.invalidate();
332
+ }, 1000);
333
+ row.pulse.unref();
334
+ }
335
+ } else {
336
+ stopPulse(row);
337
+ }
273
338
  if (showNative(context) && row.nativeCall) {
274
339
  if (native.renderShell === "self") return row.nativeCall;
275
340
  // Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
@@ -283,17 +348,21 @@ export function createCompactRenderers(
283
348
  row.callCard.addChild(row.callCardBottom);
284
349
  return row.callCard;
285
350
  }
286
- const color: ThemeColor = context.isError ? "error" : context.isPartial ? "warning" : "success";
287
- const detail = callDetail(toolName, (args ?? {}) as Args, context.cwd);
351
+ const color: ThemeColor = context.isError ? "error" : context.isPartial
352
+ ? live && row.pulseBright !== false ? "accent" : "dim"
353
+ : "success";
354
+ const titleColor = context.isPartial || context.isError ? "toolTitle" : "dim";
355
+ const detail = sanitizeStatus(callDetail(toolName, (args ?? {}) as Args, context.cwd));
288
356
  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
- ]);
357
+ return lines((_width, icons) => [
358
+ pad + theme.fg(color, icons.toolCall) + " " + theme.fg(titleColor, titleColor === "dim" ? label : theme.bold(label))
359
+ + (detail ? theme.fg(context.isPartial || context.isError ? "muted" : "dim", `(${detail})`) : ""),
360
+ ], theme, glyphs, !context.isPartial);
293
361
  },
294
362
 
295
363
  renderResult(result, options, theme, context) {
296
364
  const row = rowState(context);
365
+ if (!options.isPartial || context.isError) stopPulse(row);
297
366
  row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
298
367
  if (showNative(context) && row.nativeResult) {
299
368
  if (native.renderShell === "self") return row.nativeResult;
@@ -309,26 +378,30 @@ export function createCompactRenderers(
309
378
  }
310
379
  const pad = " ".repeat(context.outputPad);
311
380
  const args = (context.args ?? {}) as Args;
312
- return lines(() => {
313
- const { toolResult } = glyphs();
381
+ if (!options.isPartial && !context.isError && (toolName === "edit" || toolName === "write")) {
382
+ const source = toolName === "edit" ? str(details(result).diff) : str(args.content);
383
+ const path = str(args.file_path ?? args.path);
384
+ if (!row.preview || row.preview.source !== source || row.preview.path !== path || row.preview.colors !== theme.colors) {
385
+ row.preview = { source, path, colors: theme.colors, render: prepareToolPreview(toolName, source, path, theme) };
386
+ }
387
+ }
388
+ const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
389
+ const summary = options.isPartial ? "" : context.isError
390
+ ? summarizeError(toolName, result, duration)
391
+ : summarizeResult(toolName, result, args) + duration;
392
+ const tail = options.isPartial && isShell ? tailLines(textOutput(result), LIVE_TAIL_LINES) : [];
393
+ return lines((width, { toolResult }) => {
314
394
  const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
315
395
  const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
316
396
  if (options.isPartial) {
317
397
  if (!isShell) return [gutter + theme.fg("warning", "Running…")];
318
- const tail = tailLines(textOutput(result), LIVE_TAIL_LINES).map((line) => theme.fg("toolOutput", line));
319
398
  const elapsed = row.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - row.startedAt)}`;
320
- return [...tail, theme.fg("warning", `Running…${elapsed}`)]
399
+ return [...tail.map((line) => theme.fg("toolOutput", line)), theme.fg("warning", `Running…${elapsed}`)]
321
400
  .map((line, index) => (index === 0 ? gutter : indent) + line);
322
401
  }
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
- });
402
+ if (context.isError) return [gutter + theme.fg("error", summary)];
403
+ return [gutter + theme.fg("dim", summary), ...(row.preview?.render(width, indent) ?? [])];
404
+ }, theme, glyphs, !options.isPartial);
332
405
  },
333
406
  };
334
407
  }
@@ -340,9 +413,21 @@ const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
340
413
  * when Pi builds a row, so switching it affects new rows only.
341
414
  */
342
415
  export function registerToolView(pi: ExtensionAPI, getMode: () => ToolViewMode, glyphs: () => IconGlyphs): void {
416
+ const running = new Map<string, RowState | undefined>();
417
+ pi.on("tool_execution_start", (event) => { running.set(event.toolCallId, undefined); });
418
+ pi.on("tool_execution_end", (event) => {
419
+ const row = running.get(event.toolCallId);
420
+ if (row) stopPulse(row);
421
+ running.delete(event.toolCallId);
422
+ });
423
+ pi.on("session_shutdown", () => {
424
+ for (const row of running.values()) if (row) stopPulse(row);
425
+ running.clear();
426
+ });
343
427
  pi.registerToolRenderer((toolName, next) => {
344
428
  const native = next();
345
429
  if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
346
- return createCompactRenderers(toolName, native, glyphs);
430
+ if (pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo?.source !== "builtin") return native;
431
+ return createCompactRenderers(toolName, native, glyphs, running);
347
432
  });
348
433
  }
@@ -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 the
139
- * surviving segment texts in original order, space-joined.
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.filter((it) => it.text !== "").map((it) => it.text);
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.0",
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.",
3
+ "version": "0.4.2",
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",
@@ -20,12 +20,13 @@
20
20
  "files": [
21
21
  "extensions/",
22
22
  "assets/",
23
+ "CHANGELOG.md",
23
24
  "README.md",
24
25
  "README.zh-CN.md",
25
26
  "LICENSE"
26
27
  ],
27
28
  "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",
29
+ "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
30
  "typecheck": "tsc --noEmit"
30
31
  },
31
32
  "pi": {
@@ -35,8 +36,8 @@
35
36
  "image": "https://raw.githubusercontent.com/OldSuns/pi-open-tui/main/assets/preview_dashboard_1.png"
36
37
  },
37
38
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": ">=1.2.0",
39
39
  "@earendil-works/pi-ai": ">=1.2.0",
40
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
40
41
  "@earendil-works/pi-tui": ">=1.2.0"
41
42
  },
42
43
  "peerDependenciesMeta": {
@@ -51,17 +52,13 @@
51
52
  }
52
53
  },
53
54
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "^1.2.0",
55
55
  "@earendil-works/pi-ai": "^1.2.0",
56
+ "@earendil-works/pi-coding-agent": "^1.2.0",
56
57
  "@earendil-works/pi-tui": "^1.2.0",
57
58
  "@types/node": "^22.0.0",
58
59
  "typescript": "^5.5.0"
59
60
  },
60
- "allowScripts": {
61
- "protobufjs@7.6.5": true,
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
61
+ "dependencies": {
62
+ "diff": "^9.0.0"
66
63
  }
67
64
  }