@underactive/pi-topping-statusline 0.2.8 → 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/src/token-rate.ts CHANGED
@@ -3,9 +3,8 @@
3
3
  *
4
4
  * Upstream oh-my-pi computes the badge from the last assistant message's
5
5
  * usage.output / duration, but pi only reports usage at message end, so a
6
- * live rate needs an estimator. The pipeline (StreamingWordCounter + EMA
7
- * tracker + hold/fade lifecycle) is copied from pi-topping (same author,
8
- * ../pi-topping/src/{format,activity-meter,session}.ts):
6
+ * live rate needs an estimator. The pipeline (word counter, EMA tracker and
7
+ * hold/fade lifecycle) comes from @underactive/pi-topping-primitives/activity:
9
8
  *
10
9
  * - text/thinking deltas are counted as approximate tokens (word boundaries);
11
10
  * - an EMA (alpha 0.4) smooths 100ms samples into a rate;
@@ -15,6 +14,7 @@
15
14
  * The segment itself never disappears: idle renders as `--- tok/s`.
16
15
  */
17
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { RateHoldFade, StreamingWordCounter } from "@underactive/pi-topping-primitives/activity";
18
18
  import { FADE_SHADE_COUNT } from "./theme.js";
19
19
  import type { TokenRateDisplay } from "./types.js";
20
20
 
@@ -23,48 +23,6 @@ const SAMPLE_INTERVAL_MS = 100;
23
23
  const HOLD_MS = 1_500;
24
24
  /** pi-topping fades in 250ms; here the fade is stretched to 0.5s. */
25
25
  const FADE_MS = 500;
26
- const EMA_ALPHA = 0.4;
27
-
28
- /** Incremental whitespace-boundary word counter (pi-topping StreamingWordCounter). */
29
- class StreamingWordCounter {
30
- #inWordByStream = new Map<string, boolean>();
31
-
32
- count(text: string, stream: string): number {
33
- let inWord = this.#inWordByStream.get(stream) ?? false;
34
- let count = 0;
35
- for (let i = 0; i < text.length; i++) {
36
- const code = text.charCodeAt(i);
37
- if (isWhitespace(code)) {
38
- inWord = false;
39
- } else if (!inWord) {
40
- count++;
41
- inWord = true;
42
- }
43
- }
44
- this.#inWordByStream.set(stream, inWord);
45
- return count;
46
- }
47
-
48
- reset(): void {
49
- this.#inWordByStream.clear();
50
- }
51
- }
52
-
53
- function isWhitespace(code: number): boolean {
54
- return (
55
- code === 32 ||
56
- (code >= 9 && code <= 13) ||
57
- code === 160 ||
58
- code === 0x1680 ||
59
- (code >= 0x2000 && code <= 0x200a) ||
60
- code === 0x2028 ||
61
- code === 0x2029 ||
62
- code === 0x202f ||
63
- code === 0x205f ||
64
- code === 0x3000 ||
65
- code === 0xfeff
66
- );
67
- }
68
26
 
69
27
  export class TokenRateMonitor {
70
28
  #requestRender: () => void;
@@ -77,18 +35,13 @@ export class TokenRateMonitor {
77
35
  #liveTokens = 0;
78
36
  #counter = new StreamingWordCounter();
79
37
 
80
- // EMA tracker state (pi-topping TokRateTracker).
81
- #emaRate = 0;
82
- #emaLastTotal = 0;
83
- #emaLastTime = 0;
84
- #emaHasSample = false;
85
- #emaPendingTokens = 0;
86
-
87
- // Hold/fade lifecycle (pi-topping session.ts tick).
88
- #lastSampledAt = 0;
89
- #lastSampleTotal = 0;
90
- #heldRate: number | null = null;
91
- #fadeStartsAt = 0;
38
+ // EMA tracker plus hold/fade lifecycle.
39
+ #rate = new RateHoldFade({
40
+ holdMs: HOLD_MS,
41
+ fadeMs: FADE_MS,
42
+ shadeCount: FADE_SHADE_COUNT,
43
+ sampleIntervalMs: SAMPLE_INTERVAL_MS,
44
+ });
92
45
  #lastRendered: TokenRateDisplay | undefined;
93
46
 
