pi-open-tui 0.4.2 → 0.4.4

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";
@@ -55,13 +56,14 @@ function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean):
55
56
  );
56
57
  }
57
58
 
58
- /** Compact context form: icon + percentage, no bar or token counts. */
59
- function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs): string {
59
+ /** Compact context form: percentage only, no bar, token counts, or icon when the bar is off. */
60
+ function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs, showBar: boolean): string {
60
61
  const contextUsage = ctx.getContextUsage();
61
62
  const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
62
63
  if (contextWindow <= 0) return "";
63
64
  const contextPct = contextUsage?.percent ?? 0;
64
- return `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
65
+ const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
66
+ return showBar ? `${theme.fg(stressColor(contextPct), glyphs.context)} ${pctText}` : pctText;
65
67
  }
66
68
 
67
69
  function renderGitSegment(
@@ -148,6 +150,7 @@ function renderContextBar(
148
150
  width: number,
149
151
  glyphs: IconGlyphs,
150
152
  iconMode: OpenTuiConfig["icons"]["mode"],
153
+ showBar: boolean,
151
154
  ): string {
152
155
  const contextUsage = ctx.getContextUsage();
153
156
  const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
@@ -160,6 +163,7 @@ function renderContextBar(
160
163
 
161
164
  const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
162
165
  const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
166
+ if (!showBar) return `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
163
167
  const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
164
168
  // Cap the bar at `width - reserved` (with a floor of 4) so the full form
165
169
  // never forces the left segments out before compact/drop logic kicks in.
@@ -168,12 +172,12 @@ function renderContextBar(
168
172
  return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
169
173
  }
170
174
 
171
- function renderStatsBlock(
175
+ function renderStatsSegments(
172
176
  theme: Theme,
173
177
  totals: UsageTotals,
174
178
  glyphs: IconGlyphs,
175
179
  segments: OpenTuiConfig["footerSegments"],
176
- ): string {
180
+ ): { tokens: string; cost: string } {
177
181
  const stats: string[] = [];
178
182
  if (segments.tokens) {
179
183
  stats.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(totals.input, totals.cacheRead)}`));
@@ -185,11 +189,10 @@ function renderStatsBlock(
185
189
  stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
186
190
  }
187
191
  }
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", "|")} `);
192
+ return {
193
+ tokens: stats.join(` ${theme.fg("dim", "|")} `),
194
+ cost: segments.cost ? theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`) : "",
195
+ };
193
196
  }
194
197
 
195
198
  const ANSI_RESET = "\x1b[0m";
@@ -226,6 +229,7 @@ export interface FooterHooks {
226
229
  setRequestRender: (fn: (() => void) | undefined) => void;
227
230
  scheduleGitRefresh: () => void;
228
231
  getLiveTps?: () => LiveTps | null;
232
+ getExtensionSegments?: () => readonly RegisteredFooterSegment[];
229
233
  canEmbedInEditor?: () => boolean;
230
234
  }
231
235
 
@@ -278,6 +282,84 @@ function fitInlineSegments(
278
282
  ).filter(Boolean).join(separator);
279
283
  }
280
284
 
285
+ type FooterSide = "left" | "right";
286
+ type FooterLayoutMode = "plain" | "inline";
287
+
288
+ // Empty built-ins retain their slots so before/after contributions remain visible.
289
+ const FOOTER_ANCHORS: Record<FooterLayoutMode, Record<1 | 2, Record<FooterSide, readonly FooterBuiltinSegment[]>>> = {
290
+ plain: {
291
+ 1: { left: ["cwd", "hostname", "sessionName", "git", "runtime", "timer"], right: ["context"] },
292
+ 2: { left: ["model"], right: ["tokens", "cost"] },
293
+ },
294
+ inline: {
295
+ 1: { left: ["sessionName", "git"], right: ["cwd", "hostname", "runtime", "timer", "context"] },
296
+ 2: { left: ["model"], right: ["tokens", "cost"] },
297
+ },
298
+ };
299
+
300
+ function fitContributedRow(
301
+ row: 1 | 2,
302
+ mode: FooterLayoutMode,
303
+ builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>>,
304
+ contributions: readonly RegisteredFooterSegment[],
305
+ width: number,
306
+ theme: Theme,
307
+ ): InlineFooterLine | undefined {
308
+ let contributed = false;
309
+ const collect = (side: FooterSide): PrioritizedSegment[] => {
310
+ const parts: PrioritizedSegment[] = [];
311
+ const append = (matches: (position: FooterPosition) => boolean) => {
312
+ for (const segment of contributions) {
313
+ if (!matches(segment.position)) continue;
314
+ contributed = true;
315
+ parts.push({
316
+ text: renderStatusText(theme, segment.text),
317
+ compactText: segment.compactText && visibleWidth(segment.compactText) > 0
318
+ ? renderStatusText(theme, segment.compactText)
319
+ : undefined,
320
+ priority: segment.priority,
321
+ });
322
+ }
323
+ };
324
+ const atEdge = (edge: "start" | "end") => append((position) => "row" in position
325
+ && position.row === row && position.side === side && (position.edge ?? "end") === edge);
326
+ atEdge("start");
327
+ for (const anchor of FOOTER_ANCHORS[mode][row][side]) {
328
+ append((position) => "before" in position && position.before === anchor);
329
+ const builtin = builtins[anchor];
330
+ if (builtin?.text) parts.push(builtin);
331
+ append((position) => "after" in position && position.after === anchor);
332
+ }
333
+ atEdge("end");
334
+ return parts;
335
+ };
336
+ const left = collect("left");
337
+ const right = collect("right");
338
+ if (!contributed) return undefined;
339
+
340
+ const leftSeparator = row === 1 && mode === "plain" ? " " : theme.fg("dim", " · ");
341
+ const rightSeparator = row === 1 ? leftSeparator : theme.fg("dim", " | ");
342
+ const fitted = fitSegmentsByPriority([...left, ...right], width, theme.fg("dim", "..."), {
343
+ compactOrder: "priority",
344
+ gapWidth: (previous, next) => {
345
+ if (previous < left.length && next >= left.length) return 1;
346
+ return visibleWidth(next < left.length ? leftSeparator : rightSeparator);
347
+ },
348
+ });
349
+ // Truncation can remove an input's trailing reset; terminate each surviving style.
350
+ const join = (parts: string[], separator: string) => parts.filter(Boolean)
351
+ .map((part) => `${part}${ANSI_RESET}`).join(separator);
352
+ return {
353
+ left: join(fitted.slice(0, left.length), leftSeparator),
354
+ right: join(fitted.slice(left.length), rightSeparator),
355
+ };
356
+ }
357
+
358
+ function alignContributedRow(line: InlineFooterLine, width: number, theme: Theme): string {
359
+ if (!line.right) return truncateToWidth(line.left, width, theme.fg("dim", "..."));
360
+ return alignRight(line.left, line.right, width, theme);
361
+ }
362
+
281
363
  function renderFooterContent(
282
364
  ctx: ExtensionContext,
283
365
  getState: () => FooterState,
@@ -287,6 +369,7 @@ function renderFooterContent(
287
369
  footerData: FooterDataLike,
288
370
  width: number,
289
371
  getLiveTps: () => LiveTps | null,
372
+ getExtensionSegments: () => readonly RegisteredFooterSegment[],
290
373
  ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
291
374
  if (width <= 0) {
292
375
  return {
@@ -310,6 +393,10 @@ function renderFooterContent(
310
393
  }
311
394
  const meta = getModelMeta();
312
395
  const totals = getUsageTotals(ctx);
396
+ const builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>> = {};
397
+ const contributions = segments.extensionSegments
398
+ ? getExtensionSegments().filter((segment) => visibleWidth(segment.text) > 0)
399
+ : [];
313
400
 
314
401
  const leftParts: PrioritizedSegment[] = [];
315
402
  const inlineTopLeftParts: PrioritizedSegment[] = [];
@@ -332,6 +419,7 @@ function renderFooterContent(
332
419
  },
333
420
  });
334
421
  inlineTopRightParts.push(leftParts.at(-1)!);
422
+ builtins.cwd = leftParts.at(-1)!;
335
423
  }
336
424
  if (segments.hostname) {
337
425
  const shortHost = shortHostname(osHostname());
@@ -341,6 +429,7 @@ function renderFooterContent(
341
429
  priority: 3,
342
430
  });
343
431
  inlineTopRightParts.push(leftParts.at(-1)!);
432
+ builtins.hostname = leftParts.at(-1)!;
344
433
  }
345
434
  }
346
435
  const sessionName = ctx.sessionManager.getSessionName();
@@ -352,18 +441,21 @@ function renderFooterContent(
352
441
  if (segments.sessionName) {
353
442
  leftParts.push(sessionPart);
354
443
  inlineTopLeftParts.push(sessionPart);
444
+ builtins.sessionName = sessionPart;
355
445
  }
356
446
  }
357
447
  const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
358
448
  if (gitSeg) {
359
449
  leftParts.push({ text: gitSeg, priority: 1 });
360
450
  inlineTopLeftParts.push(leftParts.at(-1)!);
451
+ builtins.git = leftParts.at(-1)!;
361
452
  }
362
453
  if (segments.runtime) {
363
454
  const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode, segments.nerdIconSpacing);
364
455
  if (runtimeSeg) {
365
456
  leftParts.push({ text: runtimeSeg, priority: 0 });
366
457
  inlineTopRightParts.push(leftParts.at(-1)!);
458
+ builtins.runtime = leftParts.at(-1)!;
367
459
  }
368
460
  }
369
461
  const timerSeg = renderTimerSegment(theme, state, glyphs);
@@ -377,6 +469,7 @@ function renderFooterContent(
377
469
  // Narrow widths shed the live TPS before the timer itself.
378
470
  leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 3 });
379
471
  inlineTopRightParts.push(leftParts.at(-1)!);
472
+ builtins.timer = leftParts.at(-1)!;
380
473
  }
381
474
 
382
475
  // The context bar competes with the left segments for the same row:
@@ -384,8 +477,8 @@ function renderFooterContent(
384
477
  let contextText = "";
385
478
  let contextCompact: string | undefined;
386
479
  if (segments.context) {
387
- contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
388
- const compact = renderContextCompact(theme, ctx, glyphs);
480
+ contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode, segments.contextBar);
481
+ const compact = renderContextCompact(theme, ctx, glyphs, segments.contextBar);
389
482
  if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
390
483
  contextCompact = compact;
391
484
  }
@@ -396,16 +489,20 @@ function renderFooterContent(
396
489
  allParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
397
490
  // Context stays at the far right; cwd is the first item in this group.
398
491
  inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
492
+ builtins.context = allParts.at(-1)!;
399
493
  }
400
494
 
401
495
  const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
402
496
  const fittedContext = contextText ? fitted.pop() ?? "" : "";
403
- const line1 = alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
497
+ const contributedTop = fitContributedRow(1, "plain", builtins, contributions, width, theme);
498
+ const line1 = contributedTop
499
+ ? alignContributedRow(contributedTop, width, theme)
500
+ : alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
404
501
 
405
502
  const inlineLeftBudget = Math.floor(width * 0.45);
406
503
  const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
407
504
  const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
408
- const inlineTop: InlineFooterLine = {
505
+ const inlineTop: InlineFooterLine = fitContributedRow(1, "inline", builtins, contributions, width, theme) ?? {
409
506
  left: inlineTopLeft,
410
507
  right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
411
508
  };
@@ -420,9 +517,16 @@ function renderFooterContent(
420
517
  modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
421
518
  }
422
519
  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);
520
+ const stats = renderStatsSegments(theme, totals, glyphs, segments);
521
+ const statsBlock = [stats.tokens, stats.cost].filter(Boolean).join(` ${theme.fg("dim", "|")} `);
522
+ builtins.model = { text: modelBlock, priority: 0 };
523
+ builtins.tokens = { text: stats.tokens, priority: 2 };
524
+ builtins.cost = { text: stats.cost, priority: 1 };
525
+ const contributedBottom = fitContributedRow(2, "plain", builtins, contributions, width, theme);
526
+ const inlineBottom = contributedBottom ?? fitInlineLine(modelBlock, statsBlock, width, theme);
527
+ const line2 = contributedBottom
528
+ ? alignContributedRow(contributedBottom, width, theme)
529
+ : alignRight(inlineBottom.left, inlineBottom.right, width, theme);
426
530
  const mainLines: [string, string] = [line1, line2]
427
531
  .map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
428
532
  const extensionLines = segments.extensionStatuses
@@ -439,8 +543,11 @@ export function installFooter(
439
543
  hooks: FooterHooks,
440
544
  ): FooterHandle {
441
545
  let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
546
+ let mounted = false;
442
547
 
443
548
  ctx.ui.setFooter((tui, theme, footerData) => {
549
+ mounted = true;
550
+ let disposed = false;
444
551
  hooks.setRequestRender(() => tui.requestRender());
445
552
  const unsubBranch = footerData.onBranchChange(() => {
446
553
  hooks.scheduleGitRefresh();
@@ -455,12 +562,16 @@ export function installFooter(
455
562
  footerData,
456
563
  width,
457
564
  hooks.getLiveTps ?? (() => null),
565
+ hooks.getExtensionSegments ?? (() => []),
458
566
  );
459
567
 
460
568
  renderInline = (width) => getContent(width).inlineLines;
461
569
 
462
570
  return {
463
571
  dispose() {
572
+ if (disposed) return;
573
+ disposed = true;
574
+ mounted = false;
464
575
  unsubBranch();
465
576
  hooks.setRequestRender(undefined);
466
577
  renderInline = () => undefined;
@@ -478,7 +589,7 @@ export function installFooter(
478
589
  return {
479
590
  renderInline: (width) => renderInline(width),
480
591
  cleanup() {
481
- ctx.ui.setFooter(undefined);
592
+ if (mounted) ctx.ui.setFooter(undefined);
482
593
  },
483
594
  };
484
595
  }
@@ -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
  );