pi-zentui 0.15.1 → 0.16.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
@@ -21,7 +21,7 @@ Zentui brings two popular aesthetics to Pi:
21
21
  - `on branch` — git branch with icon
22
22
  - `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
23
23
  - `via v5.5.0` — runtime detection with version and Starship-style Nerd Font runtime/language modules
24
- - Optional segments (off by default): session name, `user@host`, current time, OS icon, session duration, and the **project package version** (e.g. `package.json` → `0.6.0`) — distinct from the runtime segment, which shows the installed toolchain
24
+ - Optional segments (off by default): selected model/provider, `user@host`, current time, OS icon, session duration, and the **project package version** (e.g. `package.json` → `0.6.0`) — distinct from the runtime segment, which shows the installed toolchain
25
25
  - Right side shows context usage, token counts, and cost
26
26
  - Built-in footer segments can be shown or hidden individually from `/zentui`
27
27
  - Fully custom Starship-style layout via a `footerFormat` template string — see [Footer Format Template](#footer-format-template)
@@ -133,9 +133,9 @@ User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or
133
133
  The interactive `/zentui` menu is split into exactly six sections, in this order. Use `Tab` and `Shift+Tab` to switch sections. Every listed control patches the shown JSON equivalent:
134
134
 
135
135
  1. **Appearance** — Starship/footer colors (`colorSources.starship`); editor + previous-message colors (`colorSources.editor` and `colorSources.userMessages`); separator (`separator`); icon mode (`icons.mode`).
136
- 2. **Editor** — editor enabled (`features.editor`); editor model label (`editorModelLabel`); copy-friendly mode (`features.copyFriendly`); viewport indicators (`features.viewportIndicators`); fixed editor (`fixedEditor.enabled`); and, while fixed editor is enabled, mouse scroll (`fixedEditor.mouseScroll`) and copy notice (`fixedEditor.copyNotice`).
136
+ 2. **Editor** — editor enabled (`features.editor`); editor border color mode (`editorBorderColorMode`); editor model label (`editorModelLabel`); copy-friendly mode (`features.copyFriendly`); viewport indicators (`features.viewportIndicators`); fixed editor (`fixedEditor.enabled`); and, while fixed editor is enabled, mouse scroll (`fixedEditor.mouseScroll`) and copy notice (`fixedEditor.copyNotice`).
137
137
  3. **Footer** — status line enabled (`features.statusLine`); responsive footer (`responsiveFooter`); compact footer rows (`compactFooterMaxLines`); context style (`contextStyle`); path display (`pathDisplay.mode`); path depth (`pathDisplay.depth`).
138
- 4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
138
+ 4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `modelInfo`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
139
139
  5. **Git** — Git branch visibility (`footerSegments.gitBranch`); branch length (`gitBranch.maxLength`); Git status visibility (`footerSegments.gitStatus`); Git counts visibility (`footerSegments.gitCounts`); Git commit visibility (`footerSegments.gitCommit`); commit-only-detached (`gitCommit.onlyDetached`); exact-match tag (`gitCommit.showTag`); Git metrics visibility (`footerSegments.gitMetrics`); hide zero metrics (`gitMetrics.onlyNonzero`); ignore submodules (`gitMetrics.ignoreSubmodules`).
140
140
  6. **Extensions** — default placement (`extensionStatuses.defaultPlacement`) first, followed by placement (`extensionStatuses.placements[key]`) and color (`extensionStatuses.colorModes[key]`) controls for currently active status keys.
141
141
 
@@ -176,6 +176,7 @@ Default config values — copy this and change any value you want:
176
176
  "separator": "pipe",
177
177
  "contextStyle": "text",
178
178
  "editorModelLabel": "id",
179
+ "editorBorderColorMode": "static",
179
180
  "contextThresholds": {
180
181
  "warning": 70,
181
182
  "error": 90
@@ -260,6 +261,7 @@ Default config values — copy this and change any value you want:
260
261
  "gitStatus": true,
261
262
  "gitCounts": false,
262
263
  "runtime": true,
264
+ "modelInfo": false,
263
265
  "context": true,
264
266
  "tokens": true,
265
267
  "cost": true,
@@ -295,8 +297,9 @@ Default config values — copy this and change any value you want:
295
297
 
296
298
  - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
297
299
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
298
- - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
299
- - `editorModelLabel`: controls the model shown in the editor frame. `id` (default) shows the model id; `name` shows the model's display name (including custom `name` values set in `models.json`), falling back to the id when no name is set.
300
+ - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Finite context percentages use one decimal place. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
301
+ - `editorModelLabel`: controls the model shown in the editor frame, built-in model-info footer segment, and footer `$model` variable. `id` (default) shows the model id; `name` shows the model's display name (including custom `name` values set in `models.json`), falling back to the id when no name is set.
302
+ - `editorBorderColorMode`: `static` (default) uses `colors.editorBorder`; `adaptive` follows Pi's current shell-mode and thinking-level editor border color. Cycle it from the `/zentui` **Editor** tab.
300
303
  - `editorMetadataFormat`: JSON-only template for the left side of the editor metadata row. Missing, non-string, or empty values restore the default `$model $provider( $thinking)` layout; non-empty strings, including whitespace-only strings, are preserved. See [Editor Metadata Format](#editor-metadata-format) below.
301
304
  - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Appearance** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
302
305
  - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
@@ -305,7 +308,7 @@ Default config values — copy this and change any value you want:
305
308
  - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
306
309
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
307
310
  - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. `viewportIndicators` preserves Pi's native `↑ N more` / `↓ N more` wrapped-row counts in Zentui's editor borders (default `true`). All four can be changed from `/zentui` or direct slash-command arguments.
308
- - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
311
+ - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `modelInfo`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). `modelInfo` is off by default and shows the selected model plus the provider unless the model label already contains it. Toggle non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
309
312
  - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Footer** tab configures responsive behavior, compact rows, context style, and path display mode/depth; **Appearance** configures separator and icon mode; **Git** configures branch length; set or clear custom formats with `/zentui format`.
310
313
  - `responsiveFooter`: enabled by default. Zentui keeps the current aligned one-row footer while every settings-resolved left/middle/right zone fits without layout truncation. Otherwise it tries two complete left-aligned rows, preferring `left` / `middle right` and then `left middle` / `right`. Only when neither split fits does it use `compactFooterFormat`. Set `false` to restore the legacy one-row fitting behavior. Selection uses measured terminal-cell width, not fixed device breakpoints.
311
314
  - `compactFooterFormat`: JSON-only template used by the compact stage. The default keeps cwd, session name, git branch/status, context, and abbreviated token/cache metrics. A top-level `$wrap` is an automatic wrap opportunity: one space on the same row or no space at a row break. `$wrap_sep` is the same kind of boundary but renders the styled ` | ` divider only when its adjacent chunks share a row. Nested uses of either boundary remain empty variables. `$fill` is ignored. A standalone `$extensions` chunk inserts active non-`off` extension statuses in left/middle/right placement order; embedded uses render empty. Custom `footerFormat` values use this built-in compact fallback unless this key is also customized.
@@ -316,7 +319,7 @@ Default config values — copy this and change any value you want:
316
319
  - The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
317
320
  - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
318
321
  - `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
