@cocasts/pi-status-line 0.0.0-stage → 0.1.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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure formatting helpers for the status line: token counts, rates, latencies, cost, paths, and
3
+ * context-pressure colours.
4
+ *
5
+ * Nothing here reads the filesystem, the environment, or the clock — every export is a function of
6
+ * its arguments alone, so the test suite can pin each rule without a pi runtime. The one piece of
7
+ * host state these helpers need (the home directory) is passed in by the caller.
8
+ */
9
+
10
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
11
+
12
+ import type { ThemeColor } from '@earendil-works/pi-coding-agent';
13
+
14
+ /** Token counts: 999, 1.2k, 12k, 1.2M, 12M. */
15
+ export function formatTokens(count: number): string {
16
+ if (count < 1000) return String(count);
17
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
18
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
19
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
20
+ return `${Math.round(count / 1000000)}M`;
21
+ }
22
+
23
+ /** Whole tokens from ten up, one decimal below (matches the harness formatter). */
24
+ export function formatTps(tps: number): string {
25
+ const clamped = Math.max(0, tps);
26
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
27
+ }
28
+
29
+ /** Milliseconds below a second, seconds with one decimal under ten, whole seconds from there. */
30
+ export function formatLatency(ms: number): string {
31
+ const clamped = Math.max(0, ms);
32
+ if (clamped < 1000) return `${Math.round(clamped)}ms`;
33
+ const seconds = clamped / 1000;
34
+ return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
35
+ }
36
+
37
+ /**
38
+ * A USD amount, trailing zeros trimmed (`$0.42`, `$2.7`, `$17.8`).
39
+ *
40
+ * A nonzero amount that rounds down to zero renders at the smallest readable amount, `$0.01` — the
41
+ * billing figure never says `$0` for money that was spent.
42
+ */
43
+ export function formatCost(cost: number): string {
44
+ if (cost > 0 && Number(cost.toFixed(2)) === 0) return '$0.01';
45
+ return `$${cost.toFixed(2).replace(/\.?0+$/, '')}`;
46
+ }
47
+
48
+ /**
49
+ * An exact integer with the grouping the terminal font can carry cheaply: plain digits, so the
50
+ * column never shifts when a comma appears at the fourth digit.
51
+ */
52
+ export function formatExact(count: number): string {
53
+ return String(Math.round(count));
54
+ }
55
+
56
+ /** Context pressure: warning starts above 70%, error above 90%, the native dim below that. */
57
+ export function percentColor(percent: number | null): ThemeColor {
58
+ if (percent === null) return 'dim';
59
+ if (percent > 90) return 'error';
60
+ if (percent > 70) return 'warning';
61
+ return 'dim';
62
+ }
63
+
64
+ /**
65
+ * Home-relative path, or the absolute path when it is outside the home directory.
66
+ *
67
+ * `home` is a parameter rather than `os.homedir()` so the rule is testable and so a caller that
68
+ * already resolved the home directory does not resolve it again per frame.
69
+ */
70
+ export function formatCwd(cwd: string, home: string): string {
71
+ if (!home) return cwd;
72
+ try {
73
+ const rel = relative(resolve(home), resolve(cwd));
74
+ const inside = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
75
+ if (!inside) return cwd;
76
+ return rel === '' ? '~' : `~${sep}${rel}`;
77
+ } catch {
78
+ return cwd;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Shorten a path from the left, keeping the tail that identifies the project. Never returns a
84
+ * mid-word fragment: it degrades to `…/last` and then to `last…`.
85
+ */
86
+ export function shortenPath(path: string, maxWidth: number): string {
87
+ if (path.length <= maxWidth) return path;
88
+ const parts = path.split(sep).filter(Boolean);
89
+ for (let i = parts.length - 1; i >= 0; i--) {
90
+ const candidate = `…${sep}${parts.slice(i).join(sep)}`;
91
+ if (candidate.length <= maxWidth) return candidate;
92
+ }
93
+ const tail = parts.at(-1) ?? path;
94
+ return tail.length <= maxWidth ? tail : `${tail.slice(0, Math.max(1, maxWidth - 1))}…`;
95
+ }
96
+
97
+ /** The last segment of a slash-separated model id (`anthropic/claude-sonnet-4` → `claude-sonnet-4`). */
98
+ export function modelShortId(model: string): string {
99
+ return model.split('/').filter(Boolean).at(-1) ?? model;
100
+ }
101
+
102
+ /** Last two segments of a path, for the `parent` path format (`~/dev/project` → `dev/project`). */
103
+ export function pathTail(path: string, segments = 2): string {
104
+ const parts = path.split(sep).filter(Boolean);
105
+ return parts.slice(-segments).join(sep);
106
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * `@cocasts/pi-status-line` — a modular single-row footer for the pi coding agent.
3
+ *
4
+ * This file is only the wiring: it loads the config, collects the numbers the modules read, keeps
5
+ * the footer repainting while a turn streams, and registers `/statusline`. Everything that decides
6
+ * what the line looks like is a pure function under `render.ts`, `modules.ts`, and `config.ts`, so
7
+ * the interesting rules are covered by tests that never start pi.
8
+ *
9
+ * Layout of the turn-metrics contract is the reference footer's (deepseek-harness): a request opens
10
+ * at `before_provider_request`, its first streamed delta freezes TTFT, and a message's decode rate is
11
+ * `usage.output / ((messageEnd - firstToken) / 1000)` — reporting absence, not a fake, when the
12
+ * decode window is too short to measure.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionCommandContext,
22
+ ExtensionContext,
23
+ ReadonlyFooterDataProvider,
24
+ } from '@earendil-works/pi-coding-agent';
25
+
26
+ import { type ParsedConfig, parseConfig, reportConfig } from './config.ts';
27
+ import { formatCwd, formatLatency, formatTps } from './format.ts';
28
+ import type { StatusLineParts } from './modules.ts';
29
+ import { renderStatusLine } from './render.ts';
30
+ import { collectTotals } from './session-metrics.ts';
31
+ import { frozenThroughput, ttftMs, windowThroughput } from './throughput.ts';
32
+
33
+ /**
34
+ * Where the module lists live. Read at session start and on `/reload`.
35
+ *
36
+ * Pi's own `PI_CODING_AGENT_DIR` override is honoured, so a throwaway agent directory (tests, a
37
+ * sandboxed run) also gets a throwaway status line instead of writing into the real config.
38
+ */
39
+ function configPath(): string {
40
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
41
+ return join(agentDir, 'status-line', 'config.json');
42
+ }
43
+ /** A streaming turn repaints at most this often; the footer is not an animation. */
44
+ const LIVE_RENDER_MS = 200;
45
+ /** The trailing window the in-flight rate is measured over. */
46
+ const WINDOW_MS = 2000;
47
+ /** A decode window shorter than this is a burst arrival, not a measurable decode phase. */
48
+ const MIN_SAMPLE_MS = 200;
49
+ /** Pi's own estimateTokens() heuristic, used only until a stream gives a real tokens-per-char ratio. */
50
+ const FALLBACK_TOKENS_PER_CHAR = 0.25;
51
+
52
+ /** Last context usage pi reported with real numbers, shown when pi's current answer is stale. */
53
+ interface KnownUsage {
54
+ tokens: number;
55
+ percent: number;
56
+ contextWindow: number;
57
+ }
58
+
59
+ /** The latest turn reading: the rate, and whether it came from provider usage or an estimate. */
60
+ interface Reading {
61
+ rate: number;
62
+ exact: boolean;
63
+ }
64
+
65
+ /** The last completed turn's timing, reported by `/statusline` and rendered by the tps module. */
66
+ interface LastTurn {
67
+ /** Time to first token, or null when the anchors did not line up. */
68
+ ttftMs: number | null;
69
+ decodeMs: number;
70
+ rate: number | null;
71
+ exact: boolean;
72
+ }
73
+
74
+ /** One plain-text line for the last turn, for the `/statusline` report. */
75
+ function describeLastTurn(turn: LastTurn | null): string {
76
+ if (turn === null) return ' not measured yet';
77
+ const ttft = turn.ttftMs === null ? 'ttft n/a' : `ttft ${formatLatency(turn.ttftMs)}`;
78
+ const rate = turn.rate === null ? 'no measurable rate' : `${turn.exact ? '' : '~'}${formatTps(turn.rate)} tok/s`;
79
+ return ` ${ttft} · decode ${formatLatency(turn.decodeMs)} · ${rate}`;
80
+ }
81
+
82
+ export default function (pi: ExtensionAPI) {
83
+ let parsed: ParsedConfig = parseConfig(null);
84
+ let configFound = false;
85
+
86
+ let requestRender: (() => void) | null = null;
87
+ let lastKnownUsage: KnownUsage | null = null;
88
+
89
+ let reading: Reading | null = null;
90
+ let lastTurn: LastTurn | null = null;
91
+ let requestAt: number | null = null;
92
+ let firstTokenAt: number | null = null;
93
+ /** Characters streamed for the current message, used for the live rate estimate. */
94
+ let chars = 0;
95
+ /** Tokens per character, learned from finished messages rather than assumed forever. */
96
+ let ratio: number | null = null;
97
+ let windowAt = 0;
98
+ let windowTokens = 0;
99
+ let renderedAt = 0;
100
+
101
+ function resetStream(): void {
102
+ chars = 0;
103
+ firstTokenAt = null;
104
+ windowAt = 0;
105
+ windowTokens = 0;
106
+ renderedAt = 0;
107
+ }
108
+
109
+ /** Drops the in-flight anchor. A run that ends without a first token must not keep counting. */
110
+ function clearInFlightRequest(): void {
111
+ requestAt = null;
112
+ }
113
+
114
+ function publish(rate: number, exact: boolean): void {
115
+ reading = { rate, exact };
116
+ requestRender?.();
117
+ }
118
+
119
+ /** Reads the config file and replaces the live layout. Problems are reported once per load. */
120
+ async function loadConfig(notify: (message: string, type?: 'info' | 'warning' | 'error') => void): Promise<void> {
121
+ let text: string | null = null;
122
+ try {
123
+ text = await readFile(configPath(), 'utf8');
124
+ configFound = true;
125
+ } catch {
126
+ // No config file is the normal first run: the defaults are the documented layout.
127
+ configFound = false;
128
+ }
129
+ parsed = parseConfig(text);
130
+ if (parsed.problems.length > 0) {
131
+ notify(`status line: ${parsed.problems.join('; ')}`, 'warning');
132
+ }
133
+ }
134
+
135
+ function installFooter(ctx: ExtensionContext): void {
136
+ ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
137
+ requestRender = () => tui.requestRender();
138
+
139
+ return {
140
+ invalidate() {},
141
+ dispose() {
142
+ requestRender = null;
143
+ },
144
+ render(width: number): string[] {
145
+ if (width <= 0) return [''];
146
+
147
+ const usage = ctx.getContextUsage();
148
+ // pi nulls tokens and percent when the last usage predates a compaction and no response
149
+ // has landed since: those numbers are stale, not zero. Keep the last trusted reading.
150
+ const trusted =
151
+ usage !== undefined && usage.tokens !== null && usage.percent !== null
152
+ ? { tokens: usage.tokens, percent: usage.percent, contextWindow: usage.contextWindow }
153
+ : null;
154
+ if (trusted !== null) lastKnownUsage = trusted;
155
+ const shown = trusted ?? lastKnownUsage;
156
+
157
+ const totals = collectTotals(ctx.sessionManager.getEntries());
158
+ // Native only names the provider when a route can be chosen; with one provider it is noise.
159
+ const provider = footerData.getAvailableProviderCount() > 1 ? (ctx.model?.provider ?? null) : null;
160
+ const parts: StatusLineParts = {
161
+ model: ctx.model?.id ?? 'no model',
162
+ provider,
163
+ effort: ctx.thinkingLevel ?? null,
164
+ path: formatCwd(ctx.cwd, homedir()),
165
+ branch: footerData.getGitBranch() ?? null,
166
+ session: ctx.sessionManager.getSessionName() ?? null,
167
+ input: totals.input,
168
+ output: totals.output,
169
+ cacheHitRate: totals.cacheHitRate,
170
+ // The latest turn, not the session average: an average folds in cold-cache warm-up
171
+ // turns and reports a speed this route no longer has.
172
+ tps: reading === null ? null : `${reading.exact ? '' : '~'}${formatTps(reading.rate)}`,
173
+ contextTokens: shown?.tokens ?? 0,
174
+ contextWindow: usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0,
175
+ contextPercent: shown?.percent ?? null,
176
+ cost: totals.cost,
177
+ };
178
+
179
+ return renderStatusLine(theme, width, parsed.layout, parts);
180
+ },
181
+ };
182
+ });
183
+ }
184
+
185
+ // TTFT's request anchor, from the agent loop: turn_start opens the LLM call and
186
+ // before_provider_request is the last thing pi does before the wire. message_start is not that
187
+ // moment, and anchoring there made a first token that arrived early read as a clamped 0ms.
188
+ pi.on('turn_start', clearInFlightRequest);
189
+ pi.on('before_provider_request', () => {
190
+ requestAt = Date.now();
191
+ });
192
+
193
+ // Every way a run can retire retires the in-flight request with it.
194
+ pi.on('turn_end', clearInFlightRequest);
195
+ pi.on('agent_end', clearInFlightRequest);
196
+ pi.on('agent_settled', clearInFlightRequest);
197
+
198
+ pi.on('session_start', async (_event, ctx) => {
199
+ reading = null;
200
+ requestAt = null;
201
+ ratio = null;
202
+ lastKnownUsage = null;
203
+ lastTurn = null;
204
+ resetStream();
205
+ await loadConfig((message, type) => ctx.ui.notify(message, type ?? 'info'));
206
+ installFooter(ctx);
207
+ });
208
+
209
+ pi.on('message_start', async (event) => {
210
+ if (event.message.role !== 'assistant') return;
211
+ resetStream();
212
+ });
213
+
214
+ pi.on('message_update', async (event) => {
215
+ const delta = event.assistantMessageEvent;
216
+ // Narrowing on the discriminant is what keeps the payload typed; membership in a Set of
217
+ // strings tells the compiler nothing.
218
+ if (delta.type !== 'text_delta' && delta.type !== 'thinking_delta' && delta.type !== 'toolcall_delta') {
219
+ return;
220
+ }
221
+
222
+ const now = Date.now();
223
+ if (firstTokenAt === null) firstTokenAt = now;
224
+ chars += delta.delta.length;
225
+
226
+ const tokens = chars * (ratio ?? FALLBACK_TOKENS_PER_CHAR);
227
+ if (windowAt === 0 || now - windowAt > WINDOW_MS) {
228
+ windowAt = now;
229
+ windowTokens = tokens;
230
+ }
231
+
232
+ const decodeMs = now - firstTokenAt;
233
+ if (decodeMs < MIN_SAMPLE_MS || now - renderedAt < LIVE_RENDER_MS) return;
234
+
235
+ const rate = windowThroughput(tokens, windowTokens, now - windowAt);
236
+ if (rate === null) return;
237
+ renderedAt = now;
238
+ publish(rate, false);
239
+ });
240
+
241
+ pi.on('message_end', async (event) => {
242
+ if (event.message.role !== 'assistant') return;
243
+
244
+ const message = event.message as { usage?: { output?: number } };
245
+ const output = typeof message.usage?.output === 'number' ? message.usage.output : 0;
246
+ // Learn the tokens-per-character ratio from the finished message itself: the same streamed
247
+ // characters that fed the live estimate, against the provider's authoritative output count.
248
+ if (output > 0 && chars > 0) {
249
+ const sample = output / chars;
250
+ ratio = ratio === null ? sample : (ratio + sample) / 2;
251
+ }
252
+
253
+ const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0;
254
+ const tokens = output > 0 ? output : chars * (ratio ?? FALLBACK_TOKENS_PER_CHAR);
255
+ const rate = frozenThroughput(decodeMs, tokens, MIN_SAMPLE_MS);
256
+ const measured = ttftMs(requestAt, firstTokenAt);
257
+ lastTurn = { ttftMs: measured, decodeMs, rate, exact: output > 0 };
258
+ // A sub-floor window is a burst arrival: keep no rate rather than print a confident fake.
259
+ if (rate === null) reading = null;
260
+ else publish(rate, output > 0);
261
+
262
+ // Null it with the stream: a request that has produced its message is no longer in flight.
263
+ requestAt = null;
264
+ resetStream();
265
+ });
266
+
267
+ pi.registerCommand('statusline', {
268
+ description: 'Show the effective status-line rows, their formatting, and any config problems',
269
+ handler: async (_args, ctx: ExtensionCommandContext) => {
270
+ const source = configFound ? configPath() : `${configPath()} (not found — using the built-in defaults)`;
271
+ const lines = reportConfig(parsed, source);
272
+ lines.push('', 'last turn:', describeLastTurn(lastTurn));
273
+ lines.push('', 'Edit the file, then run /reload to apply it.');
274
+ ctx.ui.notify(lines.join('\n'), parsed.problems.length > 0 ? 'warning' : 'info');
275
+ },
276
+ });
277
+ }