@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
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contracts for the opt-in embedded
|
|
3
|
-
* passes pi's pre-rendered status through verbatim, the top-left group
|
|
4
|
-
*
|
|
5
|
-
* truncates it
|
|
2
|
+
* Contracts for the opt-in embedded status indicator: the `working` segment
|
|
3
|
+
* passes pi's pre-rendered status through verbatim, the top-left group keeps
|
|
4
|
+
* the Pi symbol and configured model details while a stream is live, and the
|
|
5
|
+
* layout truncates it before any other left segment is dropped. The caller
|
|
6
|
+
* chooses whether truncated status text uses an ellipsis, and how far an
|
|
7
|
+
* appearing status has slid out ahead of the model details.
|
|
6
8
|
*/
|
|
7
9
|
import assert from "node:assert/strict";
|
|
8
10
|
import test from "node:test";
|
|
9
11
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { stripAnsi } from "../footer.ts";
|
|
10
13
|
import { buildStatusLine } from "../layout.ts";
|
|
11
14
|
import { SEGMENTS } from "../segments.ts";
|
|
15
|
+
import { getSeparator } from "../separators.ts";
|
|
12
16
|
import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
|
|
13
17
|
import { easeFade, theme } from "../theme.ts";
|
|
14
18
|
import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
|
|
15
19
|
|
|
16
|
-
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
17
20
|
const noGap = (s: string) => s;
|
|
18
21
|
|
|
19
22
|
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
@@ -48,15 +51,20 @@ test("working segment is invisible when idle", () => {
|
|
|
48
51
|
assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
|
|
49
52
|
});
|
|
50
53
|
|
|
51
|
-
test("topLeftSegments keeps pi
|
|
52
|
-
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
|
|
54
|
+
test("topLeftSegments keeps pi and model details around the working status", () => {
|
|
55
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working", "model"]);
|
|
53
56
|
assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
|
|
54
|
-
assert.ok(EFFECTIVE.leftSegments.
|
|
57
|
+
assert.ok(EFFECTIVE.leftSegments.includes("path"), "default path segment is suppressed while working");
|
|
55
58
|
});
|
|
56
59
|
|
|
57
|
-
test("topLeftSegments
|
|
60
|
+
test("topLeftSegments respects disabled pi and model details", () => {
|
|
58
61
|
const noPi = resolveEffectiveSettings({ segments: { pi: false } });
|
|
59
|
-
assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
|
|
62
|
+
assert.deepEqual(topLeftSegments(noPi, true), ["working", "model"]);
|
|
63
|
+
|
|
64
|
+
const noModelDetails = resolveEffectiveSettings({
|
|
65
|
+
segments: { model: false, provider: false, thinking: false },
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(topLeftSegments(noModelDetails, true), ["pi", "working"]);
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
@@ -64,35 +72,114 @@ test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
|
64
72
|
assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
|
|
65
73
|
});
|
|
66
74
|
|
|
67
|
-
test("top bar renders pi symbol,
|
|
68
|
-
const
|
|
69
|
-
|
|
75
|
+
test("top bar renders pi symbol, status, then model details", () => {
|
|
76
|
+
const ctx = ctxWith("⠙ Mulling 28 tps");
|
|
77
|
+
ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
|
|
78
|
+
ctx.thinkingLevel = "max";
|
|
79
|
+
const effective = resolveEffectiveSettings({
|
|
80
|
+
transparent: true,
|
|
81
|
+
segments: { model: true, provider: true, thinking: true },
|
|
82
|
+
});
|
|
83
|
+
ctx.options = effective.segmentOptions;
|
|
84
|
+
const bar = buildStatusLine(120, ctx, effective, noGap, {
|
|
85
|
+
left: topLeftSegments(effective, true),
|
|
70
86
|
right: [],
|
|
71
87
|
});
|
|
72
88
|
const plain = stripAnsi(bar);
|
|
73
89
|
const piAt = plain.indexOf(theme.icon.pi);
|
|
74
90
|
const statusAt = plain.indexOf("⠙ Mulling 28 tps");
|
|
91
|
+
const modelAt = plain.indexOf("GPT-5.6 Luna");
|
|
75
92
|
assert.ok(piAt >= 0, "pi symbol present");
|
|
76
93
|
assert.ok(statusAt > piAt, "status follows the pi symbol");
|
|
77
|
-
assert.
|
|
94
|
+
assert.ok(modelAt > statusAt, "model details follow the status");
|
|
95
|
+
assert.ok(plain.includes("openai-codex"), "provider remains visible");
|
|
96
|
+
assert.ok(plain.includes("max"), "thinking level remains visible");
|
|
97
|
+
assert.equal(visibleWidth(bar), 120);
|
|
78
98
|
});
|
|
79
99
|
|
|
80
|
-
test("narrow widths truncate the status
|
|
100
|
+
test("narrow widths truncate the status before dropping model details", () => {
|
|
81
101
|
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
82
|
-
const
|
|
102
|
+
const ctx = ctxWith(status);
|
|
103
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
104
|
+
const bar = buildStatusLine(24, ctx, EFFECTIVE, noGap, {
|
|
105
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
106
|
+
right: [],
|
|
107
|
+
});
|
|
83
108
|
const plain = stripAnsi(bar);
|
|
84
109
|
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
85
110
|
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
111
|
+
assert.ok(plain.includes("Luna"), "model details survive");
|
|
86
112
|
assert.ok(!plain.includes("…"), "no ellipsis");
|
|
87
113
|
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
88
|
-
assert.equal(visibleWidth(bar),
|
|
114
|
+
assert.equal(visibleWidth(bar), 24);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("opaque bars restore the group background after truncating the status", () => {
|
|
118
|
+
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
119
|
+
const ctx = ctxWith(status);
|
|
120
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
121
|
+
const opaque = resolveEffectiveSettings({ transparent: false });
|
|
122
|
+
ctx.options = opaque.segmentOptions;
|
|
123
|
+
const bar = buildStatusLine(24, ctx, opaque, noGap, {
|
|
124
|
+
left: topLeftSegments(opaque, true),
|
|
125
|
+
right: [],
|
|
126
|
+
});
|
|
127
|
+
const plain = stripAnsi(bar);
|
|
128
|
+
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
129
|
+
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
130
|
+
const reset = theme.getBgAnsi() + theme.getFgAnsi("text");
|
|
131
|
+
const resetAt = bar.indexOf(reset);
|
|
132
|
+
const modelAt = bar.indexOf("Luna");
|
|
133
|
+
assert.ok(resetAt >= 0, "group background and text color are re-asserted after the truncated status");
|
|
134
|
+
assert.ok(resetAt < modelAt, "the reset lands before the model text");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("overflow drops trailing model details before touching the live status", () => {
|
|
138
|
+
const status = "⠙ Working";
|
|
139
|
+
const ctx = ctxWith(status);
|
|
140
|
+
ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
|
|
141
|
+
ctx.thinkingLevel = "max";
|
|
142
|
+
const effective = resolveEffectiveSettings({
|
|
143
|
+
transparent: true,
|
|
144
|
+
segments: { model: true, provider: true, thinking: true },
|
|
145
|
+
});
|
|
146
|
+
ctx.options = effective.segmentOptions;
|
|
147
|
+
const bar = buildStatusLine(34, ctx, effective, noGap, {
|
|
148
|
+
left: topLeftSegments(effective, true),
|
|
149
|
+
right: [],
|
|
150
|
+
});
|
|
151
|
+
const plain = stripAnsi(bar);
|
|
152
|
+
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
153
|
+
assert.ok(plain.includes("⠙ Working"), "the spinner status survives untruncated");
|
|
154
|
+
assert.ok(!plain.includes("GPT-5.6"), "model details are dropped to protect the live status");
|
|
155
|
+
assert.equal(visibleWidth(bar), 34);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("workingEllipsis marks truncated prose without changing the bar width", () => {
|
|
159
|
+
const status = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
|
|
160
|
+
for (const width of [24, 40, 60]) {
|
|
161
|
+
const bar = buildStatusLine(
|
|
162
|
+
width,
|
|
163
|
+
ctxWith(status),
|
|
164
|
+
EFFECTIVE,
|
|
165
|
+
noGap,
|
|
166
|
+
{ left: topLeftSegments(EFFECTIVE, true), right: [] },
|
|
167
|
+
{ col: 0, row: 0 },
|
|
168
|
+
{ workingEllipsis: "…" },
|
|
169
|
+
);
|
|
170
|
+
const plain = stripAnsi(bar);
|
|
171
|
+
assert.ok(plain.includes(theme.icon.pi), `pi symbol survives at width ${width}`);
|
|
172
|
+
assert.ok(plain.includes("…"), `truncated status ends with an ellipsis at width ${width}`);
|
|
173
|
+
assert.ok(!plain.includes("cancel"), `status tail is cut at width ${width}`);
|
|
174
|
+
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
175
|
+
}
|
|
89
176
|
});
|
|
90
177
|
|
|
91
178
|
test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
92
179
|
const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
|
|
93
180
|
for (const width of [12, 16, 24, 40, 60]) {
|
|
94
181
|
const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
|
|
95
|
-
left:
|
|
182
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
96
183
|
right: [],
|
|
97
184
|
});
|
|
98
185
|
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
@@ -101,7 +188,10 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
|
101
188
|
});
|
|
102
189
|
|
|
103
190
|
test("the status drops entirely rather than the pi symbol when there is no room", () => {
|
|
104
|
-
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, {
|
|
191
|
+
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, {
|
|
192
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
193
|
+
right: [],
|
|
194
|
+
});
|
|
105
195
|
const plain = stripAnsi(bar);
|
|
106
196
|
assert.ok(plain.includes(theme.icon.pi));
|
|
107
197
|
assert.ok(!plain.includes("⠙"));
|
|
@@ -123,18 +213,144 @@ test("easeFade is monotonic and pinned at both ends", () => {
|
|
|
123
213
|
assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
|
|
124
214
|
});
|
|
125
215
|
|
|
126
|
-
test("leftFade recolors the
|
|
216
|
+
test("leftFade recolors the working status but not pi or model details", () => {
|
|
127
217
|
const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
|
|
128
|
-
const
|
|
218
|
+
const ctx = ctxWith(status);
|
|
219
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
220
|
+
const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = {
|
|
221
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
222
|
+
right: [],
|
|
223
|
+
};
|
|
129
224
|
const origin = { col: 0, row: 0 };
|
|
130
|
-
const build = (fade?: number) =>
|
|
131
|
-
buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
225
|
+
const build = (fade?: number) => buildStatusLine(60, ctx, EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
132
226
|
const solid = build();
|
|
133
227
|
const faded = build(0.2);
|
|
228
|
+
const renderedModel = SEGMENTS.model.render(ctx);
|
|
134
229
|
assert.equal(visibleWidth(faded), 60);
|
|
135
230
|
assert.equal(stripAnsi(faded), stripAnsi(solid));
|
|
136
231
|
assert.ok(!faded.includes("10;200;30"), "status color blended");
|
|
232
|
+
assert.ok(renderedModel.visible && faded.includes(renderedModel.content), "model styling untouched");
|
|
137
233
|
const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
|
|
138
234
|
assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
|
|
139
235
|
assert.equal(build(1), solid);
|
|
140
236
|
});
|
|
237
|
+
|
|
238
|
+
test("leftFade does not recolor the right group", () => {
|
|
239
|
+
const ctx = ctxWith("⠙ Working");
|
|
240
|
+
ctx.sessionName = "stable-session";
|
|
241
|
+
const renderedSession = SEGMENTS.session_name.render(ctx);
|
|
242
|
+
const bar = buildStatusLine(
|
|
243
|
+
80,
|
|
244
|
+
ctx,
|
|
245
|
+
EFFECTIVE,
|
|
246
|
+
noGap,
|
|
247
|
+
{ left: topLeftSegments(EFFECTIVE, true), right: ["session_name"] },
|
|
248
|
+
{ col: 0, row: 0 },
|
|
249
|
+
{ leftFade: 0.2 },
|
|
250
|
+
);
|
|
251
|
+
assert.ok(renderedSession.visible && bar.includes(renderedSession.content), "right group styling untouched");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/** A top bar mid-slide, with the status revealed by `reveal` and Luna as the model. */
|
|
255
|
+
function slidingBar(
|
|
256
|
+
status: string,
|
|
257
|
+
reveal: number | undefined,
|
|
258
|
+
{ width = 60, effective = EFFECTIVE, leftFade }: {
|
|
259
|
+
width?: number;
|
|
260
|
+
effective?: ReturnType<typeof resolveEffectiveSettings>;
|
|
261
|
+
leftFade?: number;
|
|
262
|
+
} = {},
|
|
263
|
+
): string {
|
|
264
|
+
const ctx = ctxWith(status);
|
|
265
|
+
ctx.options = effective.segmentOptions;
|
|
266
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
267
|
+
ctx.sessionName = "stable-session";
|
|
268
|
+
return buildStatusLine(
|
|
269
|
+
width,
|
|
270
|
+
ctx,
|
|
271
|
+
effective,
|
|
272
|
+
noGap,
|
|
273
|
+
{ left: topLeftSegments(effective, true), right: ["session_name"] },
|
|
274
|
+
{ col: 0, row: 0 },
|
|
275
|
+
{ leftFade, workingReveal: reveal },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
test("workingReveal slides the status out behind the pi symbol, pushing model details right", () => {
|
|
280
|
+
const settled = slidingBar("⠙ Working...", undefined);
|
|
281
|
+
const piAt = stripAnsi(settled).indexOf(theme.icon.pi);
|
|
282
|
+
assert.equal(slidingBar("⠙ Working...", 1), settled);
|
|
283
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0)).includes("⠙"), "nothing has emerged at the start");
|
|
284
|
+
|
|
285
|
+
let modelAt = -1;
|
|
286
|
+
for (let step = 0; step <= 30; step++) {
|
|
287
|
+
const bar = slidingBar("⠙ Working...", step / 30);
|
|
288
|
+
const plain = stripAnsi(bar);
|
|
289
|
+
assert.equal(visibleWidth(bar), 60, `width at step ${step}`);
|
|
290
|
+
assert.equal(plain.indexOf(theme.icon.pi), piAt, `pi symbol stays put at step ${step}`);
|
|
291
|
+
const at = plain.indexOf("Luna");
|
|
292
|
+
assert.ok(at >= modelAt, `model details never move left at step ${step}`);
|
|
293
|
+
modelAt = at;
|
|
294
|
+
}
|
|
295
|
+
assert.equal(modelAt, stripAnsi(settled).indexOf("Luna"), "model details end where they settle");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the status's tail emerges first, led by its trailing separator", () => {
|
|
299
|
+
const status = "⠙ Working...";
|
|
300
|
+
const sep = getSeparator(EFFECTIVE.separator).left;
|
|
301
|
+
const unitWidth = visibleWidth(status) + visibleWidth(sep) + 2;
|
|
302
|
+
const frame = (shown: number) => stripAnsi(slidingBar(status, shown / unitWidth));
|
|
303
|
+
const model = `${theme.icon.model} Luna`;
|
|
304
|
+
assert.ok(frame(2).includes(`${theme.icon.pi} ${sep} ${sep} ${model}`), "separator first");
|
|
305
|
+
assert.ok(frame(7).includes(`${theme.icon.pi} ${sep} g... ${sep} ${model}`), "then the status's tail");
|
|
306
|
+
assert.ok(!frame(7).includes("Workin"), "the head is still hidden");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("without model details the status slides out alone after the pi separator", () => {
|
|
310
|
+
const effective = resolveEffectiveSettings({
|
|
311
|
+
transparent: true,
|
|
312
|
+
segments: { model: false, provider: false, thinking: false },
|
|
313
|
+
});
|
|
314
|
+
const sep = getSeparator(effective.separator).left;
|
|
315
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0, { effective })).includes(sep), "no separator yet");
|
|
316
|
+
const plain = stripAnsi(slidingBar("⠙ Working...", 4 / 12, { effective }));
|
|
317
|
+
assert.ok(plain.includes(`${theme.icon.pi} ${sep} g...`), "tail follows the pi separator");
|
|
318
|
+
assert.ok(!plain.includes("Workin"), "the head is still hidden");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("the slide keeps the overflow decided for the fully revealed status", () => {
|
|
322
|
+
// At 40 cells the settled status leaves no room for the session name, even
|
|
323
|
+
// though a partly revealed one would.
|
|
324
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", undefined, { width: 40 })).includes("stable-session"));
|
|
325
|
+
for (let step = 0; step <= 20; step++) {
|
|
326
|
+
const bar = slidingBar("⠙ Working...", step / 20, { width: 40 });
|
|
327
|
+
assert.ok(!stripAnsi(bar).includes("stable-session"), `right group stays dropped at step ${step}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("sliding frames keep the bar width with opaque caps, wide glyphs, and truncation", () => {
|
|
332
|
+
const opaque = resolveEffectiveSettings({ transparent: false });
|
|
333
|
+
const statuses = ["⠙ Working...", "🌑 Working", `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(10)}\x1b[39m`];
|
|
334
|
+
for (const effective of [EFFECTIVE, opaque]) {
|
|
335
|
+
for (const status of statuses) {
|
|
336
|
+
for (const width of [24, 60]) {
|
|
337
|
+
for (let step = 0; step <= 20; step++) {
|
|
338
|
+
const bar = slidingBar(status, step / 20, { width, effective });
|
|
339
|
+
assert.equal(visibleWidth(bar), width, `${JSON.stringify(status)} at width ${width}, step ${step}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a status fading mid-slide blends its emerged tail but not model details", () => {
|
|
347
|
+
const status = "\x1b[38;2;10;200;30m⠙ Working...\x1b[39m";
|
|
348
|
+
const bar = slidingBar(status, 0.5, { leftFade: 0.2 });
|
|
349
|
+
const ctx = ctxWith(status);
|
|
350
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
351
|
+
const renderedModel = SEGMENTS.model.render(ctx);
|
|
352
|
+
assert.ok(stripAnsi(bar).includes("..."), "part of the status has emerged");
|
|
353
|
+
assert.ok(!bar.includes("10;200;30"), "emerged status blended");
|
|
354
|
+
assert.ok(renderedModel.visible && bar.includes(renderedModel.content), "model styling untouched");
|
|
355
|
+
assert.equal(visibleWidth(bar), 60);
|
|
356
|
+
});
|
package/src/token-rate.ts
CHANGED
|
@@ -29,7 +29,7 @@ const EMA_ALPHA = 0.4;
|
|
|
29
29
|
class StreamingWordCounter {
|
|
30
30
|
#inWordByStream = new Map<string, boolean>();
|
|
31
31
|
|
|
32
|
-
count(text: string, stream
|
|
32
|
+
count(text: string, stream: string): number {
|
|
33
33
|
let inWord = this.#inWordByStream.get(stream) ?? false;
|
|
34
34
|
let count = 0;
|
|
35
35
|
for (let i = 0; i < text.length; i++) {
|
package/src/types.ts
CHANGED
|
@@ -19,7 +19,9 @@ export type StatusLineSegmentId =
|
|
|
19
19
|
| "feeds"
|
|
20
20
|
| "context_graph"
|
|
21
21
|
| "scroll_hint"
|
|
22
|
-
| "working"
|
|
22
|
+
| "working"
|
|
23
|
+
| "compaction_info"
|
|
24
|
+
| "compaction_graph";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* A feed the statusline subscribes to: custom session entries published by
|
|
@@ -74,8 +76,14 @@ export interface StatusLineSettings {
|
|
|
74
76
|
rainbowBorder?: boolean;
|
|
75
77
|
/** Animate the rainbow border when it is visible. */
|
|
76
78
|
rainbowAnimation?: boolean;
|
|
77
|
-
/**
|
|
79
|
+
/** NVIDIA-green gradient border while the model provider is Switchyard. */
|
|
80
|
+
nvidiaGreenBorder?: boolean;
|
|
81
|
+
/** Animate the Switchyard green border when it is visible. */
|
|
82
|
+
nvidiaGreenAnimation?: boolean;
|
|
83
|
+
/** Render pi's status spinners in the top bar instead of their own row. */
|
|
78
84
|
embedWorkingStatus?: boolean;
|
|
85
|
+
/** Host pi-topping-compact's compaction progress in the bottom bar while a compaction runs. */
|
|
86
|
+
embedCompactionProgress?: boolean;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
/** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
|
|
@@ -108,7 +116,10 @@ export interface EffectiveStatusLineSettings {
|
|
|
108
116
|
segmentOptions: StatusLineSegmentOptions;
|
|
109
117
|
rainbowBorder: boolean;
|
|
110
118
|
rainbowAnimation: boolean;
|
|
119
|
+
nvidiaGreenBorder: boolean;
|
|
120
|
+
nvidiaGreenAnimation: boolean;
|
|
111
121
|
embedWorkingStatus: boolean;
|
|
122
|
+
embedCompactionProgress: boolean;
|
|
112
123
|
includes: SegmentIncludes;
|
|
113
124
|
}
|
|
114
125
|
|
|
@@ -122,10 +133,15 @@ export interface SegmentContext {
|
|
|
122
133
|
thinkingLevel: string;
|
|
123
134
|
cwd: string;
|
|
124
135
|
sessionName: string | undefined;
|
|
125
|
-
/** Context usage percent, or null when unknown (
|
|
136
|
+
/** Context usage percent, or null when unknown (no model, or a session pi cannot size). */
|
|
126
137
|
contextPercent: number | null;
|
|
127
138
|
contextTokens: number;
|
|
128
139
|
contextWindow: number;
|
|
140
|
+
/**
|
|
141
|
+
* True while contextPercent and contextTokens are pi's estimate of a freshly
|
|
142
|
+
* compacted context, shown until pi measures the next response.
|
|
143
|
+
*/
|
|
144
|
+
contextEstimated?: boolean;
|
|
129
145
|
git: {
|
|
130
146
|
branch: string | null;
|
|
131
147
|
status: { staged: number; unstaged: number; untracked: number } | null;
|
|
@@ -138,10 +154,15 @@ export interface SegmentContext {
|
|
|
138
154
|
/** Pi's own footer stats, when this frame needs them. */
|
|
139
155
|
piStats: string | undefined;
|
|
140
156
|
/**
|
|
141
|
-
* Pi's embedded
|
|
142
|
-
*
|
|
157
|
+
* Pi's embedded status spinner, pre-rendered and pre-colored by the host for
|
|
158
|
+
* this frame. Absent while no status indicator is active.
|
|
143
159
|
*/
|
|
144
160
|
workingStatus?: string;
|
|
161
|
+
/**
|
|
162
|
+
* pi-topping-compact's live compaction progress, while its bar and figures
|
|
163
|
+
* stand in for the context graph and pi's stats in the bottom-right group.
|
|
164
|
+
*/
|
|
165
|
+
compaction?: CompactionProgressView;
|
|
145
166
|
/** Live tok/s display state, when this frame needs it. */
|
|
146
167
|
tokenRate: TokenRateDisplay | undefined;
|
|
147
168
|
/**
|
|
@@ -158,6 +179,17 @@ export interface SegmentContext {
|
|
|
158
179
|
feedDisplayState?: Record<string, FeedDisplayState>;
|
|
159
180
|
}
|
|
160
181
|
|
|
182
|
+
/** One broadcast from pi-topping-compact: its bar, pre-rendered, plus the snapshot it was drawn from. */
|
|
183
|
+
export interface CompactionProgressView {
|
|
184
|
+
/** The 20-cell bar, pre-rendered and pre-colored by pi-topping-compact with fg-only resets. */
|
|
185
|
+
bar: string;
|
|
186
|
+
/** How much of the context has been summarized so far, 0..100. */
|
|
187
|
+
percent: number;
|
|
188
|
+
tokensBefore: number;
|
|
189
|
+
contextWindow: number;
|
|
190
|
+
elapsedMs: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
export type FeedPhase = "active" | "fading" | "hidden";
|
|
162
194
|
|
|
163
195
|
export interface FeedDisplayState {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event-bus channel on which this extension announces, as `{ embedded: boolean }`,
|
|
5
|
+
* whether its bar hosts Pi's working status. pi-topping slides its loader's response
|
|
6
|
+
* model out only while it does. Shared with pi-topping.
|
|
7
|
+
*/
|
|
8
|
+
export const WORKING_STATUS_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
|
|
9
|
+
|
|
10
|
+
type EmbeddingEditor = Partial<Pick<CustomEditor, "embedWorkingStatus" | "setWorkingStatusIndicator">>;
|
|
11
|
+
|
|
12
|
+
/** Whether an editor this extension built draws the host's working status in its bar. */
|
|
13
|
+
export function hostsWorkingStatus(editor: EmbeddingEditor | undefined): boolean {
|
|
14
|
+
return editor?.embedWorkingStatus === true && typeof editor.setWorkingStatusIndicator === "function";
|
|
15
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { easeFade } from "./theme.js";
|
|
3
|
+
|
|
4
|
+
export type StatusIndicatorKind = NonNullable<Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]>["kind"];
|
|
5
|
+
|
|
6
|
+
/** Slide-out time for a status that appears while the top-left group shows none. */
|
|
7
|
+
export const STATUS_SLIDE_MS = 300;
|
|
8
|
+
/** Briefly retain a cleared status so handoffs between host indicators do not flash. */
|
|
9
|
+
export const STATUS_HOLD_MS = 150;
|
|
10
|
+
/** Cross-fade budget when the embedded working status disappears: half out, half in. */
|
|
11
|
+
export const WORKING_FADE_MS = 750;
|
|
12
|
+
|
|
13
|
+
/** True when a status kind is a message-style indicator rather than the working spinner. */
|
|
14
|
+
export function isMessageKind(kind: StatusIndicatorKind | undefined): boolean {
|
|
15
|
+
return kind !== undefined && kind !== "working";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Which status the top-left group shows this frame and how far it has slid out or faded. */
|
|
19
|
+
export interface StatusFrame {
|
|
20
|
+
status: string | undefined;
|
|
21
|
+
leftFade?: number;
|
|
22
|
+
/** How far the status has slid out (0..1); absent once it is fully out. */
|
|
23
|
+
reveal?: number;
|
|
24
|
+
/** Kind of the status this frame shows, for truncation styling. */
|
|
25
|
+
kind: StatusIndicatorKind | undefined;
|
|
26
|
+
/** True while a slide, hold, or fade still needs repaints. */
|
|
27
|
+
pending: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StatusExit {
|
|
31
|
+
holdUntil: number;
|
|
32
|
+
fadeMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve host status-indicator handoffs into stable top-bar frames.
|
|
37
|
+
*
|
|
38
|
+
* A status that appears while the top-left group shows none slides out over
|
|
39
|
+
* STATUS_SLIDE_MS. pi 0.86 routes working, retry, compaction, and
|
|
40
|
+
* branch-summary indicators through the editor border and may clear the slot
|
|
41
|
+
* between them. A cleared status is held briefly to bridge that gap, and a
|
|
42
|
+
* status arriving while the previous one is still drawn takes its place
|
|
43
|
+
* without sliding again. Only the working indicator — the one that genuinely
|
|
44
|
+
* ends a stream — then cross-fades back to user segments.
|
|
45
|
+
*/
|
|
46
|
+
export class StatusTransition {
|
|
47
|
+
#liveShown = false;
|
|
48
|
+
#lastStatus: string | undefined;
|
|
49
|
+
#lastKind: StatusIndicatorKind | undefined;
|
|
50
|
+
#exit: StatusExit | undefined;
|
|
51
|
+
#slideFrom: number | undefined;
|
|
52
|
+
|
|
53
|
+
resolve(live: string | undefined, kind: StatusIndicatorKind | undefined, now: number): StatusFrame {
|
|
54
|
+
if (live !== undefined) {
|
|
55
|
+
if (!this.#liveShown && !this.#exitDrawsStatus(now)) this.#slideFrom = now;
|
|
56
|
+
this.#liveShown = true;
|
|
57
|
+
this.#lastStatus = live;
|
|
58
|
+
this.#lastKind = kind;
|
|
59
|
+
this.#exit = undefined;
|
|
60
|
+
return this.#withSlide({ status: live, kind, pending: false }, now);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.#liveShown) {
|
|
64
|
+
this.#liveShown = false;
|
|
65
|
+
this.#exit = {
|
|
66
|
+
holdUntil: now + STATUS_HOLD_MS,
|
|
67
|
+
fadeMs: isMessageKind(this.#lastKind) ? 0 : WORKING_FADE_MS,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const exit = this.#exit;
|
|
72
|
+
if (!exit) return { status: undefined, kind: undefined, pending: false };
|
|
73
|
+
if (now < exit.holdUntil) {
|
|
74
|
+
return this.#withSlide({ status: this.#lastStatus, kind: this.#lastKind, pending: true }, now);
|
|
75
|
+
}
|
|
76
|
+
const elapsed = now - exit.holdUntil;
|
|
77
|
+
if (elapsed >= exit.fadeMs) {
|
|
78
|
+
this.#clearExit();
|
|
79
|
+
return { status: undefined, kind: undefined, pending: false };
|
|
80
|
+
}
|
|
81
|
+
const half = exit.fadeMs / 2;
|
|
82
|
+
const outgoing = elapsed < half;
|
|
83
|
+
const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
|
|
84
|
+
if (!outgoing) return { status: undefined, kind: undefined, leftFade, pending: true };
|
|
85
|
+
return this.#withSlide({ status: this.#lastStatus, kind: this.#lastKind, leftFade, pending: true }, now);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pending(now: number): boolean {
|
|
89
|
+
const exit = this.#exit;
|
|
90
|
+
if (exit) return now < exit.holdUntil + exit.fadeMs;
|
|
91
|
+
return this.#slideFrom !== undefined && now < this.#slideFrom + STATUS_SLIDE_MS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
reset(): void {
|
|
95
|
+
this.#liveShown = false;
|
|
96
|
+
this.#clearExit();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A cleared status is still drawn through its hold and the fade's outgoing half. */
|
|
100
|
+
#exitDrawsStatus(now: number): boolean {
|
|
101
|
+
const exit = this.#exit;
|
|
102
|
+
return exit !== undefined && now < exit.holdUntil + exit.fadeMs / 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Add slide-out progress to a frame that draws a status. A status cleared
|
|
107
|
+
* mid-slide keeps sliding through its exit rather than snapping to full width.
|
|
108
|
+
*/
|
|
109
|
+
#withSlide(frame: StatusFrame, now: number): StatusFrame {
|
|
110
|
+
if (this.#slideFrom === undefined) return frame;
|
|
111
|
+
const progress = (now - this.#slideFrom) / STATUS_SLIDE_MS;
|
|
112
|
+
if (progress >= 1) {
|
|
113
|
+
this.#slideFrom = undefined;
|
|
114
|
+
return frame;
|
|
115
|
+
}
|
|
116
|
+
return { ...frame, reveal: easeFade(progress), pending: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#clearExit(): void {
|
|
120
|
+
this.#lastStatus = undefined;
|
|
121
|
+
this.#lastKind = undefined;
|
|
122
|
+
this.#exit = undefined;
|
|
123
|
+
this.#slideFrom = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|