pi-open-tui 0.4.2 → 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.
@@ -2,6 +2,7 @@ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-cod
2
2
  import { hostname as osHostname } from "node:os";
3
3
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
4
  import type { OpenTuiConfig } from "./config.ts";
5
+ import type { FooterBuiltinSegment, FooterPosition, RegisteredFooterSegment } from "./footer-segments.ts";
5
6
  import type { IconGlyphs } from "./icons.ts";
6
7
  import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
7
8
  import type { GitStatus } from "./git.ts";
@@ -168,12 +169,12 @@ function renderContextBar(
168
169
  return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
169
170
  }
170
171
 
171
- function renderStatsBlock(
172
+ function renderStatsSegments(
172
173
  theme: Theme,
173
174
  totals: UsageTotals,
174
175
  glyphs: IconGlyphs,
175
176
  segments: OpenTuiConfig["footerSegments"],
176
- ): string {
177
+ ): { tokens: string; cost: string } {
177
178
  const stats: string[] = [];
178
179
  if (segments.tokens) {
179
180
  stats.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(totals.input, totals.cacheRead)}`));
@@ -185,11 +186,10 @@ function renderStatsBlock(
185
186
  stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
186
187
  }
187
188
  }
188
- if (segments.cost) {
189
- stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
190
- }
191
-
192
- return stats.join(` ${theme.fg("dim", "|")} `);
189
+ return {
190
+ tokens: stats.join(` ${theme.fg("dim", "|")} `),
191
+ cost: segments.cost ? theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`) : "",
192
+ };
193
193
  }
194
194
 
195
195
  const ANSI_RESET = "\x1b[0m";
@@ -226,6 +226,7 @@ export interface FooterHooks {
226
226
  setRequestRender: (fn: (() => void) | undefined) => void;
227
227
  scheduleGitRefresh: () => void;
228
228
  getLiveTps?: () => LiveTps | null;
229
+ getExtensionSegments?: () => readonly RegisteredFooterSegment[];
229
230
  canEmbedInEditor?: () => boolean;
230
231
  }
231
232
 
@@ -278,6 +279,84 @@ function fitInlineSegments(
278
279
  ).filter(Boolean).join(separator);
279
280
  }
280
281
 
