@underactive/pi-topping-statusline 0.2.7 → 0.3.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
@@ -62,10 +62,10 @@ and apply live.
62
62
  ╟─ Top Right Segment Group ──────────────────────────────────────╢
63
63
  ║ [ ] Token rate OFF ║
64
64
  ║ [■] Session name ON ║
65
- ║ … Bottom Right / Bottom Left groups — 8 more toggles ║
65
+ ║ … Bottom Right / Bottom Left groups — 10 more rows ║
66
66
  ║ ║
67
67
  ╟─ Feeds ────────────────────────────────────────────────────────╢
68
- ║ 1. type pi-prompt-cache/savings ║
68
+ ║ 1. type my-extension/savings ║
69
69
  ║ 1. field savedUsd ║
70
70
  ║ 1. prefix CS ║
71
71
  ║ 1. format ‹ currency › ║
@@ -73,7 +73,7 @@ and apply live.
73
73
  ║ + add feed ║
74
74
  ║ ║
75
75
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
76
- ╚════════════════════════════════════════════════════════[ 1/33 ]╝
76
+ ╚════════════════════════════════════════════════════════[ 1/35 ]╝
77
77
  ```
78
78
 
79
79
  | Section | Settings |
@@ -81,10 +81,12 @@ and apply live.
81
81
  | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · NVIDIA-green when using Switchyard · Animate Switchyard green border · Embed status spinners · Embed compaction progress |
82
82
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
83
83
  | Top Right Segment Group | Token rate · Session name |
84
- | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
84
+ | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Cache hit bar · CH bar segments (`3`–`10`) · Context bar · Context stats |
85
85
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
86
86
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
87
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off · Embed compaction progress on |
87
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off · Embed compaction progress on · CH bar segments `4` |
88
+
89
+ **Cache hit bar** renders a logarithmic graph and its `CH` percentage as a separate bottom-right segment. **CH bar segments** sets its length, from 3 to 10 (default 4). Only a 0% hit rate leaves it empty; any hit fills the first cell, the second fills at 50% and the last at 99.2%, and the cells between are spaced evenly on a log scale of the miss rate, so every length keeps its resolution near 100%. At five segments the cells fill at above 0%, 50%, 87.5%, 96.9% and 99.2%, each quartering the remaining miss rate. The filled cells share one color picked from the hit rate at any length: red below 50%, orange below 87.5%, yellow below 96.9%, then a subdued green. When the Cache hit bar is enabled, the repeated `CH` field is removed from Pi stats; the configured separator sits between Pi stats, the cache-hit segment, and the context graph.
88
90
 
89
91
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
90
92
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -175,7 +177,7 @@ Each subscription is four fields, all configurable in the settings TUI:
175
177
 
176
178
  | Field | Meaning | Example |
177
179
  | --- | --- | --- |
178
- | type | the publisher's `customType` | `pi-prompt-cache/savings` |
180
+ | type | the publisher's `customType` | `my-extension/savings` |
179
181
  | field | property of the entry's `data` to read | `savedUsd` |
180
182
  | prefix | literal label drawn before the value | `CS` |
181
183
  | format | `currency`, `number`, or `text` | `currency` |
@@ -185,10 +187,9 @@ In `settings.json` the first column is persisted as `customType`; the settings T
185
187
  it as `TOK ` when you want `TOK 42`; leading and trailing spaces survive, while escape
186
188
  sequences and control characters are stripped.
187
189
 
188
- The list ships seeded with the savings feed published by
189
- [pi-prompt-cache](https://github.com/underactive/pi-prompt-cache), which is where the
190
- `currency` rules come from: figures under half a cent are hidden, matching that extension's
191
- own footer cutoff so the two never disagree. That floor hides the negative values published
190
+ The list ships empty. The `currency` rules come from the savings feed that
191
+ pi-topping-compact (formerly pi-prompt-cache, a private extension) used to publish: figures
192
+ under half a cent are hidden, matching that extension's own footer cutoff. That floor hides the negative values published
192
193
  early in a session, before the cache-write premium is amortized by reads. Above the floor,
193
194
  `currency` shows two decimals below $100 and whole dollars above.
194
195
 
package/index.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type ExtensionContext,
27
27
  } from "@earendil-works/pi-coding-agent";
28
28
  import type { Component, TUI } from "@earendil-works/pi-tui";
29
+ import { CHANNELS, emit } from "@underactive/pi-topping-primitives/contracts";
29
30
  import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "./src/box.js";
30
31
  import { hostsCompactionProgress } from "./src/compaction-embed.js";
31
32
  import { CompactionMonitor } from "./src/compaction-progress.js";
@@ -46,7 +47,7 @@ import { theme } from "./src/theme.js";
46
47
  import { TokenRateMonitor } from "./src/token-rate.js";
47
48
  import type { StatusLineSegmentId } from "./src/types.js";
48
49
  import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
49
- import { hostsWorkingStatus, WORKING_STATUS_EMBED_CHANNEL } from "./src/working-status-embed.js";
50
+ import { hostsWorkingStatus } from "./src/working-status-embed.js";
50
51
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
51
52
 
52
53
  const STATUS_ANIMATION_FRAME_MS = 30;
@@ -86,7 +87,7 @@ export default function (pi: ExtensionAPI) {
86
87
  };
87
88
 
88
89
  // pi-topping slides its loader's response model out only while this bar hosts the loader.
89
- const announceEmbed = (embedded: boolean): void => pi.events.emit(WORKING_STATUS_EMBED_CHANNEL, { embedded });
90
+ const announceEmbed = (embedded: boolean): void => emit(pi.events, CHANNELS.workingStatusEmbedded, { embedded });
90
91
 
91
92
  const requestRender = () => activeTui?.requestRender();
92
93
  builder.setRequestRender(requestRender);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "typecheck": "tsc --noEmit",
34
- "test": "node --import ./src/tests/ts-resolve.ts --test src/tests/*.test.ts"
34
+ "test": "node -C compiled --import ./src/tests/ts-resolve.ts --test src/tests/*.test.ts"
35
35
  },
36
36
  "pi": {
37
37
  "extensions": [
@@ -45,10 +45,13 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "1.1.0",
49
- "@earendil-works/pi-coding-agent": "1.1.0",
50
- "@earendil-works/pi-tui": "1.1.0",
48
+ "@earendil-works/pi-ai": "1.4.0",
49
+ "@earendil-works/pi-coding-agent": "1.4.0",
50
+ "@earendil-works/pi-tui": "1.4.0",
51
51
  "@types/node": "^22.0.0",
52
52
  "typescript": "^5.7.0"
53
+ },
54
+ "dependencies": {
55
+ "@underactive/pi-topping-primitives": "^0.10.0"
53
56
  }
54
57
  }
@@ -0,0 +1,107 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { FILLED, TROUGH } from "./context-graph.js";
4
+ import { theme, type StatusColor } from "./theme.js";
5
+
6
+ export const CACHE_HIT_MIN_CELLS = 3;
7
+ export const CACHE_HIT_MAX_CELLS = 10;
8
+ export const CACHE_HIT_DEFAULT_CELLS = 4;
9
+
10
+ /** A bar of any size starts its log scale here: the second cell fills at 50% (a 50% miss rate)... */
11
+ const SECOND_CELL_MISS_PERCENT = 50;
12
+ /** ...and the last cell fills at 99.21875% (a 0.78125% miss rate, 64x below the second cell's). */
13
+ const LAST_CELL_MISS_PERCENT = 0.78125;
14
+
15
+ /**
16
+ * Hit-rate bands for the bar's single color, independent of the bar's size so a
17
+ * given hit rate reads the same at every length: red below 50%, orange below
18
+ * 87.5%, yellow below 96.875%, then a subdued green so high hit rates read as good.
19
+ */
20
+ const CACHE_HIT_COLOR_BANDS: readonly { below: number; color: StatusColor }[] = [
21
+ { below: 50, color: "error" },
22
+ { below: 87.5, color: "statusLineContextElevated" },
23
+ { below: 96.875, color: "warning" },
24
+ ];
25
+
26
+ const tokens = (value: number): number => (Number.isFinite(value) ? Math.max(0, value) : 0);
27
+
28
+ /**
29
+ * Pi's footer `CH` figure: cache reads over all prompt tokens (cache writes
30
+ * included) for the latest assistant response, not the session total. Null
31
+ * when no assistant response has reported prompt tokens.
32
+ */
33
+ export function getCacheHitPercent(entries: readonly SessionEntry[]): number | null {
34
+ for (let i = entries.length - 1; i >= 0; i--) {
35
+ const entry = entries[i];
36
+ if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
37
+ const usage: Usage | undefined = entry.message.usage;
38
+ if (!usage) return null;
39
+ const read = tokens(usage.cacheRead);
40
+ const promptTokens = tokens(usage.input) + read + tokens(usage.cacheWrite);
41
+ return promptTokens > 0 ? (read / promptTokens) * 100 : null;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** The `CH<pct>%` figure in pi's footer stats line, when pi shows one. */
47
+ const CH_STAT_RE = /(?:^|\s)CH(\d+(?:\.\d+)?)%/;
48
+
49
+ export function parseCacheHitStat(stats: string | undefined): number | null {
50
+ const match = CH_STAT_RE.exec(stats ?? "");
51
+ return match ? Number(match[1]) : null;
52
+ }
53
+
54
+ /** Remove Pi's CH figure after it has been moved beside the cache-hit graph. */
55
+ export function stripCacheHitStat(stats: string | undefined): string | undefined {
56
+ return stats?.replace(CH_STAT_RE, "").trim() || undefined;
57
+ }
58
+
59
+ /** A bar size from settings, snapped to a whole number in range; anything unusable becomes the default. */
60
+ export function clampCacheHitCells(value: unknown): number {
61
+ if (typeof value !== "number" || !Number.isFinite(value)) return CACHE_HIT_DEFAULT_CELLS;
62
+ return Math.min(CACHE_HIT_MAX_CELLS, Math.max(CACHE_HIT_MIN_CELLS, Math.round(value)));
63
+ }
64
+
65
+ /**
66
+ * Hit rates at which cells 2..`cells` fill. The first cell is not listed: any hit
67
+ * above 0% fills it. The rest fill logarithmically, so the miss rate shrinks by
68
+ * the same factor from one cell to the next, from 50% at the second cell to
69
+ * 99.21875% at the last. That keeps the resolution near 100% at any size: five
70
+ * cells quarter the miss rate per cell (50, 87.5, 96.875, 99.21875), four cells
71
+ * cut it to an eighth, ten to roughly 0.59x.
72
+ */
73
+ export function getCacheHitThresholds(cells: number): number[] {
74
+ const size = clampCacheHitCells(cells);
75
+ const steps = size - 2;
76
+ const thresholds: number[] = [];
77
+ for (let k = 0; k <= steps; k++) {
78
+ const miss = SECOND_CELL_MISS_PERCENT * (LAST_CELL_MISS_PERCENT / SECOND_CELL_MISS_PERCENT) ** (k / steps);
79
+ // Rounded so float noise cannot nudge an exact figure like 87.5 just past its own threshold.
80
+ thresholds.push(Math.round((100 - miss) * 1e6) / 1e6);
81
+ }
82
+ return thresholds;
83
+ }
84
+
85
+ /** Filled cells on a logarithmic scale: only 0% is empty, and any hit at all fills the first cell. */
86
+ export function getCacheHitGraphCells(percent: number, cells = CACHE_HIT_DEFAULT_CELLS): number {
87
+ if (!Number.isFinite(percent) || percent <= 0) return 0;
88
+ return 1 + getCacheHitThresholds(cells).filter(threshold => percent >= threshold).length;
89
+ }
90
+
91
+ /** The bar's single color for a hit rate; the same bands at every bar size. */
92
+ function getCacheHitColor(percent: number): StatusColor {
93
+ return CACHE_HIT_COLOR_BANDS.find(band => percent < band.below)?.color ?? "statusLineCacheHit";
94
+ }
95
+
96
+ /**
97
+ * Cache-hit graph of `cells` segments (3–10, default 4). The whole filled bar
98
+ * takes one color from the hit rate: red, orange, yellow, then a subdued green
99
+ * from 96.875% through a perfect hit.
100
+ */
101
+ export function renderCacheHitGraph(percent: number, cells = CACHE_HIT_DEFAULT_CELLS): string {
102
+ const size = clampCacheHitCells(cells);
103
+ const filled = getCacheHitGraphCells(percent, size);
104
+ const filledPart = filled > 0 ? `${theme.getFgAnsi(getCacheHitColor(percent))}${FILLED.repeat(filled)}` : "";
105
+ const troughPart = filled < size ? `\x1b[39m\x1b[2m${TROUGH.repeat(size - filled)}\x1b[22m` : "";
106
+ return `${filledPart}${troughPart}\x1b[39m`;
107
+ }
@@ -1,58 +1,18 @@
1
1
  /**
2
- * The compaction bar, drawn with the same cells and trough glyphs as the context graph.
2
+ * The compaction bar: the shared phosphor comet bar from the primitives package, drawn with the
3
+ * same cells and trough glyphs as the context graph. This module reads the terminal's colour
4
+ * depth for it.
3
5
  */
4
- import { CELLS, FILLED, TROUGH } from "./context-graph.js";
5
- import { detectColorMode, mixRgb, rgbToFgAnsi } from "./theme.js";
6
+ import { renderCometBar } from "@underactive/pi-topping-primitives/color";
7
+ import { detectColorMode } from "./theme.js";
6
8
  import type { ColorMode } from "./theme.js";
7
9
 
8
- const COMET_SWEEP_MS = 2_000;
9
- const COMET_START_PADDING_CELLS = 2;
10
- const COMET_END_PADDING_CELLS = 6;
11
- const COMET_HALF_WIDTH_CELLS = 1.5;
12
- const COMET_TRAIL_DECAY_CELLS = 3.2;
13
-
14
- const DEEP_GREEN: readonly [number, number, number] = [11, 61, 32];
15
- const BASE_GREEN: readonly [number, number, number] = [114, 241, 184];
16
- const LIGHT_GREEN: readonly [number, number, number] = [185, 248, 217];
17
- const TROUGH_GREEN: readonly [number, number, number] = [38, 100, 70];
18
-
19
- function cosineFalloff(distance: number, halfWidth: number): number {
20
- if (distance >= halfWidth) return 0;
21
- return 0.5 * (1 + Math.cos((Math.PI * distance) / halfWidth));
22
- }
23
-
24
- function normalizeElapsedMs(elapsedMs: number | undefined): number {
25
- return Number.isFinite(elapsedMs) && elapsedMs !== undefined && elapsedMs > 0 ? elapsedMs : 0;
26
- }
27
-
28
- function renderCometCell(position: number, filled: number, elapsedMs: number, mode: ColorMode): string {
29
- const phase = (elapsedMs % COMET_SWEEP_MS) / COMET_SWEEP_MS;
30
- const head = filled + COMET_START_PADDING_CELLS - phase * (filled + COMET_END_PADDING_CELLS);
31
- const behind = position - head;
32
- const trail = behind >= 0 ? Math.exp(-behind / COMET_TRAIL_DECAY_CELLS) : 0;
33
- const crest = cosineFalloff(Math.abs(position - head), COMET_HALF_WIDTH_CELLS);
34
- const intensity = Math.min(1, Math.max(trail * 0.82, crest));
35
- const trailBase = mixRgb(DEEP_GREEN, BASE_GREEN, 0.62 + 0.38 * trail);
36
- const color = mixRgb(trailBase, LIGHT_GREEN, intensity * 0.9);
37
- const emphasized = crest > 0.25;
38
- return `${emphasized ? "\x1b[1m" : ""}${rgbToFgAnsi(...color, mode)}${FILLED}${emphasized ? "\x1b[22m" : ""}`;
39
- }
40
-
41
10
  export function renderCompactionBar(
42
11
  percent: number,
43
12
  opts: { mode?: ColorMode; shimmerElapsedMs?: number } = {},
44
13
  ): string {
45
- const mode = opts.mode ?? detectColorMode();
46
- const clamped = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
47
- const filled = Math.min(CELLS, Math.max(0, Math.round((clamped * CELLS) / 100)));
48
- const elapsedMs = normalizeElapsedMs(opts.shimmerElapsedMs);
49
- let bar = "";
50
- for (let i = 0; i < filled; i++) {
51
- bar += renderCometCell(i, filled, elapsedMs, mode);
52
- }
53
- if (filled < CELLS) {
54
- bar += `\x1b[39m\x1b[2m${rgbToFgAnsi(...TROUGH_GREEN, mode)}${TROUGH.repeat(CELLS - filled)}\x1b[22m`;
55
- }
56
- bar += "\x1b[39m";
57
- return bar;
14
+ return renderCometBar(percent, {
15
+ mode: opts.mode ?? detectColorMode(),
16
+ elapsedMs: opts.shimmerElapsedMs,
17
+ });
58
18
  }
@@ -4,12 +4,15 @@
4
4
  * band its own position falls into, so the graph's gradient matches the bar's
5
5
  * context percentage color at every fill level.
6
6
  */
7
+ import {
8
+ BAR_CELLS as CELLS,
9
+ BAR_FILLED as FILLED,
10
+ BAR_TROUGH as TROUGH,
11
+ } from "@underactive/pi-topping-primitives/color";
7
12
  import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds.js";
8
13
  import { theme } from "./theme.js";
9
14
 
10
- export const CELLS = 20;
11
- export const FILLED = "\u258b";
12
- export const TROUGH = "\u2591";
15
+ export { CELLS, FILLED, TROUGH };
13
16
 
14
17
  const cellColor = (percent: number, contextWindow: number): string =>
15
18
  theme.getFgAnsi(getContextUsageThemeColor(getContextUsageLevel(percent, contextWindow)));
package/src/context.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type ExtensionAPI,
15
15
  type ExtensionContext,
16
16
  } from "@earendil-works/pi-coding-agent";
17
+ import { getCacheHitPercent, parseCacheHitStat, stripCacheHitStat } from "./cache-hit.js";
17
18
  import { FADE_SHADE_COUNT } from "./theme.js";
18
19
  import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
19
20
  import { feedKey } from "./utils.js";
@@ -24,6 +25,8 @@ const EXEC_TIMEOUT_MS = 2_000;
24
25
 
25
26
  /** getEntries() filters and copies the whole session, so rescans are rate-limited. */
26
27
  const FEED_TTL_MS = 2_000;
28
+ /** The fallback cache-hit rate reads the same append-only session data as Pi's footer. */
29
+ const CACHE_HIT_TTL_MS = 250;
27
30
  /** Unchanged feed values remain fully visible for five minutes. */
28
31
  export const FEED_HOLD_MS = 5 * 60 * 1_000;
29
32
  /** Match the existing token-rate fade cadence. */
@@ -141,6 +144,8 @@ export class SegmentContextBuilder {
141
144
  #feedTimerAt: number | undefined;
142
145
 
143
146
  #contextEstimate: ContextEstimate | undefined;
147
+ #cacheHitPercent: number | null = null;
148
+ #cacheHitFetchedAt = 0;
144
149
 
145
150
  constructor(pi: ExtensionAPI) {
146
151
  this.#pi = pi;
@@ -160,7 +165,7 @@ export class SegmentContextBuilder {
160
165
 
161
166
  attach(ctx: ExtensionContext): void {
162
167
  this.#ctx = ctx;
163
- // A publisher's figure typically covers one process run — pi-prompt-cache
168
+ // A publisher's figure typically covers one process run — pi-topping-compact
164
169
  // restarts its savings at zero on every session start — while a resumed
165
170
  // session still carries the previous run's entries. Anything predating this
166
171
  // attach belongs to that stale run.
@@ -170,6 +175,8 @@ export class SegmentContextBuilder {
170
175
  this.#feedsScannedKey = undefined;
171
176
  this.#clearFeedLifecycle();
172
177
  this.#contextEstimate = undefined;
178
+ this.#cacheHitPercent = null;
179
+ this.#cacheHitFetchedAt = 0;
173
180
  if (this.#repoCwd !== ctx.cwd) {
174
181
  this.#resetGitState();
175
182
  this.#repoCwd = ctx.cwd;
@@ -307,7 +314,7 @@ export class SegmentContextBuilder {
307
314
  if (!cwd || !this.#repo || this.#gitStatusInFlight) return;
308
315
  this.#gitStatusInFlight = true;
309
316
  try {
310
- const out = await this.#exec("git", ["status", "--porcelain"], cwd);
317
+ const out = await this.#exec("git", ["-c", "core.fsmonitor=false", "status", "--porcelain"], cwd);
311
318
  if (this.#repoCwd !== cwd) return;
312
319
  this.#gitStatusFetchedAt = Date.now();
313
320
  const next = out === undefined ? null : parseGitStatusCounts(out);
@@ -472,6 +479,13 @@ export class SegmentContextBuilder {
472
479
  }
473
480
  }
474
481
 
482
+ #deriveCacheHitPercent(ctx: ExtensionContext | undefined, now: number): number | null {
483
+ if (!ctx || now - this.#cacheHitFetchedAt < CACHE_HIT_TTL_MS) return this.#cacheHitPercent;
484
+ this.#cacheHitFetchedAt = now;
485
+ this.#cacheHitPercent = getCacheHitPercent(ctx.sessionManager.getEntries());
486
+ return this.#cacheHitPercent;
487
+ }
488
+
475
489
  // ── snapshot ─────────────────────────────────────────────────────────────
476
490
 
477
491
  build(
@@ -515,6 +529,17 @@ export class SegmentContextBuilder {
515
529
  : undefined;
516
530
 
517
531
  const { contextWindow, tokens, percent: contextPercent, estimated } = this.#deriveContextUsage(ctx);
532
+ // Pi's own CH figure wins so the graph always agrees with the stats beside it;
533
+ // the derived rate covers hosts or widths where pi's stats line is unavailable.
534
+ const piStats = include.piStats || include.cacheHit ? this.#piStatsProvider?.(width) : undefined;
535
+ const cacheHitPercent =
536
+ include.cacheHit
537
+ ? piStats === undefined
538
+ ? this.#deriveCacheHitPercent(ctx, now)
539
+ : parseCacheHitStat(piStats)
540
+ : null;
541
+ const shownPiStats =
542
+ include.piStats && include.cacheHit && cacheHitPercent !== null ? stripCacheHitStat(piStats) : piStats;
518
543
 
519
544
  return {
520
545
  options,
@@ -526,6 +551,7 @@ export class SegmentContextBuilder {
526
551
  contextTokens: tokens,
527
552
  contextWindow,
528
553
  contextEstimated: estimated,
554
+ cacheHitPercent,
529
555
  git: {
530
556
  branch: include.git || include.pr ? this.#branch : null,
531
557
  status: include.git ? this.#gitStatus : null,
@@ -533,7 +559,7 @@ export class SegmentContextBuilder {
533
559
  },
534
560
  worktree: this.#worktree,
535
561
  scrollHint,
536
- piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
562
+ piStats: include.piStats ? shownPiStats : undefined,
537
563
  tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
538
564
  feedData: include.feeds.length > 0 ? this.#feedData : undefined,
539
565
  feedDisplayState,
package/src/footer.ts CHANGED
@@ -2,11 +2,9 @@
2
2
  * Pure helpers for parsing host-rendered rows: the editor's plain ─── border
3
3
  * rows (with or without the scroll hint) and the default footer's stats line.
4
4
  */
5
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
5
+ import { stripSgr as stripAnsi } from "@underactive/pi-topping-primitives/text";
6
6
 
7
- export function stripAnsi(str: string): string {
8
- return str.replace(ANSI_RE, "");
9
- }
7
+ export { stripAnsi };
10
8
 
11
9
  /** Drop host footer rows that contain only whitespace or SGR control codes. */
12
10
  export function filterVisibleRows(rows: readonly string[]): string[] {
@@ -1,17 +1,21 @@
1
1
  /** Provider-specific NVIDIA green border colorizer for Switchyard. */
2
- import { wrapDeg, type BorderColorizer } from "./rainbow.js";
3
- import { detectColorMode, mixRgb, parseHex, rgbToFgAnsi, type ColorMode } from "./theme.js";
2
+ import {
3
+ isSwitchyardProvider,
4
+ mixRgb,
5
+ NVIDIA_GREEN_DARK_HEX,
6
+ NVIDIA_GREEN_HEX,
7
+ parseHex,
8
+ rgbToFgAnsi,
9
+ wrapDeg,
10
+ } from "@underactive/pi-topping-primitives/color";
11
+ import type { BorderColorizer } from "./rainbow.js";
12
+ import { detectColorMode, type ColorMode } from "./theme.js";
13
+
14
+ export { isSwitchyardProvider, NVIDIA_GREEN_DARK_HEX, NVIDIA_GREEN_HEX };
4
15
 
5
- const SWITCHYARD_PROVIDER = "switchyard";
6
- export const NVIDIA_GREEN_HEX = "#84c51a";
7
- export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
8
16
  const GREEN_SHADE_COUNT = 256;
9
17
  const GREEN_BIAS = 0.65;
10
18
 
11
- export function isSwitchyardProvider(provider: string | undefined): boolean {
12
- return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
13
- }
14
-
15
19
  const ansiByShadeByMode = new Map<ColorMode, string[]>();
16
20
 
17
21
  function buildAnsiByShade(mode: ColorMode): string[] {
@@ -28,7 +32,7 @@ function buildAnsiByShade(mode: ColorMode): string[] {
28
32
  for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
29
33
  const progress = i / (GREEN_SHADE_COUNT - 1);
30
34
  const rgb = mixRgb(dark, bright, progress);
31
- ansiByShade.push(rgbToFgAnsi(...rgb, mode));
35
+ ansiByShade.push(rgbToFgAnsi(rgb, mode));
32
36
  }
33
37
 
34
38
  ansiByShadeByMode.set(mode, ansiByShade);
package/src/rainbow.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  * (renderBoxed in index.ts) or construct a fresh instance with a phase
13
13
  * derived from elapsed time (the settings preview).
14
14
  */
15
- import { detectColorMode, rgbToFgAnsi, type ColorMode } from "./theme.js";
15
+ import { hslToRgb, rgbToFgAnsi, wrapDeg } from "@underactive/pi-topping-primitives/color";
16
+ import { detectColorMode, type ColorMode } from "./theme.js";
16
17
 
17
18
  /** Frame cadence of the sweep (also the settings-preview refresh delay). */
18
19
  export const RAINBOW_FRAME_MS = 50;
@@ -25,9 +26,6 @@ const SATURATION = 1;
25
26
  const LIGHTNESS = 0.62;
26
27
  const ansiByHueByMode = new Map<ColorMode, string[]>();
27
28
 
28
- /** Normalize a degree value into the [0, 360) range. */
29
- export const wrapDeg = (deg: number): number => ((deg % 360) + 360) % 360;
30
-
31
29
  /**
32
30
  * Number of border cells in the box (rows 0..bottomIdx, cols 0..width-1):
33
31
  * two horizontal edges of `width` cells plus two vertical edges of
@@ -50,24 +48,6 @@ export function perimeterPosition(row: number, col: number, width: number, botto
50
48
  return 2 * width + 2 * bottomIdx - 2 - row; // col === 0
51
49
  }
52
50
 
53
- export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
54
- const c = (1 - Math.abs(2 * l - 1)) * s;
55
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
56
- const m = l - c / 2;
57
- let rgb: [number, number, number];
58
- if (h < 60) rgb = [c, x, 0];
59
- else if (h < 120) rgb = [x, c, 0];
60
- else if (h < 180) rgb = [0, c, x];
61
- else if (h < 240) rgb = [0, x, c];
62
- else if (h < 300) rgb = [x, 0, c];
63
- else rgb = [c, 0, x];
64
- return [Math.round((rgb[0] + m) * 255), Math.round((rgb[1] + m) * 255), Math.round((rgb[2] + m) * 255)];
65
- }
66
-
67
- export function rgbToHex([r, g, b]: [number, number, number]): string {
68
- return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
69
- }
70
-
71
51
  export interface BorderColorizer {
72
52
  colorChar(ch: string, perimeterPos: number, perimeter: number): string;
73
53
  /** ANSI foreground-color escape for a perimeter position, with no glyph or reset. */
@@ -107,7 +87,7 @@ export class RainbowBorder implements BorderColorizer {
107
87
  const bucket = Math.round(hue) % 360;
108
88
  let ansi = ansiByHue[bucket];
109
89
  if (ansi === undefined) {
110
- ansi = rgbToFgAnsi(...hslToRgb(bucket, SATURATION, LIGHTNESS), mode);
90
+ ansi = rgbToFgAnsi(hslToRgb(bucket, SATURATION, LIGHTNESS), mode);
111
91
  ansiByHue[bucket] = ansi;
112
92
  }
113
93
  return ansi;
package/src/segments.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
11
+ import { renderCacheHitGraph } from "./cache-hit.js";
11
12
  import { renderContextGraph } from "./context-graph.js";
12
13
  import { formatContextUsage } from "./context-thresholds.js";
13
14
  import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
@@ -250,7 +251,7 @@ const tokenRateSegment: StatusLineSegment = {
250
251
  };
251
252
 
252
253
  /**
253
- * Half a cent is pi-prompt-cache's own display cutoff. Matching it keeps this
254
+ * Half a cent is pi-topping-compact's own display cutoff. Matching it keeps this
254
255
  * segment and that extension's footer from ever contradicting each other, and
255
256
  * suppresses the negative figures published early in a session while the
256
257
  * cache-write premium has yet to be amortized by reads.
@@ -329,6 +330,16 @@ const workingSegment: StatusLineSegment = {
329
330
  },
330
331
  };
331
332
 
333
+ /** Cache-hit graph + its moved CH figure; related parts stay in one segment. */
334
+ const cacheHitGraphSegment: StatusLineSegment = {
335
+ id: "cache_hit_graph",
336
+ render(ctx) {
337
+ const percent = ctx.cacheHitPercent;
338
+ if (percent == null) return INVISIBLE;
339
+ return { content: `${renderCacheHitGraph(percent, ctx.options.cacheHit.cells)} ${theme.fg("dim", `CH${percent.toFixed(1)}%`)}`, visible: true };
340
+ },
341
+ };
342
+
332
343
  /** Context bar + stats — related parts joined by a space, not the group separator. */
333
344
  const contextGraphSegment: StatusLineSegment = {
334
345
  id: "context_graph",
@@ -436,6 +447,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
436
447
  pi_stats: piStatsSegment,
437
448
  token_rate: tokenRateSegment,
438
449
  feeds: feedsSegment,
450
+ cache_hit_graph: cacheHitGraphSegment,
439
451
  context_graph: contextGraphSegment,
440
452
  scroll_hint: scrollHintSegment,
441
453
  working: workingSegment,