pi-zentui 0.26.0 → 0.27.0

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.
@@ -4,6 +4,16 @@
4
4
 
5
5
  Zentui reads optional user configuration from `~/.pi/agent/zentui.json`. Missing or invalid known values fall back to defaults. Unknown fields are ignored at runtime but preserved on disk by component save operations where they are user-owned migration or future-style data.
6
6
 
7
+ ## Refresh and cache freshness
8
+
9
+ - Settings telemetry checks file signatures on each demanded sync, including inode, size, modification and change times. Ordinary edits, replacements and trust changes reload the snapshot; untrusted project settings are never inspected. Signature equality assumes normal filesystem metadata updates, not content hashing. Read failures retry.
10
+ - Session usage reuses one event-owned snapshot under Pi's append-only session contract. Session/leaf changes and message, agent, settlement, tree and compaction boundaries refresh totals. Silent same-leaf history edits outside those boundaries are not detected by this fast path; the generic usage helper remains mutation-safe.
11
+ - Repository path rendering uses the latest controlled project refresh. Creating or removing a `.git` marker becomes visible on the next demanded event, explicit or interval refresh, not every render. Disabling polling leaves event/explicit refreshes active.
12
+ - Runtime and package discovery share one asynchronous filesystem snapshot per refresh; manifests are read fresh. Runtime versions retain the bounded 32-project cache, 60-second successful-version lifetime and 5-second failure retry, with marker and environment changes invalidating earlier results.
13
+ - Footer syntax uses a 32-entry exact-format cache. Format and alias edits are observed immediately; theme, width, status and quota output are never cached with syntax.
14
+
15
+ Disabled, native and unowned surfaces do not demand these probes on behalf of Zentui. Pi and other extensions may perform their own work.
16
+
7
17
  ## Start with minimal overrides
8
18
 
9
19
  Do not copy the complete defaults into your file. Omitted fields keep defaults and source-aware inheritance. New installs enable Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer; Working line and Thinking (Experimental) are disabled.
@@ -215,7 +225,8 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
215
225
  "contextThresholds": {
216
226
  "warning": 70,
217
227
  "error": 90
218
- }
228
+ },
229
+ "separator": "dash"
219
230
  }
220
231
  }
221
232
  },
@@ -236,6 +247,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
236
247
  },