282
+ type FooterSide = "left" | "right";
283
+ type FooterLayoutMode = "plain" | "inline";
284
+
285
+ // Empty built-ins retain their slots so before/after contributions remain visible.
286
+ const FOOTER_ANCHORS: Record<FooterLayoutMode, Record<1 | 2, Record<FooterSide, readonly FooterBuiltinSegment[]>>> = {
287
+ plain: {
288
+ 1: { left: ["cwd", "hostname", "sessionName", "git", "runtime", "timer"], right: ["context"] },
289
+ 2: { left: ["model"], right: ["tokens", "cost"] },
290
+ },
291
+ inline: {
292
+ 1: { left: ["sessionName", "git"], right: ["cwd", "hostname", "runtime", "timer", "context"] },
293
+ 2: { left: ["model"], right: ["tokens", "cost"] },
294
+ },
295
+ };
296
+
297
+ function fitContributedRow(
298
+ row: 1 | 2,
299
+ mode: FooterLayoutMode,
300
+ builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>>,
301
+ contributions: readonly RegisteredFooterSegment[],
302
+ width: number,
303
+ theme: Theme,
304
+ ): InlineFooterLine | undefined {
305
+ let contributed = false;
306
+ const collect = (side: FooterSide): PrioritizedSegment[] => {
307
+ const parts: PrioritizedSegment[] = [];
308
+ const append = (matches: (position: FooterPosition) => boolean) => {
309
+ for (const segment of contributions) {
310
+ if (!matches(segment.position)) continue;
311
+ contributed = true;
312
+ parts.push({
313
+ text: renderStatusText(theme, segment.text),
314
+ compactText: segment.compactText && visibleWidth(segment.compactText) > 0
315
+ ? renderStatusText(theme, segment.compactText)
316
+ : undefined,
317
+ priority: segment.priority,
318
+ });
319
+ }
320
+ };
321
+ const atEdge = (edge: "start" | "end") => append((position) => "row" in position
322
+ && position.row === row && position.side === side && (position.edge ?? "end") === edge);
323
+ atEdge("start");
324
+ for (const anchor of FOOTER_ANCHORS[mode][row][side]) {
325
+ append((position) => "before" in position && position.before === anchor);
326
+ const builtin = builtins[anchor];
327
+ if (builtin?.text) parts.push(builtin);
328
+ append((position) => "after" in position && position.after === anchor);
329
+ }
330
+ atEdge("end");
331
+ return parts;
332
+ };
333
+ const left = collect("left");
334
+ const right = collect("right");
335
+ if (!contributed) return undefined;
336
+
337
+ const leftSeparator = row === 1 && mode === "plain" ? " " : theme.fg("dim", " · ");
338
+ const rightSeparator = row === 1 ? leftSeparator : theme.fg("dim", " | ");
339
+ const fitted = fitSegmentsByPriority([...left, ...right], width, theme.fg("dim", "..."), {
340
+ compactOrder: "priority",
341
+ gapWidth: (previous, next) => {
342
+ if (previous < left.length && next >= left.length) return 1;
343
+ return visibleWidth(next < left.length ? leftSeparator : rightSeparator);
344
+ },
345
+ });
346
+ // Truncation can remove an input's trailing reset; terminate each surviving style.
347
+ const join = (parts: string[], separator: string) => parts.filter(Boolean)
348
+ .map((part) => `${part}${ANSI_RESET}`).join(separator);
349
+ return {
350
+ left: join(fitted.slice(0, left.length), leftSeparator),
351
+ right: join(fitted.slice(left.length), rightSeparator),
352
+ };
353
+ }
354
+
355
+ function alignContributedRow(line: InlineFooterLine, width: number, theme: Theme): string {
356
+ if (!line.right) return truncateToWidth(line.left, width, theme.fg("dim", "..."));
357
+ return alignRight(line.left, line.right, width, theme);
358
+ }
359
+
281
360
  function renderFooterContent(
282
361
  ctx: ExtensionContext,
283
362
  getState: () => FooterState,
@@ -287,6 +366,7 @@ function renderFooterContent(
287
366
  footerData: FooterDataLike,
288
367
  width: number,
289
368
  getLiveTps: () => LiveTps | null,
369
+ getExtensionSegments: () => readonly RegisteredFooterSegment[],
290
370
  ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
291
371
  if (width <= 0) {
292
372
  return {
@@ -310,6 +390,10 @@ function renderFooterContent(
310
390
  }
311
391
  const meta = getModelMeta();
312
392
  const totals = getUsageTotals(ctx);
393
+ const builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>> = {};
394
+ const contributions = segments.extensionSegments
395
+ ? getExtensionSegments().filter((segment) => visibleWidth(segment.text) > 0)
396
+ : [];
313
397
 
314
398
  const leftParts: PrioritizedSegment[] = [];
315
399
  const inlineTopLeftParts: PrioritizedSegment[] = [];
@@ -332,6 +416,7 @@ function renderFooterContent(
332
416
  },
333
417
  });
334
418
  inlineTopRightParts.push(leftParts.at(-1)!);
419
+ builtins.cwd = leftParts.at(-1)!;
335
420
  }
336
421
  if (segments.hostname) {
337
422
  const shortHost = shortHostname(osHostname());
@@ -341,6 +426,7 @@ function renderFooterContent(
341
426
  priority: 3,
342
427
  });
343
428
  inlineTopRightParts.push(leftParts.at(-1)!);
429
+ builtins.hostname = leftParts.at(-1)!;
344
430
  }
345
431
  }
346
432
  const sessionName = ctx.sessionManager.getSessionName();
@@ -352,18 +438,21 @@ function renderFooterContent(
352
438
  if (segments.sessionName) {
353
439
  leftParts.push(sessionPart);
354
440
  inlineTopLeftParts.push(sessionPart);
441
+ builtins.sessionName = sessionPart;
355
442
  }
356
443
  }
