pi-zentui 0.24.0 → 0.25.1

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;
@@ -137,20 +149,36 @@ export class AgentDurationClock {
137
149
  }
138
150
  }
139
151
 
140
- function segmentGraphemes(value: string): Iterable<string> {
152
+ // Creating an `Intl.Segmenter` per call is very expensive (ICU init). The working
153
+ // line segments the same row for every animation frame, so reuse one instance.
154
+ let sharedGraphemeSegmenter: Intl.Segmenter | undefined;
155
+ let sharedGraphemeSegmenterResolved = false;
156
+
157
+ function getSharedGraphemeSegmenter(): Intl.Segmenter | undefined {
158
+ if (sharedGraphemeSegmenterResolved) return sharedGraphemeSegmenter;
159
+ sharedGraphemeSegmenterResolved = true;
141
160
  try {
142
161
  const Segmenter = Intl.Segmenter;
143
162
  if (typeof Segmenter === "function") {
144
- const segments = new Segmenter(undefined, { granularity: "grapheme" }).segment(value);
145
- return {
146
- *[Symbol.iterator]() {
147
- for (const part of segments) yield part.segment;
148
- },
149
- };
163
+ sharedGraphemeSegmenter = new Segmenter(undefined, { granularity: "grapheme" });
150
164
  }
151
165
  } catch {
152
- // Without Intl.Segmenter, treat the complete value as one conservative grapheme.
166
+ sharedGraphemeSegmenter = undefined;
167
+ }
168
+ return sharedGraphemeSegmenter;
169
+ }
170
+
171
+ function segmentGraphemes(value: string): Iterable<string> {
172
+ const segmenter = getSharedGraphemeSegmenter();
173
+ if (segmenter !== undefined) {
174
+ const segments = segmenter.segment(value);
175
+ return {
176
+ *[Symbol.iterator]() {
177
+ for (const part of segments) yield part.segment;
178
+ },
179
+ };
153
180
  }
181
+ // Without Intl.Segmenter, treat the complete value as one conservative grapheme.
154
182
  return [value];
155
183
  }
156
184
 
@@ -763,6 +791,41 @@ function renderWorkingLineSchedule(
763
791
  const frameStates: WorkingLineFrameState[] = [];
764
792
  let codeUnits = 0;
765
793
  const scheduleOrigin = definition.stateAt(scheduleStartFrame);
794
+ // `row` is invariant across the whole schedule; segment it once instead of per frame.
795
+ const rowCells = graphemeCells(row).cells;
796
+ // Many frames of the schedule share the same textTick/spinnerTick. Render each
797
+ // distinct value once and reuse — this dominates on slow CPUs (e.g. a Pi).
798
+ const textRenderCache = new Map<number, string>();
799
+ const renderTextForTick = (tick: number): string => {
800
+ let cachedText = textRenderCache.get(tick);
801
+ if (cachedText === undefined) {
802
+ cachedText = renderAnimatedText(
803
+ theme,
804
+ config,
805
+ colors,
806
+ rowCells,
807
+ width,
808
+ tick,
809
+ config.textAnimation,
810
+ );
811
+ textRenderCache.set(tick, cachedText);
812
+ }
813
+ return cachedText;
814
+ };
815
+ const spinnerRenderCache = new Map<number, string>();
816
+ const renderSpinnerForTick = (tick: number): string => {
817
+ // Key by the glyph index, not the raw tick: the glyph only repeats every
818
+ // `spinner.frames.length` ticks, so keying raw under-caches badly.
819
+ const frameCount = spinner.frames.length;
820
+ const index = ((tick % frameCount) + frameCount) % frameCount;
821
+ let cachedSpinner = spinnerRenderCache.get(index);
822
+ if (cachedSpinner === undefined) {
823
+ const glyph = spinner.frames[index] ?? spinner.frames[0];
824
+ cachedSpinner = renderTier(theme, config, colors, "high", glyph);
825
+ spinnerRenderCache.set(index, cachedSpinner);
826
+ }
827
+ return cachedSpinner;
828
+ };
766
829
  for (let index = 0; index < definition.frameCount; index += 1) {
767
830
  const scheduled = definition.stateAt(scheduleStartFrame + index);
768
831
  const state = {
@@ -784,15 +847,7 @@ function renderWorkingLineSchedule(
784
847
  state.textTick,
785
848
  config.textAnimation,
786
849
  )}${SGR_RESET}`
787
- : `${renderTier(theme, config, colors, "high", spinnerGlyph)} ${renderAnimatedText(
788
- theme,
789
- config,
790
- colors,
791
- graphemeCells(row).cells,
792
- width,
793
- state.textTick,
794
- config.textAnimation,
795
- )}${SGR_RESET}`;
850
+ : `${renderSpinnerForTick(state.spinnerTick)} ${renderTextForTick(state.textTick)}${SGR_RESET}`;
796
851
  codeUnits += frame.length;
797
852
  if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS) return undefined;
798
853
  frames.push(frame);
@@ -1051,6 +1106,11 @@ export class WorkingLineController {
1051
1106
  private elapsedUpdatesContext: WorkingLineContext | undefined;
1052
1107
  private elapsedUpdatesGeneration = 0;
1053
1108
  private stopElapsedUpdates: (() => void) | undefined;
1109
+ private lastMetricWriteAt: number | undefined;
1110
+ private metricUpdateScheduled = false;
1111
+ private metricUpdateHandle: unknown;
1112
+ private metricUpdateContext: WorkingLineContext | undefined;
1113
+ private metricUpdateGeneration = 0;
1054
1114
 
1055
1115
  constructor(
1056
1116
  private readonly getConfig: () => ZentuiConfig,
@@ -1060,6 +1120,7 @@ export class WorkingLineController {
1060
1120
  private readonly now: () => number = Date.now,
1061
1121
  private readonly getThought: () => WorkingLineRuntimeSegments["thought"] = () => this.thought,
1062
1122
  private readonly onUnavailable: () => void = () => {},
1123
+ private readonly metricScheduler: WorkingLineMetricScheduler = defaultMetricScheduler,
1063
1124
  ) {}
1064
1125
 
1065
1126
  startSession(ctx: WorkingLineContext): WorkingLineReconcileResult {
@@ -1082,6 +1143,7 @@ export class WorkingLineController {
1082
1143
  }
1083
1144
 
1084
1145
  startTurn(ctx: WorkingLineContext): WorkingLineReconcileResult {
1146
+ this.lastMetricWriteAt = undefined;
1085
1147
  const config = this.getConfig().components.workingLine;
1086
1148
  this.activeTools.clear();
1087
1149
  const selectedMessage = selectWorkingLineMessage(config, this.random);
@@ -1099,12 +1161,15 @@ export class WorkingLineController {
1099
1161
  ): void {
1100
1162
  this.tokens = tokens;
1101
1163
  this.thought = thought;
1102
- this.updateIndicator(ctx);
1164
+ this.scheduleMetricIndicator(ctx);
1103
1165
  this.reconcileElapsedUpdates(ctx);
1104
1166
  }
1105
1167
 
1106
1168
  updateTokens(tokens: WorkingLineRuntimeSegments["tokens"], ctx: WorkingLineContext): void {
1107
- this.updateMetrics(tokens, this.getThought(), ctx);
1169
+ this.tokens = tokens;
1170
+ this.thought = this.getThought();
1171
+ this.updateIndicator(ctx);
1172
+ this.reconcileElapsedUpdates(ctx);
1108
1173
  }
1109
1174
 
1110
1175
  updateExtensionSegments(segments: readonly string[], ctx: WorkingLineContext): boolean {
@@ -1143,7 +1208,7 @@ export class WorkingLineController {
1143
1208
  this.updateIndicator(ctx);
1144
1209
  }
1145
1210
 
1146
- settle(
1211
+ flushMetrics(
1147
1212
  tokens: WorkingLineRuntimeSegments["tokens"],
1148
1213
  thought: WorkingLineRuntimeSegments["thought"],
1149
1214
  ctx: WorkingLineContext,
@@ -1154,6 +1219,14 @@ export class WorkingLineController {
1154
1219
  this.reconcileElapsedUpdates(ctx);
1155
1220
  }
1156
1221
 
1222
+ settle(
1223
+ tokens: WorkingLineRuntimeSegments["tokens"],
1224
+ thought: WorkingLineRuntimeSegments["thought"],
1225
+ ctx: WorkingLineContext,
1226
+ ): void {
1227
+ this.flushMetrics(tokens, thought, ctx);
1228
+ }
1229
+
1157
1230
  reconcile(ctx: WorkingLineContext): WorkingLineReconcileResult {
1158
1231
  const config = this.getConfig().components.workingLine;
1159
1232
  if (!config.enabled) {
@@ -1195,6 +1268,7 @@ export class WorkingLineController {
1195
1268
  rebasePhase = false,
1196
1269
  selectedMessage?: string,
1197
1270
  ): WorkingLineReconcileResult {
1271
+ this.cancelMetricIndicator();
1198
1272
  const ui = workingLineUi(ctx);
1199
1273
  if (!ui) {
1200
1274
  this.installed = false;
@@ -1350,6 +1424,7 @@ export class WorkingLineController {
1350
1424
  }
1351
1425
 
1352
1426
  private updateIndicator(ctx: WorkingLineContext): boolean {
1427
+ this.cancelMetricIndicator();
1353
1428
  const rootConfig = this.getConfig();
1354
1429
  if (!rootConfig.components.workingLine.enabled || !this.installed) return false;
1355
1430
  const ui = workingLineUi(ctx);
@@ -1365,6 +1440,49 @@ export class WorkingLineController {
1365
1440
  }
1366
1441
  }
1367
1442
 
1443
+ private scheduleMetricIndicator(ctx: WorkingLineContext): void {
1444
+ const config = this.getConfig().components.workingLine;
1445
+ if (!config.enabled || !this.installed) return;
1446
+ this.metricUpdateContext = ctx;
1447
+ const sampledAt = this.now();
1448
+ const elapsed =
1449
+ this.lastMetricWriteAt === undefined
1450
+ ? Number.POSITIVE_INFINITY
1451
+ : Math.max(0, sampledAt - this.lastMetricWriteAt);
1452
+ if (elapsed >= WORKING_LINE_METRIC_UPDATE_INTERVAL_MS) {
1453
+ this.updateIndicator(ctx);
1454
+ this.lastMetricWriteAt = sampledAt;
1455
+ return;
1456
+ }
1457
+ if (this.metricUpdateScheduled) return;
1458
+ this.metricUpdateScheduled = true;
1459
+ const generation = ++this.metricUpdateGeneration;
1460
+ this.metricUpdateHandle = this.metricScheduler.setTimeout(
1461
+ () => {
1462
+ if (!this.metricUpdateScheduled || generation !== this.metricUpdateGeneration) return;
1463
+ this.metricUpdateScheduled = false;
1464
+ this.metricUpdateHandle = undefined;
1465
+ const latest = this.metricUpdateContext;
1466
+ this.metricUpdateContext = undefined;
1467
+ if (latest) {
1468
+ this.updateIndicator(latest);
1469
+ this.lastMetricWriteAt = this.now();
1470
+ }
1471
+ },
1472
+ Math.max(0, WORKING_LINE_METRIC_UPDATE_INTERVAL_MS - elapsed),
1473
+ );
1474
+ }
1475
+
1476
+ private cancelMetricIndicator(): void {
1477
+ this.metricUpdateContext = undefined;
1478
+ this.metricUpdateGeneration++;
1479
+ if (this.metricUpdateScheduled) {
1480
+ this.metricScheduler.clearTimeout(this.metricUpdateHandle);
1481
+ }
1482
+ this.metricUpdateScheduled = false;
1483
+ this.metricUpdateHandle = undefined;
1484
+ }
1485
+
1368
1486
  private reconcileElapsedUpdates(ctx: WorkingLineContext): void {
1369
1487
  this.elapsedUpdatesContext = ctx;
1370
1488
  const config = this.getConfig().components.workingLine;
@@ -1473,6 +1591,7 @@ export class WorkingLineController {
1473
1591
  }
1474
1592
 
1475
1593
  private reset(ctx: WorkingLineContext): void {
1594
+ this.cancelMetricIndicator();
1476
1595
  this.deactivateElapsedUpdates();
1477
1596
  if (!this.ownsIndicator && !this.ownsMessage) return;
1478
1597
  const ui = workingLineUi(ctx);
@@ -1499,6 +1618,8 @@ export class WorkingLineController {
1499
1618
  }
1500
1619
 
1501
1620
  private clearRuntime(): void {
1621
+ this.cancelMetricIndicator();
1622
+ this.lastMetricWriteAt = undefined;
1502
1623
  this.deactivateElapsedUpdates();
1503
1624
  this.agentActive = false;
1504
1625
  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.1",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",