@underactive/pi-topping-statusline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +208 -0
- package/index.ts +355 -0
- package/package.json +54 -0
- package/src/context-graph.ts +48 -0
- package/src/context-thresholds.ts +84 -0
- package/src/context.ts +387 -0
- package/src/layout.ts +164 -0
- package/src/menu.ts +687 -0
- package/src/rainbow.ts +98 -0
- package/src/segments.ts +349 -0
- package/src/separators.ts +22 -0
- package/src/settings-menu.ts +370 -0
- package/src/settings.ts +169 -0
- package/src/tests/context-thresholds.test.ts +50 -0
- package/src/tests/feeds.test.ts +368 -0
- package/src/tests/menu.test.ts +109 -0
- package/src/tests/rainbow.test.ts +133 -0
- package/src/tests/token-rate-monitor.test.ts +87 -0
- package/src/tests/token-rate.test.ts +75 -0
- package/src/tests/ts-resolve.ts +20 -0
- package/src/theme.ts +454 -0
- package/src/token-rate.ts +242 -0
- package/src/types.ts +166 -0
- package/src/utils.ts +161 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /topping-statusline-settings command: menu sections for the settings
|
|
3
|
+
* model, a live preview that renders the real box bars with the un-applied
|
|
4
|
+
* menu values, and the command registration.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { SegmentContextBuilder } from "./context.js";
|
|
9
|
+
import { buildStatusLine } from "./layout.js";
|
|
10
|
+
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
11
|
+
import {
|
|
12
|
+
perimeterLength,
|
|
13
|
+
perimeterPosition,
|
|
14
|
+
RAINBOW_CYCLE_MS,
|
|
15
|
+
RAINBOW_FRAME_MS,
|
|
16
|
+
RainbowBorder,
|
|
17
|
+
} from "./rainbow.js";
|
|
18
|
+
import {
|
|
19
|
+
applySettings,
|
|
20
|
+
BORDER_STYLES,
|
|
21
|
+
DEFAULT_FEEDS,
|
|
22
|
+
DEFAULT_SEGMENTS,
|
|
23
|
+
FEED_FORMATS,
|
|
24
|
+
resolveEffectiveSettings,
|
|
25
|
+
saveSettings,
|
|
26
|
+
sanitizeFeeds,
|
|
27
|
+
SEPARATORS,
|
|
28
|
+
type SettingsState,
|
|
29
|
+
} from "./settings.js";
|
|
30
|
+
import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
31
|
+
import type {
|
|
32
|
+
FeedFormat,
|
|
33
|
+
SegmentContext,
|
|
34
|
+
SegmentIncludes,
|
|
35
|
+
StatusLineFeed,
|
|
36
|
+
StatusLineSegmentToggles,
|
|
37
|
+
StatusLineSeparatorStyle,
|
|
38
|
+
StatusLineSettings,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
const SYMBOL_LABELS: Record<SymbolPreset, string> = { nerd: "nerdfont", unicode: "unicode", ascii: "ascii" };
|
|
42
|
+
const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = { nerdfont: "nerd", unicode: "unicode", ascii: "ascii" };
|
|
43
|
+
const SYMBOL_VALUES: readonly string[] = ["nerdfont", "unicode", "ascii"];
|
|
44
|
+
|
|
45
|
+
const SECTION_TITLES = [
|
|
46
|
+
"Top Left Segment Group",
|
|
47
|
+
"Top Right Segment Group",
|
|
48
|
+
"Bottom Right Segment Group",
|
|
49
|
+
"Bottom Left Segment Group",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
const SEGMENT_ROWS: readonly {
|
|
53
|
+
id: keyof StatusLineSegmentToggles;
|
|
54
|
+
label: string;
|
|
55
|
+
section: (typeof SECTION_TITLES)[number];
|
|
56
|
+
}[] = [
|
|
57
|
+
{ id: "pi", label: "Pi symbol", section: "Top Left Segment Group" },
|
|
58
|
+
{ id: "model", label: "Model", section: "Top Left Segment Group" },
|
|
59
|
+
{ id: "provider", label: "Provider", section: "Top Left Segment Group" },
|
|
60
|
+
{ id: "thinking", label: "Thinking level", section: "Top Left Segment Group" },
|
|
61
|
+
{ id: "path", label: "Path", section: "Top Left Segment Group" },
|
|
62
|
+
{ id: "git", label: "Git", section: "Top Left Segment Group" },
|
|
63
|
+
{ id: "pr", label: "PR", section: "Top Left Segment Group" },
|
|
64
|
+
{ id: "tokenRateTopRight", label: "Token rate", section: "Top Right Segment Group" },
|
|
65
|
+
{ id: "sessionName", label: "Session name", section: "Top Right Segment Group" },
|
|
66
|
+
{ id: "feeds", label: "Feeds", section: "Bottom Right Segment Group" },
|
|
67
|
+
{ id: "tokenRate", label: "Token rate", section: "Bottom Right Segment Group" },
|
|
68
|
+
{ id: "piStats", label: "Pi stats", section: "Bottom Right Segment Group" },
|
|
69
|
+
{ id: "contextBar", label: "Context bar", section: "Bottom Right Segment Group" },
|
|
70
|
+
{ id: "contextStats", label: "Context stats", section: "Bottom Right Segment Group" },
|
|
71
|
+
{ id: "scrollHint", label: "Scroll hint", section: "Bottom Left Segment Group" },
|
|
72
|
+
{ id: "feedsBottomLeft", label: "Feeds", section: "Bottom Left Segment Group" },
|
|
73
|
+
{ id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Feed rows are addressed by index so a rebuild can drop one cleanly.
|
|
77
|
+
const FEED_PREFIX = "feed";
|
|
78
|
+
const feedKey = (index: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${index}.${part}`;
|
|
79
|
+
const ADD_FEED_ID = `${FEED_PREFIX}.add`;
|
|
80
|
+
const removeFeedId = (index: number) => `${FEED_PREFIX}.${index}.remove`;
|
|
81
|
+
const FEED_ROW_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.`);
|
|
82
|
+
|
|
83
|
+
function feedSection(feeds: readonly StatusLineFeed[]): MenuSection {
|
|
84
|
+
const items: MenuSection["items"] = [];
|
|
85
|
+
for (const [index, feed] of feeds.entries()) {
|
|
86
|
+
const n = index + 1;
|
|
87
|
+
items.push(
|
|
88
|
+
{ id: feedKey(index, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
|
|
89
|
+
{ id: feedKey(index, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
|
|
90
|
+
{ id: feedKey(index, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
|
|
91
|
+
{ id: feedKey(index, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
|
|
92
|
+
{ id: removeFeedId(index), label: `${n}. remove this feed`, value: false, action: true },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
|
|
96
|
+
return { title: "Feeds", items };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the feed rows back out of the menu's flat value map. */
|
|
100
|
+
function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
|
|
101
|
+
const indices = new Set<number>();
|
|
102
|
+
for (const key of Object.keys(values)) {
|
|
103
|
+
const match = FEED_ROW_RE.exec(key);
|
|
104
|
+
if (match) indices.add(Number(match[1]));
|
|
105
|
+
}
|
|
106
|
+
return [...indices]
|
|
107
|
+
.sort((a, b) => a - b)
|
|
108
|
+
.map((index): StatusLineFeed => {
|
|
109
|
+
const rawFormat = String(values[feedKey(index, "format")] ?? "text");
|
|
110
|
+
return {
|
|
111
|
+
customType: String(values[feedKey(index, "customType")] ?? ""),
|
|
112
|
+
field: String(values[feedKey(index, "field")] ?? ""),
|
|
113
|
+
prefix: String(values[feedKey(index, "prefix")] ?? ""),
|
|
114
|
+
format: FEED_FORMATS.includes(rawFormat as FeedFormat) ? (rawFormat as FeedFormat) : "text",
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.filter(feed => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
121
|
+
const seg = { ...DEFAULT_SEGMENTS, ...settings.segments };
|
|
122
|
+
const global: MenuSection = {
|
|
123
|
+
title: "Global",
|
|
124
|
+
items: [
|
|
125
|
+
{ id: "transparent", label: "Transparent Segments", value: settings.transparent ?? true },
|
|
126
|
+
{
|
|
127
|
+
id: "separator",
|
|
128
|
+
label: "Separator",
|
|
129
|
+
value: settings.separator ?? "powerline-thin",
|
|
130
|
+
cycleValues: SEPARATORS,
|
|
131
|
+
},
|
|
132
|
+
{ id: "symbols", label: "Symbols", value: SYMBOL_LABELS[settings.symbols ?? "nerd"], cycleValues: SYMBOL_VALUES },
|
|
133
|
+
{
|
|
134
|
+
id: "borderStyle",
|
|
135
|
+
label: "Border style",
|
|
136
|
+
value: settings.borderStyle ?? "rounded",
|
|
137
|
+
cycleValues: BORDER_STYLES,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "rainbowBorder",
|
|
141
|
+
label: "Rainbow border on max thinking",
|
|
142
|
+
value: settings.rainbowBorder ?? true,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
return [
|
|
147
|
+
global,
|
|
148
|
+
...SECTION_TITLES.map(title => ({
|
|
149
|
+
title,
|
|
150
|
+
items: SEGMENT_ROWS.filter(row => row.section === title).map(row => ({
|
|
151
|
+
id: row.id,
|
|
152
|
+
label: row.label,
|
|
153
|
+
value: seg[row.id],
|
|
154
|
+
})),
|
|
155
|
+
})),
|
|
156
|
+
feedSection(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings {
|
|
161
|
+
const segments: StatusLineSegmentToggles = {};
|
|
162
|
+
for (const row of SEGMENT_ROWS) segments[row.id] = values[row.id] === true;
|
|
163
|
+
const separator = values.separator as StatusLineSeparatorStyle;
|
|
164
|
+
const borderStyle = values.borderStyle as BorderStyle;
|
|
165
|
+
return {
|
|
166
|
+
transparent: values.transparent === true,
|
|
167
|
+
separator: SEPARATORS.includes(separator) ? separator : "powerline-thin",
|
|
168
|
+
symbols: SYMBOL_FROM_LABEL[values.symbols as string] ?? "nerd",
|
|
169
|
+
borderStyle: BORDER_STYLES.includes(borderStyle) ? borderStyle : "rounded",
|
|
170
|
+
segments,
|
|
171
|
+
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
172
|
+
rainbowBorder: values.rainbowBorder === true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Canned stand-ins keep every toggle visible in the preview even when the
|
|
177
|
+
// live session lacks the data (no PR, no scrollback, fresh session).
|
|
178
|
+
const CANNED_PR = { number: 42, url: "https://github.com/underactive/pi-topping-statusline/pull/42" };
|
|
179
|
+
const CANNED_TOKEN_RATE = { rate: 87, phase: "active", fadeShade: 0 } as const;
|
|
180
|
+
/** Stand-in payloads so configured feeds still preview without a live publisher. */
|
|
181
|
+
const CANNED_FEED_NUMBER = 1.23;
|
|
182
|
+
const CANNED_HINT = "↑ 3 more";
|
|
183
|
+
const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
|
|
184
|
+
const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
|
|
185
|
+
const CANNED_PERCENT = 42;
|
|
186
|
+
const CANNED_WINDOW = 200_000;
|
|
187
|
+
|
|
188
|
+
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
189
|
+
function cannedFeedData(
|
|
190
|
+
feeds: readonly StatusLineFeed[],
|
|
191
|
+
live: Record<string, unknown> | undefined,
|
|
192
|
+
): Record<string, unknown> {
|
|
193
|
+
const data: Record<string, unknown> = { ...live };
|
|
194
|
+
for (const feed of feeds) {
|
|
195
|
+
if (data[feed.customType] !== undefined || !feed.field) continue;
|
|
196
|
+
data[feed.customType] = {
|
|
197
|
+
[feed.field]: feed.format === "text" ? "sample" : CANNED_FEED_NUMBER,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return data;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class StatusLinePreview {
|
|
204
|
+
readonly #state: SettingsState;
|
|
205
|
+
readonly #builder: SegmentContextBuilder;
|
|
206
|
+
readonly #uiTheme: Theme;
|
|
207
|
+
|
|
208
|
+
constructor(state: SettingsState, builder: SegmentContextBuilder, uiTheme: Theme) {
|
|
209
|
+
this.#state = state;
|
|
210
|
+
this.#builder = builder;
|
|
211
|
+
this.#uiTheme = uiTheme;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
render(
|
|
215
|
+
values: Record<string, MenuValue>,
|
|
216
|
+
elapsedMs: number,
|
|
217
|
+
_activeItemId: string | undefined,
|
|
218
|
+
innerWidth: number,
|
|
219
|
+
): PreviewResult {
|
|
220
|
+
const effective = resolveEffectiveSettings(valuesToSettings(values));
|
|
221
|
+
// Preview lines are indented one cell by the menu; mirror renderBoxed's
|
|
222
|
+
// 6-cell chrome budget for the bars themselves.
|
|
223
|
+
const boxWidth = Math.max(24, innerWidth - 1);
|
|
224
|
+
const barWidth = boxWidth - 6;
|
|
225
|
+
const rainbowOn = effective.rainbowBorder;
|
|
226
|
+
|
|
227
|
+
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
228
|
+
// symbol preset for this synchronous render only; the live bar behind
|
|
229
|
+
// the overlay keeps the applied preset.
|
|
230
|
+
const applied = this.#state.effective.symbols;
|
|
231
|
+
theme.setSymbolPreset(effective.symbols);
|
|
232
|
+
try {
|
|
233
|
+
const include: SegmentIncludes = {
|
|
234
|
+
git: true,
|
|
235
|
+
pr: true,
|
|
236
|
+
piStats: true,
|
|
237
|
+
tokenRate: true,
|
|
238
|
+
feeds: effective.segmentOptions.feeds.map(f => f.customType),
|
|
239
|
+
};
|
|
240
|
+
const base = this.#builder.build(barWidth, effective.segmentOptions, include, CANNED_HINT);
|
|
241
|
+
const contextWindow = base.contextWindow || CANNED_WINDOW;
|
|
242
|
+
const ctx: SegmentContext = {
|
|
243
|
+
...base,
|
|
244
|
+
// The preview demos the rainbow whenever the toggle is on,
|
|
245
|
+
// regardless of the live session's thinking level.
|
|
246
|
+
thinkingLevel: rainbowOn ? "max" : base.thinkingLevel,
|
|
247
|
+
git: {
|
|
248
|
+
branch: base.git.branch ?? CANNED_GIT.branch,
|
|
249
|
+
status: base.git.status ?? CANNED_GIT.status,
|
|
250
|
+
pr: base.git.pr ?? CANNED_PR,
|
|
251
|
+
},
|
|
252
|
+
contextPercent: base.contextPercent ?? CANNED_PERCENT,
|
|
253
|
+
contextTokens: base.contextPercent == null ? Math.round((contextWindow * CANNED_PERCENT) / 100) : base.contextTokens,
|
|
254
|
+
contextWindow,
|
|
255
|
+
piStats: base.piStats ?? CANNED_STATS,
|
|
256
|
+
tokenRate: base.tokenRate ?? CANNED_TOKEN_RATE,
|
|
257
|
+
feedData: cannedFeedData(effective.segmentOptions.feeds, base.feedData),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const box = theme.getBox(effective.borderStyle);
|
|
261
|
+
const staticBorder = (s: string, _startCol: number, _row: number) => this.#uiTheme.fg("border", s);
|
|
262
|
+
// The preview box is three rows tall: top bar, side verticals, bottom bar.
|
|
263
|
+
const bottomIdx = 2;
|
|
264
|
+
const rainbow = new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS);
|
|
265
|
+
const perimeter = perimeterLength(boxWidth, bottomIdx);
|
|
266
|
+
const paint = (row: number, col: number, ch: string): string =>
|
|
267
|
+
rainbowOn
|
|
268
|
+
? rainbow.colorChar(ch, perimeterPosition(row, col, boxWidth, bottomIdx), perimeter)
|
|
269
|
+
: staticBorder(ch, col, row);
|
|
270
|
+
const horizRun = (row: number, startCol: number, count: number): string => {
|
|
271
|
+
let out = "";
|
|
272
|
+
for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
|
|
273
|
+
return out;
|
|
274
|
+
};
|
|
275
|
+
const gapColor = (str: string, startCol: number, row: number): string =>
|
|
276
|
+
rainbowOn ? [...str].map((ch, k) => paint(row, startCol + k, ch)).join("") : staticBorder(str, startCol, row);
|
|
277
|
+
const row = (bar: string, rowIdx: number, left: string, right: string): string => {
|
|
278
|
+
const pad = Math.max(0, barWidth - visibleWidth(bar));
|
|
279
|
+
return (
|
|
280
|
+
paint(rowIdx, 0, left) +
|
|
281
|
+
horizRun(rowIdx, 1, 2) +
|
|
282
|
+
bar +
|
|
283
|
+
horizRun(rowIdx, 3 + visibleWidth(bar), pad + 2) +
|
|
284
|
+
paint(rowIdx, boxWidth - 1, right)
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
const top = buildStatusLine(
|
|
288
|
+
barWidth,
|
|
289
|
+
ctx,
|
|
290
|
+
effective,
|
|
291
|
+
gapColor,
|
|
292
|
+
{
|
|
293
|
+
left: effective.leftSegments,
|
|
294
|
+
right: effective.rightSegments,
|
|
295
|
+
},
|
|
296
|
+
{ col: 3, row: 0 },
|
|
297
|
+
);
|
|
298
|
+
const bottom = buildStatusLine(
|
|
299
|
+
barWidth,
|
|
300
|
+
ctx,
|
|
301
|
+
effective,
|
|
302
|
+
gapColor,
|
|
303
|
+
{
|
|
304
|
+
left: effective.bottomLeftSegments,
|
|
305
|
+
right: effective.bottomRightSegments,
|
|
306
|
+
},
|
|
307
|
+
{ col: 3, row: bottomIdx },
|
|
308
|
+
);
|
|
309
|
+
return {
|
|
310
|
+
lines: [
|
|
311
|
+
row(top, 0, box.topLeft, box.topRight),
|
|
312
|
+
paint(1, 0, box.vertical) + " ".repeat(Math.max(0, boxWidth - 2)) + paint(1, boxWidth - 1, box.vertical),
|
|
313
|
+
row(bottom, bottomIdx, box.bottomLeft, box.bottomRight),
|
|
314
|
+
],
|
|
315
|
+
// Keep the preview animated only while the rainbow is enabled;
|
|
316
|
+
// a static preview refreshes once and stops.
|
|
317
|
+
nextRefreshInMs: rainbowOn ? RAINBOW_FRAME_MS : undefined,
|
|
318
|
+
};
|
|
319
|
+
} finally {
|
|
320
|
+
theme.setSymbolPreset(applied);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function registerSettingsCommand(
|
|
326
|
+
pi: ExtensionAPI,
|
|
327
|
+
state: SettingsState,
|
|
328
|
+
builder: SegmentContextBuilder,
|
|
329
|
+
onChange: () => void,
|
|
330
|
+
): void {
|
|
331
|
+
pi.registerCommand("topping-statusline-settings", {
|
|
332
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border",
|
|
333
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
334
|
+
if (ctx.mode !== "tui") {
|
|
335
|
+
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const preview = new StatusLinePreview(state, builder, ctx.ui.theme);
|
|
339
|
+
const result = await showMenu<Record<string, MenuValue>>(ctx, {
|
|
340
|
+
title: "Pi Topping Statusline: Settings",
|
|
341
|
+
sections: buildSections(state.settings),
|
|
342
|
+
hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
|
|
343
|
+
preview: preview.render.bind(preview),
|
|
344
|
+
onAction: (id, values) => {
|
|
345
|
+
const feeds = feedsFromValues(values);
|
|
346
|
+
if (id === ADD_FEED_ID) {
|
|
347
|
+
feeds.push({ customType: "", field: "", prefix: "", format: "currency" });
|
|
348
|
+
} else {
|
|
349
|
+
const match = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`).exec(id);
|
|
350
|
+
if (!match) return undefined;
|
|
351
|
+
feeds.splice(Number(match[1]), 1);
|
|
352
|
+
}
|
|
353
|
+
// Rows are index-addressed, so the whole menu is regenerated to
|
|
354
|
+
// keep ids contiguous after an insert or removal.
|
|
355
|
+
return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(feeds)];
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
if (!result.applied) return;
|
|
359
|
+
const next = valuesToSettings(result.values);
|
|
360
|
+
applySettings(state, next);
|
|
361
|
+
onChange();
|
|
362
|
+
try {
|
|
363
|
+
saveSettings(next);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
366
|
+
ctx.ui.notify(`Failed to save statusline settings: ${message}`, "error");
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings persistence. Pi has no per-extension settings store, so the
|
|
3
|
+
* statusline settings live in a small JSON at
|
|
4
|
+
* ~/.pi/agent/pi-topping-statusline/settings.json.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
10
|
+
import type {
|
|
11
|
+
EffectiveStatusLineSettings,
|
|
12
|
+
FeedFormat,
|
|
13
|
+
StatusLineFeed,
|
|
14
|
+
StatusLineSegmentId,
|
|
15
|
+
StatusLineSegmentToggles,
|
|
16
|
+
StatusLineSeparatorStyle,
|
|
17
|
+
StatusLineSettings,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
const SETTINGS_PATH = path.join(getAgentDir(), "pi-topping-statusline", "settings.json");
|
|
21
|
+
|
|
22
|
+
export const SEPARATORS: readonly StatusLineSeparatorStyle[] = ["powerline", "powerline-thin", "slash", "pipe", "ascii"];
|
|
23
|
+
const SYMBOL_PRESETS: SymbolPreset[] = ["nerd", "unicode", "ascii"];
|
|
24
|
+
export const BORDER_STYLES: readonly BorderStyle[] = ["rounded", "heavy", "double", "single"];
|
|
25
|
+
|
|
26
|
+
export const FEED_FORMATS: readonly FeedFormat[] = ["currency", "number", "text"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Seeded with pi-prompt-cache's published savings feed, so the segment works
|
|
30
|
+
* as soon as it is switched on and doubles as a worked example of the shape.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_FEEDS: readonly StatusLineFeed[] = [
|
|
33
|
+
{ customType: "pi-prompt-cache/savings", field: "savedUsd", prefix: "CS", format: "currency" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SEGMENTS: Required<StatusLineSegmentToggles> = {
|
|
37
|
+
pi: true,
|
|
38
|
+
model: true,
|
|
39
|
+
provider: false,
|
|
40
|
+
thinking: true,
|
|
41
|
+
path: true,
|
|
42
|
+
git: true,
|
|
43
|
+
pr: true,
|
|
44
|
+
sessionName: true,
|
|
45
|
+
tokenRateTopRight: false,
|
|
46
|
+
piStats: true,
|
|
47
|
+
tokenRate: false,
|
|
48
|
+
feeds: false,
|
|
49
|
+
feedsBottomLeft: false,
|
|
50
|
+
tokenRateBottomLeft: false,
|
|
51
|
+
contextBar: true,
|
|
52
|
+
contextStats: true,
|
|
53
|
+
scrollHint: true,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const SEGMENT_KEYS = Object.keys(DEFAULT_SEGMENTS) as (keyof StatusLineSegmentToggles)[];
|
|
57
|
+
|
|
58
|
+
/** Keep only well-formed rows; a feed without a customType or field can render nothing. */
|
|
59
|
+
export function sanitizeFeeds(raw: unknown): StatusLineFeed[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
const feeds: StatusLineFeed[] = [];
|
|
62
|
+
for (const item of raw) {
|
|
63
|
+
if (!item || typeof item !== "object") continue;
|
|
64
|
+
const { customType, field, prefix, format } = item as Record<string, unknown>;
|
|
65
|
+
if (typeof customType !== "string" || typeof field !== "string") continue;
|
|
66
|
+
if (!customType.trim() || !field.trim()) continue;
|
|
67
|
+
feeds.push({
|
|
68
|
+
customType: customType.trim(),
|
|
69
|
+
field: field.trim(),
|
|
70
|
+
prefix: typeof prefix === "string" ? prefix : "",
|
|
71
|
+
format: FEED_FORMATS.includes(format as FeedFormat) ? (format as FeedFormat) : "text",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return feeds;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadSettings(): StatusLineSettings {
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")) as StatusLineSettings;
|
|
80
|
+
const settings: StatusLineSettings = {};
|
|
81
|
+
if (typeof raw.transparent === "boolean") settings.transparent = raw.transparent;
|
|
82
|
+
if (raw.separator && SEPARATORS.includes(raw.separator)) settings.separator = raw.separator;
|
|
83
|
+
if (raw.symbols && SYMBOL_PRESETS.includes(raw.symbols)) settings.symbols = raw.symbols;
|
|
84
|
+
if (raw.borderStyle && BORDER_STYLES.includes(raw.borderStyle)) settings.borderStyle = raw.borderStyle;
|
|
85
|
+
if (raw.segments && typeof raw.segments === "object") {
|
|
86
|
+
const segments: StatusLineSegmentToggles = {};
|
|
87
|
+
for (const key of SEGMENT_KEYS) {
|
|
88
|
+
if (typeof raw.segments[key] === "boolean") segments[key] = raw.segments[key];
|
|
89
|
+
}
|
|
90
|
+
settings.segments = segments;
|
|
91
|
+
}
|
|
92
|
+
if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
|
|
93
|
+
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
94
|
+
return settings;
|
|
95
|
+
} catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Atomic write; throws so callers can surface the failure. */
|
|
101
|
+
export function saveSettings(settings: StatusLineSettings): void {
|
|
102
|
+
fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
103
|
+
fs.writeFileSync(`${SETTINGS_PATH}.tmp`, `${JSON.stringify(settings, null, "\t")}\n`, { mode: 0o600 });
|
|
104
|
+
fs.renameSync(`${SETTINGS_PATH}.tmp`, SETTINGS_PATH);
|
|
105
|
+
fs.chmodSync(SETTINGS_PATH, 0o600);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveEffectiveSettings(settings: StatusLineSettings): EffectiveStatusLineSettings {
|
|
109
|
+
const seg = { ...DEFAULT_SEGMENTS, ...settings.segments };
|
|
110
|
+
|
|
111
|
+
const leftSegments: StatusLineSegmentId[] = [];
|
|
112
|
+
if (seg.pi) leftSegments.push("pi");
|
|
113
|
+
if (seg.model || seg.provider || seg.thinking) leftSegments.push("model");
|
|
114
|
+
if (seg.path) leftSegments.push("path");
|
|
115
|
+
if (seg.git) leftSegments.push("git");
|
|
116
|
+
if (seg.pr) leftSegments.push("pr");
|
|
117
|
+
|
|
118
|
+
const bottomRightSegments: StatusLineSegmentId[] = [];
|
|
119
|
+
if (seg.feeds) bottomRightSegments.push("feeds");
|
|
120
|
+
if (seg.tokenRate) bottomRightSegments.push("token_rate");
|
|
121
|
+
if (seg.piStats) bottomRightSegments.push("pi_stats");
|
|
122
|
+
if (seg.contextBar || seg.contextStats) bottomRightSegments.push("context_graph");
|
|
123
|
+
|
|
124
|
+
const rightSegments: StatusLineSegmentId[] = [];
|
|
125
|
+
// The rate leads so its width changes never shift the session name.
|
|
126
|
+
if (seg.tokenRateTopRight) rightSegments.push("token_rate");
|
|
127
|
+
if (seg.sessionName) rightSegments.push("session_name");
|
|
128
|
+
|
|
129
|
+
const bottomLeftSegments: StatusLineSegmentId[] = [];
|
|
130
|
+
if (seg.scrollHint) bottomLeftSegments.push("scroll_hint");
|
|
131
|
+
if (seg.feedsBottomLeft) bottomLeftSegments.push("feeds");
|
|
132
|
+
if (seg.tokenRateBottomLeft) bottomLeftSegments.push("token_rate");
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
transparent: settings.transparent ?? true,
|
|
136
|
+
separator: settings.separator ?? "powerline-thin",
|
|
137
|
+
symbols: settings.symbols ?? "nerd",
|
|
138
|
+
borderStyle: settings.borderStyle ?? "rounded",
|
|
139
|
+
leftSegments,
|
|
140
|
+
rightSegments,
|
|
141
|
+
bottomLeftSegments,
|
|
142
|
+
bottomRightSegments,
|
|
143
|
+
rainbowBorder: settings.rainbowBorder ?? true,
|
|
144
|
+
segmentOptions: {
|
|
145
|
+
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
146
|
+
path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
|
|
147
|
+
context: { showBar: seg.contextBar, showStats: seg.contextStats },
|
|
148
|
+
feeds: settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface SettingsState {
|
|
154
|
+
settings: StatusLineSettings;
|
|
155
|
+
effective: EffectiveStatusLineSettings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function applySettings(state: SettingsState, settings: StatusLineSettings): void {
|
|
159
|
+
state.settings = settings;
|
|
160
|
+
state.effective = resolveEffectiveSettings(settings);
|
|
161
|
+
theme.setSymbolPreset(state.effective.symbols);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createSettingsState(): SettingsState {
|
|
165
|
+
const settings = loadSettings();
|
|
166
|
+
const effective = resolveEffectiveSettings(settings);
|
|
167
|
+
theme.setSymbolPreset(effective.symbols);
|
|
168
|
+
return { settings, effective };
|
|
169
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context threshold bands: percent vs. token-derived caps, and
|
|
3
|
+
* formatContextUsage's unknown-window/unknown-percent rendering.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { formatContextUsage, getContextUsageLevel } from "../context-thresholds.ts";
|
|
8
|
+
|
|
9
|
+
test("percent bands trigger at 50/70/90 when the window is small enough that token caps don't bind first", () => {
|
|
10
|
+
const window = 200_000;
|
|
11
|
+
assert.equal(getContextUsageLevel(49.9, window), "normal");
|
|
12
|
+
assert.equal(getContextUsageLevel(50, window), "warning");
|
|
13
|
+
assert.equal(getContextUsageLevel(69.9, window), "warning");
|
|
14
|
+
assert.equal(getContextUsageLevel(70, window), "elevated");
|
|
15
|
+
assert.equal(getContextUsageLevel(89.9, window), "elevated");
|
|
16
|
+
assert.equal(getContextUsageLevel(90, window), "error");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a large window makes the token thresholds bind before the percent thresholds", () => {
|
|
20
|
+
const window = 1_000_000;
|
|
21
|
+
assert.equal(getContextUsageLevel(14.9, window), "normal");
|
|
22
|
+
assert.equal(getContextUsageLevel(15, window), "warning");
|
|
23
|
+
assert.equal(getContextUsageLevel(27, window), "elevated");
|
|
24
|
+
assert.equal(getContextUsageLevel(50, window), "error");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("an unknown window (<=0) falls back to the raw percent thresholds", () => {
|
|
28
|
+
assert.equal(getContextUsageLevel(49, 0), "normal");
|
|
29
|
+
assert.equal(getContextUsageLevel(50, 0), "warning");
|
|
30
|
+
assert.equal(getContextUsageLevel(90, -1), "error");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("zero or negative percent never crosses any threshold", () => {
|
|
34
|
+
assert.equal(getContextUsageLevel(0, 200_000), "normal");
|
|
35
|
+
assert.equal(getContextUsageLevel(-5, 200_000), "normal");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an unknown window falls back to the raw token count", () => {
|
|
39
|
+
assert.equal(formatContextUsage(42, 0, 1234), "1.2K/?");
|
|
40
|
+
assert.equal(formatContextUsage(42, 0, undefined), "0/?");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a null or undefined percent against a known window renders '?'", () => {
|
|
44
|
+
assert.equal(formatContextUsage(null, 200_000, 1000), "?/200K");
|
|
45
|
+
assert.equal(formatContextUsage(undefined, 200_000, 1000), "?/200K");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a known percent renders with one decimal", () => {
|
|
49
|
+
assert.equal(formatContextUsage(42.567, 200_000), "42.6%/200K");
|
|
50
|
+
});
|