94
47
  constructor(requestRender: () => void) {
@@ -125,9 +78,7 @@ export class TokenRateMonitor {
125
78
  if (exact !== undefined) {
126
79
  // Reconciliation resets the EMA at message boundaries; the held rate
127
80
  // bridges the gap until fresh samples rebuild it.
128
- this.#resetEma();
129
- this.#lastSampledAt = 0;
130
- this.#lastSampleTotal = this.#confirmTokens;
81
+ this.#rate.resetSampling(this.#confirmTokens);
131
82
  }
132
83
  this.#liveTokens = 0;
133
84
  this.#counter.reset();
@@ -148,75 +99,23 @@ export class TokenRateMonitor {
148
99
 
149
100
  /** Pure snapshot for the segment render; safe to call on any frame. */
150
101
  getDisplay(now = Date.now()): TokenRateDisplay {
151
- if (this.#heldRate !== null && now < this.#fadeStartsAt) {
152
- return { rate: this.#heldRate, phase: "active", fadeShade: 0 };
153
- }
154
- if (this.#heldRate !== null && now < this.#fadeStartsAt + FADE_MS) {
155
- const shade = Math.floor((now - this.#fadeStartsAt) / (FADE_MS / FADE_SHADE_COUNT));
156
- return { rate: this.#heldRate, phase: "fading", fadeShade: shade };
157
- }
158
- return { rate: null, phase: "idle", fadeShade: 0 };
102
+ return this.#rate.display(now);
159
103
  }
160
104
 
161
105
  #resetTurn(): void {
162
106
  this.#confirmTokens = 0;
163
107
  this.#liveTokens = 0;
164
108
  this.#counter.reset();
165
- this.#resetEma();
166
- this.#lastSampledAt = 0;
167
- this.#lastSampleTotal = 0;
168
- this.#heldRate = null;
169
- this.#fadeStartsAt = 0;
170
- }
171
-
172
- #resetEma(): void {
173
- this.#emaRate = 0;
174
- this.#emaLastTotal = 0;
175
- this.#emaLastTime = 0;
176
- this.#emaHasSample = false;
177
- this.#emaPendingTokens = 0;
178
- }
179
-
180
- #sampleEma(total: number, now: number): void {
181
- if (!this.#emaHasSample) {
182
- this.#emaLastTotal = total;
183
- this.#emaHasSample = true;
184
- this.#emaLastTime = now;
185
- return;
186
- }
187
- const elapsedSeconds = (now - this.#emaLastTime) / 1_000;
188
- if (elapsedSeconds <= 0) {
189
- this.#emaPendingTokens += Math.max(0, total - this.#emaLastTotal);
190
- this.#emaLastTotal = total;
191
- return;
192
- }
193
- const totalDelta = this.#emaPendingTokens + Math.max(0, total - this.#emaLastTotal);
194
- const instantRate = totalDelta / elapsedSeconds;
195
- this.#emaRate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * this.#emaRate;
196
- this.#emaLastTotal = total;
197
- this.#emaLastTime = now;
198
- this.#emaPendingTokens = 0;
109
+ this.#rate.reset();
199
110
  }
200
111
 
201
112
  #tick(): void {
202
113
  const now = Date.now();
203
- if (this.#busy && now - this.#lastSampledAt >= SAMPLE_INTERVAL_MS) {
204
- const total = this.#confirmTokens + this.#liveTokens;
205
- // Do not reset the fade for an EMA-only decay while output is quiet.
206
- const hasNewTokens = total > this.#lastSampleTotal;
207
- this.#lastSampleTotal = total;
208
- this.#sampleEma(total, now);
209
- this.#lastSampledAt = now;
210
- const rounded = Math.round(this.#emaRate);
211
- if (rounded > 0 && hasNewTokens) {
212
- this.#heldRate = rounded;
213
- this.#fadeStartsAt = now + HOLD_MS;
214
- }
215
- }
216
- if (!this.#busy && now >= this.#fadeStartsAt + FADE_MS) {
114
+ if (this.#busy) this.#rate.sample(this.#confirmTokens + this.#liveTokens, now);
115
+ const display = this.getDisplay(now);
116
+ if (!this.#busy && display.phase === "idle") {
217
117
  this.#stopTimer();
218
118
  }
219
- const display = this.getDisplay(now);
220
119
  if (
221
120
  !this.#lastRendered ||
222
121
  display.rate !== this.#lastRendered.rate ||
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@ export type StatusLineSegmentId =
17
17
  | "pi_stats"
18
18
  | "token_rate"
19
19
  | "feeds"
20
+ | "cache_hit_graph"
20
21
  | "context_graph"
21
22
  | "scroll_hint"
22
23
  | "working"
@@ -25,8 +26,8 @@ export type StatusLineSegmentId =
25
26
 
26
27
  /**
27
28
  * A feed the statusline subscribes to: custom session entries published by
28
- * another extension under `customType`, as pi-prompt-cache does with
29
- * "pi-prompt-cache/savings". `field` names the property of the entry's data
29
+ * another extension under `customType`, for example
30
+ * `pi.appendEntry("my-extension/savings", { savedUsd })`. `field` names the property of the entry's data
30
31
  * object to display, and `prefix` is the literal label drawn before it.
31
32
  */
32
33
  export interface StatusLineFeed {
@@ -37,9 +38,9 @@ export interface StatusLineFeed {
37
38
  }
38
39
 
39
40
  /**
40
- * currency keeps the rules pi-prompt-cache's author specified for the savings
41
- * figure: a signed half-cent floor so this segment and that extension's own
42
- * footer never disagree, two decimals below $100 and whole dollars above.
41
+ * currency keeps the rules specified for the savings figure this format was written
42
+ * for (published by pi-topping-compact, a private extension): a signed half-cent
43
+ * floor, two decimals below $100 and whole dollars above.
43
44
  */
44
45
  export type FeedFormat = "currency" | "number" | "text";
45
46
 
@@ -58,6 +59,7 @@ export interface StatusLineSegmentToggles {
58
59
  feeds?: boolean;
59
60
  feedsBottomLeft?: boolean;
60
61
  tokenRateBottomLeft?: boolean;
62
+ cacheHitBar?: boolean;
61
63
  contextBar?: boolean;
62
64
  contextStats?: boolean;
63
65
  scrollHint?: boolean;
@@ -84,6 +86,8 @@ export interface StatusLineSettings {
84
86
  embedWorkingStatus?: boolean;
85
87
  /** Host compaction progress in the bottom bar while pi compacts. */
86
88
  embedCompactionProgress?: boolean;
89
+ /** Length of the cache hit bar in segments (3–10, default 4); the bar is logarithmic at every size. */
90
+ cacheHitBarSegments?: number;
87
91
  }
88
92
 
89
93
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -91,6 +95,7 @@ export interface StatusLineSegmentOptions {
91
95
  model: { showModel: boolean; showProvider: boolean; showThinking: boolean };
92
96
  path: { abbreviate: boolean; maxLength: number; stripWorkPrefix: boolean };
93
97
  context: { showBar: boolean; showStats: boolean };
98
+ cacheHit: { cells: number };
94
99
  /** Subscriptions rendered by the feeds segment, in display order. */
95
100
  feeds: StatusLineFeed[];
96
101
  }
@@ -101,6 +106,8 @@ export interface SegmentIncludes {
101
106
  pr: boolean;
102
107
  piStats: boolean;
103
108
  tokenRate: boolean;
109
+ /** Whether the cache-hit graph needs a session-usage snapshot. */
110
+ cacheHit: boolean;
104
111
  feeds: readonly string[];
105
112
  }
106
113
 
@@ -137,6 +144,8 @@ export interface SegmentContext {
137
144
  contextPercent: number | null;
138
145
  contextTokens: number;
139
146
  contextWindow: number;
147
+ /** The latest assistant response's cache-read share of its prompt tokens (pi's footer CH), or null when none has reported prompt tokens. */
148
+ cacheHitPercent?: number | null;
140
149
  /**
141
150
  * True while contextPercent and contextTokens are pi's estimate of a freshly
142
151
  * compacted context, shown until pi measures the next response.
package/src/utils.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  * session-color.ts, tools/render-utils.ts, modes/shared.ts). Bun.color is
5
5
  * replaced with local hex math so this runs under any pi runtime.
6
6
  */
7
- import { stripVTControlCharacters } from "node:util";
8
- import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
9
- import { parseHex } from "./theme.js";
10
- import { hslToRgb, rgbToHex } from "./rainbow.js";
7
+ import { colorToHex, rgbColor, sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { hslToRgb, parseHex } from "@underactive/pi-topping-primitives/color";
9
+ import { sanitizeLabel } from "@underactive/pi-topping-primitives/text";
10
+
11
+ export { sanitizeLabel };
11
12
 
12
13
  export function formatNumber(n: number): string {
13
14
  if (n < 1_000) return n.toString();
@@ -25,8 +26,8 @@ export function formatNumber(n: number): string {
25
26
  return `${Math.round(n / 1_000_000_000)}B`;
26
27
  }
27
28
 
28
- export function shortenPath(filePath: string, homeDir?: string): string {
29
- const home = homeDir ?? process.env.HOME ?? "";
29
+ export function shortenPath(filePath: string): string {
30
+ const home = process.env.HOME ?? "";
30
31
  if (home && filePath.startsWith(home)) {
31
32
  const suffix = filePath.slice(home.length);
32
33
  if (suffix === "" || suffix.startsWith("/")) return `~${suffix}`;
@@ -68,16 +69,6 @@ export function sanitizeStatusText(text: string): string {
68
69
  return sanitizeLabel(text).trim();
69
70
  }
70
71
 
71
- /**
72
- * Same safety as {@link sanitizeStatusText} but without trimming, so a label
73
- * written as "TOK " keeps the space that separates it from its value.
74
- */
75
- export function sanitizeLabel(text: string): string {
76
- return stripVTControlCharacters(text)
77
- .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, " ")
78
- .replace(/ +/g, " ");
79
- }
80
-
81
72
  export function withIcon(icon: string, text: string): string {
82
73
  return icon ? `${icon} ${text}` : text;
83
74
  }
@@ -149,5 +140,5 @@ export function getSessionAccentHex(name: string, themeColorHexes: string[]): st
149
140
  let targetHue = DARK_HUE_START + (nameToHue(name) % range);
150
141
  const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
151
142
  targetHue = findSafeHue(targetHue, themeHues, DARK_HUE_START, DARK_HUE_END);
152
- return rgbToHex(hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS));
143
+ return colorToHex(rgbColor(...hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS)));
153
144
  }
@@ -1,12 +1,5 @@
1
1
  import type { CustomEditor } from "@earendil-works/pi-coding-agent";
2
2
 
3
- /**
4
- * Event-bus channel on which this extension announces, as `{ embedded: boolean }`,
5
- * whether its bar hosts Pi's working status. pi-topping slides its loader's response
6
- * model out only while it does. Shared with pi-topping.
7
- */
8
- export const WORKING_STATUS_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
9
-
10
3
  type EmbeddingEditor = Partial<Pick<CustomEditor, "embedWorkingStatus" | "setWorkingStatusIndicator">>;
11
4
 
12
5
  /** Whether an editor this extension built draws the host's working status in its bar. */
@@ -1,10 +1,9 @@
1
1
  import type { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+ import { WORKING_STATUS_SLIDE_MS } from "@underactive/pi-topping-primitives/contracts";
2
3
  import { easeFade } from "./theme.js";
3
4
 
4
5
  export type StatusIndicatorKind = NonNullable<Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]>["kind"];
5
6
 
6
- /** Slide-out time for a status that appears while the top-left group shows none. */
7
- export const STATUS_SLIDE_MS = 300;
8
7
  /** Briefly retain a cleared status so handoffs between host indicators do not flash. */
9
8
  export const STATUS_HOLD_MS = 150;
10
9
  /** Cross-fade budget when the embedded working status disappears: half out, half in. */
@@ -36,7 +35,7 @@ interface StatusExit {
36
35
  * Resolve host status-indicator handoffs into stable top-bar frames.
37
36
  *
38
37
  * A status that appears while the top-left group shows none slides out over
39
- * STATUS_SLIDE_MS. pi 0.86 routes working, retry, compaction, and
38
+ * WORKING_STATUS_SLIDE_MS. pi 0.86 routes working, retry, compaction, and
40
39
  * branch-summary indicators through the editor border and may clear the slot
41
40
  * between them. A cleared status is held briefly to bridge that gap, and a
42
41
  * status arriving while the previous one is still drawn takes its place
@@ -88,7 +87,7 @@ export class StatusTransition {
88
87
  pending(now: number): boolean {
89
88
  const exit = this.#exit;
90
89
  if (exit) return now < exit.holdUntil + exit.fadeMs;
91
- return this.#slideFrom !== undefined && now < this.#slideFrom + STATUS_SLIDE_MS;
90
+ return this.#slideFrom !== undefined && now < this.#slideFrom + WORKING_STATUS_SLIDE_MS;
92
91
  }
93
92
 
94
93
  reset(): void {
@@ -108,7 +107,7 @@ export class StatusTransition {
108
107
  */
109
108
  #withSlide(frame: StatusFrame, now: number): StatusFrame {
110
109
  if (this.#slideFrom === undefined) return frame;
111
- const progress = (now - this.#slideFrom) / STATUS_SLIDE_MS;
110
+ const progress = (now - this.#slideFrom) / WORKING_STATUS_SLIDE_MS;
112
111
  if (progress >= 1) {
113
112
  this.#slideFrom = undefined;
114
113
  return frame;