319
- - `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
322
+ - `editorBorder` styles previous user-message top/bottom borders and the active editor in static border color mode; the border glyph stays `─`.
320
323
  - `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
321
324
 
322
325
  Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
@@ -381,16 +384,22 @@ Center the branch between directory and cost:
381
384
  | `$git_added` | | added line count (`+N`) |
382
385
  | `$git_deleted` | | deleted line count (`−N`) |
383
386
  | `$runtime` | | runtime icon + version |
387
+ | `$model` | | selected model label (`editorModelLabel`) |
388
+ | `$provider` | | formatted provider label |
384
389
  | `$package` | | project package version, `is <glyph> <version>` (manifest-derived) |
385
390
  | `$package_version` | | raw project package version (no icon) |
386
391
  | `$session_duration` | `$duration` | session running time |
387
392
  | `$username` | | `user@host` |
388
393
  | `$os` | | operating-system icon |
389
394
  | `$time` | | current time `HH:MM` |
390
- | `$context` | | context usage (text and/or gauge via config) |
391
- | `$tokens` | | input/output token counts |
395
+ | `$context` | | context usage (text and/or gauge; finite percentages use one decimal) |
396
+ | `$tokens` | | input/output counts and existing cache-hit percentage |
397
+ | `$cache_read` | | cache-read total (`R1.2k`); empty at zero or when unavailable |
398
+ | `$cache_write` | | cache-write total (`W300`); empty at zero or when unavailable |
392
399
  | `$cost` | | session cost |
393
- | `$sep` | `$separator` | themed `\|` using `colors.separator` |
400
+ | `$subscription` | | `(sub)` in subscription mode; otherwise empty |
401
+ | `$auto_compaction` | | `(auto)` when automatic compaction is enabled; otherwise empty |
402
+ | `$sep` | `$separator` | themed `\|` using `colors.separator` |
394
403
  | `$fill` | — | special: splits zones |
395
404
 
396
405
  ### `$fill` behavior
@@ -406,6 +415,9 @@ Center the branch between directory and cost:
406
415
  - Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
407
416
  - Conditional groups: wrap optional pieces in parentheses, e.g. `$cwd( on $git_branch)($git_status)$fill($context)`. If every `$var` inside a group is empty, the whole group (including its literals) is dropped.
408
417
  - `$session_name` is available whenever `footerFormat` is set, independently of `footerSegments.sessionName`; use a conditional group such as `($sep$session_name)` so unnamed sessions leave no separator.
