pi-open-tui 0.4.1 → 0.4.3
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/CHANGELOG.md +54 -0
- package/README.md +54 -121
- package/README.zh-CN.md +54 -121
- package/docs/configuration.md +245 -0
- package/docs/footer-segments.md +121 -0
- package/docs/implementation.md +185 -0
- package/extensions/open-tui/config.ts +25 -7
- package/extensions/open-tui/copy-buttons.ts +172 -66
- package/extensions/open-tui/footer-segments.ts +119 -0
- package/extensions/open-tui/footer.ts +121 -13
- package/extensions/open-tui/index.ts +8 -1
- package/extensions/open-tui/settings-command.ts +312 -63
- package/extensions/open-tui/tool-preview.ts +8 -2
- package/extensions/open-tui/tool-view.ts +206 -38
- package/extensions/open-tui/utils.ts +18 -6
- package/package.json +10 -8
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
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 {
|
|
3
|
+
import type { ExtensionAPI, SourceInfo, Theme, ThemeColor, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Box, type Component, getCapabilities, getImageDimensions, imageFallback, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { ToolViewConfig } from "./config.ts";
|
|
6
6
|
import type { IconGlyphs } from "./icons.ts";
|
|
7
7
|
import { codeLines, prepareToolPreview } from "./tool-preview.ts";
|
|
8
8
|
import { sanitizeStatus, stripAnsi } from "./utils.ts";
|
|
@@ -24,6 +24,15 @@ const TOOL_LABELS: Record<string, string> = {
|
|
|
24
24
|
ls: "LS",
|
|
25
25
|
};
|
|
26
26
|
const LIVE_TAIL_LINES = 3;
|
|
27
|
+
/** Arguments that best identify a call to a tool this view knows nothing else about. */
|
|
28
|
+
const GENERIC_DETAIL_KEYS = ["command", "query", "sql", "pattern", "prompt", "task", "url", "path", "file_path"];
|
|
29
|
+
const GENERIC_DETAIL_ARGS = 3;
|
|
30
|
+
const GENERIC_DETAIL_VALUE_CHARS = 80;
|
|
31
|
+
const FFF_TOOLS = new Map<string, "grep" | "find">([
|
|
32
|
+
["ffgrep", "grep"], ["grep", "grep"],
|
|
33
|
+
["fffind", "find"], ["find", "find"],
|
|
34
|
+
["fff-multi-grep", "grep"], ["multi_grep", "grep"],
|
|
35
|
+
]);
|
|
27
36
|
|
|
28
37
|
/** Per-row slots: Pi's native renderers keep their own state and components here. */
|
|
29
38
|
interface RowState {
|
|
@@ -33,6 +42,7 @@ interface RowState {
|
|
|
33
42
|
callCard?: Box;
|
|
34
43
|
callCardBottom?: Spacer;
|
|
35
44
|
resultCard?: Box;
|
|
45
|
+
hasResult?: boolean;
|
|
36
46
|
startedAt?: number;
|
|
37
47
|
pulse?: ReturnType<typeof setInterval>;
|
|
38
48
|
pulseBright?: boolean;
|
|
@@ -165,6 +175,23 @@ export function callDetail(toolName: string, args: Args, cwd: string): string {
|
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
|
|
178
|
+
/** `Tool(...)` for tools with unknown arguments: the most identifying one, else the first few as `key=value`. */
|
|
179
|
+
export function genericDetail(args: Args, cwd: string): string {
|
|
180
|
+
for (const key of GENERIC_DETAIL_KEYS) {
|
|
181
|
+
const value = args[key];
|
|
182
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
183
|
+
return key === "path" || key === "file_path" ? displayPath(value, cwd) : firstLine(value);
|
|
184
|
+
}
|
|
185
|
+
return Object.entries(args)
|
|
186
|
+
.filter(([, value]) => value !== undefined && value !== null)
|
|
187
|
+
.slice(0, GENERIC_DETAIL_ARGS)
|
|
188
|
+
.map(([key, value]) => {
|
|
189
|
+
const shown = typeof value === "string" ? firstLine(value) : JSON.stringify(value) ?? String(value);
|
|
190
|
+
return `${key}=${shown.slice(0, GENERIC_DETAIL_VALUE_CHARS)}`;
|
|
191
|
+
})
|
|
192
|
+
.join(", ");
|
|
193
|
+
}
|
|
194
|
+
|
|
168
195
|
function details(result: ToolResult): Record<string, unknown> {
|
|
169
196
|
const value: unknown = result.details;
|
|
170
197
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
@@ -235,6 +262,77 @@ export function summarizeResult(toolName: string, result: ToolResult, args: Args
|
|
|
235
262
|
}
|
|
236
263
|
}
|
|
237
264
|
|
|
265
|
+
/**
|
|
266
|
+
* One-line summary for a tool whose output format is unknown, so nothing is parsed out of it.
|
|
267
|
+
* The first line goes last so narrow rows cut it before the counts and duration.
|
|
268
|
+
*/
|
|
269
|
+
export function summarizeGeneric(result: ToolResult, duration = ""): string {
|
|
270
|
+
const body = textOutput(result).split("\n").filter((line) => line.trim());
|
|
271
|
+
const images = result.content.filter((block) => block.type === "image").length;
|
|
272
|
+
const head = sanitizeStatus((body[0] ?? "").replace(/^#+\s+/, ""));
|
|
273
|
+
const info = truncation(result);
|
|
274
|
+
const totalLines = info.truncated && typeof info.totalLines === "number"
|
|
275
|
+
&& Number.isSafeInteger(info.totalLines) && info.totalLines >= body.length
|
|
276
|
+
? info.totalLines : body.length;
|
|
277
|
+
const counts = [
|
|
278
|
+
...(totalLines ? [plural(totalLines, "line")] : []),
|
|
279
|
+
...(images ? [plural(images, "image")] : []),
|
|
280
|
+
].join(" · ");
|
|
281
|
+
return (counts || "Done") + (info.truncated ? " · truncated" : "") + duration + (head ? ` · ${head}` : "");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** FFF's grep count belongs to this page; find's count covers all matching files. */
|
|
285
|
+
function summarizeFff(kind: "grep" | "find", result: ToolResult, duration = ""): string {
|
|
286
|
+
const info = details(result);
|
|
287
|
+
const matched = info.totalMatched;
|
|
288
|
+
if (typeof matched !== "number" || !Number.isSafeInteger(matched) || matched < 0
|
|
289
|
+
|| result.content.some((block) => block.type === "image")) return summarizeGeneric(result, duration);
|
|
290
|
+
const output = textOutput(result).trim();
|
|
291
|
+
const footer = /\n\n\[(Invalid regex: [\s\S]+, used literal match(?:\. Continue with cursor="fff_c\d+")?|[^\n]+)\]$/.exec(output);
|
|
292
|
+
const body = footer ? output.slice(0, footer.index) : output;
|
|
293
|
+
// FFF also returns zero counts for its wildcard guard; keep that explanation first.
|
|
294
|
+
if (matched === 0 && body !== (kind === "grep" ? "No matches found" : "No files found matching pattern")) {
|
|
295
|
+
return summarizeGeneric(result, duration);
|
|
296
|
+
}
|
|
297
|
+
const notice = footer?.[1] ?? "";
|
|
298
|
+
const fuzzy = kind === "grep" && output.startsWith("[0 exact matches. Maybe you meant this?]\n");
|
|
299
|
+
const literal = kind === "grep" && /^Invalid regex: [\s\S]+, used literal match(?:\. Continue with cursor="fff_c\d+")?$/.test(notice);
|
|
300
|
+
let more: boolean;
|
|
301
|
+
let weak: RegExpExecArray | null = null;
|
|
302
|
+
if (kind === "find") {
|
|
303
|
+
if (typeof info.hasMore !== "boolean" || typeof info.pageIndex !== "number"
|
|
304
|
+
|| !Number.isSafeInteger(info.pageIndex) || info.pageIndex < 0) return summarizeGeneric(result, duration);
|
|
305
|
+
// Weak fuzzy results can have hasMore=true while FFF intentionally provides no cursor.
|
|
306
|
+
weak = /^Query "[^\n]*" produced only weak scattered fuzzy matches\. Output capped at (\d+)\/(\d+)\.$/.exec(notice);
|
|
307
|
+
if (weak && (Number(weak[2]) !== matched || Number(weak[1]) > matched)) return summarizeGeneric(result, duration);
|
|
308
|
+
more = info.hasMore && /^\d+ more match(?:es)? available\. cursor="\d+" to continue$/.test(notice);
|
|
309
|
+
if (info.hasMore && !more && !weak) return summarizeGeneric(result, duration);
|
|
310
|
+
} else {
|
|
311
|
+
more = /(?:^|\. )(?:Continue with cursor="fff_c\d+"|More available\. cursor="fff_c\d+" to continue)$/.test(notice);
|
|
312
|
+
}
|
|
313
|
+
const matchType = fuzzy ? "fuzzy " : literal ? "literal " : "";
|
|
314
|
+
const count = kind === "find" ? `${plural(matched, "file")} matched`
|
|
315
|
+
: `${plural(matched, `${matchType}match`, `${matchType}matches`)} on this page`;
|
|
316
|
+
const head = sanitizeStatus(output.split("\n")[fuzzy ? 1 : 0] ?? "");
|
|
317
|
+
return count + (more ? " · more available" : "")
|
|
318
|
+
+ (literal ? " · regex fallback" : "")
|
|
319
|
+
+ (weak ? " · weak matches" + (Number(weak[1]) < matched ? " · capped" : "") : "")
|
|
320
|
+
+ (truncation(result).truncated ? " · truncated" : "")
|
|
321
|
+
+ duration + (head ? ` · ${head}` : "");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Match npm provenance, its installed entry point, or the official git source and entry point. */
|
|
325
|
+
export function fffKind(toolName: string, sourceInfo: SourceInfo | undefined): "grep" | "find" | undefined {
|
|
326
|
+
const kind = FFF_TOOLS.get(toolName);
|
|
327
|
+
if (!kind || !sourceInfo) return undefined;
|
|
328
|
+
const source = sourceInfo.source;
|
|
329
|
+
const path = sourceInfo.path.replace(/\\/g, "/");
|
|
330
|
+
const git = /^(?:git:)?(?:https?:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:|github\.com\/)dmtrkovalenko\/fff(?:\.git)?(?:[@#][^\s]+)?$/i.test(source)
|
|
331
|
+
&& /(?:^|\/)packages\/pi-fff\/src\/index\.ts$/.test(path);
|
|
332
|
+
return source === "npm:@ff-labs/pi-fff" || source.startsWith("npm:@ff-labs/pi-fff@")
|
|
333
|
+
|| /(?:^|\/)node_modules\/@ff-labs\/pi-fff\/src\/index\.ts$/.test(path) || git ? kind : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
238
336
|
/**
|
|
239
337
|
* One-line summary for a failed call: the shell's closing status plus its last output line,
|
|
240
338
|
* otherwise the error's first line.
|
|
@@ -258,11 +356,25 @@ function formatElapsed(ms: number): string {
|
|
|
258
356
|
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
259
357
|
}
|
|
260
358
|
|
|
261
|
-
/**
|
|
262
|
-
function lines(
|
|
359
|
+
/** Stable rows reuse their final lines until width, theme, icons, or invalidation changes them. */
|
|
360
|
+
function lines(
|
|
361
|
+
build: (width: number, icons: IconGlyphs) => string[],
|
|
362
|
+
theme: Theme,
|
|
363
|
+
glyphs: () => IconGlyphs,
|
|
364
|
+
cacheable = true,
|
|
365
|
+
): Component {
|
|
366
|
+
let cache: { width: number; colors: Theme["colors"]; toolCall: string; toolResult: string; lines: string[] } | undefined;
|
|
263
367
|
return {
|
|
264
|
-
render
|
|
265
|
-
|
|
368
|
+
render(width) {
|
|
369
|
+
const icons = glyphs();
|
|
370
|
+
const colors = theme.colors;
|
|
371
|
+
if (cacheable && cache && cache.width === width && cache.colors === colors
|
|
372
|
+
&& cache.toolCall === icons.toolCall && cache.toolResult === icons.toolResult) return cache.lines;
|
|
373
|
+
const rendered = build(width, icons).map((line) => truncateToWidth(line, Math.max(0, width), "…"));
|
|
374
|
+
if (cacheable) cache = { width, colors, toolCall: icons.toolCall, toolResult: icons.toolResult, lines: rendered };
|
|
375
|
+
return rendered;
|
|
376
|
+
},
|
|
377
|
+
invalidate() { cache = undefined; },
|
|
266
378
|
};
|
|
267
379
|
}
|
|
268
380
|
|
|
@@ -286,24 +398,58 @@ function stopPulse(row: RowState): void {
|
|
|
286
398
|
row.pulse = undefined;
|
|
287
399
|
}
|
|
288
400
|
|
|
401
|
+
/** Pi's expanded card for a tool without its own call renderer. */
|
|
402
|
+
function genericCall(toolName: string, args: unknown, theme: Theme): Component {
|
|
403
|
+
const entries = typeof args === "object" && args !== null ? Object.entries(args) : [];
|
|
404
|
+
const rows = entries.map(([key, value]) => {
|
|
405
|
+
const shown = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value);
|
|
406
|
+
return ` ${sanitizeStatus(key)}: ${cleanOutput(shown).replace(/\t/g, " ").split("\n").join("\n ")}`;
|
|
407
|
+
});
|
|
408
|
+
const title = theme.fg("toolTitle", theme.bold(sanitizeStatus(toolName)));
|
|
409
|
+
return new Text(rows.length ? `${title}\n${theme.fg("muted", rows.join("\n"))}` : title, 0, 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Pi's expanded card for a tool without its own result renderer. Pi still draws images it can show. */
|
|
413
|
+
function genericResult(result: ToolResult, theme: Theme, context: RenderContext): Component {
|
|
414
|
+
const inline = getCapabilities().images && context.showImages;
|
|
415
|
+
const missing = inline ? [] : result.content.flatMap((block) => (block.type === "image" ? [imageFallback(block.mimeType, getImageDimensions(block.data, block.mimeType) ?? undefined)] : []));
|
|
416
|
+
const output = [textOutput(result).replace(/\t/g, " "), ...missing].filter(Boolean).join("\n");
|
|
417
|
+
return new Text(output ? theme.fg("toolOutput", output) : "", 0, 0);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `generic` is for tools other than Pi's own built-ins: their output format and renderers are
|
|
422
|
+
* unknown. A known extension can supply its own summary. Native renderers start on expansion;
|
|
423
|
+
* after collapse they still receive completion and error updates, but skip hidden progress.
|
|
424
|
+
*/
|
|
289
425
|
export function createCompactRenderers(
|
|
290
426
|
toolName: string,
|
|
291
|
-
native: ToolRenderers,
|
|
427
|
+
native: ToolRenderers | undefined,
|
|
292
428
|
glyphs: () => IconGlyphs,
|
|
293
429
|
running?: Map<string, RowState | undefined>,
|
|
430
|
+
generic = false,
|
|
431
|
+
summarizeExtension = summarizeGeneric,
|
|
294
432
|
): ToolRenderers {
|
|
295
|
-
const label = TOOL_LABELS[toolName]
|
|
296
|
-
const isShell = toolName === "bash" || toolName === "powershell";
|
|
433
|
+
const label = Object.hasOwn(TOOL_LABELS, toolName) ? TOOL_LABELS[toolName]! : sanitizeStatus(toolName);
|
|
434
|
+
const isShell = !generic && (toolName === "bash" || toolName === "powershell");
|
|
435
|
+
const nativeShell = native?.renderShell;
|
|
436
|
+
const renderNativeCall: RenderCall | undefined = native?.renderCall
|
|
437
|
+
?? (generic ? (args, theme) => genericCall(toolName, args, theme) : undefined);
|
|
438
|
+
const renderNativeResult: RenderResult | undefined = native?.renderResult
|
|
439
|
+
?? (generic ? (result, _options, theme, context) => genericResult(result, theme, context) : undefined);
|
|
297
440
|
// Expanded rows hand off to Pi's own card so nothing is hidden.
|
|
298
441
|
const showNative = (context: RenderContext) => context.expanded;
|
|
442
|
+
// A generic row that was expanded skips hidden progress but still sees the call finish, for a renderer's own timers.
|
|
443
|
+
const feedCollapsedNative = (row: RowState, isPartial: boolean, isError: boolean) =>
|
|
444
|
+
row.nativeCall !== undefined && (!isPartial || isError);
|
|
299
445
|
|
|
300
446
|
return {
|
|
301
447
|
renderShell: "self",
|
|
302
448
|
renderCall(args, theme, context) {
|
|
303
449
|
const row = rowState(context);
|
|
304
450
|
// Keep native timers/edit state current; write rebuilds its full highlight cache only on expansion.
|
|
305
|
-
if (toolName !== "write"
|
|
306
|
-
row.nativeCall =
|
|
451
|
+
if (context.expanded || (generic ? feedCollapsedNative(row, context.isPartial, context.isError) : toolName !== "write")) {
|
|
452
|
+
row.nativeCall = renderNativeCall?.(args, theme, nativeContext(context, row, row.nativeCall));
|
|
307
453
|
}
|
|
308
454
|
if (context.executionStarted) row.startedAt ??= Date.now();
|
|
309
455
|
const live = context.executionStarted && context.isPartial && !context.isError;
|
|
@@ -322,7 +468,7 @@ export function createCompactRenderers(
|
|
|
322
468
|
stopPulse(row);
|
|
323
469
|
}
|
|
324
470
|
if (showNative(context) && row.nativeCall) {
|
|
325
|
-
if (
|
|
471
|
+
if (nativeShell === "self") return row.nativeCall;
|
|
326
472
|
// Recreate Pi's default card: top padding, call, then result (or bottom padding while pending).
|
|
327
473
|
row.callCard ??= new Box(context.outputPad, 0);
|
|
328
474
|
row.callCardBottom ??= new Spacer(1);
|
|
@@ -338,20 +484,33 @@ export function createCompactRenderers(
|
|
|
338
484
|
? live && row.pulseBright !== false ? "accent" : "dim"
|
|
339
485
|
: "success";
|
|
340
486
|
const titleColor = context.isPartial || context.isError ? "toolTitle" : "dim";
|
|
341
|
-
const
|
|
487
|
+
const callArgs = (args ?? {}) as Args;
|
|
488
|
+
const detail = sanitizeStatus(generic ? genericDetail(callArgs, context.cwd) : callDetail(toolName, callArgs, context.cwd));
|
|
342
489
|
const pad = " ".repeat(context.outputPad);
|
|
343
|
-
return lines(() =>
|
|
344
|
-
pad + theme.fg(color,
|
|
345
|
-
+ (detail ? theme.fg(context.isPartial || context.isError ? "muted" : "dim", `(${detail})`) : "")
|
|
346
|
-
|
|
490
|
+
return lines((_width, icons) => {
|
|
491
|
+
const call = pad + theme.fg(color, icons.toolCall) + " " + theme.fg(titleColor, titleColor === "dim" ? label : theme.bold(label))
|
|
492
|
+
+ (detail ? theme.fg(context.isPartial || context.isError ? "muted" : "dim", `(${detail})`) : "");
|
|
493
|
+
// Pi does not call renderResult until the tool provides its first result.
|
|
494
|
+
// Read the shared flag during rendering, after both slots have been prepared.
|
|
495
|
+
if (generic && live && !row.hasResult) {
|
|
496
|
+
const elapsed = row.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - row.startedAt)}`;
|
|
497
|
+
const gutter = `${pad} ${theme.fg("dim", icons.toolResult)} `;
|
|
498
|
+
return [call, gutter + theme.fg("warning", `Running…${elapsed}`)];
|
|
499
|
+
}
|
|
500
|
+
return [call];
|
|
501
|
+
}, theme, glyphs, !context.isPartial);
|
|
347
502
|
},
|
|
348
503
|
|
|
349
504
|
renderResult(result, options, theme, context) {
|
|
350
505
|
const row = rowState(context);
|
|
506
|
+
row.hasResult = true;
|
|
507
|
+
const partial = options.isPartial && !context.isError;
|
|
351
508
|
if (!options.isPartial || context.isError) stopPulse(row);
|
|
352
|
-
|
|
509
|
+
if (!generic || context.expanded || feedCollapsedNative(row, options.isPartial, context.isError)) {
|
|
510
|
+
row.nativeResult = renderNativeResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
|
|
511
|
+
}
|
|
353
512
|
if (showNative(context) && row.nativeResult) {
|
|
354
|
-
if (
|
|
513
|
+
if (nativeShell === "self") return row.nativeResult;
|
|
355
514
|
// The result now closes the card, so the call card drops its pending bottom padding.
|
|
356
515
|
if (row.callCard && row.callCardBottom) row.callCard.removeChild(row.callCardBottom);
|
|
357
516
|
row.resultCard ??= new Box(context.outputPad, 0);
|
|
@@ -364,29 +523,31 @@ export function createCompactRenderers(
|
|
|
364
523
|
}
|
|
365
524
|
const pad = " ".repeat(context.outputPad);
|
|
366
525
|
const args = (context.args ?? {}) as Args;
|
|
367
|
-
if (!options.isPartial && !context.isError && (toolName === "edit" || toolName === "write")) {
|
|
526
|
+
if (!generic && !options.isPartial && !context.isError && (toolName === "edit" || toolName === "write")) {
|
|
368
527
|
const source = toolName === "edit" ? str(details(result).diff) : str(args.content);
|
|
369
528
|
const path = str(args.file_path ?? args.path);
|
|
370
529
|
if (!row.preview || row.preview.source !== source || row.preview.path !== path || row.preview.colors !== theme.colors) {
|
|
371
530
|
row.preview = { source, path, colors: theme.colors, render: prepareToolPreview(toolName, source, path, theme) };
|
|
372
531
|
}
|
|
373
532
|
}
|
|
374
|
-
|
|
375
|
-
|
|
533
|
+
const timed = isShell || generic;
|
|
534
|
+
const duration = timed && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
|
|
535
|
+
const summary = partial ? "" : context.isError
|
|
536
|
+
? summarizeError(generic ? "" : toolName, result, duration)
|
|
537
|
+
: generic ? summarizeExtension(result, duration) : summarizeResult(toolName, result, args) + duration;
|
|
538
|
+
const tail = partial && isShell ? tailLines(textOutput(result), LIVE_TAIL_LINES) : [];
|
|
539
|
+
return lines((width, { toolResult }) => {
|
|
376
540
|
const gutter = `${pad} ${theme.fg("dim", toolResult)} `;
|
|
377
541
|
const indent = `${pad} ${" ".repeat(visibleWidth(toolResult))} `;
|
|
378
|
-
if (
|
|
379
|
-
if (!
|
|
380
|
-
const tail = tailLines(textOutput(result), LIVE_TAIL_LINES).map((line) => theme.fg("toolOutput", line));
|
|
542
|
+
if (partial) {
|
|
543
|
+
if (!timed) return [gutter + theme.fg("warning", "Running…")];
|
|
381
544
|
const elapsed = row.startedAt === undefined ? "" : ` ${formatElapsed(Date.now() - row.startedAt)}`;
|
|
382
|
-
return [...tail, theme.fg("warning", `Running…${elapsed}`)]
|
|
545
|
+
return [...tail.map((line) => theme.fg("toolOutput", line)), theme.fg("warning", `Running…${elapsed}`)]
|
|
383
546
|
.map((line, index) => (index === 0 ? gutter : indent) + line);
|
|
384
547
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return [gutter + theme.fg("dim", summary + duration), ...(row.preview?.render(width, indent) ?? [])];
|
|
389
|
-
});
|
|
548
|
+
if (context.isError) return [gutter + theme.fg("error", summary)];
|
|
549
|
+
return [gutter + theme.fg("dim", summary), ...(row.preview?.render(width, indent) ?? [])];
|
|
550
|
+
}, theme, glyphs, !partial);
|
|
390
551
|
},
|
|
391
552
|
};
|
|
392
553
|
}
|
|
@@ -394,10 +555,10 @@ export function createCompactRenderers(
|
|
|
394
555
|
const OWNED_TOOLS = new Set(Object.keys(TOOL_LABELS));
|
|
395
556
|
|
|
396
557
|
/**
|
|
397
|
-
* Display-only: Pi's tools, execution, arguments and results are untouched. The
|
|
398
|
-
* when Pi builds a row, so
|
|
558
|
+
* Display-only: Pi's tools, execution, arguments and results are untouched. The configuration is
|
|
559
|
+
* read when Pi builds a row, so changing it affects new rows only.
|
|
399
560
|
*/
|
|
400
|
-
export function registerToolView(pi: ExtensionAPI,
|
|
561
|
+
export function registerToolView(pi: ExtensionAPI, getView: () => ToolViewConfig, glyphs: () => IconGlyphs): void {
|
|
401
562
|
const running = new Map<string, RowState | undefined>();
|
|
402
563
|
pi.on("tool_execution_start", (event) => { running.set(event.toolCallId, undefined); });
|
|
403
564
|
pi.on("tool_execution_end", (event) => {
|
|
@@ -411,8 +572,15 @@ export function registerToolView(pi: ExtensionAPI, getMode: () => ToolViewMode,
|
|
|
411
572
|
});
|
|
412
573
|
pi.registerToolRenderer((toolName, next) => {
|
|
413
574
|
const native = next();
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
575
|
+
const view = getView();
|
|
576
|
+
if (view.mode !== "compact" || view.exclude.includes(toolName)) return native;
|
|
577
|
+
const sourceInfo = OWNED_TOOLS.has(toolName) || view.extensionTools && FFF_TOOLS.has(toolName)
|
|
578
|
+
? pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo : undefined;
|
|
579
|
+
const builtin = OWNED_TOOLS.has(toolName) && sourceInfo?.source === "builtin";
|
|
580
|
+
if (builtin) return native && createCompactRenderers(toolName, native, glyphs, running);
|
|
581
|
+
if (!view.extensionTools) return native;
|
|
582
|
+
const fff = fffKind(toolName, sourceInfo);
|
|
583
|
+
return createCompactRenderers(toolName, native, glyphs, running, true,
|
|
584
|
+
fff ? (result, duration) => summarizeFff(fff, result, duration) : summarizeGeneric);
|
|
417
585
|
});
|
|
418
586
|
}
|
|
@@ -133,6 +133,13 @@ export type PrioritizedSegment = {
|
|
|
133
133
|
truncate?: (text: string, maxWidth: number, ellipsis: string) => string;
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
+
export interface SegmentFitOptions {
|
|
137
|
+
/** Gap between adjacent surviving segments, using their original indices. */
|
|
138
|
+
gapWidth?: (previousIndex: number, nextIndex: number) => number;
|
|
139
|
+
/** Keep existing callers' compact order unless the row opts into priorities. */
|
|
140
|
+
compactOrder?: "input" | "priority";
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
/**
|
|
137
144
|
* Pack segments into maxWidth: compact segments first, then shrink/drop the
|
|
138
145
|
* lowest-priority segments (higher priority = survives longer). Returns one
|
|
@@ -142,8 +149,10 @@ export function fitSegmentsByPriority(
|
|
|
142
149
|
segs: readonly PrioritizedSegment[],
|
|
143
150
|
maxW: number,
|
|
144
151
|
ellipsis = "...",
|
|
152
|
+
options: SegmentFitOptions = {},
|
|
145
153
|
): string[] {
|
|
146
|
-
const items = segs.map((s) => ({
|
|
154
|
+
const items = segs.map((s, index) => ({
|
|
155
|
+
index,
|
|
147
156
|
text: s.text,
|
|
148
157
|
compactText: s.compactText,
|
|
149
158
|
priority: s.priority,
|
|
@@ -152,11 +161,16 @@ export function fitSegmentsByPriority(
|
|
|
152
161
|
}));
|
|
153
162
|
const totalW = () => {
|
|
154
163
|
const active = items.filter((it) => it.text !== "");
|
|
155
|
-
return active.reduce((
|
|
164
|
+
return active.reduce((width, item, index) => width + item.w + (index === 0
|
|
165
|
+
? 0
|
|
166
|
+
: options.gapWidth?.(active[index - 1]!.index, item.index) ?? 1), 0);
|
|
156
167
|
};
|
|
157
168
|
// Compact (e.g. cwd -> basename) before sacrificing any segment content.
|
|
158
169
|
if (totalW() > maxW) {
|
|
159
|
-
|
|
170
|
+
const compactItems = options.compactOrder === "priority"
|
|
171
|
+
? [...items].sort((a, b) => a.priority - b.priority || a.index - b.index)
|
|
172
|
+
: items;
|
|
173
|
+
for (const item of compactItems) {
|
|
160
174
|
if (!item.compactText || visibleWidth(item.compactText) >= item.w) continue;
|
|
161
175
|
item.text = item.compactText;
|
|
162
176
|
item.w = visibleWidth(item.text);
|
|
@@ -189,9 +203,7 @@ export function fitSegmentsByPriority(
|
|
|
189
203
|
}
|
|
190
204
|
}
|
|
191
205
|
if (target === -1) break;
|
|
192
|
-
const
|
|
193
|
-
const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
|
|
194
|
-
const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
|
|
206
|
+
const avail = maxW - (nowTotal - items[target].w);
|
|
195
207
|
if (avail <= visibleWidth(ellipsis)) {
|
|
196
208
|
items[target].text = "";
|
|
197
209
|
items[target].w = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
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": {
|
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"files": [
|
|
21
21
|
"extensions/",
|
|
22
|
+
"docs/",
|
|
22
23
|
"assets/",
|
|
24
|
+
"CHANGELOG.md",
|
|
23
25
|
"README.md",
|
|
24
26
|
"README.zh-CN.md",
|
|
25
27
|
"LICENSE"
|
|
26
28
|
],
|
|
27
29
|
"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 tests/copy-buttons.test.ts",
|
|
30
|
+
"test": "node --test tests/settings-command.test.ts tests/footer.test.ts tests/footer-protocol.test.ts tests/footer-segments.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts tests/tool-view.test.ts tests/copy-buttons.test.ts tests/copy-markdown.test.ts",
|
|
29
31
|
"typecheck": "tsc --noEmit"
|
|
30
32
|
},
|
|
31
33
|
"pi": {
|
|
@@ -35,9 +37,9 @@
|
|
|
35
37
|
"image": "https://raw.githubusercontent.com/OldSuns/pi-open-tui/main/assets/preview_dashboard_1.png"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-ai": ">=1.
|
|
39
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
40
|
-
"@earendil-works/pi-tui": ">=1.
|
|
40
|
+
"@earendil-works/pi-ai": ">=1.5.0",
|
|
41
|
+
"@earendil-works/pi-coding-agent": ">=1.5.0",
|
|
42
|
+
"@earendil-works/pi-tui": ">=1.5.0"
|
|
41
43
|
},
|
|
42
44
|
"peerDependenciesMeta": {
|
|
43
45
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -51,9 +53,9 @@
|
|
|
51
53
|
}
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-ai": "^1.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
56
|
-
"@earendil-works/pi-tui": "^1.
|
|
56
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
58
|
+
"@earendil-works/pi-tui": "^1.5.0",
|
|
57
59
|
"@types/node": "^22.0.0",
|
|
58
60
|
"typescript": "^5.5.0"
|
|
59
61
|
},
|