@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 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% 🕒 16: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. Choose an appearance or information level; changes apply immediately.
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. RPC receives observable
92
- notifications instead of TUI-only controls. Unknown subcommands and trailing arguments are rejected.
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` | Wrap Pi-owned dynamic values |
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.36.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
- while (true) {
121
- const config = options.getLoaded().config;
122
- const appearanceItem = `Appearance (${config.palettePreset})`;
123
- const informationItem = `Information (${inferInformationProfile(config.segments)})`;
124
- const selection = await ctx.ui.select("pi-statusline", [
125
- appearanceItem,
126
- informationItem,
127
- "Advanced",
128
- "Status",
129
- "Help",
130
- ]);
131
- if (selection === appearanceItem) {
132
- await choosePalettePreset(ctx, options);
133
- return;
134
- }
135
- if (selection === informationItem) {
136
- await chooseInformationProfile(ctx, options);
137
- return;
138
- }
139
- switch (selection) {
140
- case "Advanced":
141
- if (await showAdvancedMenu(ctx, options)) continue;
142
- return;
143
- case "Status":
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
- case "Help":
196
+ return { kind: "close" };
197
+ },
198
+ help: async () => {
147
199
  showHelp(ctx, options.settingsPath);
148
- return;
149
- default:
150
- return;
151
- }
152
- }
153
- }
154
-
155
- async function showAdvancedMenu(
156
- ctx: ExtensionCommandContext,
157
- options: StatuslineCommandOptions,
158
- ): Promise<boolean> {
159
- const config = options.getLoaded().config;
160
- const visibleSegmentCount = config.segments.filter(
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
  );
@@ -18,6 +18,7 @@ export const INFORMATION_PROFILES: Readonly<
18
18
  "tools",
19
19
  "context",
20
20
  "tokens",
21
+ "cache",
21
22
  "cost",
22
23
  "time",
23
24
  ],
package/src/powerline.ts CHANGED
@@ -49,6 +49,7 @@ const SEGMENT_RETENTION_PRIORITY: Readonly<Record<RenderSegment["name"], number>
49
49
  thinking: 70,
50
50
  cost: 60,
51
51
  provider: 50,
52
+ cache: 45,
52
53
  tokens: 40,
53
54
  time: 30,
54
55
  turn: 20,
@@ -37,6 +37,7 @@ const SEGMENT_BLOCKS: Record<SegmentName, PowerlineBlockName> = {
37
37
  tools: "runtime",
38
38
  context: "runtime",
39
39
  tokens: "runtime",
40
+ cache: "runtime",
40
41
  cost: "meter",
41
42
  time: "meter",
42
43
  turn: "meter",
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
- return segment(name, shortenModel(ctx.model?.id ?? "no-model"), config, "accent", "header");
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 value =
148
+ const percentage =
142
149
  usage?.percent === null || usage?.percent === undefined
143
150
  ? "?"
144
- : `${usage.percent.toFixed(0)}%`;
145
- return segment(name, value, config, contextColor(usage?.percent), "runtime");
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
- totals.input === 0 && totals.output === 0
163
+ usageSummary.input === 0 && usageSummary.output === 0
151
164
  ? "tok 0"
152
- : `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
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 totals = getTokenTotals(ctx);
179
+ const subscription = isSubscriptionBacked(ctx) ? " (sub)" : "";
157
180
  return segment(
158
181
  name,
159
- totals.cost.toFixed(totals.cost >= 1 ? 2 : 3),
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 getTokenTotals(ctx: ExtensionContext): TokenTotals {
272
- const totals: TokenTotals = { input: 0, output: 0, cost: 0 };
273
-
274
- for (const entry of ctx.sessionManager.getBranch()) {
275
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
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: { prefix: "🤖 ", suffix: "" },
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 !== "prefix" && key !== "suffix") {
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 (typeof fieldValue !== "string") {
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
+ }