418
+ - The built-in wide footer appends cache totals to the token segment, `(sub)` to cost, and `(auto)` to context when available. Custom formats keep `$tokens`, `$cost`, and `$context` backward-compatible and include telemetry only through the atomic variables above.
419
+ - `DEFAULT_COMPACT_FOOTER_FORMAT` omits model/provider and atomic telemetry. Add their variables explicitly to `compactFooterFormat` to opt in at narrow widths.
420
+ - Auto-compaction settings refresh on the next normal footer synchronization event. Unsupported Pi capabilities or settings-read errors safely omit optional markers.
409
421
  - Unknown `$variables` render empty.
410
422
  - Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
411
423
 
@@ -33,6 +33,7 @@ export type { IconMode } from "./icons";
33
33
  export type ContextStyle = "text" | "gauge" | "text+gauge";
34
34
  export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
35
35
  export type ModelLabelSource = "id" | "name";
36
+ export type EditorBorderColorMode = "static" | "adaptive";
36
37
  export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
37
38
 
38
39
  export const DEFAULT_COMPACT_FOOTER_FORMAT =
@@ -79,6 +80,7 @@ export type FooterSegmentsConfig = {
79
80
  gitCommit: boolean;
80
81
  gitMetrics: boolean;
81
82
  runtime: boolean;
83
+ modelInfo: boolean;
82
84
  context: boolean;
83
85
  tokens: boolean;
84
86
  cost: boolean;
@@ -139,6 +141,7 @@ export type PolishedTuiConfig = {
139
141
  separator: SeparatorStyle;
140
142
  contextStyle: ContextStyle;
141
143
  editorModelLabel: ModelLabelSource;
144
+ editorBorderColorMode: EditorBorderColorMode;
142
145
  contextThresholds: ContextThresholds;
143
146
  pathDisplay: PathDisplayConfig;
144
147
  gitBranch: GitBranchConfig;
@@ -196,13 +199,19 @@ export const FOOTER_FORMAT_VARIABLES = [
196
199
  "git_status",
197
200
  "git_state",
198
201
  "runtime",
202
+ "model",
203
+ "provider",
199
204
  "session_duration",
200
205
  "username",
201
206
  "os",
202
207
  "time",
203
208
  "context",
204
209
  "tokens",
210
+ "cache_read",
211
+ "cache_write",
205
212
  "cost",
213
+ "subscription",
214
+ "auto_compaction",
206
215
  "package",
207
216
  "package_version",
208
217
  "git_commit",
@@ -240,6 +249,7 @@ export const defaultConfig: PolishedTuiConfig = {
240
249
  separator: "pipe",
241
250
  contextStyle: "text",
242
251
  editorModelLabel: "id",
252
+ editorBorderColorMode: "static",
243
253
  contextThresholds: { warning: 70, error: 90 },
244
254
  pathDisplay: { mode: "basename", depth: 0 },
245
255
  gitBranch: { maxLength: "full" },
@@ -287,6 +297,7 @@ export const defaultConfig: PolishedTuiConfig = {
287
297
  gitStatus: true,
288
298
  gitCounts: false,
289
299
  runtime: true,
300
+ modelInfo: false,
290
301
  context: true,
291
302
  tokens: true,
292
303
  cost: true,
@@ -350,6 +361,11 @@ function parseEditorModelLabel(value: unknown): ModelLabelSource {
350
361
  return defaultConfig.editorModelLabel;
351
362
  }
352
363
 
364
+ function parseEditorBorderColorMode(value: unknown): EditorBorderColorMode {
365
+ if (value === "static" || value === "adaptive") return value;
366
+ return defaultConfig.editorBorderColorMode;
367
+ }
368
+
353
369
  export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
354
370
  return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
355
371
  }
@@ -521,6 +537,7 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
521
537
  gitStatus: footerSegmentValue(record, "gitStatus"),
522
538
  gitCounts: footerSegmentValue(record, "gitCounts"),
523
539
  runtime: footerSegmentValue(record, "runtime"),
540
+ modelInfo: footerSegmentValue(record, "modelInfo"),
524
541
  context: footerSegmentValue(record, "context"),
525
542
  tokens: footerSegmentValue(record, "tokens"),
526
543
  cost: footerSegmentValue(record, "cost"),
@@ -638,6 +655,7 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
638
655
  value === "gitStatus" ||
639
656
  value === "gitCounts" ||
640
657
  value === "runtime" ||
658
+ value === "modelInfo" ||
641
659
  value === "context" ||
642
660
  value === "tokens" ||
643
661
  value === "cost" ||
@@ -815,6 +833,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
815
833
  separator: parseSeparatorStyle(config.separator),
816
834
  contextStyle: parseContextStyle(config.contextStyle),
817
835
  editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
836
+ editorBorderColorMode: parseEditorBorderColorMode(config.editorBorderColorMode),
818
837
  contextThresholds: parseContextThresholds(config.contextThresholds),
819
838
  pathDisplay: parsePathDisplay(config.pathDisplay),
820
839
  gitBranch,
@@ -1007,6 +1026,15 @@ export function saveEditorModelLabel(
1007
1026
  });
1008
1027
  }
1009
1028
 
1029
+ export function saveEditorBorderColorMode(
1030
+ value: EditorBorderColorMode,
1031
+ path = configPath,
1032
+ ): PolishedTuiConfig {
1033
+ return mutateConfig(path, (record) => {
1034
+ record.editorBorderColorMode = parseEditorBorderColorMode(value);
1035
+ });
1036
+ }
1037
+
1010
1038
  export function saveGitCommitPatch(
1011
1039
  patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
1012
1040
  path = configPath,
@@ -51,6 +51,18 @@ function joinStatusTexts(statusTexts: string[], separator: string): string {
51
51
  return statusTexts.filter(Boolean).join(separator);
52
52
  }
53
53
 
54
+ function normalizeModelInfoPart(value: string): string {
55
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
56
+ }
57
+
58
+ function composeModelInfoLabel(model: string, provider: string): string {
59
+ const normalizedModel = normalizeModelInfoPart(model);
60
+ const normalizedProvider = normalizeModelInfoPart(provider);
61
+ const providerIsDuplicated =
62
+ normalizedProvider.length > 0 && normalizedModel.includes(normalizedProvider);
63
+ return [model, providerIsDuplicated ? "" : provider].filter(Boolean).join(" ");
64
+ }
65
+
54
66
  function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string {
55
67
  if (maxWidth <= 0) return "";
56
68
 
@@ -260,6 +272,18 @@ export function installFooter(
260
272
  : tier === "warning"
261
273
  ? config.colors.contextWarning
262
274
  : config.colors.contextNormal;
275
+ const cacheReadLabel = state.cacheReadLabel
276
+ ? renderStyleForSource(theme, colorSource, config.colors.tokens, state.cacheReadLabel)
277
+ : "";
278
+ const cacheWriteLabel = state.cacheWriteLabel
279
+ ? renderStyleForSource(theme, colorSource, config.colors.tokens, state.cacheWriteLabel)
280
+ : "";
281
+ const subscriptionLabel = state.subscription
282
+ ? renderStyleForSource(theme, colorSource, config.colors.cost, "(sub)")
283
+ : "";
284
+ const autoCompactionLabel = state.autoCompaction
285
+ ? renderStyleForSource(theme, colorSource, contextColor, "(auto)")
286
+ : "";
263
287
  const gitColor = (text: string) =>
264
288
  renderStyleForSource(theme, colorSource, config.colors.gitBranch, text);
265
289
  const gitStatusColor = (text: string) =>
@@ -325,6 +349,10 @@ export function installFooter(
325
349
  const label = state.runtime.version ? `${symbol} ${state.runtime.version}` : symbol;
326
350
  return renderStyleForSource(theme, colorSource, state.runtime.style, label);
327
351
  }
352
+ case "model":
353
+ return sanitizeExtensionStatusText(state.modelLabel);
354
+ case "provider":
355
+ return sanitizeExtensionStatusText(state.providerLabel);
328
356
  case "session_duration":
329
357
  return state.sessionStartEpoch
330
358
  ? renderStyleForSource(
@@ -364,8 +392,16 @@ export function installFooter(
364
392
  config.colors.tokens,
365
393
  state.tokenLabel,
366
394
  );
395
+ case "cache_read":
396
+ return cacheReadLabel;
397
+ case "cache_write":
398
+ return cacheWriteLabel;
367
399
  case "cost":
368
400
  return renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel);
401
+ case "subscription":
402
+ return subscriptionLabel;
403
+ case "auto_compaction":
404
+ return autoCompactionLabel;
369
405
  case "package":
370
406
  return formatPackageVersionSegment(
371
407
  theme,
@@ -544,6 +580,12 @@ export function installFooter(
544
580
  .filter(Boolean)
545
581
  .join(" ");
546
582
 
583
+ const modelInfoSegment = config.footerSegments.modelInfo
584
+ ? composeModelInfoLabel(
585
+ sanitizeExtensionStatusText(state.modelLabel),
586
+ sanitizeExtensionStatusText(state.providerLabel),
587
+ )
588
+ : "";
547
589
  const timeSegment = config.footerSegments.time
548
590
  ? renderStyleForSource(
549
591
  theme,
@@ -552,16 +594,30 @@ export function installFooter(
552
594
  formatTimeLabel(config.icons.time),
553
595
  )
554
596
  : "";
597
+ const builtInContextLabel = [
598
+ renderStyleForSource(theme, colorSource, contextColor, contextLabel),
599
+ autoCompactionLabel,
600
+ ]
601
+ .filter(Boolean)
602
+ .join(" ");
603
+ const builtInTokenLabel = [
604
+ renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
605
+ cacheReadLabel,
606
+ cacheWriteLabel,
607
+ ]
608
+ .filter(Boolean)
609
+ .join(" ");
610
+ const builtInCostLabel = [
611
+ renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
612
+ subscriptionLabel,
613
+ ]
614
+ .filter(Boolean)
615
+ .join(" ");
555
616
  const right = [
556
- config.footerSegments.context
557
- ? renderStyleForSource(theme, colorSource, contextColor, contextLabel)
558
- : "",
559
- config.footerSegments.tokens
560
- ? renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel)
561
- : "",
562
- config.footerSegments.cost
563
- ? renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel)
564
- : "",
617
+ modelInfoSegment,
618
+ config.footerSegments.context ? builtInContextLabel : "",
619
+ config.footerSegments.tokens ? builtInTokenLabel : "",
620
+ config.footerSegments.cost ? builtInCostLabel : "",
565
621
  timeSegment,
566
622
  ]
567
623
  .filter(Boolean)
@@ -275,6 +275,14 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
275
275
  return totals;
276
276
  }
277
277
 
278
+ export function buildCacheReadLabel(cacheRead: number): string {
279
+ return cacheRead > 0 ? `R${formatCount(cacheRead)}` : "";
280
+ }
281
+
282
+ export function buildCacheWriteLabel(cacheWrite: number): string {
283
+ return cacheWrite > 0 ? `W${formatCount(cacheWrite)}` : "";
284
+ }
285
+
278
286
  export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = "󰆼"): string {
279
287
  const parts: string[] = [];
280
288
  if (totals.input) parts.push(`↑${formatCount(totals.input)}`);
@@ -326,9 +334,9 @@ export function formatContextPercentLabel(
326
334
  ): string {
327
335
  if (!contextWindow || contextWindow <= 0) return "--";
328
336
  const percentLabel =
329
- percent === null || percent === undefined
337
+ percent === null || percent === undefined || !Number.isFinite(percent)
330
338
  ? "?"
331
- : `${Math.max(0, Math.min(999, Math.round(percent)))}%`;
339
+ : `${Math.max(0, Math.min(999, percent)).toFixed(1)}%`;
332
340
  return `${percentLabel}/${formatCount(contextWindow)}`;
333
341
  }
334
342
 
@@ -8,6 +8,7 @@ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
8
  import {
9
9
  type ColorSourcesConfig,
10
10
  type ContextStyle,
11
+ type EditorBorderColorMode,
11
12
  type ExtensionStatusColorMode,
12
13
  type ExtensionStatusPlacement,
13
14
  ensureConfigExists,
@@ -25,6 +26,7 @@ import {
25
26
  type SeparatorStyle,
26
27
  saveColorSourcesPatch,
27
28
  saveContextStylePatch,
29
+ saveEditorBorderColorMode,
28
30
  saveEditorModelLabel,
29
31
  saveExtensionStatusColorMode,
30
32
  saveExtensionStatusDefaultPlacement,
@@ -69,6 +71,7 @@ import { installSelectorBorderStyle } from "./selector-border";
69
71
  import { SessionLifecycle } from "./session-lifecycle";
70
72
  import { registerZentuiSettingsCommand } from "./settings-command";
71
73
  import { createInitialState, type FooterState, syncState } from "./state";
74
+ import { resolveFooterTelemetry } from "./telemetry";
72
75
  import { PolishedEditor, WrappedPolishedEditor } from "./ui";
73
76
  import { installUserMessageStyle } from "./user-message";
74
77
 
@@ -177,7 +180,13 @@ export default function (pi: ExtensionAPI) {
177
180
  const getThinkingLevel = () =>
178
181
  sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
179
182
  const syncFooterState = (ctx: ExtensionContext) =>
180
- syncState(state, ctx, currentConfig.icons.cacheHit, currentConfig.editorModelLabel);
183
+ syncState(
184
+ state,
185
+ ctx,
186
+ currentConfig.icons.cacheHit,
187
+ currentConfig.editorModelLabel,
188
+ resolveFooterTelemetry(ctx),
189
+ );
181
190
 
182
191
  type ProjectRefreshTarget = { cwd: string; generation: number };
183
192
  const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget) => {
@@ -659,6 +668,9 @@ export default function (pi: ExtensionAPI) {
659
668
  currentConfig = saveEditorModelLabel(value);
660
669
  syncFooterState(ctx);
661
670
  },
671
+ setEditorBorderColorMode(value: EditorBorderColorMode) {
672
+ currentConfig = saveEditorBorderColorMode(value);
673
+ },
662
674
  setGitCommit(
663
675
  patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
664
676
  ctx: ExtensionContext,
@@ -716,7 +728,8 @@ export default function (pi: ExtensionAPI) {
716
728
  });
717
729
  pi.on("agent_end", (event, ctx) => {
718
730
  liveContext.clear();
719
- syncInteractiveAndProjectState(event, ctx);
731
+ // Reconcile once more after Pi has persisted the assistant message.
732
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
720
733
  });
721
734
  pi.on("model_select", (event, ctx) => {
722
735
  liveContext.clear();
@@ -15,6 +15,7 @@ import {
15
15
  type ColorSourcesConfig,
16
16
  type CompactFooterMaxLines,
17
17
  type ContextStyle,
18
+ type EditorBorderColorMode,
18
19
  type ExtensionStatusColorMode,
19
20
  type ExtensionStatusPlacement,
20
21
  type FixedEditorConfig,
@@ -56,6 +57,7 @@ const pathDepthValues = ["0", "1", "2", "3", "4", "5"] as const;
56
57
  const branchLengthPresetValues = ["full", "10", "20", "30", "40", "50"] as const;
57
58
  const iconModeValues: IconMode[] = ["auto", "nerd", "ascii"];
58
59
  const modelLabelValues: ModelLabelSource[] = ["id", "name"];
60
+ const editorBorderColorModeValues: EditorBorderColorMode[] = ["static", "adaptive"];
59
61
  const compactFooterMaxLineValues = ["1", "2", "3", "unlimited"] as const;
60
62
  type FeatureState = "enabled" | "disabled";
61
63
 
@@ -83,6 +85,7 @@ type BoundedSettingId =
83
85
  | "branchLength"
84
86
  | "iconMode"
85
87
  | "editorModelLabel"
88
+ | "editorBorderColorMode"
86
89
  | "gitCommitOnlyDetached"
87
90
  | "gitCommitShowTag"
88
91
  | "gitMetricsOnlyNonzero"
@@ -109,6 +112,7 @@ type SettingsCommandDeps = {
109
112
  setPathDisplay: (patch: Partial<PathDisplayConfig>) => void;
110
113
  setGitBranch: (patch: Partial<GitBranchConfig>) => void;
111
114
  setEditorModelLabel?: (value: ModelLabelSource, ctx: ExtensionContext) => void;
115
+ setEditorBorderColorMode?: (value: EditorBorderColorMode) => void;
112
116
  setGitCommit?: (
113
117
  patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
114
118
  ctx: ExtensionContext,
@@ -162,6 +166,7 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
162
166
  time: "Current time",
163
167
  os: "OS icon",
164
168
  runtime: "Runtime",
169
+ modelInfo: "Model info",
165
170
  context: "Context usage",
166
171
  tokens: "Token counts",
167
172
  cost: "Session cost",
@@ -182,6 +187,7 @@ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> =
182
187
  time: "Show the current time (HH:MM) on the right.",
183
188
  os: "Show an operating-system icon on the left.",
184
189
  runtime: "Show or hide the detected runtime/language segment on the left.",
190
+ modelInfo: "Show the selected model and non-duplicate provider on the right.",
185
191
  context: "Show or hide context usage on the right.",
186
192
  tokens: "Show or hide input/output token counts on the right.",
187
193
  cost: "Show or hide session cost on the right.",
@@ -253,6 +259,7 @@ function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingI
253
259
  value === "gitCounts" ||
254
260
  value === "sessionDuration" ||
255
261
  value === "runtime" ||
262
+ value === "modelInfo" ||
256
263
  value === "context" ||
257
264
  value === "tokens" ||
258
265
  value === "cost" ||
@@ -314,6 +321,7 @@ function isBoundedSettingId(value: string): value is BoundedSettingId {
314
321
  value === "branchLength" ||
315
322
  value === "iconMode" ||
316
323
  value === "editorModelLabel" ||
324
+ value === "editorBorderColorMode" ||
317
325
  value === "gitCommitOnlyDetached" ||
318
326
  value === "gitCommitShowTag" ||
319
327
  value === "gitMetricsOnlyNonzero" ||
@@ -508,13 +516,24 @@ function buildItems(
508
516
  currentValue: featureValue(config.features[key]),
509
517
  values: featureStateValues,
510
518
  }));
511
- items.splice(1, 0, {
512
- id: "editorModelLabel",
513
- label: "Editor model label",
514
- description: "Show the model id or display name in the editor frame.",
515
- currentValue: config.editorModelLabel,
516
- values: modelLabelValues,
517
- });
519
+ items.splice(
520
+ 1,
521
+ 0,
522
+ {
523
+ id: "editorBorderColorMode",
524
+ label: "Editor border color",
525
+ description: "Keep Zentui's configured border color or follow Pi's shell/thinking color.",
526
+ currentValue: config.editorBorderColorMode,
527
+ values: editorBorderColorModeValues,
528
+ },
529
+ {
530
+ id: "editorModelLabel",
531
+ label: "Editor model label",
532
+ description: "Show the model id or display name in the editor frame.",
533
+ currentValue: config.editorModelLabel,
534
+ values: modelLabelValues,
535
+ },
536
+ );
518
537
  items.push({
519
538
  id: "fixedEditor",
520
539
  label: "Fixed editor (experimental)",
@@ -596,6 +615,7 @@ function buildItems(
596
615
  "cwd",
597
616
  "sessionName",
598
617
  "runtime",
618
+ "modelInfo",
599
619
  "context",
600
620
  "tokens",
601
621
  "cost",
@@ -892,6 +912,19 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
892
912
  return;
893
913
  }
894
914
 
915
+ if (
916
+ id === "editorBorderColorMode" &&
917
+ (newValue === "static" || newValue === "adaptive")
918
+ ) {
919
+ if (!deps.setEditorBorderColorMode) return;
920
+ deps.setEditorBorderColorMode(newValue);
921
+ settingsList.updateValue(id, newValue);
922
+ deps.requestRender();
923
+ ctx.ui.notify(`Editor border color: ${newValue}`, "info");
924
+ tui.requestRender();
925
+ return;
926
+ }
927
+
895
928
  if (id === "responsiveFooter" && isFeatureState(newValue)) {
896
929
  deps.setResponsiveFooter?.({ responsiveFooter: newValue === "enabled" }, ctx);
897
930
  settingsList.updateValue(id, newValue);
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ModelLabelSource } from "./config";
3
3
  import {
4
+ buildCacheReadLabel,
5
+ buildCacheWriteLabel,
4
6
  buildContextLabel,
5
7
  buildCostLabel,
6
8
  buildTokenLabel,
@@ -10,6 +12,7 @@ import {
10
12
  import type { GitStatusSummary } from "./git";
11
13
  import type { PackageVersionResult } from "./package-version";
12
14
  import type { RuntimeInfo } from "./runtime";
15
+ import type { FooterTelemetry } from "./telemetry";
13
16
 
14
17
  export type FooterState = GitStatusSummary & {
15
18
  modelLabel: string;
@@ -18,7 +21,11 @@ export type FooterState = GitStatusSummary & {
18
21
  providerLabel: string;
19
22
  contextLabel: string;
20
23
  tokenLabel: string;
24
+ cacheReadLabel: string;
25
+ cacheWriteLabel: string;
21
26
  costLabel: string;
27
+ subscription: boolean;
28
+ autoCompaction: boolean;
22
29
  runtime?: RuntimeInfo;
23
30
  packageVersion?: PackageVersionResult;
24
31
  sessionStartEpoch?: number;
@@ -32,7 +39,11 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
32
39
  providerLabel: "Unknown",
33
40
  contextLabel: "--",
34
41
  tokenLabel: "↑0 ↓0",
42
+ cacheReadLabel: "",
43
+ cacheWriteLabel: "",
35
44
  costLabel: "$0.000",
45
+ subscription: false,
46
+ autoCompaction: false,
36
47
  runtime: undefined,
37
48
  packageVersion: undefined,
38
49
  sessionStartEpoch: Date.now(),
@@ -45,6 +56,7 @@ export function syncState(
45
56
  ctx: ExtensionContext,
46
57
  cacheHitIcon: string,
47
58
  modelLabelSource: ModelLabelSource,
59
+ telemetry: FooterTelemetry = {},
48
60
  ): void {
49
61
  const totals = getUsageTotals(ctx);
50
62
  const m = ctx.model;
@@ -54,5 +66,9 @@ export function syncState(
54
66
  state.providerLabel = formatProviderLabel(ctx.model?.provider);
55
67
  state.contextLabel = buildContextLabel(ctx);
56
68
  state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
69
+ state.cacheReadLabel = buildCacheReadLabel(totals.cacheRead);
70
+ state.cacheWriteLabel = buildCacheWriteLabel(totals.cacheWrite);
57
71
  state.costLabel = buildCostLabel(totals);
72
+ state.subscription = telemetry.subscription === true;
73
+ state.autoCompaction = telemetry.autoCompaction === true;
58
74
  }
@@ -0,0 +1,82 @@
1
+ import { type ExtensionContext, SettingsManager } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type FooterTelemetry = {
4
+ subscription?: boolean;
5
+ autoCompaction?: boolean;
6
+ };
7
+
8
+ type SettingsManagerLike = {
9
+ drainErrors?: () => unknown[];
10
+ getCompactionEnabled?: () => unknown;
11
+ };
12
+
13
+ type SettingsManagerFactory = {
14
+ create?: (
15
+ cwd: string,
16
+ agentDir?: string,
17
+ options?: { projectTrusted?: boolean },
18
+ ) => SettingsManagerLike;
19
+ };
20
+
21
+ export type TelemetryCapabilities = {
22
+ settingsManager?: SettingsManagerFactory;
23
+ };
24
+
25
+ function resolveSubscription(ctx: ExtensionContext): boolean | undefined {
26
+ const model = ctx.model;
27
+ if (!model) return undefined;
28
+ if (model.provider === "kimi-coding") return true;
29
+
30
+ try {
31
+ const registry = ctx.modelRegistry as {
32
+ isUsingOAuth?: (candidate: typeof model) => unknown;
33
+ };
34
+ if (typeof registry?.isUsingOAuth !== "function") return undefined;
35
+ const result = registry.isUsingOAuth(model);
36
+ return typeof result === "boolean" ? result : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ function resolveAutoCompaction(
43
+ ctx: ExtensionContext,
44
+ factory: SettingsManagerFactory | undefined,
45
+ ): boolean | undefined {
46
+ try {
47
+ const isProjectTrusted = (
48
+ ctx as ExtensionContext & {
49
+ isProjectTrusted?: () => unknown;
50
+ }
51
+ ).isProjectTrusted;
52
+ if (typeof factory?.create !== "function" || typeof isProjectTrusted !== "function") {
53
+ return undefined;
54
+ }
55
+ const trusted = isProjectTrusted.call(ctx);
56
+ if (typeof trusted !== "boolean") return undefined;
57
+ const settings = factory.create(ctx.cwd, undefined, { projectTrusted: trusted });
58
+ if (
59
+ typeof settings?.drainErrors !== "function" ||
60
+ typeof settings.getCompactionEnabled !== "function"
61
+ ) {
62
+ return undefined;
63
+ }
64
+ if (settings.drainErrors().length > 0) return undefined;
65
+ const enabled = settings.getCompactionEnabled();
66
+ return typeof enabled === "boolean" ? enabled : undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /** Resolve optional Pi telemetry without depending on private session or TUI fields. */
73
+ export function resolveFooterTelemetry(
74
+ ctx: ExtensionContext,
75
+ capabilities: TelemetryCapabilities = {},
76
+ ): FooterTelemetry {
77
+ const settingsManager = capabilities.settingsManager ?? SettingsManager;
78
+ return {
79
+ subscription: resolveSubscription(ctx),
80
+ autoCompaction: resolveAutoCompaction(ctx, settingsManager),
81
+ };
82
+ }
@@ -84,6 +84,7 @@ type PolishedFrameOptions = {
84
84
  rightStatus?: string;
85
85
  ownedFrame?: PolishedFrameSplit;
86
86
  trustedBaseFrame?: boolean;
87
+ borderColor?: (text: string) => string;
87
88
  };
88
89
 
89
90
  type PolishedFrameResult = { lines: string[]; decorated: boolean };
@@ -319,6 +320,7 @@ function renderPolishedFrame({
319
320
  rightStatus,
320
321
  ownedFrame,
321
322
  trustedBaseFrame = false,
323
+ borderColor,
322
324
  }: PolishedFrameOptions): PolishedFrameResult {
323
325
  if (width <= 2) return { lines: clampRenderedLines(baseRendered, width), decorated: false };
324
326
 
@@ -380,22 +382,33 @@ function renderPolishedFrame({
380
382
  const copyFriendlyMeta = composeMetadataLine(meta, rightStatus, Math.max(0, width - 1));
381
383
  const railedMeta = composeMetadataLine(meta, rightStatus, innerWidth);
382
384
 
383
- const top = renderStyleForSourceOrFallback(
384
- uiTheme,
385
- colorSource,
386
- config.colors.editorBorder,
387
- EDITOR_BORDER_FALLBACK,
385
+ const renderStaticBorder = (text: string) =>
386
+ renderStyleForSourceOrFallback(
387
+ uiTheme,
388
+ colorSource,
389
+ config.colors.editorBorder,
390
+ EDITOR_BORDER_FALLBACK,
391
+ text,
392
+ );
393
+ const renderBorder = (text: string) => {
394
+ if (config.editorBorderColorMode !== "adaptive" || typeof borderColor !== "function") {
395
+ return renderStaticBorder(text);
396
+ }
397
+ try {
398
+ const rendered = borderColor(text);
399
+ return typeof rendered === "string" ? rendered : renderStaticBorder(text);
400
+ } catch {
401
+ return renderStaticBorder(text);
402
+ }
403
+ };
404
+ const top = renderBorder(
388
405
  renderEditorBorder(
389
406
  width,
390
407
  "above",
391
408
  config.features.viewportIndicators ? viewport.above : undefined,
392
409
  ),
393
410
  );
394
- const bottom = renderStyleForSourceOrFallback(
395
- uiTheme,
396
- colorSource,
397
- config.colors.editorBorder,
398
- EDITOR_BORDER_FALLBACK,
411
+ const bottom = renderBorder(
399
412
  renderEditorBorder(
400
413
  width,
401
414
  "below",
@@ -477,6 +490,7 @@ export class PolishedEditor extends CustomEditor {
477
490
  modelMeta: this.getModelMeta(),
478
491
  thinkingLevel: this.getThinkingLevel(),
479
492
  trustedBaseFrame: true,
493
+ borderColor: this.borderColor,
480
494
  }).lines;
481
495
  } catch {
482
496
  return clampRenderedLines(rendered, width);
@@ -628,6 +642,7 @@ export class WrappedPolishedEditor implements EditorComponent {
628
642
  thinkingLevel: this.getThinkingLevel(),
629
643
  rightStatus: readVimStatus(this.base, this.uiTheme),
630
644
  ownedFrame,
645
+ borderColor: this.borderColor,
631
646
  });
632
647
  }
633
648
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",