@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 +44 -4
- package/package.json +4 -1
- package/src/commands.ts +92 -56
- package/src/render.ts +100 -2
- package/src/settings.ts +79 -20
- package/src/statusline.ts +11 -0
- package/src/types.ts +10 -1
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.
|
|
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.
|
|
94
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
196
|
+
return { kind: "close" };
|
|
197
|
+
},
|
|
198
|
+
help: async () => {
|
|
148
199
|
showHelp(ctx, options.settingsPath);
|
|
149
|
-
return;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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 (
|
|
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
|
|