@narumitw/pi-statusline 0.36.0 → 0.38.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 +68 -11
- package/package.json +4 -1
- package/src/commands.ts +93 -56
- package/src/information-profiles.ts +1 -0
- package/src/powerline.ts +1 -0
- package/src/presets/index.ts +1 -0
- package/src/render.ts +134 -36
- package/src/settings.ts +80 -20
- package/src/statusline.ts +11 -0
- package/src/types.ts +11 -1
- package/src/usage.ts +53 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ without setup and keeps useful context visible as the terminal narrows.
|
|
|
8
8
|
A representative uncolored layout:
|
|
9
9
|
|
|
10
10
|
```text
|
|
11
|
-
░▒▓ 🤖 sonnet-4 🧠 high 📁 pi-extensions 🌿 main ~2 🪟 ctx 42
|
|
11
|
+
░▒▓ 🤖 sonnet-4 🧠 high 📁 pi-extensions 🌿 main ~2 🪟 ctx 42.0%/200k 🕒 16:42
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
## ✨ Features
|
|
@@ -16,6 +16,7 @@ A representative uncolored layout:
|
|
|
16
16
|
- **Zero-config default:** model, thinking, workspace, Git/PR state, context use, and local time.
|
|
17
17
|
- **Responsive:** removes lower-priority segments before important information gets clipped.
|
|
18
18
|
- **Quiet when idle:** activity appears only while Pi is streaming or running tools.
|
|
19
|
+
- **Native-aligned usage:** optional token, prompt-cache, subscription, and cost details.
|
|
19
20
|
- **Easy choices:** three information levels and seven previewable color presets.
|
|
20
21
|
- **Still flexible:** custom layouts, multiline rows, colors, labels, separators, and status icons stay
|
|
21
22
|
available under **Advanced**.
|
|
@@ -47,7 +48,8 @@ For the best result, use a terminal font that includes Powerline glyphs and emoj
|
|
|
47
48
|
|
|
48
49
|
1. Install the extension and start Pi.
|
|
49
50
|
2. Run `/statusline`.
|
|
50
|
-
3.
|
|
51
|
+
3. Use the standard Main/Advanced navigation, then choose an appearance or information level;
|
|
52
|
+
specialized previews still apply changes immediately.
|
|
51
53
|
|
|
52
54
|
```text
|
|
53
55
|
Appearance (tokyo-night)
|
|
@@ -74,10 +76,11 @@ preserved.
|
|
|
74
76
|
| --- | --- |
|
|
75
77
|
| **Minimal** | `model cwd branch context` |
|
|
76
78
|
| **Balanced** (default) | `model thinking cwd branch tools context time` |
|
|
77
|
-
| **Detailed** | `provider model thinking cwd branch tools context tokens cost time` |
|
|
79
|
+
| **Detailed** | `provider model thinking cwd branch tools context tokens cache cost time` |
|
|
78
80
|
| **Custom** | Any other segment order, including explicit line breaks |
|
|
79
81
|
|
|
80
|
-
The `tools` segment takes no space while idle.
|
|
82
|
+
The `tools` segment takes no space while idle. `cache` takes no space when Pi has reported no cache
|
|
83
|
+
reads or writes.
|
|
81
84
|
|
|
82
85
|
## 💬 Commands
|
|
83
86
|
|
|
@@ -88,8 +91,11 @@ The `tools` segment takes no space while idle.
|
|
|
88
91
|
| `/statusline status` | Show the effective settings and diagnostics |
|
|
89
92
|
| `/statusline help` | Show command and schema guidance |
|
|
90
93
|
|
|
91
|
-
The direct `settings`, `status`, and `help` routes remain for compatibility.
|
|
92
|
-
|
|
94
|
+
The direct `settings`, `status`, and `help` routes remain for compatibility. The root standard menu
|
|
95
|
+
is TUI-only; Escape returns from Advanced or closes Main. RPC receives observable notifications
|
|
96
|
+
instead of TUI-only controls. Unknown subcommands and trailing arguments are rejected. Palette and
|
|
97
|
+
information previews, the width-aware layout editor, and the JSON editor remain specialized UI
|
|
98
|
+
because they provide live preview/edit behavior rather than standard navigation.
|
|
93
99
|
|
|
94
100
|
## 📐 Runtime behavior
|
|
95
101
|
|
|
@@ -100,7 +106,7 @@ lowest-priority segment, recomputes the powerline transitions, and repeats until
|
|
|
100
106
|
Retention priority is highest to lowest:
|
|
101
107
|
|
|
102
108
|
```text
|
|
103
|
-
context model branch tools cwd thinking cost provider tokens time turn brand
|
|
109
|
+
context model branch tools cwd thinking cost provider cache tokens time turn brand
|
|
104
110
|
```
|
|
105
111
|
|
|
106
112
|
Explicit `line_break` entries remain row boundaries. A single segment that is still too wide is
|
|
@@ -117,6 +123,20 @@ ANSI-safely truncated.
|
|
|
117
123
|
- Context color changes to warning at 70% and error at 90%.
|
|
118
124
|
- Git state is cached outside footer rendering and stale session results are ignored.
|
|
119
125
|
|
|
126
|
+
### Usage and context
|
|
127
|
+
|
|
128
|
+
- `context` renders one-decimal current usage and the model window, such as `2.4%/272k`. After
|
|
129
|
+
compaction it can temporarily render `?/272k` until the next valid assistant response.
|
|
130
|
+
- `tokens`, `cache`, and `cost` total every usage-bearing session entry, matching Pi's native footer:
|
|
131
|
+
assistant messages, nested-LLM tool results, compactions, and branch summaries, including abandoned
|
|
132
|
+
branches retained in the session.
|
|
133
|
+
- Cache tokens are `R<read>`, `W<write>`, and `CH<rate>`. `R` and `W` are cumulative; `CH` uses only
|
|
134
|
+
the latest assistant prompt: `cacheRead / (input + cacheRead + cacheWrite) * 100`.
|
|
135
|
+
- Subscription-backed OAuth models and `kimi-coding` append `(sub)` to cost. The dollar value is
|
|
136
|
+
usage cost, not proof of an amount billed under a subscription.
|
|
137
|
+
- Pi's public extension API does not expose the current auto-compaction toggle, so this footer cannot
|
|
138
|
+
reliably show the native `(auto)` marker.
|
|
139
|
+
|
|
120
140
|
## ⚙️ Settings
|
|
121
141
|
|
|
122
142
|
The extension uses one user-level file:
|
|
@@ -142,7 +162,7 @@ automatically; rename it to `pi-statusline.json`. If both files exist, `pi-statu
|
|
|
142
162
|
| `density` | `compact`, `cozy` | Control horizontal padding |
|
|
143
163
|
| `separator` | `none`, `dot`, `bar`, `powerline`, `round` | Separate adjacent segments in one color block |
|
|
144
164
|
| `segments` | Ordered unique segment names and `line_break` | Control visibility, order, and rows |
|
|
145
|
-
| `segmentText` | Per-segment `prefix` and `suffix
|
|
165
|
+
| `segmentText` | Per-segment `prefix` and `suffix`; model truncation fields | Format Pi-owned dynamic values |
|
|
146
166
|
| `extensionStatusIcons` | Raw status key or `namespace:*` to icon string | Customize extension status icons |
|
|
147
167
|
|
|
148
168
|
All fields are optional in an existing document. Missing fields use defaults. Unknown fields produce a
|
|
@@ -156,8 +176,13 @@ A compact customization example:
|
|
|
156
176
|
"palettePreset": "ocean",
|
|
157
177
|
"density": "compact",
|
|
158
178
|
"separator": "dot",
|
|
159
|
-
"segments": ["model", "thinking", "cwd", "branch", "context", "cost"],
|
|
179
|
+
"segments": ["model", "thinking", "cwd", "branch", "context", "cache", "cost"],
|
|
160
180
|
"segmentText": {
|
|
181
|
+
"model": {
|
|
182
|
+
"truncationLength": 40,
|
|
183
|
+
"truncationSymbol": "…",
|
|
184
|
+
"truncationDirection": "middle"
|
|
185
|
+
},
|
|
161
186
|
"context": { "prefix": "ctx ", "suffix": "" }
|
|
162
187
|
},
|
|
163
188
|
"extensionStatusIcons": {
|
|
@@ -198,6 +223,37 @@ When `palettePreset` is `custom`, `palette` maps segment names to foreground/bac
|
|
|
198
223
|
`segmentText` values must be single-line text without terminal control characters. Use `line_break`
|
|
199
224
|
for another row rather than inserting a newline into a prefix or suffix.
|
|
200
225
|
|
|
226
|
+
### Model truncation
|
|
227
|
+
|
|
228
|
+
Long model IDs are truncated out of the box so the balanced footer can retain useful model context:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"segmentText": {
|
|
233
|
+
"model": {
|
|
234
|
+
"truncationLength": 36,
|
|
235
|
+
"truncationSymbol": "…",
|
|
236
|
+
"truncationDirection": "start"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`truncationLength` counts model grapheme clusters retained before the symbol. The built-in value is
|
|
243
|
+
`36`; set it to `0` to display the complete ID. The direction names the removed portion:
|
|
244
|
+
|
|
245
|
+
- `start` retains the suffix and is the default, which is useful for long llama.cpp paths and model
|
|
246
|
+
variants.
|
|
247
|
+
- `middle` retains both ends.
|
|
248
|
+
- `end` retains the prefix.
|
|
249
|
+
|
|
250
|
+
Truncation runs after the built-in Claude/GPT shortening rules but before the configured model prefix
|
|
251
|
+
and suffix. It changes display only—the provider model ID is untouched. Terminal control sequences
|
|
252
|
+
in model IDs are removed at render time, and unsafe configured symbols are rejected. An empty
|
|
253
|
+
`truncationSymbol` truncates without a marker. pi-statusline treats model IDs as opaque strings and
|
|
254
|
+
does not parse paths, repositories, GGUF suffixes, or quantization names. At very narrow widths, the
|
|
255
|
+
existing responsive priorities may still omit the model rather than overflow the terminal.
|
|
256
|
+
|
|
201
257
|
## 🧩 Advanced layout
|
|
202
258
|
|
|
203
259
|
Open **Advanced → Custom layout** when the curated levels are not enough.
|
|
@@ -218,7 +274,7 @@ Every successful change saves and applies immediately; closing the screen does n
|
|
|
218
274
|
Available data segments:
|
|
219
275
|
|
|
220
276
|
```text
|
|
221
|
-
brand provider model thinking cwd branch tools context tokens cost time turn
|
|
277
|
+
brand provider model thinking cwd branch tools context tokens cache cost time turn
|
|
222
278
|
```
|
|
223
279
|
|
|
224
280
|
Data segments must be unique. `line_break` may repeat when data segments separate occurrences, but
|
|
@@ -278,6 +334,7 @@ extensions/pi-statusline/
|
|
|
278
334
|
│ ├── index.ts
|
|
279
335
|
│ ├── statusline.ts
|
|
280
336
|
│ ├── render.ts
|
|
337
|
+
│ ├── usage.ts
|
|
281
338
|
│ ├── powerline.ts
|
|
282
339
|
│ ├── information-profiles.ts
|
|
283
340
|
│ ├── commands.ts
|
|
@@ -299,7 +356,7 @@ extensions/pi-statusline/
|
|
|
299
356
|
## 🔎 Keywords
|
|
300
357
|
|
|
301
358
|
Pi extension, Pi coding agent, statusline, Tokyo Night, powerline, responsive terminal footer,
|
|
302
|
-
context usage, model status.
|
|
359
|
+
context usage, prompt cache, cache hit rate, model status.
|
|
303
360
|
|
|
304
361
|
## 📄 License
|
|
305
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Pi extension that replaces the footer with an information-rich statusline.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,5 +38,8 @@
|
|
|
38
38
|
"type": "git",
|
|
39
39
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
40
40
|
"directory": "extensions/pi-statusline"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@narumitw/pi-tui-kit": "<1"
|
|
41
44
|
}
|
|
42
45
|
}
|
package/src/commands.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
truncateToWidth,
|
|
15
15
|
wrapTextWithAnsi,
|
|
16
16
|
} from "@earendil-works/pi-tui";
|
|
17
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
17
18
|
import {
|
|
18
19
|
INFORMATION_PROFILE_NAMES,
|
|
19
20
|
INFORMATION_PROFILES,
|
|
@@ -51,6 +52,7 @@ const SEGMENT_DESCRIPTIONS: Record<SegmentName, string> = {
|
|
|
51
52
|
tools: "Current tool and streaming activity",
|
|
52
53
|
context: "Current context-window usage",
|
|
53
54
|
tokens: "Session token totals",
|
|
55
|
+
cache: "Prompt-cache reads, writes, and latest hit rate",
|
|
54
56
|
cost: "Session cost",
|
|
55
57
|
time: "Current local time",
|
|
56
58
|
turn: "Current session turn count",
|
|
@@ -67,6 +69,7 @@ export interface StatuslineCommandOptions {
|
|
|
67
69
|
apply(loaded: LoadedStatuslineSettings, ctx: ExtensionCommandContext): void;
|
|
68
70
|
preview?(palettePreset: PalettePreset | undefined, ctx: ExtensionCommandContext): void;
|
|
69
71
|
save?: (settingsPath: string, rawDocument: string) => LoadedStatuslineSettings;
|
|
72
|
+
getMenuOwner?(): { signal: AbortSignal; isCurrent(): boolean };
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export function registerStatuslineCommand(pi: ExtensionAPI, options: StatuslineCommandOptions) {
|
|
@@ -114,67 +117,100 @@ export function registerStatuslineCommand(pi: ExtensionAPI, options: StatuslineC
|
|
|
114
117
|
|
|
115
118
|
async function showMainMenu(ctx: ExtensionCommandContext, options: StatuslineCommandOptions) {
|
|
116
119
|
if (ctx.mode !== "tui") {
|
|
117
|
-
if (ctx.hasUI) ctx.ui.notify("/statusline requires an interactive Pi UI.", "error");
|
|
120
|
+
if (ctx.hasUI) ctx.ui.notify("/statusline requires an interactive Pi UI in TUI mode.", "error");
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
123
|
+
const fallbackController = new AbortController();
|
|
124
|
+
const owner = options.getMenuOwner?.() ?? {
|
|
125
|
+
signal: fallbackController.signal,
|
|
126
|
+
isCurrent: () => !fallbackController.signal.aborted,
|
|
127
|
+
};
|
|
128
|
+
type Screen = "main" | "advanced";
|
|
129
|
+
type Action = "appearance" | "information" | "layout" | "edit" | "status" | "help" | "back";
|
|
130
|
+
const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
|
|
131
|
+
start: "main",
|
|
132
|
+
screens: {
|
|
133
|
+
main: () => {
|
|
134
|
+
const config = options.getLoaded().config;
|
|
135
|
+
return {
|
|
136
|
+
kind: "actions",
|
|
137
|
+
title: "pi-statusline",
|
|
138
|
+
items: [
|
|
139
|
+
{
|
|
140
|
+
id: "appearance",
|
|
141
|
+
label: `Appearance (${config.palettePreset})`,
|
|
142
|
+
action: "appearance",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "information",
|
|
146
|
+
label: `Information (${inferInformationProfile(config.segments)})`,
|
|
147
|
+
action: "information",
|
|
148
|
+
},
|
|
149
|
+
{ id: "advanced", label: "Advanced", to: "advanced" },
|
|
150
|
+
{ id: "status", label: "Status", action: "status" },
|
|
151
|
+
{ id: "help", label: "Help", action: "help" },
|
|
152
|
+
],
|
|
153
|
+
hint: "close",
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
advanced: () => {
|
|
157
|
+
const config = options.getLoaded().config;
|
|
158
|
+
const visibleSegmentCount = config.segments.filter(
|
|
159
|
+
(segment): segment is SegmentName => segment !== LINE_BREAK_SEGMENT_NAME,
|
|
160
|
+
).length;
|
|
161
|
+
return {
|
|
162
|
+
kind: "actions",
|
|
163
|
+
title: "pi-statusline — Advanced",
|
|
164
|
+
items: [
|
|
165
|
+
{
|
|
166
|
+
id: "layout",
|
|
167
|
+
label: `Custom layout (${visibleSegmentCount}/${SEGMENT_NAMES.length} shown)`,
|
|
168
|
+
action: "layout",
|
|
169
|
+
},
|
|
170
|
+
{ id: "edit", label: EDIT_SETTINGS_LABEL, action: "edit" },
|
|
171
|
+
{ id: "back", label: "Back", action: "back" },
|
|
172
|
+
],
|
|
173
|
+
hint: "back",
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
actions: {
|
|
178
|
+
appearance: async () => {
|
|
179
|
+
await choosePalettePreset(ctx, options);
|
|
180
|
+
return { kind: "close" };
|
|
181
|
+
},
|
|
182
|
+
information: async () => {
|
|
183
|
+
await chooseInformationProfile(ctx, options);
|
|
184
|
+
return { kind: "close" };
|
|
185
|
+
},
|
|
186
|
+
layout: async () => {
|
|
187
|
+
await chooseSegments(ctx, options);
|
|
188
|
+
return { kind: "close" };
|
|
189
|
+
},
|
|
190
|
+
edit: async () => {
|
|
191
|
+
await editSettings(ctx, options);
|
|
192
|
+
return { kind: "close" };
|
|
193
|
+
},
|
|
194
|
+
status: async () => {
|
|
144
195
|
showStatus(ctx, options);
|
|
145
|
-
return;
|
|
146
|
-
|
|
196
|
+
return { kind: "close" };
|
|
197
|
+
},
|
|
198
|
+
help: async () => {
|
|
147
199
|
showHelp(ctx, options.settingsPath);
|
|
148
|
-
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
(segment): segment is SegmentName => segment !== LINE_BREAK_SEGMENT_NAME,
|
|
162
|
-
).length;
|
|
163
|
-
const layoutItem = `Custom layout (${visibleSegmentCount}/${SEGMENT_NAMES.length} shown)`;
|
|
164
|
-
const selection = await ctx.ui.select("pi-statusline — Advanced", [
|
|
165
|
-
layoutItem,
|
|
166
|
-
EDIT_SETTINGS_LABEL,
|
|
167
|
-
"Back",
|
|
168
|
-
]);
|
|
169
|
-
if (selection === layoutItem) {
|
|
170
|
-
await chooseSegments(ctx, options);
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
if (selection === EDIT_SETTINGS_LABEL) {
|
|
174
|
-
await editSettings(ctx, options);
|
|
175
|
-
return false;
|
|
200
|
+
return { kind: "close" };
|
|
201
|
+
},
|
|
202
|
+
back: async () => ({ kind: "back" }),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
await runMenu(ctx, menu, {
|
|
207
|
+
getState: () => undefined,
|
|
208
|
+
signal: owner.signal,
|
|
209
|
+
isCurrent: owner.isCurrent,
|
|
210
|
+
});
|
|
211
|
+
} finally {
|
|
212
|
+
fallbackController.abort(new DOMException("Statusline menu closed", "AbortError"));
|
|
176
213
|
}
|
|
177
|
-
return selection === "Back";
|
|
178
214
|
}
|
|
179
215
|
|
|
180
216
|
async function choosePalettePreset(
|
|
@@ -919,6 +955,7 @@ function showHelp(ctx: ExtensionCommandContext, settingsPath: string) {
|
|
|
919
955
|
"Press M for move mode, Alt+Up/Alt+Down for quick move, and B for a line break.",
|
|
920
956
|
"Line breaks (line_break) may repeat when separated by data segments, but cannot be consecutive.",
|
|
921
957
|
"segmentText supports prefix and suffix strings around Pi-owned dynamic values.",
|
|
958
|
+
"segmentText.model also supports truncationLength, truncationSymbol, and truncationDirection (start, middle, end).",
|
|
922
959
|
].join("\n"),
|
|
923
960
|
"info",
|
|
924
961
|
);
|
package/src/powerline.ts
CHANGED
package/src/presets/index.ts
CHANGED
package/src/render.ts
CHANGED
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
type RenderSegment,
|
|
21
21
|
type SegmentName,
|
|
22
22
|
type StatuslineConfig,
|
|
23
|
+
type TruncationDirection,
|
|
23
24
|
} from "./types.js";
|
|
25
|
+
import { type FooterUsageSummary, summarizeFooterUsage } from "./usage.js";
|
|
24
26
|
|
|
25
27
|
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
26
28
|
export interface RuntimeState extends ExtensionStatusRuntime {
|
|
@@ -31,11 +33,6 @@ export interface RuntimeState extends ExtensionStatusRuntime {
|
|
|
31
33
|
gitStatus?: GitStatusSummary;
|
|
32
34
|
requestRender?: () => void;
|
|
33
35
|
}
|
|
34
|
-
interface TokenTotals {
|
|
35
|
-
input: number;
|
|
36
|
-
output: number;
|
|
37
|
-
cost: number;
|
|
38
|
-
}
|
|
39
36
|
const GITHUB_PR_KEY = "github-pr";
|
|
40
37
|
const GITHUB_PR_STATUS_KEYS = new Set([GITHUB_PR_KEY]);
|
|
41
38
|
export function renderStatusline(
|
|
@@ -48,6 +45,7 @@ export function renderStatusline(
|
|
|
48
45
|
): string {
|
|
49
46
|
if (width <= 0) return "";
|
|
50
47
|
|
|
48
|
+
const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
|
|
51
49
|
const rows: Array<{ configuredSegments: number; segments: RenderSegment[] }> = [
|
|
52
50
|
{ configuredSegments: 0, segments: [] },
|
|
53
51
|
];
|
|
@@ -60,7 +58,7 @@ export function renderStatusline(
|
|
|
60
58
|
const row = rows.at(-1);
|
|
61
59
|
if (!row) continue;
|
|
62
60
|
row.configuredSegments += 1;
|
|
63
|
-
const rendered = buildSegment(name, ctx, footerData, config, runtime);
|
|
61
|
+
const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
|
|
64
62
|
if (rendered && rendered.text.length > 0) row.segments.push(rendered);
|
|
65
63
|
}
|
|
66
64
|
|
|
@@ -103,14 +101,23 @@ function buildSegment(
|
|
|
103
101
|
footerData: ReadonlyFooterDataProvider,
|
|
104
102
|
config: StatuslineConfig,
|
|
105
103
|
runtime: RuntimeState,
|
|
104
|
+
usageSummary: FooterUsageSummary,
|
|
106
105
|
): RenderSegment | undefined {
|
|
107
106
|
switch (name) {
|
|
108
107
|
case "brand":
|
|
109
108
|
return segment(name, "π", config, "accent", "header", true);
|
|
110
109
|
case "provider":
|
|
111
110
|
return segment(name, ctx.model?.provider ?? "no-provider", config, "accent", "header");
|
|
112
|
-
case "model":
|
|
113
|
-
|
|
111
|
+
case "model": {
|
|
112
|
+
const presentation = config.segmentText.model;
|
|
113
|
+
const model = truncateModel(
|
|
114
|
+
shortenModel(ctx.model?.id ?? "no-model"),
|
|
115
|
+
presentation.truncationLength,
|
|
116
|
+
presentation.truncationSymbol,
|
|
117
|
+
presentation.truncationDirection,
|
|
118
|
+
);
|
|
119
|
+
return segment(name, model, config, "accent", "header");
|
|
120
|
+
}
|
|
114
121
|
case "thinking":
|
|
115
122
|
return segment(
|
|
116
123
|
name,
|
|
@@ -138,25 +145,41 @@ function buildSegment(
|
|
|
138
145
|
}
|
|
139
146
|
case "context": {
|
|
140
147
|
const usage = ctx.getContextUsage();
|
|
141
|
-
const
|
|
148
|
+
const percentage =
|
|
142
149
|
usage?.percent === null || usage?.percent === undefined
|
|
143
150
|
? "?"
|
|
144
|
-
: `${usage.percent.toFixed(
|
|
145
|
-
|
|
151
|
+
: `${usage.percent.toFixed(1)}%`;
|
|
152
|
+
const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
153
|
+
return segment(
|
|
154
|
+
name,
|
|
155
|
+
`${percentage}/${formatCount(contextWindow)}`,
|
|
156
|
+
config,
|
|
157
|
+
contextColor(usage?.percent),
|
|
158
|
+
"runtime",
|
|
159
|
+
);
|
|
146
160
|
}
|
|
147
161
|
case "tokens": {
|
|
148
|
-
const totals = getTokenTotals(ctx);
|
|
149
162
|
const value =
|
|
150
|
-
|
|
163
|
+
usageSummary.input === 0 && usageSummary.output === 0
|
|
151
164
|
? "tok 0"
|
|
152
|
-
: `↑${formatCount(
|
|
165
|
+
: `↑${formatCount(usageSummary.input)} ↓${formatCount(usageSummary.output)}`;
|
|
153
166
|
return segment(name, value, config, "accent", "runtime");
|
|
154
167
|
}
|
|
168
|
+
case "cache": {
|
|
169
|
+
if (usageSummary.cacheRead === 0 && usageSummary.cacheWrite === 0) return undefined;
|
|
170
|
+
const values: string[] = [];
|
|
171
|
+
if (usageSummary.cacheRead > 0) values.push(`R${formatCount(usageSummary.cacheRead)}`);
|
|
172
|
+
if (usageSummary.cacheWrite > 0) values.push(`W${formatCount(usageSummary.cacheWrite)}`);
|
|
173
|
+
if (usageSummary.latestCacheHitRate !== undefined) {
|
|
174
|
+
values.push(`CH${usageSummary.latestCacheHitRate.toFixed(1)}%`);
|
|
175
|
+
}
|
|
176
|
+
return segment(name, values.join(" "), config, "accent", "runtime");
|
|
177
|
+
}
|
|
155
178
|
case "cost": {
|
|
156
|
-
const
|
|
179
|
+
const subscription = isSubscriptionBacked(ctx) ? " (sub)" : "";
|
|
157
180
|
return segment(
|
|
158
181
|
name,
|
|
159
|
-
|
|
182
|
+
`${usageSummary.cost.toFixed(usageSummary.cost >= 1 ? 2 : 3)}${subscription}`,
|
|
160
183
|
config,
|
|
161
184
|
"accent",
|
|
162
185
|
"meter",
|
|
@@ -268,26 +291,12 @@ function compactPrState(value: string): string | undefined {
|
|
|
268
291
|
return undefined;
|
|
269
292
|
}
|
|
270
293
|
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const usage = entry.message.usage as
|
|
278
|
-
| {
|
|
279
|
-
input?: number;
|
|
280
|
-
output?: number;
|
|
281
|
-
cost?: { total?: number };
|
|
282
|
-
}
|
|
283
|
-
| undefined;
|
|
284
|
-
|
|
285
|
-
totals.input += usage?.input ?? 0;
|
|
286
|
-
totals.output += usage?.output ?? 0;
|
|
287
|
-
totals.cost += usage?.cost?.total ?? 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return totals;
|
|
294
|
+
function isSubscriptionBacked(ctx: ExtensionContext): boolean {
|
|
295
|
+
const model = ctx.model;
|
|
296
|
+
return (
|
|
297
|
+
model !== undefined &&
|
|
298
|
+
(model.provider === "kimi-coding" || ctx.modelRegistry.isUsingOAuth(model))
|
|
299
|
+
);
|
|
291
300
|
}
|
|
292
301
|
|
|
293
302
|
export function formatCount(value: number): string {
|
|
@@ -303,6 +312,95 @@ function formatTime(): string {
|
|
|
303
312
|
return `${hours}:${minutes}`;
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
316
|
+
|
|
317
|
+
export function truncateModel(
|
|
318
|
+
model: string,
|
|
319
|
+
length: number,
|
|
320
|
+
symbol: string,
|
|
321
|
+
direction: TruncationDirection,
|
|
322
|
+
): string {
|
|
323
|
+
const safeModel = sanitizeModelDisplay(model);
|
|
324
|
+
if (length === 0) return safeModel;
|
|
325
|
+
const graphemes = [...graphemeSegmenter.segment(safeModel)].map(({ segment }) => segment);
|
|
326
|
+
if (graphemes.length <= length) return safeModel;
|
|
327
|
+
const safeSymbol = sanitizeModelDisplay(symbol);
|
|
328
|
+
|
|
329
|
+
switch (direction) {
|
|
330
|
+
case "start":
|
|
331
|
+
return `${safeSymbol}${graphemes.slice(-length).join("")}`;
|
|
332
|
+
case "middle": {
|
|
333
|
+
const headLength = Math.ceil(length / 2);
|
|
334
|
+
const tailLength = Math.floor(length / 2);
|
|
335
|
+
const tail = tailLength > 0 ? graphemes.slice(-tailLength).join("") : "";
|
|
336
|
+
return `${graphemes.slice(0, headLength).join("")}${safeSymbol}${tail}`;
|
|
337
|
+
}
|
|
338
|
+
case "end":
|
|
339
|
+
return `${graphemes.slice(0, length).join("")}${safeSymbol}`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function sanitizeModelDisplay(value: string): string {
|
|
344
|
+
let result = "";
|
|
345
|
+
for (let index = 0; index < value.length; ) {
|
|
346
|
+
const codePoint = value.codePointAt(index) ?? 0;
|
|
347
|
+
const character = String.fromCodePoint(codePoint);
|
|
348
|
+
if (codePoint === 0x1b || codePoint === 0x9b || codePoint === 0x9d) {
|
|
349
|
+
index = skipTerminalEscape(value, index, codePoint);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
codePoint <= 0x1f ||
|
|
354
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
355
|
+
codePoint === 0x2028 ||
|
|
356
|
+
codePoint === 0x2029
|
|
357
|
+
) {
|
|
358
|
+
if (
|
|
359
|
+
codePoint === 0x09 ||
|
|
360
|
+
codePoint === 0x0a ||
|
|
361
|
+
codePoint === 0x0d ||
|
|
362
|
+
codePoint === 0x85 ||
|
|
363
|
+
codePoint === 0x2028 ||
|
|
364
|
+
codePoint === 0x2029
|
|
365
|
+
) {
|
|
366
|
+
result += " ";
|
|
367
|
+
}
|
|
368
|
+
index += character.length;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
result += character;
|
|
372
|
+
index += character.length;
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function skipTerminalEscape(value: string, start: number, codePoint: number): number {
|
|
378
|
+
let index = start + 1;
|
|
379
|
+
const next = value.charCodeAt(index);
|
|
380
|
+
const isOsc = codePoint === 0x9d || (codePoint === 0x1b && next === 0x5d);
|
|
381
|
+
if (isOsc) {
|
|
382
|
+
if (codePoint === 0x1b) index += 1;
|
|
383
|
+
while (index < value.length) {
|
|
384
|
+
const current = value.charCodeAt(index);
|
|
385
|
+
if (current === 0x07 || current === 0x9c) return index + 1;
|
|
386
|
+
if (current === 0x1b && value.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
387
|
+
index += 1;
|
|
388
|
+
}
|
|
389
|
+
return index;
|
|
390
|
+
}
|
|
391
|
+
const isCsi = codePoint === 0x9b || (codePoint === 0x1b && next === 0x5b);
|
|
392
|
+
if (isCsi) {
|
|
393
|
+
if (codePoint === 0x1b) index += 1;
|
|
394
|
+
while (index < value.length) {
|
|
395
|
+
const current = value.charCodeAt(index);
|
|
396
|
+
index += 1;
|
|
397
|
+
if (current >= 0x40 && current <= 0x7e) break;
|
|
398
|
+
}
|
|
399
|
+
return index;
|
|
400
|
+
}
|
|
401
|
+
return Math.min(value.length, start + (codePoint === 0x1b ? 2 : 1));
|
|
402
|
+
}
|
|
403
|
+
|
|
306
404
|
export function shortenModel(model: string): string {
|
|
307
405
|
return model
|
|
308
406
|
.replace(/^claude-/, "")
|
package/src/settings.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type SegmentName,
|
|
17
17
|
type SegmentPalette,
|
|
18
18
|
type StatuslineConfig,
|
|
19
|
+
TRUNCATION_DIRECTIONS,
|
|
19
20
|
} from "./types.js";
|
|
20
21
|
|
|
21
22
|
export const SETTINGS_FILE_NAME = "pi-statusline.json";
|
|
@@ -60,13 +61,20 @@ export const DEFAULT_STATUSLINE_CONFIG: StatuslineConfig = {
|
|
|
60
61
|
segmentText: {
|
|
61
62
|
brand: { prefix: "", suffix: "" },
|
|
62
63
|
provider: { prefix: "🔌 ", suffix: "" },
|
|
63
|
-
model: {
|
|
64
|
+
model: {
|
|
65
|
+
prefix: "🤖 ",
|
|
66
|
+
suffix: "",
|
|
67
|
+
truncationLength: 36,
|
|
68
|
+
truncationSymbol: "…",
|
|
69
|
+
truncationDirection: "start",
|
|
70
|
+
},
|
|
64
71
|
thinking: { prefix: "🧠 ", suffix: "" },
|
|
65
72
|
cwd: { prefix: "📁 ", suffix: "" },
|
|
66
73
|
branch: { prefix: "🌿 ", suffix: "" },
|
|
67
74
|
tools: { prefix: "", suffix: "" },
|
|
68
75
|
context: { prefix: "🪟 ctx ", suffix: "" },
|
|
69
76
|
tokens: { prefix: "🔢 ", suffix: "" },
|
|
77
|
+
cache: { prefix: "📦 ", suffix: "" },
|
|
70
78
|
cost: { prefix: "💸 $", suffix: "" },
|
|
71
79
|
time: { prefix: "🕒 ", suffix: "" },
|
|
72
80
|
turn: { prefix: "🔁 #", suffix: "" },
|
|
@@ -202,32 +210,25 @@ export function normalizeStatuslineConfig(value: unknown): {
|
|
|
202
210
|
diagnostics.push(invalidDiagnostic(path, "Expected an object"));
|
|
203
211
|
continue;
|
|
204
212
|
}
|
|
213
|
+
const knownFields = new Set([
|
|
214
|
+
"prefix",
|
|
215
|
+
"suffix",
|
|
216
|
+
...(name === "model"
|
|
217
|
+
? ["truncationLength", "truncationSymbol", "truncationDirection"]
|
|
218
|
+
: []),
|
|
219
|
+
]);
|
|
205
220
|
for (const key of Object.keys(presentation)) {
|
|
206
|
-
if (key
|
|
207
|
-
diagnostics.push(unknownDiagnostic(`${path}.${key}`));
|
|
208
|
-
}
|
|
221
|
+
if (!knownFields.has(key)) diagnostics.push(unknownDiagnostic(`${path}.${key}`));
|
|
209
222
|
}
|
|
210
223
|
for (const field of ["prefix", "suffix"] as const) {
|
|
211
224
|
const fieldValue = presentation[field];
|
|
212
225
|
if (fieldValue === undefined) continue;
|
|
213
|
-
if (
|
|
214
|
-
diagnostics.push(invalidDiagnostic(`${path}.${field}`, "Expected a string"));
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (/[\r\n\u2028\u2029]/u.test(fieldValue)) {
|
|
218
|
-
diagnostics.push(
|
|
219
|
-
invalidDiagnostic(`${path}.${field}`, "Line breaks are not allowed; use line_break"),
|
|
220
|
-
);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (hasControlCharacter(fieldValue)) {
|
|
224
|
-
diagnostics.push(
|
|
225
|
-
invalidDiagnostic(`${path}.${field}`, "Control characters are not allowed"),
|
|
226
|
-
);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
226
|
+
if (!isSafeSegmentText(fieldValue, `${path}.${field}`, diagnostics)) continue;
|
|
229
227
|
config.segmentText[name][field] = fieldValue;
|
|
230
228
|
}
|
|
229
|
+
if (name === "model") {
|
|
230
|
+
normalizeModelTruncation(presentation, config, diagnostics);
|
|
231
|
+
}
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
234
|
}
|
|
@@ -504,6 +505,65 @@ function normalizePalette(
|
|
|
504
505
|
config.palettePreset = "custom";
|
|
505
506
|
}
|
|
506
507
|
|
|
508
|
+
function normalizeModelTruncation(
|
|
509
|
+
presentation: Record<string, unknown>,
|
|
510
|
+
config: StatuslineConfig,
|
|
511
|
+
diagnostics: StatuslineConfigDiagnostic[],
|
|
512
|
+
) {
|
|
513
|
+
const path = "segmentText.model";
|
|
514
|
+
const length = presentation.truncationLength;
|
|
515
|
+
if (length !== undefined) {
|
|
516
|
+
if (typeof length !== "number" || !Number.isInteger(length) || length < 0 || length > 1000) {
|
|
517
|
+
diagnostics.push(
|
|
518
|
+
invalidDiagnostic(`${path}.truncationLength`, "Expected an integer from 0 through 1000"),
|
|
519
|
+
);
|
|
520
|
+
} else config.segmentText.model.truncationLength = length;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const symbol = presentation.truncationSymbol;
|
|
524
|
+
if (symbol !== undefined && isSafeSegmentText(symbol, `${path}.truncationSymbol`, diagnostics)) {
|
|
525
|
+
config.segmentText.model.truncationSymbol = symbol;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const direction = presentation.truncationDirection;
|
|
529
|
+
if (direction !== undefined) {
|
|
530
|
+
if (
|
|
531
|
+
typeof direction !== "string" ||
|
|
532
|
+
!TRUNCATION_DIRECTIONS.includes(direction as (typeof TRUNCATION_DIRECTIONS)[number])
|
|
533
|
+
) {
|
|
534
|
+
diagnostics.push(
|
|
535
|
+
invalidDiagnostic(
|
|
536
|
+
`${path}.truncationDirection`,
|
|
537
|
+
`Expected one of: ${TRUNCATION_DIRECTIONS.join(", ")}`,
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
} else {
|
|
541
|
+
config.segmentText.model.truncationDirection =
|
|
542
|
+
direction as (typeof TRUNCATION_DIRECTIONS)[number];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isSafeSegmentText(
|
|
548
|
+
value: unknown,
|
|
549
|
+
path: string,
|
|
550
|
+
diagnostics: StatuslineConfigDiagnostic[],
|
|
551
|
+
): value is string {
|
|
552
|
+
if (typeof value !== "string") {
|
|
553
|
+
diagnostics.push(invalidDiagnostic(path, "Expected a string"));
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
if (/[\r\n\u2028\u2029]/u.test(value)) {
|
|
557
|
+
diagnostics.push(invalidDiagnostic(path, "Line breaks are not allowed; use line_break"));
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
if (hasControlCharacter(value)) {
|
|
561
|
+
diagnostics.push(invalidDiagnostic(path, "Control characters are not allowed"));
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
507
567
|
function normalizeEnum<
|
|
508
568
|
K extends "palettePreset" | "density" | "separator",
|
|
509
569
|
T extends StatuslineConfig[K],
|
package/src/statusline.ts
CHANGED
|
@@ -40,6 +40,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
let sessionGeneration = 0;
|
|
43
|
+
let menuController = new AbortController();
|
|
43
44
|
let gitStatusRequestId = 0;
|
|
44
45
|
let activeGitStatusTarget: { cwd: string; generation: number } | undefined;
|
|
45
46
|
let gitStatusRefreshInFlight = false;
|
|
@@ -114,6 +115,8 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
114
115
|
|
|
115
116
|
const installFooter = (ctx: ExtensionContext) => {
|
|
116
117
|
const generation = ++sessionGeneration;
|
|
118
|
+
menuController.abort(new DOMException("Statusline session context replaced", "AbortError"));
|
|
119
|
+
menuController = new AbortController();
|
|
117
120
|
const cwd = ctx.cwd;
|
|
118
121
|
activeSessionManager = ctx.sessionManager;
|
|
119
122
|
previewPalettePreset = undefined;
|
|
@@ -180,6 +183,13 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
180
183
|
registerStatuslineCommand(pi, {
|
|
181
184
|
settingsPath: configPath,
|
|
182
185
|
getLoaded: () => loaded ?? loadStatuslineSettings(configPath),
|
|
186
|
+
getMenuOwner: () => {
|
|
187
|
+
const generation = sessionGeneration;
|
|
188
|
+
return {
|
|
189
|
+
signal: menuController.signal,
|
|
190
|
+
isCurrent: () => generation === sessionGeneration && !menuController.signal.aborted,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
183
193
|
apply(next, ctx) {
|
|
184
194
|
if (ctx.sessionManager !== activeSessionManager) return;
|
|
185
195
|
previewPalettePreset = undefined;
|
|
@@ -215,6 +225,7 @@ export default function statusline(pi: ExtensionAPI) {
|
|
|
215
225
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
216
226
|
if (!ownsRuntime(ctx)) return;
|
|
217
227
|
sessionGeneration += 1;
|
|
228
|
+
menuController.abort(new DOMException("Statusline session shut down", "AbortError"));
|
|
218
229
|
activeSessionManager = undefined;
|
|
219
230
|
previewPalettePreset = undefined;
|
|
220
231
|
activeGitStatusTarget = undefined;
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const SEGMENT_NAMES = [
|
|
|
10
10
|
"tools",
|
|
11
11
|
"context",
|
|
12
12
|
"tokens",
|
|
13
|
+
"cache",
|
|
13
14
|
"cost",
|
|
14
15
|
"time",
|
|
15
16
|
"turn",
|
|
@@ -39,6 +40,9 @@ export type Density = (typeof DENSITIES)[number];
|
|
|
39
40
|
export const SEPARATOR_NAMES = ["none", "dot", "bar", "powerline", "round"] as const;
|
|
40
41
|
export type SeparatorName = (typeof SEPARATOR_NAMES)[number];
|
|
41
42
|
|
|
43
|
+
export const TRUNCATION_DIRECTIONS = ["start", "middle", "end"] as const;
|
|
44
|
+
export type TruncationDirection = (typeof TRUNCATION_DIRECTIONS)[number];
|
|
45
|
+
|
|
42
46
|
export type PowerlineBlockName = "header" | "directory" | "git" | "runtime" | "meter";
|
|
43
47
|
|
|
44
48
|
export interface SegmentTextConfig {
|
|
@@ -46,6 +50,12 @@ export interface SegmentTextConfig {
|
|
|
46
50
|
suffix: string;
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
export interface ModelSegmentTextConfig extends SegmentTextConfig {
|
|
54
|
+
truncationLength: number;
|
|
55
|
+
truncationSymbol: string;
|
|
56
|
+
truncationDirection: TruncationDirection;
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export interface SegmentPaletteColor {
|
|
50
60
|
fg?: string;
|
|
51
61
|
bg?: string;
|
|
@@ -59,7 +69,7 @@ export interface StatuslineConfig {
|
|
|
59
69
|
density: Density;
|
|
60
70
|
separator: SeparatorName;
|
|
61
71
|
segments: ConfigSegmentName[];
|
|
62
|
-
segmentText: Record<SegmentName, SegmentTextConfig
|
|
72
|
+
segmentText: Record<SegmentName, SegmentTextConfig> & { model: ModelSegmentTextConfig };
|
|
63
73
|
extensionStatusIcons: Record<string, string>;
|
|
64
74
|
}
|
|
65
75
|
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
interface UsageLike {
|
|
4
|
+
input?: number;
|
|
5
|
+
output?: number;
|
|
6
|
+
cacheRead?: number;
|
|
7
|
+
cacheWrite?: number;
|
|
8
|
+
cost?: { total?: number };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FooterUsageSummary {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
latestCacheHitRate?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function summarizeFooterUsage(entries: readonly SessionEntry[]): FooterUsageSummary {
|
|
21
|
+
const totals: FooterUsageSummary = {
|
|
22
|
+
input: 0,
|
|
23
|
+
output: 0,
|
|
24
|
+
cacheRead: 0,
|
|
25
|
+
cacheWrite: 0,
|
|
26
|
+
cost: 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
let usage: UsageLike | undefined;
|
|
31
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
32
|
+
usage = entry.message.usage;
|
|
33
|
+
const input = usage.input ?? 0;
|
|
34
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
35
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
36
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
37
|
+
totals.latestCacheHitRate = promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
|
|
38
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult") {
|
|
39
|
+
usage = entry.message.usage;
|
|
40
|
+
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
41
|
+
usage = entry.usage;
|
|
42
|
+
}
|
|
43
|
+
if (!usage) continue;
|
|
44
|
+
|
|
45
|
+
totals.input += usage.input ?? 0;
|
|
46
|
+
totals.output += usage.output ?? 0;
|
|
47
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
48
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
49
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return totals;
|
|
53
|
+
}
|