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.
@@ -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 { ToolViewMode } from "./config.ts";
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
- /** Lines truncated to the render width; rebuilt on every render call. */
262
- function lines(build: (width: number) => string[]): Component {
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: (width) => build(width).map((line) => truncateToWidth(line, Math.max(0, width), "…")),
265
- invalidate() {},
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] ?? 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" || context.expanded) {
306
- row.nativeCall = native.renderCall?.(args, theme, nativeContext(context, row, 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 (native.renderShell === "self") return row.nativeCall;
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 detail = sanitizeStatus(callDetail(toolName, (args ?? {}) as Args, context.cwd));
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, glyphs().toolCall) + " " + theme.fg(titleColor, titleColor === "dim" ? label : theme.bold(label))
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
- row.nativeResult = native.renderResult?.(result, options, theme, nativeContext(context, row, row.nativeResult));
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 (native.renderShell === "self") return row.nativeResult;
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
- return lines((width) => {
375
- const { toolResult } = glyphs();
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 (options.isPartial) {
379
- if (!isShell) return [gutter + theme.fg("warning", "Running…")];
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
- const duration = isShell && context.durationMs !== undefined ? ` · ${formatElapsed(context.durationMs)}` : "";
386
- if (context.isError) return [gutter + theme.fg("error", summarizeError(toolName, result, duration))];
387
- const summary = summarizeResult(toolName, result, args);
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 mode is read
398
- * when Pi builds a row, so switching it affects new rows only.
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, getMode: () => ToolViewMode, glyphs: () => IconGlyphs): void {
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
- if (!native || !OWNED_TOOLS.has(toolName) || getMode() !== "compact") return native;
415
- if (pi.getAllTools().find((tool) => tool.name === toolName)?.sourceInfo?.source !== "builtin") return native;
416
- return createCompactRenderers(toolName, native, glyphs, running);
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((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
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
- for (const item of items) {
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 others = items.filter((_, i) => i !== target && items[i].text !== "");
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.1",
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.2.0",
39
- "@earendil-works/pi-coding-agent": ">=1.2.0",
40
- "@earendil-works/pi-tui": ">=1.2.0"
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.2.0",
55
- "@earendil-works/pi-coding-agent": "^1.2.0",
56
- "@earendil-works/pi-tui": "^1.2.0",
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
  },