237
248
  "workingLine": {
238
249
  "enabled": false,
250
+ "placement": "above",
239
251
  "turnSummary": true,
240
252
  "spinner": "star-bloom",
241
253
  "spinnerIntervalMs": 100,
@@ -412,6 +424,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
412
424
  - `components.thinkingSteps` independently owns opt-in **Thinking (Experimental)** display. It defaults to `{ "enabled": false, "mode": "tree" }`; canonical modes are `rail | tree | streaming`. The former persisted `streaming-experimental` value is accepted only as a migration alias and is normalized to `streaming` on save.
413
425
  - All three modes decorate Pi's private host renderer and are tested on exact Pi versions 0.80.5, 0.82.1, 0.83.0, 0.84.0, 0.84.4, and 0.85.1. Active Streaming can switch live to Rail or Tree, and Rail and Tree can switch live between each other. Entering Streaming from a structural mode, first enable, and re-enable after live disable require restart; live disable restores native thinking. Disabled mode changes only preconfigure.
414
426
  - `components.workingLine.enabled` is the sole Working-line ownership switch. Thinking (Experimental) never enables, configures, or owns the Working line and leaves the existing **Thinking time** option unchanged.
427
+ - `components.workingLine.placement` (`above | border`) selects the default floating row or the Minimalist/Opencode top border. Accent Rail supports only Above. Disabled, native, unavailable, or unsafe/narrow editors and Pi versions without working-row visibility control fall back to Above without changing saved choices. Legacy `input` normalizes to `above`; the prompt remains available for typing.
415
428
  - `components.selectorBorders` owns selector-border enablement, fixed `zentui` style, and color source. Disable it for native Pi behavior.
416
429
  - `components.footer` owns `native | starship | hidden` style selection, color source, model label, and Starship options. Hidden hides its main segments, retaining only allowed extension statuses; an empty status line occupies no rows.
417
430
  - Starship's package-version segment reads the project manifest and is distinct from the runtime segment, which reports the installed toolchain.
@@ -521,7 +534,7 @@ Set `ZENTUI_DEBUG=1` when launching Pi to log the workaround diagnostic without
521
534
 
522
535
  Set `components.editor.style` to `minimalist` or select it in `/zentui`. The rounded frame places viewport counts, Bash state, current/completed turn duration, and explicit session name at top left; cost, model, thinking, context, and optional latest-prompt cache hit rate at top right; viewport count plus Git at bottom left; and configured path at bottom right. Unnamed sessions add no placeholder.
523
536
 
524
- Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable.
537
+ Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable. Metadata items are joined with dashes by default (`separator: "dash"`) or dots (`separator: "dot"`).
525
538
 
526
539
  Autocomplete stays inside the frame when Pi output can be split safely. Unknown third-party layouts fail open. Footer visibility remains independently controlled by `components.footer.style`; Minimalist does not remove Pi's header.
527
540
 
@@ -54,10 +54,12 @@ export type WorkingLineSpinner =
54
54
  | "claude-inspired"
55
55
  | "pulse";
56
56
  export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
57
+ export type WorkingLinePlacement = "above" | "border";
57
58
  export type ThinkingStepsMode = "rail" | "tree" | "streaming";
58
59
  export type ComponentStyleOwner = "editor" | "userMessages" | "selectorBorders" | "footer";
59
60
  export type MinimalistPathDisplayMode = "compact" | "project" | "full";
60
61
  export type MinimalistContextFormat = "percent" | "percent-total";
62
+ export type MinimalistEditorSeparator = "dash" | "dot";
61
63
  export type EditorBorderColorMode = "static" | "adaptive";
62
64
  export type CompletionMenuStyle = "native" | "palette";
63
65
  export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
@@ -142,6 +144,7 @@ export type MinimalistEditorStyleConfig = {
142
144
  showCacheHit: boolean;
143
145
  showGit: boolean;
144
146
  contextThresholds: ContextThresholds;
147
+ separator: MinimalistEditorSeparator;
145
148
  };
146
149
 
147
150
  /** Temporary name retained for existing settings consumers. */
@@ -258,6 +261,7 @@ export type WorkingLineComponentConfig = {
258
261
  colorSource: ColorSource;
259
262
  messages: WorkingLineMessagesConfig;
260
263
  segments: WorkingLineSegmentsConfig;
264
+ placement: WorkingLinePlacement;
261
265
  };
262
266
 
263
267
  export type WorkingLineComponentPatch = Partial<
@@ -498,6 +502,7 @@ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
498
502
  showCacheHit: false,
499
503
  showGit: true,
500
504
  contextThresholds: { warning: 70, error: 90 },
505
+ separator: "dash",
501
506
  };
502
507
 
503
508
  const defaultStarshipStyle: StarshipFooterStyleConfig = {
@@ -557,6 +562,7 @@ const defaultComponents: ComponentsConfig = {
557
562
  colorSource: "theme",
558
563
  messages: { custom: true, values: [...PI_WORKING_LINE_MESSAGES] },
559
564
  segments: { tool: true, elapsed: true, thought: true, tokens: true },
565
+ placement: "above",
560
566
  },
561
567
  selectorBorders: { enabled: true, style: "zentui", colorSource: "theme" },
562
568
  footer: {
@@ -1405,6 +1411,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1405
1411
  showCacheHit: parseBoolean(minimalist.showCacheHit, defaultMinimalistStyle.showCacheHit),
1406
1412
  showGit: parseBoolean(minimalist.showGit, defaultMinimalistStyle.showGit),
1407
1413
  contextThresholds: minimalistThresholds,
1414
+ separator:
1415
+ minimalist.separator === "dash" || minimalist.separator === "dot"
1416
+ ? minimalist.separator
1417
+ : defaultMinimalistStyle.separator,
1408
1418
  },
1409
1419
  },
1410
1420
  },
