pi-zentui 0.24.0 → 0.25.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.
package/README.md CHANGED
@@ -114,7 +114,7 @@ The displayed preset is derived from your current selections: individual changes
114
114
 
115
115
  ### Minimal overrides
116
116
 
117
- Installation enables Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer. Working line and Thinking (Experimental) remain disabled. Missing fields retain those defaults; there is no automatic migration. Auto icons assume a Nerd Font without detecting one; ASCII changes icons, not the entire UI.
117
+ Installation enables Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer. Working line and Thinking (Experimental) remain disabled. Missing fields retain those defaults; there is no automatic migration. Auto icons use Nerd glyphs for iTerm2, WezTerm, Ghostty, Kitty, and Alacritty environment signals, and otherwise fall back to ASCII. Set `ZENTUI_NERD_FONTS=1` or `0` to override Auto. These environment heuristics cannot detect whether a Nerd Font is installed or configured; explicit `nerd` and `ascii` modes always win. ASCII changes icons, not the entire UI.
118
118
 
119
119
  Change only what you need. For example, this changes only the Editor's accent:
120
120
 
@@ -172,10 +172,12 @@ Pi 0.84 also provides a native fullscreen TUI with a sticky editor and Footer. P
172
172
 
173
173
  **Codex quota (opt-in):** Show remaining 5-hour/weekly quota for `openai-codex` through independent Editor and Starship Footer settings, both off by default. See [configuration and private-endpoint limitations](./docs/configuration.md#codex-account-quota), including `$codex_quota` for custom templates.
174
174
 
175
+ Minimalist can also show the latest assistant prompt's cache hit rate in its top-right metadata. Enable **Editor → Minimalist → Cache hit rate** or set `components.editor.styles.minimalist.showCacheHit` to `true`; it is off by default and does not depend on Footer.
176
+
175
177
  ## Requirements
176
178
 
177
179
  - [Pi](https://pi.dev) coding agent 0.80.5 or newer
178
- - A [Nerd Font](https://www.nerdfonts.com/) for icons, or `icons.mode: "ascii"`
180
+ - A [Nerd Font](https://www.nerdfonts.com/) for Nerd icons; Auto falls back to ASCII when no supported terminal signal is present
179
181
 
180
182
  ## Development
181
183
 
@@ -171,6 +171,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
171
171
  "showSessionName": true,
172
172
  "showTimer": true,
173
173
  "showCost": true,
174
+ "showCacheHit": false,
174
175
  "showGit": true,
175
176
  "contextThresholds": {
176
177
  "warning": 70,
@@ -475,9 +476,9 @@ Set `ZENTUI_DEBUG=1` when launching Pi to log the workaround diagnostic without
475
476
 
476
477
  ### Minimalist
477
478
 
478
- 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, and context at top right; viewport count plus Git at bottom left; and configured path at bottom right. Unnamed sessions add no placeholder.
479
+ 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.
479
480
 
480
- 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`. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, and Git can be hidden independently; model, thinking, and context remain structurally stable.
481
+ 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.
481
482
 
482
483
  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.
483
484
 
@@ -637,9 +638,15 @@ Zentui accepts at most 16 unique keys, keys up to 64 code units, and values up t
637
638
  | `↓` | Behind |
638
639
  | `⇕` | Diverged |
639
640
 
641
+ ## Icon Auto detection
642
+
643
+ `icons.mode: "auto"` preserves Auto in memory and on disk while deriving an effective mode for the current process. Exact `ZENTUI_NERD_FONTS=1` or `0` overrides Auto. Otherwise Auto selects Nerd glyphs when `TERM_PROGRAM` is `iTerm.app`, `WezTerm`, or `ghostty` (case-insensitive), or when `KITTY_WINDOW_ID` or `ALACRITTY_SOCKET` is nonempty. Unknown terminals, VS Code, and Windows Terminal default to ASCII-safe glyphs. Explicit `nerd` and `ascii` modes ignore the override and environment signals.
644
+
645
+ This is conservative terminal-environment detection, not font probing: terminal identity cannot prove that a Nerd Font is installed or configured. Use explicit mode or `ZENTUI_NERD_FONTS` when Auto chooses incorrectly. Custom icon overrides still win over either effective mode.
646
+
640
647
  ## Runtime detection
641
648
 
642
- Runtime/language modules use Starship Nerd Font symbols and defaults such as `bold green` for Node.js. Theme mode maps those styles through Pi; Footer terminal mode uses the terminal colorscheme's ANSI colors.
649
+ Runtime/language modules use Starship Nerd Font symbols and defaults such as `bold green` for Node.js. Theme mode maps those styles through Pi; Footer terminal mode uses the terminal colorscheme's ANSI colors. In Auto mode, runtime, OS, package, rail, and gauge symbols all use the same derived effective icon mode.
643
650
 
644
651
  | Runtime/language | Detection examples |
645
652
  | --- | --- |
@@ -41,8 +41,8 @@ function clampLines(lines: string[], width: number): string[] {
41
41
 
42
42
  function selectedRail(config: ZentuiConfig): string {
43
43
  const style = config.components.editor.styles["accent-rail"];
44
- const fallback = config.icons.mode === "ascii" ? "|" : "▎";
45
- const configured = config.icons.mode === "ascii" ? style.asciiRail : style.rail;
44
+ const fallback = config.icons.effectiveMode === "ascii" ? "|" : "▎";
45
+ const configured = config.icons.effectiveMode === "ascii" ? style.asciiRail : style.rail;
46
46
  const sanitized = sanitizeEditorMetadataText(configured);
47
47
  const glyph = truncateToWidth(sanitized, 1, "");
48
48
  return visibleWidth(glyph) === 1 ? glyph : fallback;
@@ -24,9 +24,9 @@ import {
24
24
  } from "./component-colors";
25
25
  import {
26
26
  ICON_GLYPH_KEYS,
27
+ type IconEnvironment,
27
28
  type IconGlyphs,
28
29
  type IconMode,
29
- NERD_DEFAULT_ICONS,
30
30
  normalizeIconMode,
31
31
  type ResolvedIcons,
32
32
  resolveConfiguredIcons,
@@ -139,6 +139,7 @@ export type MinimalistEditorStyleConfig = {
139
139
  showSessionName: boolean;
140
140
  showTimer: boolean;
141
141
  showCost: boolean;
142
+ showCacheHit: boolean;
142
143
  showGit: boolean;
143
144
  contextThresholds: ContextThresholds;
144
145
  };
@@ -477,6 +478,7 @@ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
477
478
  showSessionName: true,
478
479
  showTimer: true,
479
480
  showCost: true,
481
+ showCacheHit: false,
480
482
  showGit: true,
481
483
  contextThresholds: { warning: 70, error: 90 },
482
484
  };
@@ -550,7 +552,7 @@ const defaultComponents: ComponentsConfig = {
550
552
 
551
553
  export const defaultConfig: PolishedTuiConfig = {
552
554
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
553
- icons: { mode: "auto", ...NERD_DEFAULT_ICONS },
555
+ icons: resolveConfiguredIcons("auto"),
554
556
  colors: {
555
557
  cwd: "bold cyan",
556
558
  sessionName: "bold green",
@@ -1312,6 +1314,7 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1312
1314
  ),
1313
1315
  showTimer: parseBoolean(minimalist.showTimer, defaultMinimalistStyle.showTimer),
1314
1316
  showCost: parseBoolean(minimalist.showCost, defaultMinimalistStyle.showCost),
1317
+ showCacheHit: parseBoolean(minimalist.showCacheHit, defaultMinimalistStyle.showCacheHit),
1315
1318
  showGit: parseBoolean(minimalist.showGit, defaultMinimalistStyle.showGit),
1316
1319
  contextThresholds: minimalistThresholds,
1317
1320
  },
@@ -1529,7 +1532,10 @@ export function hasUnsupportedComponentStyle(
1529
1532
  return unsupportedComponentStyles.get(config)?.has(owner) ?? false;
1530
1533
  }
1531
1534
 
1532
- export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1535
+ export function mergeConfig(
1536
+ parsed: unknown,
1537
+ iconEnvironment: IconEnvironment = process.env,
1538
+ ): PolishedTuiConfig {
1533
1539
  const config = isRecord(parsed) ? parsed : {};
1534
1540
  const iconsRecord = recordValue(config.icons);
1535
1541
  const colorsRecord = recordValue(config.colors);
@@ -1539,6 +1545,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1539
1545
  icons: resolveConfiguredIcons(
1540
1546
  normalizeIconMode(iconsRecord.mode),
1541
1547
  normalizeIconOverrides(iconsRecord),
1548
+ iconEnvironment,
1542
1549
  ),
1543
1550
  colors: {
1544
1551
  ...defaultConfig.colors,
@@ -1806,6 +1813,7 @@ function applyMinimalistStylePatch(
1806
1813
  if (patch.showSessionName !== undefined) style.showSessionName = patch.showSessionName;
1807
1814
  if (patch.showTimer !== undefined) style.showTimer = patch.showTimer;
1808
1815
  if (patch.showCost !== undefined) style.showCost = patch.showCost;
1816
+ if (patch.showCacheHit !== undefined) style.showCacheHit = patch.showCacheHit;
1809
1817
  if (patch.showGit !== undefined) style.showGit = patch.showGit;
1810
1818
  if (patch.contextThresholds !== undefined) {
1811
1819
  style.contextThresholds = { ...style.contextThresholds, ...patch.contextThresholds };
@@ -240,7 +240,7 @@ export function installFooter(
240
240
  FOOTER_FORMAT_ALIASES,
241
241
  );
242
242
  const colorSource = config.components.footer.colorSource;
243
- const iconMode = config.icons.mode;
243
+ const iconMode = config.icons.effectiveMode;
244
244
  const pathDisplay = config.components.footer.styles.starship.pathDisplay;
245
245
  const formattedCwd = sanitizeEditorMetadataText(
246
246
  formatCwdLabel(ctx.cwd, config.icons.cwd, {
@@ -451,7 +451,12 @@ export function installFooter(
451
451
  theme,
452
452
  colorSource,
453
453
  componentColor(config, "footer", "os"),
454
- formatOsLabel(config.icons.os, iconMode),
454
+ formatOsLabel(
455
+ config.icons.os,
456
+ iconMode,
457
+ process.platform,
458
+ config.icons.osOverridden,
459
+ ),
455
460
  );
456
461
  case "time":
457
462
  return renderStyleForSource(
@@ -672,7 +677,7 @@ export function installFooter(
672
677
  theme,
673
678
  colorSource,
674
679
  componentColor(config, "footer", "os"),
675
- formatOsLabel(config.icons.os, iconMode),
680
+ formatOsLabel(config.icons.os, iconMode, process.platform, config.icons.osOverridden),
676
681
  )
677
682
  : "";
678
683
  const left = [
@@ -572,6 +572,7 @@ export function formatOsLabel(
572
572
  configuredIcon: string,
573
573
  mode: IconMode = "auto",
574
574
  platform: string = process.platform,
575
+ osOverridden = false,
575
576
  ): string {
576
- return resolveOsIcon(configuredIcon, mode, platform);
577
+ return resolveOsIcon(configuredIcon, mode, platform, process.env, osOverridden);
577
578
  }
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  export type IconMode = "auto" | "nerd" | "ascii";
10
+ export type EffectiveIconMode = Exclude<IconMode, "auto">;
11
+ export type IconEnvironment = Readonly<Record<string, string | undefined>>;
10
12
 
11
13
  export type IconGlyphs = {
12
14
  cwd: string;
@@ -31,7 +33,14 @@ export type IconGlyphs = {
31
33
  package: string;
32
34
  };
33
35
 
34
- export type ResolvedIcons = IconGlyphs & { mode: IconMode };
36
+ export type ResolvedIcons = IconGlyphs & {
37
+ /** Canonical user choice; Auto is never replaced with an environment-specific value. */
38
+ mode: IconMode;
39
+ /** Runtime-only mode derived from the canonical choice and current environment. */
40
+ effectiveMode: EffectiveIconMode;
41
+ /** Runtime-only provenance needed when an override equals the active mode default. */
42
+ osOverridden: boolean;
43
+ };
35
44
 
36
45
  export const ICON_GLYPH_KEYS = [
37
46
  "cwd",
@@ -192,39 +201,65 @@ export function normalizeIconMode(value: unknown): IconMode {
192
201
  return isIconMode(value) ? value : "auto";
193
202
  }
194
203
 
195
- export function modeDefaultIcons(mode: IconMode): IconGlyphs {
196
- return mode === "ascii" ? { ...ASCII_DEFAULT_ICONS } : { ...NERD_DEFAULT_ICONS };
204
+ export function detectAutoIconMode(env: IconEnvironment): EffectiveIconMode {
205
+ if (env.ZENTUI_NERD_FONTS === "1") return "nerd";
206
+ if (env.ZENTUI_NERD_FONTS === "0") return "ascii";
207
+
208
+ const termProgram = env.TERM_PROGRAM?.toLowerCase();
209
+ if (termProgram === "iterm.app" || termProgram === "wezterm" || termProgram === "ghostty") {
210
+ return "nerd";
211
+ }
212
+ if (env.KITTY_WINDOW_ID?.trim() || env.ALACRITTY_SOCKET?.trim()) return "nerd";
213
+ return "ascii";
214
+ }
215
+
216
+ export function resolveEffectiveIconMode(
217
+ mode: IconMode,
218
+ env: IconEnvironment = process.env,
219
+ ): EffectiveIconMode {
220
+ return mode === "auto" ? detectAutoIconMode(env) : mode;
221
+ }
222
+
223
+ export function modeDefaultIcons(mode: IconMode, env: IconEnvironment = process.env): IconGlyphs {
224
+ return resolveEffectiveIconMode(mode, env) === "ascii"
225
+ ? { ...ASCII_DEFAULT_ICONS }
226
+ : { ...NERD_DEFAULT_ICONS };
197
227
  }
198
228
 
199
229
  export function resolveConfiguredIcons(
200
230
  mode: IconMode,
201
231
  overrides: Partial<IconGlyphs> = {},
232
+ env: IconEnvironment = process.env,
202
233
  ): ResolvedIcons {
203
- const base = modeDefaultIcons(mode);
234
+ const effectiveMode = resolveEffectiveIconMode(mode, env);
235
+ const base = modeDefaultIcons(effectiveMode);
204
236
  const rail =
205
237
  typeof overrides.rail === "string" && overrides.rail.trim().length > 0
206
238
  ? overrides.rail
207
239
  : base.rail;
208
240
  return {
209
241
  mode,
242
+ effectiveMode,
210
243
  ...base,
211
244
  ...overrides,
212
245
  rail,
246
+ osOverridden: typeof overrides.os === "string",
213
247
  };
214
248
  }
215
249
 
216
- /**
217
- * Honor a custom `icons.os` when it differs from the mode default.
218
- * Otherwise map by platform for the active mode.
219
- */
250
+ /** Map the active mode default by platform while preserving explicit `icons.os` overrides. */
220
251
  export function resolveOsIcon(
221
252
  configuredOsIcon: string,
222
253
  mode: IconMode = "auto",
223
254
  platform: string = process.platform,
255
+ env: IconEnvironment = process.env,
256
+ osOverridden = false,
224
257
  ): string {
225
- const modeDefault = modeDefaultIcons(mode).os;
258
+ if (osOverridden) return configuredOsIcon;
259
+ const effectiveMode = resolveEffectiveIconMode(mode, env);
260
+ const modeDefault = modeDefaultIcons(effectiveMode).os;
226
261
  if (configuredOsIcon !== modeDefault) return configuredOsIcon;
227
- const platformMap = mode === "ascii" ? OS_PLATFORM_ICONS_ASCII : OS_PLATFORM_ICONS_NERD;
262
+ const platformMap = effectiveMode === "ascii" ? OS_PLATFORM_ICONS_ASCII : OS_PLATFORM_ICONS_NERD;
228
263
  return platformMap[platform] ?? configuredOsIcon;
229
264
  }
230
265
 
@@ -232,8 +267,9 @@ export function resolveRuntimeSymbol(
232
267
  name: string,
233
268
  nerdSymbol: string,
234
269
  mode: IconMode = "auto",
270
+ env: IconEnvironment = process.env,
235
271
  ): string {
236
- if (mode !== "ascii") return nerdSymbol;
272
+ if (resolveEffectiveIconMode(mode, env) !== "ascii") return nerdSymbol;
237
273
  return RUNTIME_ASCII_SYMBOLS[name] ?? (name.slice(0, 3) || "*");
238
274
  }
239
275
 
@@ -243,8 +279,12 @@ export function resolveRuntimeSymbol(
243
279
  * Honors a configured `icons.package` override; otherwise falls back to the
244
280
  * mode default (Nerd Font preset / ASCII label).
245
281
  */
246
- export function resolvePackageIcon(configuredPackageIcon: string, mode: IconMode = "auto"): string {
247
- const modeDefault = modeDefaultIcons(mode).package;
282
+ export function resolvePackageIcon(
283
+ configuredPackageIcon: string,
284
+ mode: IconMode = "auto",
285
+ env: IconEnvironment = process.env,
286
+ ): string {
287
+ const modeDefault = modeDefaultIcons(mode, env).package;
248
288
  if (typeof configuredPackageIcon === "string" && configuredPackageIcon.length > 0) {
249
289
  return configuredPackageIcon;
250
290
  }
@@ -796,6 +796,7 @@ export default function (pi: ExtensionAPI) {
796
796
  thinkingLevel: getThinkingLevel(),
797
797
  contextPercent: getContextPercent(ctx),
798
798
  contextWindow: getContextWindow(ctx),
799
+ cacheHitRate: state.usageTotals.latestCacheHitRate,
799
800
  sessionName: ctx.sessionManager.getSessionName() ?? "",
800
801
  agentDurationMs: getAgentDurationMs(),
801
802
  agentActive: agentRunActive,
@@ -836,6 +837,7 @@ export default function (pi: ExtensionAPI) {
836
837
  thinkingLevel: getThinkingLevel(),
837
838
  contextPercent: getContextPercent(ctx),
838
839
  contextWindow: getContextWindow(ctx),
840
+ cacheHitRate: state.usageTotals.latestCacheHitRate,
839
841
  sessionName: ctx.sessionManager.getSessionName() ?? "",
840
842
  agentDurationMs: getAgentDurationMs(),
841
843
  agentActive: agentRunActive,
@@ -1500,7 +1502,7 @@ export default function (pi: ExtensionAPI) {
1500
1502
  interactionMetrics.agentEnd();
1501
1503
  pauseAgentRun();
1502
1504
  workingLine.finishAgent(ctx);
1503
- workingLine.updateMetrics(displayTokens, interactionMetrics.currentThought(), ctx);
1505
+ workingLine.flushMetrics(displayTokens, interactionMetrics.currentThought(), ctx);
1504
1506
  // Reconcile once more after Pi has persisted the assistant message.
1505
1507
  syncInteractiveAndProjectStateWithUsage(event, ctx);
1506
1508
  });
@@ -1525,7 +1527,7 @@ export default function (pi: ExtensionAPI) {
1525
1527
  thinkingExperimental.endMessage(event);
1526
1528
  const result = interactionMetrics.messageEnd(event.message);
1527
1529
  if (result.status === "accepted") {
1528
- workingLine.updateMetrics(result.displayTokens, interactionMetrics.currentThought(), ctx);
1530
+ workingLine.flushMetrics(result.displayTokens, interactionMetrics.currentThought(), ctx);
1529
1531
  }
1530
1532
  // Pi notifies extensions before persisting a successful message, so retain its live
1531
1533
  // context until agent_end; accepted failed messages clear immediately instead of showing
@@ -10,6 +10,7 @@ import {
10
10
  bashModeLabel,
11
11
  buildContextGauge,
12
12
  contextColorTier,
13
+ formatCacheHitRate,
13
14
  formatCount,
14
15
  formatCwdLabel,
15
16
  formatElapsedDuration,
@@ -53,6 +54,7 @@ export type MinimalistEditorMetadata = {
53
54
  thinkingLevel?: string;
54
55
  contextPercent?: number;
55
56
  contextWindow?: number;
57
+ cacheHitRate?: number;
56
58
  sessionName?: string;
57
59
  agentDurationMs?: number;
58
60
  agentActive?: boolean;
@@ -256,7 +258,11 @@ function renderTopRight(
256
258
  let context = renderStyleForSource(uiTheme, source, style, text);
257
259
  if (config.components.editor.styles.minimalist.contextGauge) {
258
260
  for (const gaugeWidth of [5, 3]) {
259
- const gauge = `[${buildContextGauge(percent, gaugeWidth, config.icons.mode === "ascii")}] ${text}`;
261
+ const gauge = `[${buildContextGauge(
262
+ percent,
263
+ gaugeWidth,
264
+ config.icons.effectiveMode === "ascii",
265
+ )}] ${text}`;
260
266
  const styledGauge = renderStyleForSource(uiTheme, source, style, gauge);
261
267
  const candidate = joinParts([...parts, styledGauge]);
262
268
  if (visibleWidth(candidate) <= availableWidth) {
@@ -277,6 +283,15 @@ function renderTopRight(
277
283
  }
278
284
  parts.push(context);
279
285
  }
286
+ const cacheHit =
287
+ config.components.editor.styles.minimalist.showCacheHit &&
288
+ metadata.cacheHitRate !== undefined &&
289
+ Number.isFinite(metadata.cacheHitRate)
290
+ ? safeThemeFg(uiTheme, "muted", `Cache ${formatCacheHitRate(metadata.cacheHitRate)}`)
291
+ : "";
292
+ if (cacheHit && (!fit || visibleWidth(joinParts([...parts, cacheHit])) <= availableWidth)) {
293
+ parts.push(cacheHit);
294
+ }
280
295
  const quota = renderCodexQuota(metadata.codexQuota, uiTheme, config, "editor");
281
296
  if (quota && visibleWidth(joinParts([...parts, quota])) <= availableWidth) parts.push(quota);
282
297
  const joined = joinParts(parts);
@@ -570,7 +570,7 @@ function buildAppearanceItems(config: PolishedTuiConfig): SettingItem[] {
570
570
  id: "iconMode",
571
571
  label: "Icon mode",
572
572
  description:
573
- "Auto assumes a Nerd Font (no font detection), as does Nerd. ASCII replaces icons only, not every UI border or glyph.",
573
+ "Auto uses conservative signals: TERM_PROGRAM=iTerm.app|WezTerm|ghostty (case-insensitive), nonempty KITTY_WINDOW_ID or ALACRITTY_SOCKET, and exact ZENTUI_NERD_FONTS=1|0; otherwise ASCII. Environment heuristics cannot detect whether a Nerd Font is installed/configured.",
574
574
  currentValue: config.icons.mode,
575
575
  values: iconModeValues,
576
576
  },
@@ -711,6 +711,13 @@ function buildMinimalistEditorStyleItems(config: PolishedTuiConfig): SettingItem
711
711
  currentValue: featureValue(minimalist.showCost),
712
712
  values: featureStateValues,
713
713
  },
714
+ {
715
+ id: "minimalistShowCacheHit",
716
+ label: "Cache hit rate",
717
+ description: "Show the latest prompt cache hit rate.",
718
+ currentValue: featureValue(minimalist.showCacheHit),
719
+ values: featureStateValues,
720
+ },
714
721
  {
715
722
  id: "minimalistShowGit",
716
723
  label: "Git",
@@ -1723,9 +1730,11 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
1723
1730
  ? "showTimer"
1724
1731
  : id === "minimalistShowCost"
1725
1732
  ? "showCost"
1726
- : id === "minimalistShowGit"
1727
- ? "showGit"
1728
- : undefined;
1733
+ : id === "minimalistShowCacheHit"
1734
+ ? "showCacheHit"
1735
+ : id === "minimalistShowGit"
1736
+ ? "showGit"
1737
+ : undefined;
1729
1738
  if (!key) return;
1730
1739
  deps.setMinimalist({ [key]: enabled }, ctx);
1731
1740
  } else return;
@@ -134,6 +134,7 @@ export function renderEditorSettingsPreview(
134
134
  thinkingLevel: "high",
135
135
  contextPercent: 75,
136
136
  contextWindow: 372_000,
137
+ cacheHitRate: 98.2,
137
138
  sessionName: "Preview",
138
139
  agentDurationMs: 12_000,
139
140
  agentActive: true,
@@ -40,6 +40,8 @@ export const MAX_WORKING_LINE_NORMALIZED_CODE_UNITS = 256;
40
40
  export const MAX_WORKING_LINE_STYLE_TOKENS = 4;
41
41
  export const MAX_WORKING_LINE_STYLE_CODE_UNITS = 48;
42
42
  export const MAX_WORKING_LINE_ENTRIES_EXAMINED = 256;
43
+ /** Minimum interval between metric-only Loader replacements while an assistant streams. */
44
+ export const WORKING_LINE_METRIC_UPDATE_INTERVAL_MS = 50;
43
45
 
44
46
  const WORKING_LINE_FALLBACKS: Record<"low" | "mid" | "high", SourceStyleFallback> = {
45
47
  low: { theme: "dim", terminal: "bright-black" },
@@ -67,6 +69,16 @@ type WorkingLineContext = {
67
69
 
68
70
  type AgentDurationListener = (durationMs: number) => void;
69
71
 
72
+ export type WorkingLineMetricScheduler = {
73
+ setTimeout(callback: () => void, delayMs: number): unknown;
74
+ clearTimeout(handle: unknown): void;
75
+ };
76
+
77
+ const defaultMetricScheduler: WorkingLineMetricScheduler = {
78
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
79
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
80
+ };
81
+
70
82
  /** One agent-duration clock shared by minimalist Editor and Working-line consumers. */
71
83
  export class AgentDurationClock {
72
84
  private startedAt: number | undefined;
@@ -1051,6 +1063,11 @@ export class WorkingLineController {
1051
1063
  private elapsedUpdatesContext: WorkingLineContext | undefined;
1052
1064
  private elapsedUpdatesGeneration = 0;
1053
1065
  private stopElapsedUpdates: (() => void) | undefined;
1066
+ private lastMetricWriteAt: number | undefined;
1067
+ private metricUpdateScheduled = false;
1068
+ private metricUpdateHandle: unknown;
1069
+ private metricUpdateContext: WorkingLineContext | undefined;
1070
+ private metricUpdateGeneration = 0;
1054
1071
 
1055
1072
  constructor(
1056
1073
  private readonly getConfig: () => ZentuiConfig,
@@ -1060,6 +1077,7 @@ export class WorkingLineController {
1060
1077
  private readonly now: () => number = Date.now,
1061
1078
  private readonly getThought: () => WorkingLineRuntimeSegments["thought"] = () => this.thought,
1062
1079
  private readonly onUnavailable: () => void = () => {},
1080
+ private readonly metricScheduler: WorkingLineMetricScheduler = defaultMetricScheduler,
1063
1081
  ) {}
1064
1082
 
1065
1083
  startSession(ctx: WorkingLineContext): WorkingLineReconcileResult {
@@ -1082,6 +1100,7 @@ export class WorkingLineController {
1082
1100
  }
1083
1101
 
1084
1102
  startTurn(ctx: WorkingLineContext): WorkingLineReconcileResult {
1103
+ this.lastMetricWriteAt = undefined;
1085
1104
  const config = this.getConfig().components.workingLine;
1086
1105
  this.activeTools.clear();
1087
1106
  const selectedMessage = selectWorkingLineMessage(config, this.random);
@@ -1099,12 +1118,15 @@ export class WorkingLineController {
1099
1118
  ): void {
1100
1119
  this.tokens = tokens;
1101
1120
  this.thought = thought;
1102
- this.updateIndicator(ctx);
1121
+ this.scheduleMetricIndicator(ctx);
1103
1122
  this.reconcileElapsedUpdates(ctx);
1104
1123
  }
1105
1124
 
1106
1125
  updateTokens(tokens: WorkingLineRuntimeSegments["tokens"], ctx: WorkingLineContext): void {
1107
- this.updateMetrics(tokens, this.getThought(), ctx);
1126
+ this.tokens = tokens;
1127
+ this.thought = this.getThought();
1128
+ this.updateIndicator(ctx);
1129
+ this.reconcileElapsedUpdates(ctx);
1108
1130
  }
1109
1131
 
1110
1132
  updateExtensionSegments(segments: readonly string[], ctx: WorkingLineContext): boolean {
@@ -1143,7 +1165,7 @@ export class WorkingLineController {
1143
1165
  this.updateIndicator(ctx);
1144
1166
  }
1145
1167
 
1146
- settle(
1168
+ flushMetrics(
1147
1169
  tokens: WorkingLineRuntimeSegments["tokens"],
1148
1170
  thought: WorkingLineRuntimeSegments["thought"],
1149
1171
  ctx: WorkingLineContext,
@@ -1154,6 +1176,14 @@ export class WorkingLineController {
1154
1176
  this.reconcileElapsedUpdates(ctx);
1155
1177
  }
1156
1178
 
1179
+ settle(
1180
+ tokens: WorkingLineRuntimeSegments["tokens"],
1181
+ thought: WorkingLineRuntimeSegments["thought"],
1182
+ ctx: WorkingLineContext,
1183
+ ): void {
1184
+ this.flushMetrics(tokens, thought, ctx);
1185
+ }
1186
+
1157
1187
  reconcile(ctx: WorkingLineContext): WorkingLineReconcileResult {
1158
1188
  const config = this.getConfig().components.workingLine;
1159
1189
  if (!config.enabled) {
@@ -1195,6 +1225,7 @@ export class WorkingLineController {
1195
1225
  rebasePhase = false,
1196
1226
  selectedMessage?: string,
1197
1227
  ): WorkingLineReconcileResult {
1228
+ this.cancelMetricIndicator();
1198
1229
  const ui = workingLineUi(ctx);
1199
1230
  if (!ui) {
1200
1231
  this.installed = false;
@@ -1350,6 +1381,7 @@ export class WorkingLineController {
1350
1381
  }
1351
1382
 
1352
1383
  private updateIndicator(ctx: WorkingLineContext): boolean {
1384
+ this.cancelMetricIndicator();
1353
1385
  const rootConfig = this.getConfig();
1354
1386
  if (!rootConfig.components.workingLine.enabled || !this.installed) return false;
1355
1387
  const ui = workingLineUi(ctx);
@@ -1365,6 +1397,49 @@ export class WorkingLineController {
1365
1397
  }
1366
1398
  }
1367
1399
 
1400
+ private scheduleMetricIndicator(ctx: WorkingLineContext): void {
1401
+ const config = this.getConfig().components.workingLine;
1402
+ if (!config.enabled || !this.installed) return;
1403
+ this.metricUpdateContext = ctx;
1404
+ const sampledAt = this.now();
1405
+ const elapsed =
1406
+ this.lastMetricWriteAt === undefined
1407
+ ? Number.POSITIVE_INFINITY
1408
+ : Math.max(0, sampledAt - this.lastMetricWriteAt);
1409
+ if (elapsed >= WORKING_LINE_METRIC_UPDATE_INTERVAL_MS) {
1410
+ this.updateIndicator(ctx);
1411
+ this.lastMetricWriteAt = sampledAt;
1412
+ return;
1413
+ }
1414
+ if (this.metricUpdateScheduled) return;
1415
+ this.metricUpdateScheduled = true;
1416
+ const generation = ++this.metricUpdateGeneration;
1417
+ this.metricUpdateHandle = this.metricScheduler.setTimeout(
1418
+ () => {
1419
+ if (!this.metricUpdateScheduled || generation !== this.metricUpdateGeneration) return;
1420
+ this.metricUpdateScheduled = false;
1421
+ this.metricUpdateHandle = undefined;
1422
+ const latest = this.metricUpdateContext;
1423
+ this.metricUpdateContext = undefined;
1424
+ if (latest) {
1425
+ this.updateIndicator(latest);
1426
+ this.lastMetricWriteAt = this.now();
1427
+ }
1428
+ },
1429
+ Math.max(0, WORKING_LINE_METRIC_UPDATE_INTERVAL_MS - elapsed),
1430
+ );
1431
+ }
1432
+
1433
+ private cancelMetricIndicator(): void {
1434
+ this.metricUpdateContext = undefined;
1435
+ this.metricUpdateGeneration++;
1436
+ if (this.metricUpdateScheduled) {
1437
+ this.metricScheduler.clearTimeout(this.metricUpdateHandle);
1438
+ }
1439
+ this.metricUpdateScheduled = false;
1440
+ this.metricUpdateHandle = undefined;
1441
+ }
1442
+
1368
1443
  private reconcileElapsedUpdates(ctx: WorkingLineContext): void {
1369
1444
  this.elapsedUpdatesContext = ctx;
1370
1445
  const config = this.getConfig().components.workingLine;
@@ -1473,6 +1548,7 @@ export class WorkingLineController {
1473
1548
  }
1474
1549
 
1475
1550
  private reset(ctx: WorkingLineContext): void {
1551
+ this.cancelMetricIndicator();
1476
1552
  this.deactivateElapsedUpdates();
1477
1553
  if (!this.ownsIndicator && !this.ownsMessage) return;
1478
1554
  const ui = workingLineUi(ctx);
@@ -1499,6 +1575,8 @@ export class WorkingLineController {
1499
1575
  }
1500
1576
 
1501
1577
  private clearRuntime(): void {
1578
+ this.cancelMetricIndicator();
1579
+ this.lastMetricWriteAt = undefined;
1502
1580
  this.deactivateElapsedUpdates();
1503
1581
  this.agentActive = false;
1504
1582
  this.activeTools.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",