@underactive/pi-topping-statusline 0.2.0 → 0.2.2
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 +62 -16
- package/index.ts +214 -117
- package/package.json +4 -5
- package/src/box.ts +33 -11
- package/src/compaction-embed.ts +62 -0
- package/src/context-graph.ts +5 -5
- package/src/context-thresholds.ts +7 -3
- package/src/context.ts +72 -13
- package/src/footer.ts +5 -2
- package/src/layout.ts +81 -30
- package/src/menu.ts +25 -36
- package/src/nvidia-green.ts +66 -0
- package/src/rainbow.ts +18 -4
- package/src/segments.ts +77 -5
- package/src/settings-menu.ts +102 -47
- package/src/settings.ts +30 -4
- package/src/tests/box.test.ts +6 -6
- package/src/tests/compaction-embed.test.ts +184 -0
- package/src/tests/context-estimate.test.ts +207 -0
- package/src/tests/context-thresholds.test.ts +6 -0
- package/src/tests/feeds.test.ts +2 -8
- package/src/tests/footer.test.ts +1 -0
- package/src/tests/menu.test.ts +31 -2
- package/src/tests/nvidia-green.test.ts +51 -0
- package/src/tests/token-rate.test.ts +2 -1
- package/src/tests/working-status-embed.test.ts +12 -0
- package/src/tests/working-status.test.ts +209 -0
- package/src/tests/working.test.ts +239 -23
- package/src/token-rate.ts +1 -1
- package/src/types.ts +37 -5
- package/src/working-status-embed.ts +15 -0
- package/src/working-status.ts +125 -0
package/src/settings-menu.ts
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { makeBoxPainters, renderBoxRow } from "./box.js";
|
|
8
|
+
import { COMPACTION_FIT_PROBE } from "./compaction-embed.js";
|
|
8
9
|
import type { SegmentContextBuilder } from "./context.js";
|
|
9
10
|
import { buildStatusLine } from "./layout.js";
|
|
10
11
|
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
12
|
+
import { NvidiaGreenBorder, isSwitchyardProvider } from "./nvidia-green.js";
|
|
11
13
|
import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
|
|
12
14
|
import {
|
|
13
15
|
applySettings,
|
|
14
16
|
BORDER_STYLES,
|
|
17
|
+
bottomRightSegments,
|
|
15
18
|
DEFAULT_FEEDS,
|
|
16
19
|
DEFAULT_SEGMENTS,
|
|
17
20
|
FEED_FORMATS,
|
|
@@ -34,8 +37,10 @@ import type {
|
|
|
34
37
|
} from "./types.js";
|
|
35
38
|
|
|
36
39
|
const SYMBOL_LABELS: Record<SymbolPreset, string> = { nerd: "nerdfont", unicode: "unicode", ascii: "ascii" };
|
|
37
|
-
const
|
|
38
|
-
const
|
|
40
|
+
const SYMBOL_VALUES: readonly string[] = Object.values(SYMBOL_LABELS);
|
|
41
|
+
const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = Object.fromEntries(
|
|
42
|
+
(Object.entries(SYMBOL_LABELS) as [SymbolPreset, string][]).map(([preset, label]) => [label, preset]),
|
|
43
|
+
);
|
|
39
44
|
|
|
40
45
|
const SECTION_TITLES = [
|
|
41
46
|
"Top Left Segment Group",
|
|
@@ -68,48 +73,64 @@ const SEGMENT_ROWS: readonly {
|
|
|
68
73
|
{ id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
|
|
69
74
|
];
|
|
70
75
|
|
|
71
|
-
// Feed rows
|
|
76
|
+
// Feed rows carry a stable key (independent of on-screen position) so
|
|
77
|
+
// MenuComponent.rebuild's id-keyed value carry-over survives a removal.
|
|
72
78
|
const FEED_PREFIX = "feed";
|
|
73
|
-
const feedKey = (
|
|
79
|
+
const feedKey = (key: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${key}.${part}`;
|
|
74
80
|
const ADD_FEED_ID = `${FEED_PREFIX}.add`;
|
|
75
|
-
const removeFeedId = (
|
|
81
|
+
const removeFeedId = (key: number) => `${FEED_PREFIX}.${key}.remove`;
|
|
76
82
|
const FEED_ROW_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.`);
|
|
83
|
+
const REMOVE_FEED_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`);
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
interface FeedPair {
|
|
86
|
+
key: number;
|
|
87
|
+
feed: StatusLineFeed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pairsFromFeeds = (feeds: readonly StatusLineFeed[]): FeedPair[] => feeds.map((feed, key) => ({ key, feed }));
|
|
91
|
+
|
|
92
|
+
function feedSection(pairs: readonly FeedPair[]): MenuSection {
|
|
79
93
|
const items: MenuSection["items"] = [];
|
|
80
|
-
|
|
94
|
+
pairs.forEach(({ key, feed }, index) => {
|
|
81
95
|
const n = index + 1;
|
|
82
96
|
items.push(
|
|
83
|
-
{ id: feedKey(
|
|
84
|
-
{ id: feedKey(
|
|
85
|
-
{ id: feedKey(
|
|
86
|
-
{ id: feedKey(
|
|
87
|
-
{ id: removeFeedId(
|
|
97
|
+
{ id: feedKey(key, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
|
|
98
|
+
{ id: feedKey(key, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
|
|
99
|
+
{ id: feedKey(key, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
|
|
100
|
+
{ id: feedKey(key, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
|
|
101
|
+
{ id: removeFeedId(key), label: `${n}. remove this feed`, value: false, action: true },
|
|
88
102
|
);
|
|
89
|
-
}
|
|
103
|
+
});
|
|
90
104
|
items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
|
|
91
105
|
return { title: "Feeds", items };
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
/** Read the feed rows back out of the menu's flat value map. */
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
for (const
|
|
98
|
-
const match = FEED_ROW_RE.exec(
|
|
99
|
-
if (match)
|
|
108
|
+
/** Read the feed rows back out of the menu's flat value map, keyed by their stable id. */
|
|
109
|
+
function feedPairsFromValues(values: Record<string, MenuValue>): FeedPair[] {
|
|
110
|
+
const keys = new Set<number>();
|
|
111
|
+
for (const id of Object.keys(values)) {
|
|
112
|
+
const match = FEED_ROW_RE.exec(id);
|
|
113
|
+
if (match) keys.add(Number(match[1]));
|
|
100
114
|
}
|
|
101
|
-
return [...
|
|
115
|
+
return [...keys]
|
|
102
116
|
.sort((a, b) => a - b)
|
|
103
|
-
.map((
|
|
104
|
-
const rawFormat = String(values[feedKey(
|
|
117
|
+
.map((key): FeedPair => {
|
|
118
|
+
const rawFormat = String(values[feedKey(key, "format")] ?? "text");
|
|
105
119
|
return {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
key,
|
|
121
|
+
feed: {
|
|
122
|
+
customType: String(values[feedKey(key, "customType")] ?? ""),
|
|
123
|
+
field: String(values[feedKey(key, "field")] ?? ""),
|
|
124
|
+
prefix: String(values[feedKey(key, "prefix")] ?? ""),
|
|
125
|
+
format: FEED_FORMATS.includes(rawFormat as FeedFormat) ? (rawFormat as FeedFormat) : "text",
|
|
126
|
+
},
|
|
110
127
|
};
|
|
111
128
|
})
|
|
112
|
-
.filter(feed => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
|
|
129
|
+
.filter(({ feed }) => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
|
|
133
|
+
return feedPairsFromValues(values).map(({ feed }) => feed);
|
|
113
134
|
}
|
|
114
135
|
|
|
115
136
|
function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
@@ -141,11 +162,26 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
141
162
|
label: "Animate rainbow border",
|
|
142
163
|
value: settings.rainbowAnimation ?? true,
|
|
143
164
|
},
|
|
165
|
+
{
|
|
166
|
+
id: "nvidiaGreenBorder",
|
|
167
|
+
label: "NVIDIA-green when using Switchyard",
|
|
168
|
+
value: settings.nvidiaGreenBorder ?? true,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "nvidiaGreenAnimation",
|
|
172
|
+
label: "Animate Switchyard green border",
|
|
173
|
+
value: settings.nvidiaGreenAnimation ?? true,
|
|
174
|
+
},
|
|
144
175
|
{
|
|
145
176
|
id: "embedWorkingStatus",
|
|
146
|
-
label: "Embed
|
|
177
|
+
label: "Embed status spinners",
|
|
147
178
|
value: settings.embedWorkingStatus ?? false,
|
|
148
179
|
},
|
|
180
|
+
{
|
|
181
|
+
id: "embedCompactionProgress",
|
|
182
|
+
label: "Embed compaction progress",
|
|
183
|
+
value: settings.embedCompactionProgress ?? true,
|
|
184
|
+
},
|
|
149
185
|
],
|
|
150
186
|
};
|
|
151
187
|
return [
|
|
@@ -158,7 +194,7 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
158
194
|
value: seg[row.id],
|
|
159
195
|
})),
|
|
160
196
|
})),
|
|
161
|
-
feedSection(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS),
|
|
197
|
+
feedSection(pairsFromFeeds(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS)),
|
|
162
198
|
];
|
|
163
199
|
}
|
|
164
200
|
|
|
@@ -176,7 +212,10 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
176
212
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
177
213
|
rainbowBorder: values.rainbowBorder === true,
|
|
178
214
|
rainbowAnimation: values.rainbowAnimation === true,
|
|
215
|
+
nvidiaGreenBorder: values.nvidiaGreenBorder === true,
|
|
216
|
+
nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
|
|
179
217
|
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
218
|
+
embedCompactionProgress: values.embedCompactionProgress === true,
|
|
180
219
|
};
|
|
181
220
|
}
|
|
182
221
|
|
|
@@ -193,6 +232,8 @@ const CANNED_PERCENT = 42;
|
|
|
193
232
|
const CANNED_WINDOW = 200_000;
|
|
194
233
|
/** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
|
|
195
234
|
const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
|
|
235
|
+
/** pi 0.86's compaction spinner: the longest status the bar has to carry. */
|
|
236
|
+
const CANNED_COMPACTION = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
|
|
196
237
|
|
|
197
238
|
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
198
239
|
function cannedFeedData(
|
|
@@ -221,7 +262,7 @@ class StatusLinePreview {
|
|
|
221
262
|
render(
|
|
222
263
|
values: Record<string, MenuValue>,
|
|
223
264
|
elapsedMs: number,
|
|
224
|
-
|
|
265
|
+
activeItemId: string | undefined,
|
|
225
266
|
innerWidth: number,
|
|
226
267
|
): PreviewResult {
|
|
227
268
|
const effective = resolveEffectiveSettings(valuesToSettings(values));
|
|
@@ -229,8 +270,6 @@ class StatusLinePreview {
|
|
|
229
270
|
// 6-cell chrome budget for the bars themselves.
|
|
230
271
|
const boxWidth = Math.max(24, innerWidth - 1);
|
|
231
272
|
const barWidth = boxWidth - 6;
|
|
232
|
-
const rainbowOn = effective.rainbowBorder;
|
|
233
|
-
const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
|
|
234
273
|
|
|
235
274
|
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
236
275
|
// symbol preset for this synchronous render only; the live bar behind
|
|
@@ -245,6 +284,10 @@ class StatusLinePreview {
|
|
|
245
284
|
feeds: effective.segmentOptions.feeds.map(f => f.customType),
|
|
246
285
|
};
|
|
247
286
|
const base = this.#builder.build(barWidth, effective.segmentOptions, include, CANNED_HINT);
|
|
287
|
+
const greenOn = effective.nvidiaGreenBorder && isSwitchyardProvider(base.model?.provider);
|
|
288
|
+
const rainbowOn = !greenOn && effective.rainbowBorder;
|
|
289
|
+
const animationOn = greenOn ? effective.nvidiaGreenAnimation : rainbowOn && effective.rainbowAnimation;
|
|
290
|
+
const phase = animationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0;
|
|
248
291
|
const contextWindow = base.contextWindow || CANNED_WINDOW;
|
|
249
292
|
const ctx: SegmentContext = {
|
|
250
293
|
...base,
|
|
@@ -267,18 +310,23 @@ class StatusLinePreview {
|
|
|
267
310
|
const box = theme.getBox(effective.borderStyle);
|
|
268
311
|
// The preview box is three rows tall: top bar, side verticals, bottom bar.
|
|
269
312
|
const bottomIdx = 2;
|
|
313
|
+
const colorizer = greenOn ? new NvidiaGreenBorder(phase) : new RainbowBorder(phase);
|
|
270
314
|
const painters = makeBoxPainters({
|
|
271
|
-
rainbowOn,
|
|
272
|
-
|
|
315
|
+
colorizerOn: greenOn || rainbowOn,
|
|
316
|
+
colorizer,
|
|
273
317
|
box,
|
|
274
318
|
width: boxWidth,
|
|
275
319
|
bottomIdx,
|
|
276
320
|
flat: s => this.#uiTheme.fg("border", s),
|
|
277
321
|
});
|
|
278
322
|
const embedOn = effective.embedWorkingStatus;
|
|
323
|
+
// Focusing the embed row demos the message-style spinners pi 0.86
|
|
324
|
+
// routes through the same slot, including their ellipsis truncation.
|
|
325
|
+
const compactionDemo = activeItemId === "embedWorkingStatus";
|
|
326
|
+
const sample = compactionDemo ? CANNED_COMPACTION : CANNED_WORKING;
|
|
279
327
|
const top = buildStatusLine(
|
|
280
328
|
barWidth,
|
|
281
|
-
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border",
|
|
329
|
+
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", sample) } : ctx,
|
|
282
330
|
effective,
|
|
283
331
|
painters.gapColor,
|
|
284
332
|
{
|
|
@@ -286,15 +334,19 @@ class StatusLinePreview {
|
|
|
286
334
|
right: effective.rightSegments,
|
|
287
335
|
},
|
|
288
336
|
{ col: 3, row: 0 },
|
|
337
|
+
{ workingEllipsis: compactionDemo ? "…" : "" },
|
|
289
338
|
);
|
|
339
|
+
// Focusing the compaction row demos the bottom bar hosting
|
|
340
|
+
// pi-topping-compact's progress, while the setting is on.
|
|
341
|
+
const progressDemo = activeItemId === "embedCompactionProgress" && effective.embedCompactionProgress;
|
|
290
342
|
const bottom = buildStatusLine(
|
|
291
343
|
barWidth,
|
|
292
|
-
ctx,
|
|
344
|
+
progressDemo ? { ...ctx, compaction: COMPACTION_FIT_PROBE } : ctx,
|
|
293
345
|
effective,
|
|
294
346
|
painters.gapColor,
|
|
295
347
|
{
|
|
296
348
|
left: effective.bottomLeftSegments,
|
|
297
|
-
right: effective
|
|
349
|
+
right: bottomRightSegments(effective, progressDemo),
|
|
298
350
|
},
|
|
299
351
|
{ col: 3, row: bottomIdx },
|
|
300
352
|
);
|
|
@@ -306,8 +358,8 @@ class StatusLinePreview {
|
|
|
306
358
|
painters.paint(1, boxWidth - 1, box.vertical),
|
|
307
359
|
renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
|
|
308
360
|
],
|
|
309
|
-
// A static
|
|
310
|
-
nextRefreshInMs:
|
|
361
|
+
// A static colorizer stays at phase zero and needs no repaint timer.
|
|
362
|
+
nextRefreshInMs: animationOn ? RAINBOW_FRAME_MS : undefined,
|
|
311
363
|
};
|
|
312
364
|
} finally {
|
|
313
365
|
theme.setSymbolPreset(applied);
|
|
@@ -322,7 +374,7 @@ export function registerSettingsCommand(
|
|
|
322
374
|
onChange: () => void,
|
|
323
375
|
): void {
|
|
324
376
|
pi.registerCommand("topping-statusline-settings", {
|
|
325
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded
|
|
377
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, the embedded status spinners, and the embedded compaction progress",
|
|
326
378
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
327
379
|
if (ctx.mode !== "tui") {
|
|
328
380
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
|
@@ -335,17 +387,20 @@ export function registerSettingsCommand(
|
|
|
335
387
|
hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
|
|
336
388
|
preview: preview.render.bind(preview),
|
|
337
389
|
onAction: (id, values) => {
|
|
338
|
-
const
|
|
390
|
+
const pairs = feedPairsFromValues(values);
|
|
391
|
+
let nextPairs: FeedPair[];
|
|
339
392
|
if (id === ADD_FEED_ID) {
|
|
340
|
-
|
|
393
|
+
const key = Math.max(-1, ...pairs.map(p => p.key)) + 1;
|
|
394
|
+
nextPairs = [...pairs, { key, feed: { customType: "", field: "", prefix: "", format: "currency" } }];
|
|
341
395
|
} else {
|
|
342
|
-
const match =
|
|
396
|
+
const match = REMOVE_FEED_RE.exec(id);
|
|
343
397
|
if (!match) return undefined;
|
|
344
|
-
|
|
398
|
+
const removedKey = Number(match[1]);
|
|
399
|
+
nextPairs = pairs.filter(p => p.key !== removedKey);
|
|
345
400
|
}
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(
|
|
401
|
+
// The whole menu is regenerated so positional labels stay correct
|
|
402
|
+
// after an insert or removal; row ids are untouched by this.
|
|
403
|
+
return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(nextPairs)];
|
|
349
404
|
},
|
|
350
405
|
});
|
|
351
406
|
if (!result.applied) return;
|
package/src/settings.ts
CHANGED
|
@@ -93,7 +93,10 @@ function loadSettings(): StatusLineSettings {
|
|
|
93
93
|
if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
|
|
94
94
|
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
95
95
|
if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
|
|
96
|
+
if (typeof raw.nvidiaGreenBorder === "boolean") settings.nvidiaGreenBorder = raw.nvidiaGreenBorder;
|
|
97
|
+
if (typeof raw.nvidiaGreenAnimation === "boolean") settings.nvidiaGreenAnimation = raw.nvidiaGreenAnimation;
|
|
96
98
|
if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
|
|
99
|
+
if (typeof raw.embedCompactionProgress === "boolean") settings.embedCompactionProgress = raw.embedCompactionProgress;
|
|
97
100
|
return settings;
|
|
98
101
|
} catch {
|
|
99
102
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -156,7 +159,10 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
156
159
|
bottomRightSegments,
|
|
157
160
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
158
161
|
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
162
|
+
nvidiaGreenBorder: settings.nvidiaGreenBorder ?? true,
|
|
163
|
+
nvidiaGreenAnimation: settings.nvidiaGreenAnimation ?? true,
|
|
159
164
|
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
165
|
+
embedCompactionProgress: settings.embedCompactionProgress ?? true,
|
|
160
166
|
includes,
|
|
161
167
|
segmentOptions: {
|
|
162
168
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
@@ -167,14 +173,34 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
167
173
|
};
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
/** Segments that remain visible, unfaded, while a pi status spinner is embedded. */
|
|
177
|
+
export const STATUS_STABLE_SEGMENTS: ReadonlySet<StatusLineSegmentId> = new Set(["pi", "model"]);
|
|
178
|
+
|
|
170
179
|
/**
|
|
171
|
-
* The top-left group for one frame. While pi
|
|
172
|
-
*
|
|
173
|
-
* symbol and
|
|
180
|
+
* The top-left group for one frame. While a pi status spinner is embedded, it
|
|
181
|
+
* stands in for the path and repository segments but keeps the leading Pi
|
|
182
|
+
* symbol and configured model details visible.
|
|
174
183
|
*/
|
|
175
184
|
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
176
185
|
if (!working) return effective.leftSegments;
|
|
177
|
-
|
|
186
|
+
const segments = effective.leftSegments.filter((id) => STATUS_STABLE_SEGMENTS.has(id));
|
|
187
|
+
segments.splice(segments.indexOf("pi") + 1, 0, "working");
|
|
188
|
+
return segments;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Bottom-right segments a hosted compaction stands in for, and their stand-ins. */
|
|
192
|
+
export const COMPACTION_STAND_INS: Partial<Record<StatusLineSegmentId, StatusLineSegmentId>> = {
|
|
193
|
+
pi_stats: "compaction_info",
|
|
194
|
+
context_graph: "compaction_graph",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The bottom-right group for one frame. While pi-topping-compact's progress is
|
|
199
|
+
* hosted, its figures stand in for pi's stats and its bar for the context graph.
|
|
200
|
+
*/
|
|
201
|
+
export function bottomRightSegments(effective: EffectiveStatusLineSettings, compacting: boolean): StatusLineSegmentId[] {
|
|
202
|
+
if (!compacting) return effective.bottomRightSegments;
|
|
203
|
+
return effective.bottomRightSegments.map((id) => COMPACTION_STAND_INS[id] ?? id);
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
export interface SettingsState {
|
package/src/tests/box.test.ts
CHANGED
|
@@ -24,8 +24,8 @@ const ROUNDED: BoxGlyphs = {
|
|
|
24
24
|
|
|
25
25
|
const identityPainters = (boxWidth: number) =>
|
|
26
26
|
makeBoxPainters({
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
colorizerOn: false,
|
|
28
|
+
colorizer: new RainbowBorder(0),
|
|
29
29
|
box: ROUNDED,
|
|
30
30
|
width: boxWidth,
|
|
31
31
|
bottomIdx: 2,
|
|
@@ -71,8 +71,8 @@ test("a bar wider than the budget clamps pad to zero", () => {
|
|
|
71
71
|
|
|
72
72
|
test("non-rainbow painters wrap whole runs in one flat call", () => {
|
|
73
73
|
const painters = makeBoxPainters({
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
colorizerOn: false,
|
|
75
|
+
colorizer: new RainbowBorder(0),
|
|
76
76
|
box: ROUNDED,
|
|
77
77
|
width: 20,
|
|
78
78
|
bottomIdx: 2,
|
|
@@ -85,8 +85,8 @@ test("non-rainbow painters wrap whole runs in one flat call", () => {
|
|
|
85
85
|
|
|
86
86
|
test("rainbow coloring never changes the geometry", () => {
|
|
87
87
|
const painters = makeBoxPainters({
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
colorizerOn: true,
|
|
89
|
+
colorizer: new RainbowBorder(0),
|
|
90
90
|
box: ROUNDED,
|
|
91
91
|
width: 20,
|
|
92
92
|
bottomIdx: 2,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts for hosting pi-topping-compact's compaction progress: the broadcast
|
|
3
|
+
* parser, the preconditions for announcing that the bar hosts it, the
|
|
4
|
+
* bottom-right stand-ins, and the two segments that draw the bar and figures.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { hostsCompactionProgress, parseCompactionProgress } from "../compaction-embed.ts";
|
|
10
|
+
import { stripAnsi } from "../footer.ts";
|
|
11
|
+
import { buildStatusLine } from "../layout.ts";
|
|
12
|
+
import { formatCompactionInfo, formatCompactionTokens, SEGMENTS } from "../segments.ts";
|
|
13
|
+
import { bottomRightSegments, resolveEffectiveSettings } from "../settings.ts";
|
|
14
|
+
import { theme } from "../theme.ts";
|
|
15
|
+
import type { CompactionProgressView, SegmentContext } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
const noGap = (s: string) => s;
|
|
18
|
+
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
19
|
+
|
|
20
|
+
const BAR = `\x1b[38;2;114;241;184m${"▋".repeat(7)}\x1b[39m\x1b[2m${"░".repeat(13)}\x1b[22m\x1b[39m`;
|
|
21
|
+
const VIEW: CompactionProgressView = {
|
|
22
|
+
bar: BAR,
|
|
23
|
+
percent: 47,
|
|
24
|
+
tokensBefore: 86_000,
|
|
25
|
+
contextWindow: 131_072,
|
|
26
|
+
elapsedMs: 16_300,
|
|
27
|
+
};
|
|
28
|
+
const DOT = theme.sep.dot;
|
|
29
|
+
|
|
30
|
+
function ctxWith(compaction: CompactionProgressView | undefined): SegmentContext {
|
|
31
|
+
return {
|
|
32
|
+
options: EFFECTIVE.segmentOptions,
|
|
33
|
+
model: undefined,
|
|
34
|
+
thinkingLevel: "off",
|
|
35
|
+
cwd: "/tmp/pi-topping-statusline-tests",
|
|
36
|
+
sessionName: undefined,
|
|
37
|
+
contextPercent: 41,
|
|
38
|
+
contextTokens: 53_740,
|
|
39
|
+
contextWindow: 131_072,
|
|
40
|
+
git: { branch: null, status: null, pr: null },
|
|
41
|
+
worktree: null,
|
|
42
|
+
scrollHint: undefined,
|
|
43
|
+
piStats: "↑ 142K ↓ 16K R 4.2M",
|
|
44
|
+
tokenRate: undefined,
|
|
45
|
+
feedData: undefined,
|
|
46
|
+
compaction,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("parseCompactionProgress accepts an active broadcast and rejects everything else", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
parseCompactionProgress({ active: true, ...VIEW, phase: "summarizing", remainingPercent: 35 }),
|
|
53
|
+
VIEW,
|
|
54
|
+
"the rest of the snapshot is ignored",
|
|
55
|
+
);
|
|
56
|
+
assert.equal(parseCompactionProgress({ active: false }), undefined);
|
|
57
|
+
assert.equal(parseCompactionProgress({ ...VIEW, active: "true" }), undefined);
|
|
58
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, bar: 7 }), undefined);
|
|
59
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: undefined }), undefined);
|
|
60
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: Number.NaN }), undefined);
|
|
61
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, tokensBefore: "86000" }), undefined);
|
|
62
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, elapsedMs: "16.3" }), undefined);
|
|
63
|
+
assert.equal(parseCompactionProgress(undefined), undefined);
|
|
64
|
+
assert.equal(parseCompactionProgress(null), undefined);
|
|
65
|
+
assert.equal(parseCompactionProgress([{ active: true, ...VIEW }]), undefined);
|
|
66
|
+
assert.equal(parseCompactionProgress("active"), undefined);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("embedCompactionProgress resolves on by default and off when set", () => {
|
|
70
|
+
assert.equal(resolveEffectiveSettings({}).embedCompactionProgress, true);
|
|
71
|
+
assert.equal(resolveEffectiveSettings({ embedCompactionProgress: false }).embedCompactionProgress, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the bar hosts the progress only with the setting on and a stand-in slot shown", () => {
|
|
75
|
+
assert.equal(hostsCompactionProgress(EFFECTIVE), true);
|
|
76
|
+
assert.equal(hostsCompactionProgress(resolveEffectiveSettings({ embedCompactionProgress: false })), false);
|
|
77
|
+
const graphOnly = resolveEffectiveSettings({ segments: { piStats: false } });
|
|
78
|
+
assert.equal(hostsCompactionProgress(graphOnly), true, "the context graph alone hosts the bar");
|
|
79
|
+
const statsOnly = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } });
|
|
80
|
+
assert.equal(hostsCompactionProgress(statsOnly), true, "pi stats alone host the figures");
|
|
81
|
+
const nowhere = resolveEffectiveSettings({ segments: { piStats: false, contextBar: false, contextStats: false } });
|
|
82
|
+
assert.equal(hostsCompactionProgress(nowhere), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("bottomRightSegments swaps in the stand-ins in place while compacting", () => {
|
|
86
|
+
const effective = resolveEffectiveSettings({ segments: { feeds: true, tokenRate: true } });
|
|
87
|
+
assert.deepEqual(effective.bottomRightSegments, ["feeds", "token_rate", "pi_stats", "context_graph"]);
|
|
88
|
+
assert.deepEqual(bottomRightSegments(effective, true), [
|
|
89
|
+
"feeds",
|
|
90
|
+
"token_rate",
|
|
91
|
+
"compaction_info",
|
|
92
|
+
"compaction_graph",
|
|
93
|
+
]);
|
|
94
|
+
assert.equal(bottomRightSegments(effective, false), effective.bottomRightSegments);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("formatCompactionInfo reads how much is summarized and the time taken", () => {
|
|
98
|
+
assert.equal(formatCompactionInfo(VIEW), `47% summarized${DOT}16.3s`);
|
|
99
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 0, elapsedMs: 0 }), `0% summarized${DOT}0.0s`);
|
|
100
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 33.4, elapsedMs: 1_234_567 }), `33% summarized${DOT}1234.6s`);
|
|
101
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 140 }), `100% summarized${DOT}16.3s`, "clamped high");
|
|
102
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: -3, elapsedMs: -5 }), `0% summarized${DOT}0.0s`, "clamped low");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("formatCompactionTokens uses the context label's number style", () => {
|
|
106
|
+
assert.equal(formatCompactionTokens(VIEW), "86K/131K (66%)");
|
|
107
|
+
assert.equal(
|
|
108
|
+
formatCompactionTokens({ ...VIEW, tokensBefore: 1_250_000, contextWindow: 2_000_000 }),
|
|
109
|
+
"1.3M/2.0M (63%)",
|
|
110
|
+
);
|
|
111
|
+
assert.equal(formatCompactionTokens({ ...VIEW, contextWindow: 0 }), "86K/?", "no share of an unknown window");
|
|
112
|
+
assert.equal(formatCompactionTokens({ ...VIEW, tokensBefore: 200_000 }), "200K/131K (100%)", "clamped");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("compaction segments are invisible without a hosted compaction", () => {
|
|
116
|
+
assert.equal(SEGMENTS.compaction_info.render(ctxWith(undefined)).visible, false);
|
|
117
|
+
assert.equal(SEGMENTS.compaction_graph.render(ctxWith(undefined)).visible, false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("compaction_info renders the figures in dim text", () => {
|
|
121
|
+
assert.deepEqual(SEGMENTS.compaction_info.render(ctxWith(VIEW)), {
|
|
122
|
+
content: theme.fg("dim", formatCompactionInfo(VIEW)),
|
|
123
|
+
visible: true,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("compaction_graph passes the bar through verbatim and honors the graph toggles", () => {
|
|
128
|
+
const both = SEGMENTS.compaction_graph.render(ctxWith(VIEW));
|
|
129
|
+
assert.ok(both.visible && both.content.startsWith(BAR), "bar first, untouched");
|
|
130
|
+
assert.equal(stripAnsi(both.content), `${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`);
|
|
131
|
+
|
|
132
|
+
const barOnly = ctxWith(VIEW);
|
|
133
|
+
barOnly.options = resolveEffectiveSettings({ segments: { contextStats: false } }).segmentOptions;
|
|
134
|
+
assert.equal(SEGMENTS.compaction_graph.render(barOnly).content, BAR);
|
|
135
|
+
|
|
136
|
+
const statsOnly = ctxWith(VIEW);
|
|
137
|
+
statsOnly.options = resolveEffectiveSettings({ segments: { contextBar: false } }).segmentOptions;
|
|
138
|
+
assert.equal(stripAnsi(SEGMENTS.compaction_graph.render(statsOnly).content), "86K/131K (66%)");
|
|
139
|
+
|
|
140
|
+
const neither = ctxWith(VIEW);
|
|
141
|
+
neither.options = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } }).segmentOptions;
|
|
142
|
+
assert.equal(SEGMENTS.compaction_graph.render(neither).visible, false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the bottom bar swaps pi's stats and the context graph for the compaction while hosted", () => {
|
|
146
|
+
const groups = (compacting: boolean) => ({
|
|
147
|
+
left: EFFECTIVE.bottomLeftSegments,
|
|
148
|
+
right: bottomRightSegments(EFFECTIVE, compacting),
|
|
149
|
+
});
|
|
150
|
+
const idle = stripAnsi(buildStatusLine(100, ctxWith(undefined), EFFECTIVE, noGap, groups(false)));
|
|
151
|
+
assert.ok(idle.includes("↑ 142K"), "pi stats show while idle");
|
|
152
|
+
assert.ok(idle.includes("41.0%/131K"), "the context label shows while idle");
|
|
153
|
+
|
|
154
|
+
const bar = buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups(true));
|
|
155
|
+
const plain = stripAnsi(bar);
|
|
156
|
+
assert.equal(visibleWidth(bar), 100);
|
|
157
|
+
assert.ok(plain.includes(`47% summarized${DOT}16.3s`), "the compaction figures replace pi stats");
|
|
158
|
+
assert.ok(plain.includes(`${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`), "the compaction bar replaces the graph");
|
|
159
|
+
assert.ok(!plain.includes("↑ 142K"), "pi stats are gone");
|
|
160
|
+
assert.ok(!plain.includes("41.0%"), "the context label is gone");
|
|
161
|
+
assert.ok(bar.includes(BAR), "the bar's own colors survive the layout");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("onFit reports which segments actually survived overflow trimming, not just what was requested", () => {
|
|
165
|
+
const groups = { left: [], right: bottomRightSegments(EFFECTIVE, true) };
|
|
166
|
+
let wide: { left: readonly string[]; right: readonly string[] } | undefined;
|
|
167
|
+
buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
|
|
168
|
+
onFit: fit => {
|
|
169
|
+
wide = fit;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
assert.deepEqual(wide?.right, ["compaction_info", "compaction_graph"], "both stand-ins fit at full width");
|
|
173
|
+
|
|
174
|
+
let narrow: { left: readonly string[]; right: readonly string[] } | undefined;
|
|
175
|
+
buildStatusLine(20, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
|
|
176
|
+
onFit: fit => {
|
|
177
|
+
narrow = fit;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
assert.ok(
|
|
181
|
+
!narrow?.right.includes("compaction_info") && !narrow?.right.includes("compaction_graph"),
|
|
182
|
+
"too narrow for either stand-in, so a caller checking onFit knows not to claim it hosts the progress",
|
|
183
|
+
);
|
|
184
|
+
});
|