@@ -1466,6 +1476,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1466
1476
  workingLine.textAnimation === "disabled"
1467
1477
  ? workingLine.textAnimation
1468
1478
  : defaultComponents.workingLine.textAnimation,
1479
+ placement:
1480
+ workingLine.placement === "border"
1481
+ ? workingLine.placement
1482
+ : defaultComponents.workingLine.placement,
1469
1483
  colorSource: parseColorSource(
1470
1484
  workingLine.colorSource,
1471
1485
  defaultComponents.workingLine.colorSource,
@@ -1903,6 +1917,7 @@ function applyMinimalistStylePatch(
1903
1917
  if (patch.showCost !== undefined) style.showCost = patch.showCost;
1904
1918
  if (patch.showCacheHit !== undefined) style.showCacheHit = patch.showCacheHit;
1905
1919
  if (patch.showGit !== undefined) style.showGit = patch.showGit;
1920
+ if (patch.separator !== undefined) style.separator = patch.separator;
1906
1921
  if (patch.contextThresholds !== undefined) {
1907
1922
  style.contextThresholds = { ...style.contextThresholds, ...patch.contextThresholds };
1908
1923
  }
@@ -1970,6 +1985,7 @@ export function saveWorkingLineComponentPatch(
1970
1985
  if (patch.textIntervalMs !== undefined) component.textIntervalMs = patch.textIntervalMs;
1971
1986
  if (patch.textAnimation !== undefined) component.textAnimation = patch.textAnimation;
1972
1987
  if (patch.colorSource !== undefined) component.colorSource = patch.colorSource;
1988
+ if (patch.placement !== undefined) component.placement = patch.placement;
1973
1989
  if (patch.messages?.custom !== undefined) component.messages.custom = patch.messages.custom;
1974
1990
  if (patch.messages?.values !== undefined) {
1975
1991
  component.messages.values = normalizeWorkingLineMessages([...patch.messages.values]);
@@ -1,11 +1,17 @@
1
- // Structural subset keeps mouse support optional on Pi versions before 0.85.
2
- export type EditorMouseEvent = {
1
+ import type { EditorComponent } from "@earendil-works/pi-tui";
2
+
3
+ type LegacyEditorMouseEvent = {
3
4
  type: string;
4
5
  x: number;
5
6
  y: number;
6
7
  width: number;
7
8
  height: number;
8
9
  };
10
+ // Use the installed Pi event contract without requiring mouse exports on pre-0.85 Pi.
11
+ type MouseEventFor<T> = T extends { handleMouse?: (event: infer Event) => unknown }
12
+ ? Event
13
+ : LegacyEditorMouseEvent;
14
+ export type EditorMouseEvent = MouseEventFor<EditorComponent>;
9
15
  export type EditorMouseResult = {
10
16
  handled?: boolean;
11
17
  capture?: boolean;
@@ -19,6 +19,131 @@ export type CompactFormatChunk =
19
19
  | { kind: "tokens"; tokens: FormatToken[]; boundary: CompactBoundaryKind }
20
20
  | { kind: "extensions"; boundary: CompactBoundaryKind };
21
21
 
22
+ /** Cached syntax is deeply readonly; public parser/compiler results remain mutable. */
23
+ export type ReadonlyFormatToken =
24
+ | Readonly<Exclude<FormatToken, { kind: "group" }>>
25
+ | { readonly kind: "group"; readonly tokens: readonly ReadonlyFormatToken[] };
26
+
27
+ type ReadonlyCompactChunk =
28
+ | {
29
+ readonly kind: "tokens";
30
+ readonly tokens: readonly ReadonlyFormatToken[];
31
+ readonly boundary: CompactBoundaryKind;
32
+ }
33
+ | { readonly kind: "extensions"; readonly boundary: CompactBoundaryKind };
34
+
35
+ export type CompiledCompactChunk = ReadonlyCompactChunk & {
36
+ readonly references: readonly string[];
37
+ };
38
+
39
+ export type CompiledFooterFormat = {
40
+ readonly tokens: readonly ReadonlyFormatToken[];
41
+ readonly references: readonly string[];
42
+ readonly compact: {
43
+ readonly left: readonly CompiledCompactChunk[];
44
+ readonly right: readonly CompiledCompactChunk[];
45
+ readonly alignRight: boolean;
46
+ };
47
+ };
48
+
49
+ type SyntaxEntry = {
50
+ tokens: readonly ReadonlyFormatToken[];
51
+ compact: {
52
+ left: readonly ReadonlyCompactChunk[];
53
+ right: readonly ReadonlyCompactChunk[];
54
+ alignRight: boolean;
55
+ };
56
+ names: readonly string[];
57
+ canonicalNames?: readonly string[];
58
+ compiled?: CompiledFooterFormat;
59
+ };
60
+
61
+ // Format text only: no session, config object, rendered output, or theme is retained.
62
+ const syntaxCache = new Map<string, SyntaxEntry>();
63
+ const SYNTAX_CACHE_LIMIT = 32;
64
+
65
+ function freezeTokens(tokens: FormatToken[]): readonly ReadonlyFormatToken[] {
66
+ for (const token of tokens) {
67
+ if (token.kind === "group") freezeTokens(token.tokens);
68
+ Object.freeze(token);
69
+ }
70
+ return Object.freeze(tokens);
71
+ }
72
+
73
+ /** Share bounded syntax/analysis across rendering and dependency-demand checks. */
74
+ export function compiledFooterFormat(
75
+ format: string,
76
+ aliases: Readonly<Record<string, string>> = {},
77
+ ): CompiledFooterFormat {
78
+ let entry = syntaxCache.get(format);
79
+ if (!entry) {
80
+ const tokens = parseFooterFormat(format);
81
+ const compact = compileCompactFormatSplit(tokens);
82
+ const names = new Set<string>();
83
+ const visit = (items: FormatToken[]) => {
84
+ for (const token of items) {
85
+ if (token.kind === "group") visit(token.tokens);
86
+ else if (token.kind === "var") names.add(token.name);
87
+ }
88
+ };
89
+ visit(tokens);
90
+ const freezeChunks = (chunks: CompactFormatChunk[]) =>
91
+ Object.freeze(
92
+ chunks.map((chunk) => {
93
+ if (chunk.kind === "tokens") freezeTokens(chunk.tokens);
94
+ return Object.freeze(chunk);
95
+ }),
96
+ );
97
+ entry = {
98
+ tokens: freezeTokens(tokens),
99
+ compact: Object.freeze({
100
+ left: freezeChunks(compact.left),
101
+ right: freezeChunks(compact.right),
102
+ alignRight: compact.alignRight,
103
+ }),
104
+ names: Object.freeze([...names]),
105
+ };
106
+ if (syntaxCache.size >= SYNTAX_CACHE_LIMIT) {
107
+ const oldest = syntaxCache.keys().next().value;
108
+ if (oldest !== undefined) syntaxCache.delete(oldest);
109
+ }
110
+ } else {
111
+ syntaxCache.delete(format);
112
+ }
113
+ syntaxCache.set(format, entry);
114
+ // Compare only names that occur in this syntax, including structural variables.
115
+ // This observes in-place alias edits without retaining mutable caller objects.
116
+ if (
117
+ entry.compiled &&
118
+ entry.names.every((name, i) => (aliases[name] ?? name) === entry.canonicalNames?.[i])
119
+ ) {
120
+ return entry.compiled;
121
+ }
122
+ const references = (tokens: readonly ReadonlyFormatToken[]) =>
123
+ Object.freeze([...collectFooterFormatReferences(tokens, aliases)]);
124
+ const analyzeChunks = (chunks: readonly ReadonlyCompactChunk[]) =>
125
+ Object.freeze(
126
+ chunks.map((chunk) =>
127
+ Object.freeze({
128
+ ...chunk,
129
+ references:
130
+ chunk.kind === "tokens" ? references(chunk.tokens) : Object.freeze([] as string[]),
131
+ }),
132
+ ),
133
+ );
134
+ entry.canonicalNames = Object.freeze(entry.names.map((name) => aliases[name] ?? name));
135
+ entry.compiled = Object.freeze({
136
+ tokens: entry.tokens,
137
+ references: references(entry.tokens),
138
+ compact: Object.freeze({
139
+ left: analyzeChunks(entry.compact.left),
140
+ right: analyzeChunks(entry.compact.right),
141
+ alignRight: entry.compact.alignRight,
142
+ }),
143
+ });
144
+ return entry.compiled;
145
+ }
146
+
22
147
  const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
23
148
 
24
149
  /**
@@ -118,7 +243,7 @@ function parseTokenSlice(
118
243
  * spaces are inserted — the user controls all spacing.
119
244
  */
120
245
  export function renderFormatSplit(
121
- tokens: FormatToken[],
246
+ tokens: readonly ReadonlyFormatToken[],
122
247
  renderVariable: (name: string) => string,
123
248
  ): { left: string; middle: string; right: string } {
124
249
  const fillIndices = findTopLevelFillIndices(tokens);
@@ -224,18 +349,18 @@ function trimBoundaryWhitespace(tokens: FormatToken[]): FormatToken[] {
224
349
  }
225
350
 
226
351
  export function renderFormatTokens(
227
- tokens: FormatToken[],
352
+ tokens: readonly ReadonlyFormatToken[],
228
353
  renderVariable: (name: string) => string,
229
354
  ): string {
230
355
  return renderTokenSlice(tokens, 0, tokens.length, renderVariable);
231
356
  }
232
357
 
233
358
  export function collectFooterFormatReferences(
234
- tokens: FormatToken[],
359
+ tokens: readonly ReadonlyFormatToken[],
235
360
  aliases: Record<string, string> = {},
236
361
  ): Set<string> {
237
362
  const references = new Set<string>();
238
- const visit = (items: FormatToken[]) => {
363
+ const visit = (items: readonly ReadonlyFormatToken[]) => {
239
364
  for (const token of items) {
240
365
  if (token.kind === "group") {
241
366
  visit(token.tokens);
@@ -252,7 +377,7 @@ export function collectFooterFormatReferences(
252
377
  return references;
253
378
  }
254
379
 
255
- function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
380
+ function findTopLevelFillIndices(tokens: readonly ReadonlyFormatToken[]): number[] {
256
381
  const fillIndices: number[] = [];
257
382
  for (let index = 0; index < tokens.length; index++) {
258
383
  if (tokens[index]?.kind === "fill") fillIndices.push(index);
@@ -261,7 +386,7 @@ function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
261
386
  }
262
387
 
263
388
  function renderTokenSlice(
264
- tokens: FormatToken[],
389
+ tokens: readonly ReadonlyFormatToken[],
265
390
  start: number,
266
391
  end: number,
267
392
  renderVariable: (name: string) => string,
@@ -275,7 +400,7 @@ function renderTokenSlice(
275
400
  return result;
276
401
  }
277
402
 
278
- function renderToken(token: FormatToken, renderVariable: (name: string) => string): string {
403
+ function renderToken(token: ReadonlyFormatToken, renderVariable: (name: string) => string): string {
279
404
  if (token.kind === "text") return token.value;
280
405
  if (token.kind === "var") return renderVariable(token.name);
281
406
  if (token.kind === "fill") return "";
@@ -298,7 +423,7 @@ const NON_CONTENT_VARS = new Set(["sep", "separator"]);
298
423
  * ignored for emptiness so orphan themed pipes do not force a group to render.
299
424
  */
300
425
  function isGroupEmpty(
301
- group: FormatToken & { kind: "group" },
426
+ group: ReadonlyFormatToken & { kind: "group" },
302
427
  renderVariable: (name: string) => string,
303
428
  ): boolean {
304
429
  let sawContentVarOrGroup = false;
@@ -317,7 +442,7 @@ function isGroupEmpty(
317
442
  return sawContentVarOrGroup || groupOnlyNonContentVars(group);
318
443
  }
319
444
 
320
- function groupOnlyNonContentVars(group: FormatToken & { kind: "group" }): boolean {
445
+ function groupOnlyNonContentVars(group: ReadonlyFormatToken & { kind: "group" }): boolean {
321
446
  let sawSep = false;
322
447
  for (const child of group.tokens) {
323
448
  if (child.kind === "text") {
@@ -14,10 +14,8 @@ import {
14
14
  sanitizeExtensionStatusText,
15
15
  } from "./extension-status";
16
16
  import {
17
- type CompactFormatChunk,
18
- collectFooterFormatReferences,
19
- compileCompactFormatSplit,
20
- parseFooterFormat,
17
+ type CompiledCompactChunk,
18
+ compiledFooterFormat,
21
19
  renderFormatSplit,
22
20
  renderFormatTokens,
23
21
  stripOrphanSeparators,
@@ -34,6 +32,7 @@ import { truncateFooterText } from "./footer-text";
34
32
  import {
35
33
  buildContextDisplayLabel,
36
34
  buildSessionDurationLabel,
35
+ type ContextUsageSnapshot,
37
36
  contextColorTier,
38
37
  formatCwdLabel,
39
38
  formatGitBranchText,
@@ -219,7 +218,11 @@ export function installFooter(
219
218
  hooks.onDispose?.();
220
219
  },
221
220
  invalidate() {},
222
- render: function renderFooter(width: number, showQuota = true): string[] {
221
+ render: function renderFooter(
222
+ width: number,
223
+ showQuota = true,
224
+ contextSnapshot?: ContextUsageSnapshot,
225
+ ): string[] {
223
226
  if (width <= 0) return [""];
224
227
  const config = getConfig();
225
228
  const footer = config.components.footer;
@@ -230,20 +233,18 @@ export function installFooter(
230
233
  let quotaLabel = styledQuota;
231
234
  let quotaProbe = "";
232
235
  const footerModelLabel = modelLabelFor(state, footer.modelLabel);
233
- const wideFormatTokens = config.components.footer.styles.starship.format
234
- ? parseFooterFormat(config.components.footer.styles.starship.format)
235
- : [];
236
- const compactFormatTokens = config.components.footer.styles.starship.responsive
237
- ? parseFooterFormat(config.components.footer.styles.starship.compactFormat)
238
- : [];
239
- const wideReferences = collectFooterFormatReferences(
240
- wideFormatTokens,
236
+ const wideFormat = compiledFooterFormat(
237
+ footer.styles.starship.format,
241
238
  FOOTER_FORMAT_ALIASES,
242
239
  );
243
- const compactReferences = collectFooterFormatReferences(
244
- compactFormatTokens,
240
+ const compactFormat = compiledFooterFormat(
241
+ footer.styles.starship.responsive ? footer.styles.starship.compactFormat : "",
245
242
  FOOTER_FORMAT_ALIASES,
246
243
  );
244
+ const wideFormatTokens = wideFormat.tokens;
245
+ const compactFormatTokens = compactFormat.tokens;
246
+ const wideReferences = wideFormat.references;
247
+ const compactReferences = compactFormat.references;
247
248
  const colorSource = config.components.footer.colorSource;
248
249
  const iconMode = config.icons.effectiveMode;
249
250
  const pathDisplay = config.components.footer.styles.starship.pathDisplay;
@@ -293,9 +294,9 @@ export function installFooter(
293
294
  );
294
295
  const needsSessionName =
295
296
  (config.components.footer.styles.starship.format
296
- ? wideReferences.has("session_name")
297
+ ? wideReferences.includes("session_name")
297
298
  : config.components.footer.styles.starship.segments.sessionName) ||
298
- compactReferences.has("session_name");
299
+ compactReferences.includes("session_name");
299
300
  const sessionName = needsSessionName
300
301
  ? sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "")
301
302
  : "";
@@ -314,10 +315,21 @@ export function installFooter(
314
315
  config.components.footer.styles.starship.gitBranch.maxLength,
315
316
  )
316
317
  : undefined;
317
- const { percent: contextPercent, contextWindow } = resolveContextUsage(
318
- ctx,
319
- hooks.getLiveContext?.(),
320
- );
318
+ const needsContext =
319
+ (config.components.footer.styles.starship.format
320
+ ? wideReferences.includes("context")
321
+ : config.components.footer.styles.starship.segments.context) ||
322
+ compactReferences.includes("context") ||
323
+ (state.autoCompaction &&
324
+ (wideReferences.includes("auto_compaction") ||
325
+ compactReferences.includes("auto_compaction")));
326
+ // Reuse only within this render's quota fallback, including resolved unknown usage.
327
+ const context =
328
+ contextSnapshot ??
329
+ (needsContext
330
+ ? resolveContextUsage(ctx, hooks.getLiveContext?.())
331
+ : { percent: undefined, contextWindow: undefined });
332
+ const { percent: contextPercent, contextWindow } = context;
321
333
  const contextLabel = buildContextDisplayLabel({
322
334
  percent: contextPercent,
323
335
  contextWindow,
@@ -794,7 +806,7 @@ export function installFooter(
794
806
  (value) => !otherText.includes(value),
795
807
  );
796
808
  // Bounded fail-open fallback if every candidate collides with non-quota text.
797
- if (!letter) return renderFooter(width, false);
809
+ if (!letter) return renderFooter(width, false, context);
798
810
  quotaProbe = codexQuotaText(quota).replace(/[a-z0-9]/gi, letter);
799
811
  quotaLabel = styledQuota.replace(codexQuotaText(quota), quotaProbe);
800
812
  }
@@ -841,7 +853,7 @@ export function installFooter(
841
853
  0,
842
854
  );
843
855
  // Recompose without quota if any occurrence was clipped, split, or omitted.
844
- if (count(source) !== count(framedRows)) return renderFooter(width, false);
856
+ if (count(source) !== count(framedRows)) return renderFooter(width, false, context);
845
857
  return framedRows.map((row) => row.replaceAll(quotaProbe, codexQuotaText(quota)));
846
858
  }
847
859
  return framedRows;
@@ -907,7 +919,9 @@ export function installFooter(
907
919
  return renderVariable(name);
908
920
  }
909
921
  };
910
- const renderCompactChunks = (chunks: CompactFormatChunk[]): CompactLayoutChunk[] => {
922
+ const renderCompactChunks = (
923
+ chunks: readonly CompiledCompactChunk[],
924
+ ): CompactLayoutChunk[] => {
911
925
  const compactChunks: CompactLayoutChunk[] = [];
912
926
  for (const chunk of chunks) {
913
927
  if (chunk.kind === "extensions") {
@@ -927,10 +941,10 @@ export function installFooter(
927
941
  let rendered = stripOrphanSeparators(
928
942
  renderFormatTokens(chunk.tokens, renderCompactVariable),
929
943
  );
930
- const references = collectFooterFormatReferences(chunk.tokens, FOOTER_FORMAT_ALIASES);
944
+ const references = chunk.references;
931
945
  if (
932
- (!quotaLabel || !references.has("codex_quota")) &&
933
- ["cwd", "session_name", "git_branch"].some((name) => references.has(name))
946
+ (!quotaLabel || !references.includes("codex_quota")) &&
947
+ ["cwd", "session_name", "git_branch"].some((name) => references.includes(name))
934
948
  ) {
935
949
  rendered = truncateFooterText(rendered, chunkBudget, "…");
936
950
  }
@@ -938,7 +952,7 @@ export function installFooter(
938
952
  }
939
953
  return compactChunks;
940
954
  };
941
- const compact = compileCompactFormatSplit(compactFormatTokens);
955
+ const compact = compactFormat.compact;
942
956
  const compactLeft = renderCompactChunks(compact.left);
943
957
  const compactRight = renderCompactChunks(compact.right);
944
958
  return frameRows(
@@ -295,6 +295,74 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
295
295
  return totals;
296
296
  }
297
297
 
298
+ const EMPTY_USAGE_TOTALS: UsageTotals = Object.freeze({
299
+ input: 0,
300
+ output: 0,
301
+ cacheRead: 0,
302
+ cacheWrite: 0,
303
+ cost: 0,
304
+ });
305
+
306
+ /**
307
+ * Production-only snapshot for Pi's append-only session contract. The owner must invalidate
308
+ * at message/agent completion, settlement, tree/compaction and session boundaries. Leaf IDs
309
+ * supplement those events; they are NOT a full-history revision. Silent same-leaf in-place
310
+ * edits outside those boundaries require the generic mutation-safe getUsageTotals instead.
311
+ */
312
+ export class EventUsageTotalsController {
313
+ private snapshot?: {
314
+ manager: ExtensionContext["sessionManager"];
315
+ sessionId: string;
316
+ leafId: string | null;
317
+ totals: UsageTotals;
318
+ };
319
+
320
+ invalidate(): void {
321
+ this.snapshot = undefined;
322
+ }
323
+
324
+ resolve(ctx: ExtensionContext, demanded: boolean): UsageTotals {
325
+ if (!demanded) {
326
+ this.invalidate();
327
+ return EMPTY_USAGE_TOTALS;
328
+ }
329
+ const manager = ctx.sessionManager;
330
+ let identity: { sessionId: string; leafId: string | null } | undefined;
331
+ try {
332
+ if (
333
+ typeof manager.getEntries === "function" &&
334
+ typeof manager.getSessionId === "function" &&
335
+ typeof manager.getLeafId === "function"
336
+ ) {
337
+ const sessionId = manager.getSessionId();
338
+ const leafId = manager.getLeafId();
339
+ if (
340
+ typeof sessionId === "string" &&
341
+ sessionId.length > 0 &&
342
+ (leafId === null || (typeof leafId === "string" && leafId.length > 0))
343
+ )
344
+ identity = { sessionId, leafId };
345
+ }
346
+ } catch {
347
+ // Older or unsupported hosts retain the generic mutation-safe path.
348
+ }
349
+ if (!identity) {
350
+ this.invalidate();
351
+ return getUsageTotals(ctx);
352
+ }
353
+ if (
354
+ this.snapshot?.manager === manager &&
355
+ this.snapshot.sessionId === identity.sessionId &&
356
+ this.snapshot.leafId === identity.leafId
357
+ )
358
+ return this.snapshot.totals;
359
+ this.invalidate();
360
+ const totals = computeUsageTotals(manager.getEntries() as readonly SessionEntry[]);
361
+ this.snapshot = { manager, ...identity, totals };
362
+ return totals;
363
+ }
364
+ }
365
+
298
366
  export function buildCacheReadLabel(cacheRead: number): string {
299
367
  return cacheRead > 0 ? `R${formatCount(cacheRead)}` : "";
300
368
  }
@@ -397,12 +465,22 @@ export function resolveContextUsage(
397
465
  ctx: Pick<ExtensionContext, "model" | "getContextUsage">,
398
466
  live?: { tokens: number },
399
467
  ): ContextUsageSnapshot {
468
+ const liveTokens =
469
+ live && Number.isFinite(live.tokens) && live.tokens >= 0 ? live.tokens : undefined;
470
+ const modelWindow = ctx.model?.contextWindow;
471
+ // Live provider usage is authoritative; avoid rebuilding the host session projection.
472
+ if (liveTokens !== undefined && modelWindow && Number.isFinite(modelWindow) && modelWindow > 0) {
473
+ return { percent: (liveTokens / modelWindow) * 100, contextWindow: modelWindow };
474
+ }
400
475
  const usage = ctx.getContextUsage();
401
- const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
476
+ const contextWindow = modelWindow ?? usage?.contextWindow;
402
477
  return {
403
478
  percent:
404
- live && contextWindow && contextWindow > 0
405
- ? (live.tokens / contextWindow) * 100
479
+ liveTokens !== undefined &&
480
+ contextWindow &&
481
+ Number.isFinite(contextWindow) &&
482
+ contextWindow > 0
483
+ ? (liveTokens / contextWindow) * 100
406
484
  : (usage?.percent ?? undefined),
407
485
  contextWindow,
408
486
  };