357
444
  const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
358
445
  if (gitSeg) {
359
446
  leftParts.push({ text: gitSeg, priority: 1 });
360
447
  inlineTopLeftParts.push(leftParts.at(-1)!);
448
+ builtins.git = leftParts.at(-1)!;
361
449
  }
362
450
  if (segments.runtime) {
363
451
  const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode, segments.nerdIconSpacing);
364
452
  if (runtimeSeg) {
365
453
  leftParts.push({ text: runtimeSeg, priority: 0 });
366
454
  inlineTopRightParts.push(leftParts.at(-1)!);
455
+ builtins.runtime = leftParts.at(-1)!;
367
456
  }
368
457
  }
369
458
  const timerSeg = renderTimerSegment(theme, state, glyphs);
@@ -377,6 +466,7 @@ function renderFooterContent(
377
466
  // Narrow widths shed the live TPS before the timer itself.
378
467
  leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 3 });
379
468
  inlineTopRightParts.push(leftParts.at(-1)!);
469
+ builtins.timer = leftParts.at(-1)!;
380
470
  }
381
471
 
382
472
  // The context bar competes with the left segments for the same row:
@@ -396,16 +486,20 @@ function renderFooterContent(
396
486
  allParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
397
487
  // Context stays at the far right; cwd is the first item in this group.
398
488
  inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
489
+ builtins.context = allParts.at(-1)!;
399
490
  }
400
491
 
401
492
  const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
402
493
  const fittedContext = contextText ? fitted.pop() ?? "" : "";
403
- const line1 = alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
494
+ const contributedTop = fitContributedRow(1, "plain", builtins, contributions, width, theme);
495
+ const line1 = contributedTop
496
+ ? alignContributedRow(contributedTop, width, theme)
497
+ : alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
404
498
 
405
499
  const inlineLeftBudget = Math.floor(width * 0.45);
406
500
  const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
407
501
  const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
