@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/README.md +11 -10
- package/index.ts +3 -2
- package/package.json +8 -5
- package/src/cache-hit.ts +107 -0
- package/src/compaction-bar.ts +9 -49
- package/src/context-graph.ts +6 -3
- package/src/context.ts +29 -3
- package/src/footer.ts +2 -4
- package/src/nvidia-green.ts +14 -10
- package/src/rainbow.ts +3 -23
- package/src/segments.ts +13 -1
- package/src/settings-menu.ts +73 -41
- package/src/settings.ts +49 -40
- package/src/tests/cache-hit.test.ts +318 -0
- package/src/tests/color-mode.test.ts +20 -0
- package/src/tests/compaction-bar.test.ts +9 -48
- package/src/tests/compaction-embed.test.ts +6 -2
- package/src/tests/context-estimate.test.ts +1 -1
- package/src/tests/feeds.test.ts +29 -11
- package/src/tests/git-status.test.ts +3 -3
- package/src/tests/nvidia-green.test.ts +0 -9
- package/src/tests/rainbow.test.ts +1 -1
- package/src/tests/settings-menu.test.ts +179 -0
- package/src/tests/settings.test.ts +56 -0
- package/src/tests/token-rate.test.ts +1 -1
- package/src/tests/working-status.test.ts +8 -8
- package/src/tests/working.test.ts +1 -7
- package/src/theme.ts +24 -101
- package/src/token-rate.ts +16 -117
- package/src/types.ts +14 -5
- package/src/utils.ts +8 -17
- package/src/working-status-embed.ts +0 -7
- package/src/working-status.ts +4 -5
- package/src/menu.ts +0 -676
- package/src/tests/menu.test.ts +0 -138
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 (
|
|
7
|
-
*
|
|
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
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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`,
|
|
29
|
-
* "
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
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
|
|
29
|
-
const 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
|
|
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. */
|
package/src/working-status.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 +
|
|
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) /
|
|
110
|
+
const progress = (now - this.#slideFrom) / WORKING_STATUS_SLIDE_MS;
|
|
112
111
|
if (progress >= 1) {
|
|
113
112
|
this.#slideFrom = undefined;
|
|
114
113
|
return frame;
|