@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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +227 -2
- package/extensions/status-line/config.ts +389 -0
- package/extensions/status-line/format.ts +106 -0
- package/extensions/status-line/index.ts +277 -0
- package/extensions/status-line/modules.ts +366 -0
- package/extensions/status-line/render.ts +160 -0
- package/extensions/status-line/session-metrics.ts +77 -0
- package/extensions/status-line/throughput.ts +36 -0
- package/package.json +44 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module registry: what the status line can show, how each module reads by default, and which
|
|
3
|
+
* modules give up their seat first when the terminal narrows.
|
|
4
|
+
*
|
|
5
|
+
* A module is deliberately inert data plus one pure `value()` function. Layout, labels, colours, and
|
|
6
|
+
* overflow live elsewhere, so adding a module is a registry entry rather than a branch in the row
|
|
7
|
+
* renderer. Nothing here imports the pi runtime — only its types — so the whole registry is
|
|
8
|
+
* exercised in tests with plain objects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
|
|
13
|
+
import { formatCost, formatExact, formatTokens, modelShortId, pathTail, percentColor } from './format.ts';
|
|
14
|
+
|
|
15
|
+
/** Everything the modules can read. Collected once per turn by the wiring layer. */
|
|
16
|
+
export interface StatusLineParts {
|
|
17
|
+
/** Model id, or the empty string when no model is selected. */
|
|
18
|
+
model: string;
|
|
19
|
+
/**
|
|
20
|
+
* The model's provider, shown only when the session can route to more than one provider — the
|
|
21
|
+
* same rule the native footer uses before it writes `(provider) model`.
|
|
22
|
+
*/
|
|
23
|
+
provider: string | null;
|
|
24
|
+
/** Thinking level (`off`…`max`), or null when the session is not thinking. */
|
|
25
|
+
effort: string | null;
|
|
26
|
+
/** Home-shortened working directory; empty when the path module is disabled. */
|
|
27
|
+
path: string;
|
|
28
|
+
branch: string | null;
|
|
29
|
+
/** Explicit session name, or null when the session has none. */
|
|
30
|
+
session: string | null;
|
|
31
|
+
/** Session prompt tokens that were neither read from nor written to cache. */
|
|
32
|
+
input: number;
|
|
33
|
+
output: number;
|
|
34
|
+
/** Cache hit rate as a share of prompt tokens, 0–100; null before the first turn. */
|
|
35
|
+
cacheHitRate: number | null;
|
|
36
|
+
/** Decode rate already formatted by formatTps, without a unit; null before the first turn. */
|
|
37
|
+
tps: string | null;
|
|
38
|
+
/** Tokens currently occupying the context window. */
|
|
39
|
+
contextTokens: number;
|
|
40
|
+
/** The model's window size, 0 when unknown. */
|
|
41
|
+
contextWindow: number;
|
|
42
|
+
/** The share of the window in use, 0–100, or null when it is not known yet. */
|
|
43
|
+
contextPercent: number | null;
|
|
44
|
+
/** Session spend in USD, or null when pi has not priced anything yet. */
|
|
45
|
+
cost: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A module's value for the current frame. */
|
|
49
|
+
export interface ModuleValue {
|
|
50
|
+
text: string;
|
|
51
|
+
color: ThemeColor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How a module's label reads. `none` is for modules whose value is self-describing (a model id). */
|
|
55
|
+
export type LabelStyle = 'none' | 'glyph' | 'word';
|
|
56
|
+
|
|
57
|
+
export type ModuleId =
|
|
58
|
+
| 'model'
|
|
59
|
+
| 'provider'
|
|
60
|
+
| 'effort'
|
|
61
|
+
| 'path'
|
|
62
|
+
| 'branch'
|
|
63
|
+
| 'session'
|
|
64
|
+
| 'input'
|
|
65
|
+
| 'output'
|
|
66
|
+
| 'cache-hit'
|
|
67
|
+
| 'tps'
|
|
68
|
+
| 'context'
|
|
69
|
+
| 'cost';
|
|
70
|
+
|
|
71
|
+
export interface ModuleDef {
|
|
72
|
+
id: ModuleId;
|
|
73
|
+
/**
|
|
74
|
+
* Shrink rank: the lowest priority is dropped first when the line does not fit, and the highest
|
|
75
|
+
* is dropped last. Floor modules are never dropped by the shrink pass.
|
|
76
|
+
*/
|
|
77
|
+
priority: number;
|
|
78
|
+
/** Kept even at the narrowest width (only `context`). */
|
|
79
|
+
floor: boolean;
|
|
80
|
+
/** One cell wide, drawn by the terminal's own monospace font, no emoji presentation. */
|
|
81
|
+
glyph: string;
|
|
82
|
+
/** The word form of the label. */
|
|
83
|
+
word: string;
|
|
84
|
+
defaultLabel: LabelStyle;
|
|
85
|
+
formats: readonly string[];
|
|
86
|
+
defaultFormat: string;
|
|
87
|
+
/** The colour used when no `color` is configured. Reports show this for static modules. */
|
|
88
|
+
defaultColor: ThemeColor;
|
|
89
|
+
/**
|
|
90
|
+
* Set when the module picks its colour per frame instead of using `defaultColor`. The
|
|
91
|
+
* `/statusline` report prints this instead, so it never claims a static colour for a dynamic one.
|
|
92
|
+
*/
|
|
93
|
+
colorNote?: string;
|
|
94
|
+
/** The rendered value, or null when the module has nothing to say this frame. */
|
|
95
|
+
value(parts: StatusLineParts, format: string): ModuleValue | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The module table, ordered by the default left-to-right reading order of a session.
|
|
100
|
+
*
|
|
101
|
+
* Colours follow pi's own footer: every reading is `dim`, except context pressure, which borrows the
|
|
102
|
+
* `warning` / `error` tokens above the same 70 % / 90 % thresholds. Identity modules are dim too, so
|
|
103
|
+
* a configured accent is an opt-in rather than the default.
|
|
104
|
+
*/
|
|
105
|
+
export const MODULES: Readonly<Record<ModuleId, ModuleDef>> = {
|
|
106
|
+
model: {
|
|
107
|
+
id: 'model',
|
|
108
|
+
priority: 90,
|
|
109
|
+
floor: false,
|
|
110
|
+
glyph: '◈',
|
|
111
|
+
word: 'model',
|
|
112
|
+
// A model id needs no label: it is the one value that identifies itself.
|
|
113
|
+
defaultLabel: 'none',
|
|
114
|
+
formats: ['id', 'short'],
|
|
115
|
+
defaultFormat: 'id',
|
|
116
|
+
defaultColor: 'dim',
|
|
117
|
+
value: (parts, format) => ({
|
|
118
|
+
text: format === 'short' ? modelShortId(parts.model) : parts.model,
|
|
119
|
+
color: 'dim',
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
provider: {
|
|
123
|
+
id: 'provider',
|
|
124
|
+
priority: 85,
|
|
125
|
+
floor: false,
|
|
126
|
+
glyph: '◇',
|
|
127
|
+
word: 'provider',
|
|
128
|
+
defaultLabel: 'none',
|
|
129
|
+
formats: ['paren', 'name'],
|
|
130
|
+
defaultFormat: 'paren',
|
|
131
|
+
defaultColor: 'dim',
|
|
132
|
+
value: (parts, format) => {
|
|
133
|
+
if (parts.provider === null || parts.provider === '') return null;
|
|
134
|
+
return { text: format === 'name' ? parts.provider : `(${parts.provider})`, color: 'dim' };
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
effort: {
|
|
138
|
+
id: 'effort',
|
|
139
|
+
priority: 40,
|
|
140
|
+
floor: false,
|
|
141
|
+
glyph: '◐',
|
|
142
|
+
word: 'effort',
|
|
143
|
+
defaultLabel: 'none',
|
|
144
|
+
// `native` spells the disabled level out, the way the native footer writes `• thinking off`.
|
|
145
|
+
formats: ['native', 'word', 'initial'],
|
|
146
|
+
defaultFormat: 'native',
|
|
147
|
+
defaultColor: 'dim',
|
|
148
|
+
value: (parts, format) => {
|
|
149
|
+
if (parts.effort === null || parts.effort === '') return null;
|
|
150
|
+
if (format === 'native' && parts.effort === 'off') return { text: 'thinking off', color: 'dim' };
|
|
151
|
+
const text = format === 'initial' ? parts.effort.slice(0, 1).toUpperCase() : parts.effort;
|
|
152
|
+
return { text, color: 'dim' };
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
path: {
|
|
156
|
+
id: 'path',
|
|
157
|
+
// Above the branch and session tail: a narrow terminal keeps the directory and drops what
|
|
158
|
+
// follows it, the way the native footer truncates the directory line from the right.
|
|
159
|
+
priority: 20,
|
|
160
|
+
floor: false,
|
|
161
|
+
glyph: '⌂',
|
|
162
|
+
word: 'path',
|
|
163
|
+
defaultLabel: 'none',
|
|
164
|
+
formats: ['full', 'tail'],
|
|
165
|
+
defaultFormat: 'full',
|
|
166
|
+
defaultColor: 'dim',
|
|
167
|
+
value: (parts, format) => {
|
|
168
|
+
if (parts.path === '') return null;
|
|
169
|
+
return { text: format === 'tail' ? pathTail(parts.path) : parts.path, color: 'dim' };
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
branch: {
|
|
173
|
+
id: 'branch',
|
|
174
|
+
priority: 15,
|
|
175
|
+
floor: false,
|
|
176
|
+
glyph: '⎇',
|
|
177
|
+
word: 'branch',
|
|
178
|
+
defaultLabel: 'none',
|
|
179
|
+
// Parenthesised is how the native footer writes the branch next to the cwd.
|
|
180
|
+
formats: ['paren', 'name'],
|
|
181
|
+
defaultFormat: 'paren',
|
|
182
|
+
defaultColor: 'dim',
|
|
183
|
+
value: (parts, format) => {
|
|
184
|
+
if (parts.branch === null || parts.branch === '') return null;
|
|
185
|
+
return { text: format === 'name' ? parts.branch : `(${parts.branch})`, color: 'dim' };
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
session: {
|
|
189
|
+
id: 'session',
|
|
190
|
+
priority: 10,
|
|
191
|
+
floor: false,
|
|
192
|
+
glyph: '❯',
|
|
193
|
+
word: 'session',
|
|
194
|
+
defaultLabel: 'none',
|
|
195
|
+
formats: ['name'],
|
|
196
|
+
defaultFormat: 'name',
|
|
197
|
+
defaultColor: 'dim',
|
|
198
|
+
value: (parts) => (parts.session === null || parts.session === '' ? null : { text: parts.session, color: 'dim' }),
|
|
199
|
+
},
|
|
200
|
+
input: {
|
|
201
|
+
id: 'input',
|
|
202
|
+
priority: 80,
|
|
203
|
+
floor: false,
|
|
204
|
+
glyph: '↑',
|
|
205
|
+
word: 'input',
|
|
206
|
+
defaultLabel: 'glyph',
|
|
207
|
+
formats: ['tokens', 'exact'],
|
|
208
|
+
defaultFormat: 'tokens',
|
|
209
|
+
defaultColor: 'dim',
|
|
210
|
+
value: (parts, format) =>
|
|
211
|
+
parts.input > 0
|
|
212
|
+
? { text: format === 'exact' ? formatExact(parts.input) : formatTokens(parts.input), color: 'dim' }
|
|
213
|
+
: null,
|
|
214
|
+
},
|
|
215
|
+
output: {
|
|
216
|
+
id: 'output',
|
|
217
|
+
priority: 60,
|
|
218
|
+
floor: false,
|
|
219
|
+
glyph: '↓',
|
|
220
|
+
word: 'output',
|
|
221
|
+
defaultLabel: 'glyph',
|
|
222
|
+
formats: ['tokens', 'exact'],
|
|
223
|
+
defaultFormat: 'tokens',
|
|
224
|
+
defaultColor: 'dim',
|
|
225
|
+
value: (parts, format) =>
|
|
226
|
+
parts.output > 0
|
|
227
|
+
? { text: format === 'exact' ? formatExact(parts.output) : formatTokens(parts.output), color: 'dim' }
|
|
228
|
+
: null,
|
|
229
|
+
},
|
|
230
|
+
'cache-hit': {
|
|
231
|
+
id: 'cache-hit',
|
|
232
|
+
priority: 50,
|
|
233
|
+
floor: false,
|
|
234
|
+
// A bullseye: the reading itself is a hit rate.
|
|
235
|
+
glyph: '◎',
|
|
236
|
+
word: 'cache',
|
|
237
|
+
defaultLabel: 'glyph',
|
|
238
|
+
formats: ['percent', 'hit'],
|
|
239
|
+
defaultFormat: 'percent',
|
|
240
|
+
defaultColor: 'dim',
|
|
241
|
+
value: (parts, format) => {
|
|
242
|
+
if (parts.cacheHitRate === null) return null;
|
|
243
|
+
const rate = `${parts.cacheHitRate.toFixed(1)}%`;
|
|
244
|
+
return { text: format === 'hit' ? `${rate} hit` : rate, color: 'dim' };
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
tps: {
|
|
248
|
+
id: 'tps',
|
|
249
|
+
priority: 30,
|
|
250
|
+
floor: false,
|
|
251
|
+
// The Greek lightning mark: speed as a single monospace cell, with no emoji presentation.
|
|
252
|
+
glyph: 'ϟ',
|
|
253
|
+
word: 'speed',
|
|
254
|
+
defaultLabel: 'glyph',
|
|
255
|
+
formats: ['rate', 'long'],
|
|
256
|
+
defaultFormat: 'rate',
|
|
257
|
+
defaultColor: 'dim',
|
|
258
|
+
value: (parts, format) =>
|
|
259
|
+
parts.tps === null ? null : { text: format === 'long' ? `${parts.tps} tok/s` : `${parts.tps}/s`, color: 'dim' },
|
|
260
|
+
},
|
|
261
|
+
context: {
|
|
262
|
+
id: 'context',
|
|
263
|
+
priority: 1000,
|
|
264
|
+
floor: true,
|
|
265
|
+
// Context is the one reading with no unit in its number, so it carries a glyph like the rest.
|
|
266
|
+
glyph: '▤',
|
|
267
|
+
word: 'ctx',
|
|
268
|
+
// The native footer writes the context reading without a label: `17.0%/200k`.
|
|
269
|
+
defaultLabel: 'none',
|
|
270
|
+
formats: ['native', 'window', 'tokens', 'percent'],
|
|
271
|
+
defaultFormat: 'native',
|
|
272
|
+
defaultColor: 'dim',
|
|
273
|
+
colorNote: 'pressure colour (70% / 90%)',
|
|
274
|
+
value: (parts, format) => {
|
|
275
|
+
const used = formatTokens(parts.contextTokens);
|
|
276
|
+
const window = formatTokens(parts.contextWindow);
|
|
277
|
+
if (format === 'percent') {
|
|
278
|
+
return {
|
|
279
|
+
text: parts.contextPercent === null ? '--' : `${Math.round(parts.contextPercent)}%`,
|
|
280
|
+
color: percentColor(parts.contextPercent),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (format === 'tokens') return { text: used, color: percentColor(parts.contextPercent) };
|
|
284
|
+
if (format === 'window') {
|
|
285
|
+
return {
|
|
286
|
+
text: parts.contextWindow > 0 ? `${used}/${window}` : used,
|
|
287
|
+
color: percentColor(parts.contextPercent),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// Native: one decimal of pressure over the abbreviated window, `?` when pi has no reading.
|
|
291
|
+
const share = parts.contextPercent === null ? '?' : `${parts.contextPercent.toFixed(1)}%`;
|
|
292
|
+
return { text: `${share}/${window}`, color: percentColor(parts.contextPercent) };
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
cost: {
|
|
296
|
+
id: 'cost',
|
|
297
|
+
priority: 70,
|
|
298
|
+
floor: false,
|
|
299
|
+
// A currency mark for the rare case someone wants a label; the value is already self-describing.
|
|
300
|
+
glyph: '¤',
|
|
301
|
+
word: 'cost',
|
|
302
|
+
defaultLabel: 'none',
|
|
303
|
+
formats: ['usd', 'exact'],
|
|
304
|
+
defaultFormat: 'usd',
|
|
305
|
+
defaultColor: 'dim',
|
|
306
|
+
value: (parts, format) => {
|
|
307
|
+
if (parts.cost === null || parts.cost <= 0) return null;
|
|
308
|
+
return { text: format === 'exact' ? `$${parts.cost.toFixed(4)}` : formatCost(parts.cost), color: 'dim' };
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** Every module id, in registry order. Used by validation and by the `/statusline` report. */
|
|
314
|
+
export const MODULE_IDS: readonly ModuleId[] = Object.keys(MODULES) as ModuleId[];
|
|
315
|
+
|
|
316
|
+
export function isModuleId(value: unknown): value is ModuleId {
|
|
317
|
+
return typeof value === 'string' && Object.hasOwn(MODULES, value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function moduleDef(id: ModuleId): ModuleDef {
|
|
321
|
+
return MODULES[id];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* One module instance as configured: the definition plus the per-entry overrides that survived
|
|
326
|
+
* validation. `null` colour means "use whatever the module chose for this frame", which is how the
|
|
327
|
+
* context reading keeps its pressure colour unless the user insists otherwise.
|
|
328
|
+
*/
|
|
329
|
+
export interface ResolvedEntry {
|
|
330
|
+
def: ModuleDef;
|
|
331
|
+
label: LabelStyle;
|
|
332
|
+
format: string;
|
|
333
|
+
color: ThemeColor | null;
|
|
334
|
+
prefix: string;
|
|
335
|
+
suffix: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `label value`, with the key quiet and the value bright.
|
|
340
|
+
*
|
|
341
|
+
* Both label styles keep a space before the value, so `↑ 132` and `input 132` read at the same
|
|
342
|
+
* rhythm; a self-describing value has no label at all.
|
|
343
|
+
*/
|
|
344
|
+
function labelled(theme: Theme, entry: ResolvedEntry, value: ModuleValue): string {
|
|
345
|
+
const text = theme.fg(entry.color ?? value.color, value.text);
|
|
346
|
+
const label =
|
|
347
|
+
entry.label === 'none'
|
|
348
|
+
? ''
|
|
349
|
+
: entry.label === 'glyph'
|
|
350
|
+
? `${theme.fg('dim', entry.def.glyph)} `
|
|
351
|
+
: `${theme.fg('dim', entry.def.word)} `;
|
|
352
|
+
const prefix = entry.prefix === '' ? '' : theme.fg('dim', entry.prefix);
|
|
353
|
+
const suffix = entry.suffix === '' ? '' : theme.fg('dim', entry.suffix);
|
|
354
|
+
return `${prefix}${label}${text}${suffix}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Renders one module, or returns null when it has nothing to show this frame.
|
|
359
|
+
*
|
|
360
|
+
* A module that is empty (no branch, no output yet, no rate yet) contributes no divider either, so
|
|
361
|
+
* the row never shows a dangling `·`.
|
|
362
|
+
*/
|
|
363
|
+
export function renderModule(theme: Theme, entry: ResolvedEntry, parts: StatusLineParts): string | null {
|
|
364
|
+
const value = entry.def.value(parts, entry.format);
|
|
365
|
+
return value === null ? null : labelled(theme, entry, value);
|
|
366
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout engine: one or more rows, each split into a left and a right group, and a fixed order
|
|
3
|
+
* in which modules give up their seat as the terminal narrows.
|
|
4
|
+
*
|
|
5
|
+
* The reference footer hard-coded both its two rows and the shrink ladder inside one function. Here
|
|
6
|
+
* the rows are configuration and the ladder is data — each module's registry `priority` — so the
|
|
7
|
+
* renderer only has to drop the lowest priority on the row that overflows and re-measure it. Each
|
|
8
|
+
* row shrinks on its own, so a long working directory never costs the metrics their seats. Floor
|
|
9
|
+
* modules (context) are never dropped by that pass; when even they do not fit, the row degrades to a
|
|
10
|
+
* plain truncation instead of a half-rendered module.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
15
|
+
|
|
16
|
+
import { type ResolvedEntry, renderModule, type StatusLineParts } from './modules.ts';
|
|
17
|
+
|
|
18
|
+
/** Minimum air between the left and right groups of one row. */
|
|
19
|
+
const GROUP_GAP = 2;
|
|
20
|
+
|
|
21
|
+
/** One row of the footer: a left group and a right group, each with its own separator. */
|
|
22
|
+
export interface Row {
|
|
23
|
+
left: ResolvedEntry[];
|
|
24
|
+
right: ResolvedEntry[];
|
|
25
|
+
divider: string;
|
|
26
|
+
rightDivider: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A layout is the rows after config resolution. */
|
|
30
|
+
export interface Layout {
|
|
31
|
+
rows: Row[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Placed {
|
|
35
|
+
entry: ResolvedEntry;
|
|
36
|
+
side: 'left' | 'right';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ComposedRow {
|
|
40
|
+
left: string;
|
|
41
|
+
right: string;
|
|
42
|
+
/** The width the row needs; zero when the row has nothing to show. */
|
|
43
|
+
needed: number;
|
|
44
|
+
/** How many modules actually rendered this frame. */
|
|
45
|
+
count: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Joins one group's module strings, skipping modules that have nothing to show this frame. */
|
|
49
|
+
function composeGroup(
|
|
50
|
+
theme: Theme,
|
|
51
|
+
entries: readonly ResolvedEntry[],
|
|
52
|
+
parts: StatusLineParts,
|
|
53
|
+
divider: string,
|
|
54
|
+
): { text: string; count: number } {
|
|
55
|
+
const sep = divider === '' ? '' : theme.fg('dim', divider);
|
|
56
|
+
const rendered = entries
|
|
57
|
+
.map((entry) => renderModule(theme, entry, parts))
|
|
58
|
+
.filter((text): text is string => text !== null);
|
|
59
|
+
return { text: rendered.join(sep), count: rendered.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The row as it currently reads, with visible modules only. */
|
|
63
|
+
function composeRow(
|
|
64
|
+
theme: Theme,
|
|
65
|
+
configRow: Row,
|
|
66
|
+
placed: readonly Placed[],
|
|
67
|
+
parts: StatusLineParts,
|
|
68
|
+
dropped: ReadonlySet<Placed>,
|
|
69
|
+
): ComposedRow {
|
|
70
|
+
const visible = placed.filter((item) => !dropped.has(item));
|
|
71
|
+
const left = composeGroup(
|
|
72
|
+
theme,
|
|
73
|
+
visible.filter((item) => item.side === 'left').map((item) => item.entry),
|
|
74
|
+
parts,
|
|
75
|
+
configRow.divider,
|
|
76
|
+
);
|
|
77
|
+
const right = composeGroup(
|
|
78
|
+
theme,
|
|
79
|
+
visible.filter((item) => item.side === 'right').map((item) => item.entry),
|
|
80
|
+
parts,
|
|
81
|
+
configRow.rightDivider,
|
|
82
|
+
);
|
|
83
|
+
const needed =
|
|
84
|
+
left.text === '' && right.text === ''
|
|
85
|
+
? 0
|
|
86
|
+
: visibleWidth(left.text) + (right.text === '' ? 0 : GROUP_GAP + visibleWidth(right.text));
|
|
87
|
+
return { left: left.text, right: right.text, needed, count: left.count + right.count };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Left group and right group on one line, padded to `width`. */
|
|
91
|
+
export function row(theme: Theme, width: number, left: string, right: string): string {
|
|
92
|
+
const leftWidth = visibleWidth(left);
|
|
93
|
+
const rightWidth = right === '' ? 0 : visibleWidth(right);
|
|
94
|
+
if (leftWidth > 0 && rightWidth > 0) {
|
|
95
|
+
if (leftWidth + rightWidth <= width) {
|
|
96
|
+
return left + ' '.repeat(width - leftWidth - rightWidth) + right;
|
|
97
|
+
}
|
|
98
|
+
// Both groups exist but the row is too wide even after shrinking: keep the left group and
|
|
99
|
+
// cut the right one, which is how the native footer spends its last columns.
|
|
100
|
+
const available = width - leftWidth - GROUP_GAP;
|
|
101
|
+
if (available > 0) {
|
|
102
|
+
const truncated = truncateToWidth(right, available, '');
|
|
103
|
+
const truncatedWidth = visibleWidth(truncated);
|
|
104
|
+
return left + ' '.repeat(Math.max(0, width - leftWidth - truncatedWidth)) + truncated;
|
|
105
|
+
}
|
|
106
|
+
return truncateToWidth(left, width, theme.fg('dim', '…'));
|
|
107
|
+
}
|
|
108
|
+
if (rightWidth > 0) {
|
|
109
|
+
return truncateToWidth(' '.repeat(Math.max(0, width - rightWidth)) + right, width, theme.fg('dim', '…'));
|
|
110
|
+
}
|
|
111
|
+
return truncateToWidth(left, width, theme.fg('dim', '…'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Shrinks one row until it fits, then paints it.
|
|
116
|
+
*
|
|
117
|
+
* Modules render in the order the user listed them on their side. When the row overflows, its
|
|
118
|
+
* lowest-priority non-floor module is removed, whichever side it sits on, and the row is measured
|
|
119
|
+
* again — so a right-side module can outlive a left-side one if its priority says so.
|
|
120
|
+
*/
|
|
121
|
+
function renderRow(theme: Theme, width: number, configRow: Row, parts: StatusLineParts): string | null {
|
|
122
|
+
const placed: Placed[] = [
|
|
123
|
+
...configRow.left.map((entry): Placed => ({ entry, side: 'left' })),
|
|
124
|
+
...configRow.right.map((entry): Placed => ({ entry, side: 'right' })),
|
|
125
|
+
];
|
|
126
|
+
const droppable = placed
|
|
127
|
+
.filter((item) => !item.entry.def.floor)
|
|
128
|
+
.sort((a, b) => a.entry.def.priority - b.entry.def.priority);
|
|
129
|
+
|
|
130
|
+
const dropped = new Set<Placed>();
|
|
131
|
+
let composed = composeRow(theme, configRow, placed, parts, dropped);
|
|
132
|
+
for (const item of droppable) {
|
|
133
|
+
if (composed.needed <= width) break;
|
|
134
|
+
// The last module on a row truncates rather than vanishing: a narrow footer still names the
|
|
135
|
+
// directory, and a lone context reading still shows its percentage.
|
|
136
|
+
if (composed.count <= 1) break;
|
|
137
|
+
dropped.add(item);
|
|
138
|
+
composed = composeRow(theme, configRow, placed, parts, dropped);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (composed.needed === 0) return null;
|
|
142
|
+
return row(theme, width, composed.left, composed.right);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The whole status line, one string per configured row.
|
|
147
|
+
*
|
|
148
|
+
* Rows with nothing to show are omitted rather than rendered blank. A footer component must return
|
|
149
|
+
* at least one line, so an explicitly cleared layout renders one blank row.
|
|
150
|
+
*/
|
|
151
|
+
export function renderStatusLine(theme: Theme, width: number, layout: Layout, parts: StatusLineParts): string[] {
|
|
152
|
+
const lines: string[] = [];
|
|
153
|
+
for (const row of layout.rows) {
|
|
154
|
+
const line = renderRow(theme, width, row, parts);
|
|
155
|
+
if (line !== null) lines.push(line);
|
|
156
|
+
}
|
|
157
|
+
return lines.length === 0 ? [''] : lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { GROUP_GAP };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session accounting for the status line: token volumes, cache reuse, and spend.
|
|
3
|
+
*
|
|
4
|
+
* The rules follow the reference footer, which followed pi's own usage records: every assistant
|
|
5
|
+
* message and every tool result carries `usage`, and compaction and branch-summary entries bill a
|
|
6
|
+
* model call of their own. The cache hit rate is the most recent assistant turn's share of prompt
|
|
7
|
+
* tokens served from cache — the latest reading, not a session average that folds in cold starts.
|
|
8
|
+
*
|
|
9
|
+
* Entries arrive as `unknown` and are narrowed structurally, so a truncated or hand-edited session
|
|
10
|
+
* file degrades to a missing number instead of a thrown footer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SessionTotals {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
/** Share of the last assistant prompt served from cache, 0–100; null before any prompt. */
|
|
17
|
+
cacheHitRate: number | null;
|
|
18
|
+
/** Session spend in USD. */
|
|
19
|
+
cost: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface UsageLike {
|
|
23
|
+
input: number;
|
|
24
|
+
output: number;
|
|
25
|
+
cacheRead: number;
|
|
26
|
+
cacheWrite: number;
|
|
27
|
+
cost: { total: number };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === 'object' && value !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function num(value: unknown): number {
|
|
35
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A structurally valid usage record, or null when the entry has none. */
|
|
39
|
+
function usageOf(value: unknown): UsageLike | null {
|
|
40
|
+
if (!isRecord(value)) return null;
|
|
41
|
+
const cost = isRecord(value.cost) ? value.cost : {};
|
|
42
|
+
return {
|
|
43
|
+
input: num(value.input),
|
|
44
|
+
output: num(value.output),
|
|
45
|
+
cacheRead: num(value.cacheRead),
|
|
46
|
+
cacheWrite: num(value.cacheWrite),
|
|
47
|
+
cost: { total: num(cost.total) },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectTotals(entries: readonly unknown[]): SessionTotals {
|
|
52
|
+
const totals: SessionTotals = { input: 0, output: 0, cacheHitRate: null, cost: 0 };
|
|
53
|
+
|
|
54
|
+
const add = (usage: UsageLike | null, assistant: boolean): void => {
|
|
55
|
+
if (usage === null) return;
|
|
56
|
+
totals.input += usage.input;
|
|
57
|
+
totals.output += usage.output;
|
|
58
|
+
totals.cost += usage.cost.total;
|
|
59
|
+
if (!assistant) return;
|
|
60
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
61
|
+
if (prompt > 0) totals.cacheHitRate = (usage.cacheRead / prompt) * 100;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!isRecord(entry)) continue;
|
|
66
|
+
if (entry.type === 'message' && isRecord(entry.message)) {
|
|
67
|
+
const role = entry.message.role;
|
|
68
|
+
if (role === 'assistant') add(usageOf(entry.message.usage), true);
|
|
69
|
+
else if (role === 'toolResult') add(usageOf(entry.message.usage), false);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Compaction and branch summaries bill a model call of their own.
|
|
73
|
+
if (entry.type === 'compaction' || entry.type === 'branch_summary') add(usageOf(entry.usage), false);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return totals;
|
|
77
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decode-rate contract: when a throughput number is honest and when it is absent.
|
|
3
|
+
*
|
|
4
|
+
* Follows the deepseek-harness turn-metrics definitions the reference footer used —
|
|
5
|
+
* `ttftMs = firstTokenTime - requestTime`, `decodeMs = completedTime - firstTokenTime`,
|
|
6
|
+
* `tok/s = output / (decodeMs / 1000)` — because those are the definitions pi's own metrics
|
|
7
|
+
* consumers understand. Everything takes its clocks as arguments, so the rules are testable and
|
|
8
|
+
* the renderer never reads the time itself.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The window between dispatching a request and the first streamed delta. */
|
|
12
|
+
export function ttftMs(requestAt: number | null, firstTokenAt: number | null): number | null {
|
|
13
|
+
if (requestAt === null || firstTokenAt === null) return null;
|
|
14
|
+
return firstTokenAt > requestAt ? firstTokenAt - requestAt : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The frozen whole-message rate once a message finishes.
|
|
19
|
+
*
|
|
20
|
+
* A decode window below the sample floor is a burst arrival — the message landed in one or two
|
|
21
|
+
* chunks — so there is no decode phase to measure. Reporting absence beats printing a confident
|
|
22
|
+
* `82000 tok/s` for an 82-token greeting.
|
|
23
|
+
*/
|
|
24
|
+
export function frozenThroughput(decodeMs: number, tokens: number, minSampleMs: number): number | null {
|
|
25
|
+
if (decodeMs < minSampleMs) return null;
|
|
26
|
+
return (tokens / decodeMs) * 1000;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The in-flight rate over a trailing window: how many tokens the stream added since the last
|
|
31
|
+
* sample, over the elapsed window. A zero-length window has no rate.
|
|
32
|
+
*/
|
|
33
|
+
export function windowThroughput(tokens: number, previousTokens: number, windowMs: number): number | null {
|
|
34
|
+
if (windowMs <= 0) return null;
|
|
35
|
+
return Math.max(0, ((tokens - previousTokens) / windowMs) * 1000);
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocasts/pi-status-line",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Modular multi-row status line for the pi coding agent — pick which modules sit on each row's left and right, and how each one is labelled and formatted",
|
|
5
|
+
"author": "moecasts <moecasts.caster@gmail.com>",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"pi",
|
|
11
|
+
"status-line",
|
|
12
|
+
"statusline",
|
|
13
|
+
"footer",
|
|
14
|
+
"status",
|
|
15
|
+
"modular"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"extensions/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions/status-line/index.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.10.0",
|
|
44
|
+
"vitest": "^4.1.9"
|
|
45
|
+
}
|
|
46
|
+
}
|