408
- const inlineTop: InlineFooterLine = {
502
+ const inlineTop: InlineFooterLine = fitContributedRow(1, "inline", builtins, contributions, width, theme) ?? {
409
503
  left: inlineTopLeft,
410
504
  right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
411
505
  };
@@ -420,9 +514,16 @@ function renderFooterContent(
420
514
  modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
421
515
  }
422
516
  const modelBlock = modelParts.join(theme.fg("dim", " · "));
423
- const statsBlock = renderStatsBlock(theme, totals, glyphs, segments);
424
- const inlineBottom = fitInlineLine(modelBlock, statsBlock, width, theme);
425
- const line2 = alignRight(inlineBottom.left, inlineBottom.right, width, theme);
517
+ const stats = renderStatsSegments(theme, totals, glyphs, segments);
518
+ const statsBlock = [stats.tokens, stats.cost].filter(Boolean).join(` ${theme.fg("dim", "|")} `);
519
+ builtins.model = { text: modelBlock, priority: 0 };
520
+ builtins.tokens = { text: stats.tokens, priority: 2 };
521
+ builtins.cost = { text: stats.cost, priority: 1 };
522
+ const contributedBottom = fitContributedRow(2, "plain", builtins, contributions, width, theme);
523
+ const inlineBottom = contributedBottom ?? fitInlineLine(modelBlock, statsBlock, width, theme);
524
+ const line2 = contributedBottom
525
+ ? alignContributedRow(contributedBottom, width, theme)
526
+ : alignRight(inlineBottom.left, inlineBottom.right, width, theme);
426
527
  const mainLines: [string, string] = [line1, line2]
427
528
  .map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
428
529
  const extensionLines = segments.extensionStatuses
@@ -439,8 +540,11 @@ export function installFooter(
439
540
  hooks: FooterHooks,
440
541
  ): FooterHandle {
441
542
  let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
543
+ let mounted = false;
442
544
 
443
545
  ctx.ui.setFooter((tui, theme, footerData) => {
546
+ mounted = true;
547
+ let disposed = false;
444
548
  hooks.setRequestRender(() => tui.requestRender());
445
549
  const unsubBranch = footerData.onBranchChange(() => {
446
550
  hooks.scheduleGitRefresh();
@@ -455,12 +559,16 @@ export function installFooter(
455
559
  footerData,
456
560
  width,
457
561
  hooks.getLiveTps ?? (() => null),
562
+ hooks.getExtensionSegments ?? (() => []),
458
563
  );
459
564
 
460
565
  renderInline = (width) => getContent(width).inlineLines;
461
566
 
462
567
  return {
463
568
  dispose() {
569
+ if (disposed) return;
570
+ disposed = true;
571
+ mounted = false;
464
572
  unsubBranch();
465
573
  hooks.setRequestRender(undefined);
466
574
  renderInline = () => undefined;
@@ -478,7 +586,7 @@ export function installFooter(
478
586
  return {
479
587
  renderInline: (width) => renderInline(width),
480
588
  cleanup() {
481
- ctx.ui.setFooter(undefined);
589
+ if (mounted) ctx.ui.setFooter(undefined);
482
590
  },
483
591
  };
484
592
  }
@@ -3,6 +3,7 @@ import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, sav
3
3
  import { installCopyButtons, markCopyScope } from "./copy-buttons.ts";
4
4
  import { installEditor } from "./editor.ts";
5
5
  import { installFooter } from "./footer.ts";
6
+ import { createFooterSegments } from "./footer-segments.ts";
6
7
  import { installHeader } from "./header.ts";
7
8
  import { emptyGitStatus, readGitStatus } from "./git.ts";
8
9
  import { readRuntimeInfo } from "./runtime.ts";
@@ -52,6 +53,7 @@ export default function (pi: ExtensionAPI) {
52
53
  let editor: ReturnType<typeof installEditor> | undefined;
53
54
  let copyButtons: ReturnType<typeof installCopyButtons> | undefined;
54
55
  let pendingUiChange: PendingUiChange | undefined;
56
+ const footerSegments = createFooterSegments(pi.events, () => requestFooterRender?.());
55
57
 
56
58
  // thinking-peek state (rendered by Pi inside its hidden-thinking block)
57
59
  const peek: PeekState = createPeekState();
@@ -114,11 +116,14 @@ export default function (pi: ExtensionAPI) {
114
116
  {
115
117
  setRequestRender: (fn) => {
116
118
  requestFooterRender = fn ?? undefined;
119
+ if (!fn) footerSegments.deactivate();
120
+ else if (active && sessionLifecycle.isCurrent()) footerSegments.activate();
117
121
  },
118
122
  scheduleGitRefresh: () => {
119
123
  void scheduleGitRefresh(ctx);
120
124
  },
121
125
  getLiveTps: () => turnTelemetry.getLiveTps(),
126
+ getExtensionSegments: footerSegments.getSegments,
122
127
  canEmbedInEditor: () => editor?.isFramed() ?? false,
123
128
  },
124
129
  );
@@ -138,6 +143,7 @@ export default function (pi: ExtensionAPI) {
138
143
  isEnabled: () => config.copyButtons,
139
144
  });
140
145
  active = true;
146
+ if (requestFooterRender) footerSegments.activate();
141
147
  }
142
148
  };
143
149
 
@@ -237,6 +243,7 @@ export default function (pi: ExtensionAPI) {
237
243
  pi.on("session_shutdown", async (_event, ctx) => {
238
244
  sessionLifecycle.shutdown();
239
245
  stopWorkingTimer();
246
+ footerSegments.deactivate();
240
247
  if (active) {
241
248
  uninstallUi(ctx);
242
249
  }
@@ -373,7 +380,7 @@ export default function (pi: ExtensionAPI) {
373
380
  config = loadConfig();
374
381
  configLoaded = true;
375
382
  }
376
- return config.enabled ? config.toolView.mode : "native";
383
+ return config.enabled ? config.toolView : { ...config.toolView, mode: "native" };
377
384
  },
378
385
  () => resolveGlyphs(config.icons.mode),
379
386
  );