@narumitw/pi-statusline 0.37.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
@@ -48,7 +48,8 @@ For the best result, use a terminal font that includes Powerline glyphs and emoj
48
48
 
49
49
  1. Install the extension and start Pi.
50
50
  2. Run `/statusline`.
51
- 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.
52
53
 
53
54
  ```text
54
55
  Appearance (tokyo-night)
@@ -90,8 +91,11 @@ reads or writes.
90
91
  | `/statusline status` | Show the effective settings and diagnostics |
91
92
  | `/statusline help` | Show command and schema guidance |
92
93
 
93
- The direct `settings`, `status`, and `help` routes remain for compatibility. RPC receives observable
94
- 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.
95
99
 
96
100
  ## 📐 Runtime behavior
97
101
 
@@ -158,7 +162,7 @@ automatically; rename it to `pi-statusline.json`. If both files exist, `pi-statu
158
162
  | `density` | `compact`, `cozy` | Control horizontal padding |
159
163
  | `separator` | `none`, `dot`, `bar`, `powerline`, `round` | Separate adjacent segments in one color block |
160
164
  | `segments` | Ordered unique segment names and `line_break` | Control visibility, order, and rows |
161
- | `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 |
162
166
  | `extensionStatusIcons` | Raw status key or `namespace:*` to icon string | Customize extension status icons |
163
167
 
164
168
  All fields are optional in an existing document. Missing fields use defaults. Unknown fields produce a
@@ -174,6 +178,11 @@ A compact customization example:
174
178
  "separator": "dot",
175
179
  "segments": ["model", "thinking", "cwd", "branch", "context", "cache", "cost"],
176
180
  "segmentText": {
181
+ "model": {
182
+ "truncationLength": 40,
183
+ "truncationSymbol": "…",
184
+ "truncationDirection": "middle"
185
+ },
177
186
  "context": { "prefix": "ctx ", "suffix": "" }
178
187
  },
179
188
  "extensionStatusIcons": {
@@ -214,6 +223,37 @@ When `palettePreset` is `custom`, `palette` maps segment names to foreground/bac
214
223
  `segmentText` values must be single-line text without terminal control characters. Use `line_break`
215
224
  for another row rather than inserting a newline into a prefix or suffix.
216
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
+
217
257
  ## 🧩 Advanced layout
218
258
 
219
259
  Open **Advanced → Custom layout** when the curated levels are not enough.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.37.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,
@@ -68,6 +69,7 @@ export interface StatuslineCommandOptions {
68
69
  apply(loaded: LoadedStatuslineSettings, ctx: ExtensionCommandContext): void;
69
70
  preview?(palettePreset: PalettePreset | undefined, ctx: ExtensionCommandContext): void;
70
71
  save?: (settingsPath: string, rawDocument: string) => LoadedStatuslineSettings;
72
+ getMenuOwner?(): { signal: AbortSignal; isCurrent(): boolean };
71
73
  }
72
74
 
73
75
  export function registerStatuslineCommand(pi: ExtensionAPI, options: StatuslineCommandOptions) {
@@ -115,67 +117,100 @@ export function registerStatuslineCommand(pi: ExtensionAPI, options: StatuslineC
115
117
 
116
118
  async function showMainMenu(ctx: ExtensionCommandContext, options: StatuslineCommandOptions) {
117
119
  if (ctx.mode !== "tui") {
118
- 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");
119
121
  return;
120
122
  }
121
- while (true) {
122
- const config = options.getLoaded().config;
123
- const appearanceItem = `Appearance (${config.palettePreset})`;
124
- const informationItem = `Information (${inferInformationProfile(config.segments)})`;
125
- const selection = await ctx.ui.select("pi-statusline", [
126
- appearanceItem,
127
- informationItem,
128
- "Advanced",
129
- "Status",
130
- "Help",
131
- ]);
132
- if (selection === appearanceItem) {
133
- await choosePalettePreset(ctx, options);
134
- return;
135
- }
136
- if (selection === informationItem) {
137
- await chooseInformationProfile(ctx, options);
138
- return;
139
- }
140
- switch (selection) {
141
- case "Advanced":
142
- if (await showAdvancedMenu(ctx, options)) continue;
143
- return;
144
- 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 () => {
145
195
  showStatus(ctx, options);
146
- return;
147
- case "Help":
196
+ return { kind: "close" };
197
+ },
198
+ help: async () => {
148
199
  showHelp(ctx, options.settingsPath);
149
- return;
150
- default:
151
- return;
152
- }
153
- }
154
- }
155
-
156
- async function showAdvancedMenu(
157
- ctx: ExtensionCommandContext,
158
- options: StatuslineCommandOptions,
159
- ): Promise<boolean> {
160
- const config = options.getLoaded().config;
161
- const visibleSegmentCount = config.segments.filter(
162
- (segment): segment is SegmentName => segment !== LINE_BREAK_SEGMENT_NAME,
163
- ).length;
164
- const layoutItem = `Custom layout (${visibleSegmentCount}/${SEGMENT_NAMES.length} shown)`;
165
- const selection = await ctx.ui.select("pi-statusline — Advanced", [
166
- layoutItem,
167
- EDIT_SETTINGS_LABEL,
168
- "Back",
169
- ]);
170
- if (selection === layoutItem) {
171
- await chooseSegments(ctx, options);
172
- return false;
173
- }
174
- if (selection === EDIT_SETTINGS_LABEL) {
175
- await editSettings(ctx, options);
176
- 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"));
177
213
  }
178
- return selection === "Back";
179
214
  }
180
215
 
181
216
  async function choosePalettePreset(
@@ -920,6 +955,7 @@ function showHelp(ctx: ExtensionCommandContext, settingsPath: string) {
920
955
  "Press M for move mode, Alt+Up/Alt+Down for quick move, and B for a line break.",
921
956
  "Line breaks (line_break) may repeat when separated by data segments, but cannot be consecutive.",
922
957
  "segmentText supports prefix and suffix strings around Pi-owned dynamic values.",
958
+ "segmentText.model also supports truncationLength, truncationSymbol, and truncationDirection (start, middle, end).",
923
959
  ].join("\n"),
924
960
  "info",
925
961
  );
package/src/render.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type RenderSegment,
21
21
  type SegmentName,
22
22
  type StatuslineConfig,
23
+ type TruncationDirection,
23
24
  } from "./types.js";
24
25
  import { type FooterUsageSummary, summarizeFooterUsage } from "./usage.js";
25
26
 
@@ -107,8 +108,16 @@ function buildSegment(
107
108
  return segment(name, "π", config, "accent", "header", true);
108
109
  case "provider":
109
110
  return segment(name, ctx.model?.provider ?? "no-provider", config, "accent", "header");
110
- case "model":
111
- 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
+ }
112
121
  case "thinking":
113
122
  return segment(
114
123
  name,
@@ -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,7 +61,13 @@ 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: "" },
@@ -203,32 +210,25 @@ export function normalizeStatuslineConfig(value: unknown): {
203
210
  diagnostics.push(invalidDiagnostic(path, "Expected an object"));
204
211
  continue;
205
212
  }
213
+ const knownFields = new Set([
214
+ "prefix",
215
+ "suffix",
216
+ ...(name === "model"
217
+ ? ["truncationLength", "truncationSymbol", "truncationDirection"]
218
+ : []),
219
+ ]);
206
220
  for (const key of Object.keys(presentation)) {
207
- if (key !== "prefix" && key !== "suffix") {
208
- diagnostics.push(unknownDiagnostic(`${path}.${key}`));
209
- }
221
+ if (!knownFields.has(key)) diagnostics.push(unknownDiagnostic(`${path}.${key}`));
210
222
  }
211
223
  for (const field of ["prefix", "suffix"] as const) {
212
224
  const fieldValue = presentation[field];
213
225
  if (fieldValue === undefined) continue;
214
- if (typeof fieldValue !== "string") {
215
- diagnostics.push(invalidDiagnostic(`${path}.${field}`, "Expected a string"));
216
- continue;
217
- }
218
- if (/[\r\n\u2028\u2029]/u.test(fieldValue)) {
219
- diagnostics.push(
220
- invalidDiagnostic(`${path}.${field}`, "Line breaks are not allowed; use line_break"),
221
- );
222
- continue;
223
- }
224
- if (hasControlCharacter(fieldValue)) {
225
- diagnostics.push(
226
- invalidDiagnostic(`${path}.${field}`, "Control characters are not allowed"),
227
- );
228
- continue;
229
- }
226
+ if (!isSafeSegmentText(fieldValue, `${path}.${field}`, diagnostics)) continue;
230
227
  config.segmentText[name][field] = fieldValue;
231
228
  }
229
+ if (name === "model") {
230
+ normalizeModelTruncation(presentation, config, diagnostics);
231
+ }
232
232
  }
233
233
  }
234
234
  }
@@ -505,6 +505,65 @@ function normalizePalette(
505
505
  config.palettePreset = "custom";
506
506
  }
507
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
+
508
567
  function normalizeEnum<
509
568
  K extends "palettePreset" | "density" | "separator",
510
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
@@ -40,6 +40,9 @@ export type Density = (typeof DENSITIES)[number];
40
40
  export const SEPARATOR_NAMES = ["none", "dot", "bar", "powerline", "round"] as const;
41
41
  export type SeparatorName = (typeof SEPARATOR_NAMES)[number];
42
42
 
43
+ export const TRUNCATION_DIRECTIONS = ["start", "middle", "end"] as const;
44
+ export type TruncationDirection = (typeof TRUNCATION_DIRECTIONS)[number];
45
+
43
46
  export type PowerlineBlockName = "header" | "directory" | "git" | "runtime" | "meter";
44
47
 
45
48
  export interface SegmentTextConfig {
@@ -47,6 +50,12 @@ export interface SegmentTextConfig {
47
50
  suffix: string;
48
51
  }
49
52
 
53
+ export interface ModelSegmentTextConfig extends SegmentTextConfig {
54
+ truncationLength: number;
55
+ truncationSymbol: string;
56
+ truncationDirection: TruncationDirection;
57
+ }
58
+
50
59
  export interface SegmentPaletteColor {
51
60
  fg?: string;
52
61
  bg?: string;
@@ -60,7 +69,7 @@ export interface StatuslineConfig {
60
69
  density: Density;
61
70
  separator: SeparatorName;
62
71
  segments: ConfigSegmentName[];
63
- segmentText: Record<SegmentName, SegmentTextConfig>;
72
+ segmentText: Record<SegmentName, SegmentTextConfig> & { model: ModelSegmentTextConfig };
64
73
  extensionStatusIcons: Record<string, string>;
65
74
  }
66
75