pi-open-tui 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}` : "";
@@ -233,7 +261,7 @@ function formatElapsed(ms: number): string {
233
261
  /** Lines truncated to the render width; rebuilt on every render call. */
234
262
  function lines(build: (width: number) => string[]): Component {
235
263
  return {
236
- render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(1, width), "…")),
264
+ render: (width) => build(width).map((line) => truncateToWidth(line, Math.max(0, width), "…")),
237
265
  invalidate() {},
238
266
  };
239
267
  }
@@ -253,10 +281,16 @@ function statusBg(theme: Theme, context: RenderContext): (text: string) => strin
253
281
  return (text) => theme.bg(bg, text);
254
282
  }
255
283
 
284
+ function stopPulse(row: RowState): void {
285
+ if (row.pulse) clearInterval(row.pulse);
286
+ row.pulse = undefined;
287
+ }
288
+
256
289
  export function createCompactRenderers(
257
290
  toolName: string,
258
291
  native: ToolRenderers,
259
292
  glyphs: () => IconGlyphs,
293
+ running?: Map<string, RowState | undefined>,
260
294
  ): ToolRenderers {
261
295
  const label = TOOL_LABELS[toolName] ?? toolName;
262
296
  const isShell = toolName === "bash" || toolName === "powershell";
@@ -267,9 +301,26 @@ export function createCompactRenderers(
267
301
  renderShell: "self",
268
302
  renderCall(args, theme, context) {
269
303
  const row = rowState(context);
270
- // 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));
304
+ // Keep native timers/edit state current; write rebuilds its full highlight cache only on expansion.
305
+ if (toolName !== "write" || context.expanded) {
306
+ row.nativeCall = native.renderCall?.(args, theme, nativeContext(context, row, row.nativeCall));
307
+ }
272
308
  if (context.executionStarted) row.startedAt ??= Date.now();
309
+ const live = context.executionStarted && context.isPartial && !context.isError;
310
+ // Actual execution events exclude history rebuilding and HTML export's partial call context.
311
+ if (live && !context.expanded && running?.has(context.toolCallId)) {
312
+ running.set(context.toolCallId, row);
313
+ if (!row.pulse) {
314
+ row.pulseBright = true;
315
+ row.pulse = setInterval(() => {
316
+ row.pulseBright = !row.pulseBright;
317
+ context.invalidate();
318
+ }, 1000);
319
+ row.pulse.unref();
320
+ }
321
+ } else {
322
+ stopPulse(row);
323
+ }
273
324
  if (showNative(context) && row.nativeCall) {
274
325
  if (native.renderShell === "self") return row.nativeCall;
275
326
  // Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
@@ -283,17 +334,21 @@ export function createCompactRenderers(
283
334
  row.callCard.addChild(row.callCardBottom);
284
335
  return row.callCard;
285
336
  }
286
- const color: ThemeColor = context.isError ? "error" : context.isPartial ? "warning" : "success";
287
- const detail = callDetail(toolName, (args ?? {}) as Args, context.cwd);
337
+ const color: ThemeColor = context.isError ? "error" : context.isPartial
338
+ ? live && row.pulseBright !== false ? "accent" : "dim"
339
+ : "success";
340
+ const titleColor = context.isPartial || context.isError ? "toolTitle" : "dim";
341
+ const detail = sanitizeStatus(callDetail(toolName, (args ?? {}) as Args, context.cwd));
288
342
  const pad = " ".repeat(context.outputPad);
289
343
  return lines(() => [
290
- pad + theme.fg(color, glyphs().toolCall) + " " + theme.fg("toolTitle", theme.bold(label))
291
- + (detail ? theme.fg("muted", `(${detail})`) : ""),
344
+ pad + theme.fg(color, glyphs().toolCall) + " " + theme.fg(titleColor, titleColor === "dim" ? label : theme.bold(label))
345
+ + (detail ? theme.fg(context.isPartial || context.isError ? "muted" : "dim", `(${detail})`) : ""),
292
346
  ]);
293
347
  },
294
348
 
295
349
  renderResult(result, options, theme, context) {
296
350
  const row = rowState(context);
351
+ if (!options.isPartial || context.isError) stopPulse(row);
297
352
  row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
298
353
  if (showNative(context) && row.nativeResult) {
299
354
  if (native.renderShell === "self") return row.nativeResult;
@@ -309,7 +364,14 @@ export function createCompactRenderers(
309
364
  }
310
365
  const pad = " ".repeat(context.outputPad);
311
366
  const args = (context.args ?? {}) as Args;
312
- return lines(() => {
367
+ if (!options.isPartial && !context.isError && (toolName === "edit" || toolName === "write")) {
368
+ const source = toolName === "edit" ? str(details(result).diff) : str(args.content);
369
+ const path = str(args.file_path ?? args.path);
370
+ if (!row.preview || row.preview.source !== source || row.preview.path !== path || row.preview.colors !== theme.colors) {
371
+ row.preview = { source, path, colors: theme.colors, render: prepareToolPreview(toolName, source, path, theme) };
372
+ }
373
+ }
374
+ return lines((width) => {
313
375
  const { toolResult } = glyphs();
314
376
  const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
315
377
  const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
@@ -323,11 +385,7 @@ export function createCompactRenderers(
323
385
  const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
324
386
  if (context.isError) return [gutter + theme.fg("error", summarizeError(toolName, result, duration))];
325
387
  const summary = summarizeResult(toolName, result, args);
326
- 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)];
388
+ return [gutter + theme.fg("dim", summary + duration), ...(row.preview?.render(width, indent) ?? [])];
331
389
  });
332
390
  },
333
391
  };
@@ -340,9 +398,21 @@ const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
340
398
  * when Pi builds a row, so switching it affects new rows only.
341
399
  */
342
400
  export function registerToolView(pi: ExtensionAPI, getMode: () => ToolViewMode, glyphs: () => IconGlyphs): void {
401
+ const running = new Map<string, RowState | undefined>();
402
+ pi.on("tool_execution_start", (event) => { running.set(event.toolCallId, undefined); });
403
+ pi.on("tool_execution_end", (event) => {
404
+ const row = running.get(event.toolCallId);
405
+ if (row) stopPulse(row);
406
+ running.delete(event.toolCallId);
407
+ });
408
+ pi.on("session_shutdown", () => {
409
+ for (const row of running.values()) if (row) stopPulse(row);
410
+ running.clear();
411
+ });
343
412
  pi.registerToolRenderer((toolName, next) => {
344
413
  const native = next();
345
414
  if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
346
- return createCompactRenderers(toolName, native, glyphs);
415
+ if (pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo?.source !== "builtin") return native;
416
+ return createCompactRenderers(toolName, native, glyphs, running);
347
417
  });
348
418
  }
@@ -135,8 +135,8 @@ export type PrioritizedSegment = {
135
135
 
136
136
  /**
137
137
  * Pack segments into maxWidth: compact segments first, then shrink/drop the
138
- * lowest-priority segments (higher priority = survives longer). Returns 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.1",
4
+ "description": "A polished TUI for Pi coding agent: branded header, responsive footer, framed editor, thinking preview, and compact tool views.",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "scripts": {
28
- "test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts tests/tool-view.test.ts",
28
+ "test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts tests/tool-view.test.ts tests/copy-buttons.test.ts",
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "pi": {
@@ -35,8 +35,8 @@
35
35
  "image": "https://raw.githubusercontent.com/OldSuns/pi-open-tui/main/assets/preview_dashboard_1.png"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": ">=1.2.0",
39
38
  "@earendil-works/pi-ai": ">=1.2.0",
39
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
40
40
  "@earendil-works/pi-tui": ">=1.2.0"
41
41
  },
42
42
  "peerDependenciesMeta": {
@@ -51,17 +51,13 @@
51
51
  }
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "^1.2.0",
55
54
  "@earendil-works/pi-ai": "^1.2.0",
55
+ "@earendil-works/pi-coding-agent": "^1.2.0",
56
56
  "@earendil-works/pi-tui": "^1.2.0",
57
57
  "@types/node": "^22.0.0",
58
58
  "typescript": "^5.5.0"
59
59
  },
60
- "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
60
+ "dependencies": {
61
+ "diff": "^9.0.0"
66
62
  }
67
63
  }