@underactive/pi-topping-statusline 0.2.0 → 0.2.1
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 +31 -15
- package/index.ts +74 -84
- package/package.json +4 -5
- package/src/box.ts +33 -11
- package/src/footer.ts +5 -2
- package/src/layout.ts +7 -5
- package/src/nvidia-green.ts +66 -0
- package/src/rainbow.ts +18 -4
- package/src/segments.ts +23 -5
- package/src/settings-menu.ts +37 -12
- package/src/settings.ts +7 -3
- package/src/tests/box.test.ts +6 -6
- package/src/tests/footer.test.ts +1 -0
- package/src/tests/nvidia-green.test.ts +51 -0
- package/src/tests/working-status.test.ts +158 -0
- package/src/tests/working.test.ts +23 -2
- package/src/types.ts +9 -3
- package/src/working-status.ts +98 -0
package/src/segments.ts
CHANGED
|
@@ -75,6 +75,24 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
|
|
|
75
75
|
return { scratch: false, relative: null };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
interface PathClassification {
|
|
79
|
+
scratch: boolean;
|
|
80
|
+
relative: string | null;
|
|
81
|
+
strippedDisplay: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let cachedCwd: string | undefined;
|
|
85
|
+
let cachedClassification: PathClassification | undefined;
|
|
86
|
+
|
|
87
|
+
/** cwd is effectively constant for a session, so cache the root scans instead of redoing them on every render (including repeated shrink passes). */
|
|
88
|
+
function classifyCwd(cwd: string): PathClassification {
|
|
89
|
+
if (cachedCwd === cwd && cachedClassification) return cachedClassification;
|
|
90
|
+
const { scratch, relative } = classifyProjectDir(cwd);
|
|
91
|
+
cachedClassification = { scratch, relative, strippedDisplay: stripDisplayRoot(cwd) };
|
|
92
|
+
cachedCwd = cwd;
|
|
93
|
+
return cachedClassification;
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
79
97
|
// Segment implementations
|
|
80
98
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -133,14 +151,14 @@ const pathSegment: StatusLineSegment = {
|
|
|
133
151
|
return { content: theme.fg("statusLinePath", content), visible: true };
|
|
134
152
|
}
|
|
135
153
|
|
|
136
|
-
const { scratch, relative } =
|
|
154
|
+
const { scratch, relative, strippedDisplay } = classifyCwd(ctx.cwd);
|
|
137
155
|
let pwd = ctx.cwd;
|
|
138
156
|
|
|
139
157
|
if (opts.stripWorkPrefix) {
|
|
140
158
|
if (scratch) {
|
|
141
159
|
if (relative) pwd = relative;
|
|
142
160
|
} else {
|
|
143
|
-
pwd =
|
|
161
|
+
pwd = strippedDisplay;
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
164
|
if (opts.abbreviate) {
|
|
@@ -296,9 +314,9 @@ const scrollHintSegment: StatusLineSegment = {
|
|
|
296
314
|
};
|
|
297
315
|
|
|
298
316
|
/**
|
|
299
|
-
* Pi's embedded
|
|
300
|
-
* host, so it is passed through verbatim. The layout
|
|
301
|
-
*
|
|
317
|
+
* Pi's embedded status spinner (spinner + message), already colored by the
|
|
318
|
+
* host, so it is passed through verbatim. The layout applies the caller's
|
|
319
|
+
* truncation style: a bare cut for working, ellipsis for message-style kinds.
|
|
302
320
|
*/
|
|
303
321
|
const workingSegment: StatusLineSegment = {
|
|
304
322
|
id: "working",
|
package/src/settings-menu.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { makeBoxPainters, renderBoxRow } from "./box.js";
|
|
|
8
8
|
import type { SegmentContextBuilder } from "./context.js";
|
|
9
9
|
import { buildStatusLine } from "./layout.js";
|
|
10
10
|
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
11
|
+
import { NvidiaGreenBorder, isSwitchyardProvider } from "./nvidia-green.js";
|
|
11
12
|
import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
|
|
12
13
|
import {
|
|
13
14
|
applySettings,
|
|
@@ -34,8 +35,10 @@ import type {
|
|
|
34
35
|
} from "./types.js";
|
|
35
36
|
|
|
36
37
|
const SYMBOL_LABELS: Record<SymbolPreset, string> = { nerd: "nerdfont", unicode: "unicode", ascii: "ascii" };
|
|
37
|
-
const
|
|
38
|
-
const
|
|
38
|
+
const SYMBOL_VALUES: readonly string[] = Object.values(SYMBOL_LABELS);
|
|
39
|
+
const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = Object.fromEntries(
|
|
40
|
+
(Object.entries(SYMBOL_LABELS) as [SymbolPreset, string][]).map(([preset, label]) => [label, preset]),
|
|
41
|
+
);
|
|
39
42
|
|
|
40
43
|
const SECTION_TITLES = [
|
|
41
44
|
"Top Left Segment Group",
|
|
@@ -141,9 +144,19 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
141
144
|
label: "Animate rainbow border",
|
|
142
145
|
value: settings.rainbowAnimation ?? true,
|
|
143
146
|
},
|
|
147
|
+
{
|
|
148
|
+
id: "nvidiaGreenBorder",
|
|
149
|
+
label: "NVIDIA-green when using Switchyard",
|
|
150
|
+
value: settings.nvidiaGreenBorder ?? true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "nvidiaGreenAnimation",
|
|
154
|
+
label: "Animate Switchyard green border",
|
|
155
|
+
value: settings.nvidiaGreenAnimation ?? true,
|
|
156
|
+
},
|
|
144
157
|
{
|
|
145
158
|
id: "embedWorkingStatus",
|
|
146
|
-
label: "Embed
|
|
159
|
+
label: "Embed status spinners",
|
|
147
160
|
value: settings.embedWorkingStatus ?? false,
|
|
148
161
|
},
|
|
149
162
|
],
|
|
@@ -176,6 +189,8 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
176
189
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
177
190
|
rainbowBorder: values.rainbowBorder === true,
|
|
178
191
|
rainbowAnimation: values.rainbowAnimation === true,
|
|
192
|
+
nvidiaGreenBorder: values.nvidiaGreenBorder === true,
|
|
193
|
+
nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
|
|
179
194
|
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
180
195
|
};
|
|
181
196
|
}
|
|
@@ -193,6 +208,8 @@ const CANNED_PERCENT = 42;
|
|
|
193
208
|
const CANNED_WINDOW = 200_000;
|
|
194
209
|
/** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
|
|
195
210
|
const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
|
|
211
|
+
/** pi 0.86's compaction spinner: the longest status the bar has to carry. */
|
|
212
|
+
const CANNED_COMPACTION = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
|
|
196
213
|
|
|
197
214
|
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
198
215
|
function cannedFeedData(
|
|
@@ -221,7 +238,7 @@ class StatusLinePreview {
|
|
|
221
238
|
render(
|
|
222
239
|
values: Record<string, MenuValue>,
|
|
223
240
|
elapsedMs: number,
|
|
224
|
-
|
|
241
|
+
activeItemId: string | undefined,
|
|
225
242
|
innerWidth: number,
|
|
226
243
|
): PreviewResult {
|
|
227
244
|
const effective = resolveEffectiveSettings(valuesToSettings(values));
|
|
@@ -229,8 +246,6 @@ class StatusLinePreview {
|
|
|
229
246
|
// 6-cell chrome budget for the bars themselves.
|
|
230
247
|
const boxWidth = Math.max(24, innerWidth - 1);
|
|
231
248
|
const barWidth = boxWidth - 6;
|
|
232
|
-
const rainbowOn = effective.rainbowBorder;
|
|
233
|
-
const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
|
|
234
249
|
|
|
235
250
|
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
236
251
|
// symbol preset for this synchronous render only; the live bar behind
|
|
@@ -245,6 +260,10 @@ class StatusLinePreview {
|
|
|
245
260
|
feeds: effective.segmentOptions.feeds.map(f => f.customType),
|
|
246
261
|
};
|
|
247
262
|
const base = this.#builder.build(barWidth, effective.segmentOptions, include, CANNED_HINT);
|
|
263
|
+
const greenOn = effective.nvidiaGreenBorder && isSwitchyardProvider(base.model?.provider);
|
|
264
|
+
const rainbowOn = !greenOn && effective.rainbowBorder;
|
|
265
|
+
const animationOn = greenOn ? effective.nvidiaGreenAnimation : rainbowOn && effective.rainbowAnimation;
|
|
266
|
+
const phase = animationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0;
|
|
248
267
|
const contextWindow = base.contextWindow || CANNED_WINDOW;
|
|
249
268
|
const ctx: SegmentContext = {
|
|
250
269
|
...base,
|
|
@@ -267,18 +286,23 @@ class StatusLinePreview {
|
|
|
267
286
|
const box = theme.getBox(effective.borderStyle);
|
|
268
287
|
// The preview box is three rows tall: top bar, side verticals, bottom bar.
|
|
269
288
|
const bottomIdx = 2;
|
|
289
|
+
const colorizer = greenOn ? new NvidiaGreenBorder(phase) : new RainbowBorder(phase);
|
|
270
290
|
const painters = makeBoxPainters({
|
|
271
|
-
rainbowOn,
|
|
272
|
-
|
|
291
|
+
colorizerOn: greenOn || rainbowOn,
|
|
292
|
+
colorizer,
|
|
273
293
|
box,
|
|
274
294
|
width: boxWidth,
|
|
275
295
|
bottomIdx,
|
|
276
296
|
flat: s => this.#uiTheme.fg("border", s),
|
|
277
297
|
});
|
|
278
298
|
const embedOn = effective.embedWorkingStatus;
|
|
299
|
+
// Focusing the embed row demos the message-style spinners pi 0.86
|
|
300
|
+
// routes through the same slot, including their ellipsis truncation.
|
|
301
|
+
const compactionDemo = activeItemId === "embedWorkingStatus";
|
|
302
|
+
const sample = compactionDemo ? CANNED_COMPACTION : CANNED_WORKING;
|
|
279
303
|
const top = buildStatusLine(
|
|
280
304
|
barWidth,
|
|
281
|
-
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border",
|
|
305
|
+
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", sample) } : ctx,
|
|
282
306
|
effective,
|
|
283
307
|
painters.gapColor,
|
|
284
308
|
{
|
|
@@ -286,6 +310,7 @@ class StatusLinePreview {
|
|
|
286
310
|
right: effective.rightSegments,
|
|
287
311
|
},
|
|
288
312
|
{ col: 3, row: 0 },
|
|
313
|
+
{ workingEllipsis: compactionDemo ? "…" : "" },
|
|
289
314
|
);
|
|
290
315
|
const bottom = buildStatusLine(
|
|
291
316
|
barWidth,
|
|
@@ -306,8 +331,8 @@ class StatusLinePreview {
|
|
|
306
331
|
painters.paint(1, boxWidth - 1, box.vertical),
|
|
307
332
|
renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
|
|
308
333
|
],
|
|
309
|
-
// A static
|
|
310
|
-
nextRefreshInMs:
|
|
334
|
+
// A static colorizer stays at phase zero and needs no repaint timer.
|
|
335
|
+
nextRefreshInMs: animationOn ? RAINBOW_FRAME_MS : undefined,
|
|
311
336
|
};
|
|
312
337
|
} finally {
|
|
313
338
|
theme.setSymbolPreset(applied);
|
|
@@ -322,7 +347,7 @@ export function registerSettingsCommand(
|
|
|
322
347
|
onChange: () => void,
|
|
323
348
|
): void {
|
|
324
349
|
pi.registerCommand("topping-statusline-settings", {
|
|
325
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded
|
|
350
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, and the embedded status spinners",
|
|
326
351
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
327
352
|
if (ctx.mode !== "tui") {
|
|
328
353
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
package/src/settings.ts
CHANGED
|
@@ -93,6 +93,8 @@ 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;
|
|
97
99
|
return settings;
|
|
98
100
|
} catch {
|
|
@@ -156,6 +158,8 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
156
158
|
bottomRightSegments,
|
|
157
159
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
158
160
|
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
161
|
+
nvidiaGreenBorder: settings.nvidiaGreenBorder ?? true,
|
|
162
|
+
nvidiaGreenAnimation: settings.nvidiaGreenAnimation ?? true,
|
|
159
163
|
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
160
164
|
includes,
|
|
161
165
|
segmentOptions: {
|
|
@@ -168,9 +172,9 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
/**
|
|
171
|
-
* The top-left group for one frame. While pi
|
|
172
|
-
*
|
|
173
|
-
*
|
|
175
|
+
* The top-left group for one frame. While a pi status spinner is embedded, it
|
|
176
|
+
* stands in for every left segment except the leading Pi symbol, so the symbol
|
|
177
|
+
* and its chevron never move when a status appears or clears.
|
|
174
178
|
*/
|
|
175
179
|
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
176
180
|
if (!working) return effective.leftSegments;
|
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,
|
package/src/tests/footer.test.ts
CHANGED
|
@@ -48,6 +48,7 @@ test("filterVisibleRows drops empty notifications but keeps visible rows", () =>
|
|
|
48
48
|
test("fullscreen footer slot can collapse and restores its minimum", () => {
|
|
49
49
|
const footer = { render: () => [] };
|
|
50
50
|
const footerContainer = { children: [footer] };
|
|
51
|
+
// minSize: 1 mirrors the footer dock shape used by hosts before pi 0.86.
|
|
51
52
|
const footerEntry = { component: footerContainer, minSize: 1 };
|
|
52
53
|
const dock = { entries: [{ component: {} }, footerEntry] };
|
|
53
54
|
const root = { entries: [{ component: {} }, { component: dock, minSize: 1 }] };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
NVIDIA_GREEN_DARK_HEX,
|
|
5
|
+
NVIDIA_GREEN_HEX,
|
|
6
|
+
NvidiaGreenBorder,
|
|
7
|
+
isSwitchyardProvider,
|
|
8
|
+
} from "../nvidia-green.ts";
|
|
9
|
+
import { resolveEffectiveSettings } from "../settings.ts";
|
|
10
|
+
import { detectColorMode, hexToFgAnsi } from "../theme.ts";
|
|
11
|
+
|
|
12
|
+
test("recognizes the Switchyard provider case-insensitively after trimming", () => {
|
|
13
|
+
assert.equal(isSwitchyardProvider("switchyard"), true);
|
|
14
|
+
assert.equal(isSwitchyardProvider(" Switchyard "), true);
|
|
15
|
+
assert.equal(isSwitchyardProvider(undefined), false);
|
|
16
|
+
assert.equal(isSwitchyardProvider(""), false);
|
|
17
|
+
assert.equal(isSwitchyardProvider("other"), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("switchyard green settings default to true and resolve explicit false", () => {
|
|
21
|
+
const defaults = resolveEffectiveSettings({});
|
|
22
|
+
assert.equal(defaults.nvidiaGreenBorder, true);
|
|
23
|
+
assert.equal(defaults.nvidiaGreenAnimation, true);
|
|
24
|
+
const off = resolveEffectiveSettings({ nvidiaGreenBorder: false, nvidiaGreenAnimation: false });
|
|
25
|
+
assert.equal(off.nvidiaGreenBorder, false);
|
|
26
|
+
assert.equal(off.nvidiaGreenAnimation, false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("colorChar wraps a glyph in a color escape and foreground reset", () => {
|
|
30
|
+
const border = new NvidiaGreenBorder();
|
|
31
|
+
assert.match(border.colorChar("─", 0, 28), /^\x1b\[38;(2;\d+;\d+;\d+|5;\d+)m─\x1b\[39m$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("phase zero has bright and dark opposite endpoints", () => {
|
|
35
|
+
const mode = detectColorMode();
|
|
36
|
+
const border = new NvidiaGreenBorder(0);
|
|
37
|
+
assert.equal(border.colorChar("─", 0, 28), `${hexToFgAnsi(NVIDIA_GREEN_HEX, mode)}─\x1b[39m`);
|
|
38
|
+
assert.equal(border.colorChar("─", 14, 28), `${hexToFgAnsi(NVIDIA_GREEN_DARK_HEX, mode)}─\x1b[39m`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("step by one full turn preserves the color", () => {
|
|
42
|
+
const stepped = new NvidiaGreenBorder(37);
|
|
43
|
+
const unchanged = new NvidiaGreenBorder(37);
|
|
44
|
+
stepped.step(360);
|
|
45
|
+
assert.equal(stepped.colorChar("─", 7, 28), unchanged.colorChar("─", 7, 28));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("opposite positions receive different shades", () => {
|
|
49
|
+
const border = new NvidiaGreenBorder();
|
|
50
|
+
assert.notEqual(border.colorChar("─", 0, 28), border.colorChar("─", 14, 28));
|
|
51
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { STATUS_HOLD_MS, StatusTransition, WORKING_FADE_MS } from "../working-status.ts";
|
|
4
|
+
|
|
5
|
+
test("a live status is returned verbatim without scheduling repaints", () => {
|
|
6
|
+
const transition = new StatusTransition();
|
|
7
|
+
|
|
8
|
+
assert.deepEqual(transition.resolve("⠙ Working", "working", 1_000), {
|
|
9
|
+
status: "⠙ Working",
|
|
10
|
+
kind: "working",
|
|
11
|
+
pending: false,
|
|
12
|
+
});
|
|
13
|
+
assert.equal(transition.pending(1_000), false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("clearing a working status holds it, cross-fades both halves, then settles", () => {
|
|
17
|
+
const transition = new StatusTransition();
|
|
18
|
+
transition.resolve("⠙ Working", "working", 1_000);
|
|
19
|
+
|
|
20
|
+
const clearedAt = 2_000;
|
|
21
|
+
const holdEndsAt = clearedAt + STATUS_HOLD_MS;
|
|
22
|
+
assert.deepEqual(transition.resolve(undefined, undefined, clearedAt), {
|
|
23
|
+
status: "⠙ Working",
|
|
24
|
+
kind: "working",
|
|
25
|
+
pending: true,
|
|
26
|
+
});
|
|
27
|
+
assert.deepEqual(transition.resolve(undefined, undefined, holdEndsAt - 1), {
|
|
28
|
+
status: "⠙ Working",
|
|
29
|
+
kind: "working",
|
|
30
|
+
pending: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const outgoingStart = transition.resolve(undefined, undefined, holdEndsAt);
|
|
34
|
+
const outgoingMiddle = transition.resolve(undefined, undefined, holdEndsAt + 150);
|
|
35
|
+
const outgoingEnd = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS / 2 - 1);
|
|
36
|
+
assert.equal(outgoingStart.status, "⠙ Working");
|
|
37
|
+
assert.equal(outgoingMiddle.status, "⠙ Working");
|
|
38
|
+
assert.equal(outgoingEnd.status, "⠙ Working");
|
|
39
|
+
assert.ok(
|
|
40
|
+
(outgoingStart.leftFade ?? -1) > (outgoingMiddle.leftFade ?? -1) &&
|
|
41
|
+
(outgoingMiddle.leftFade ?? -1) > (outgoingEnd.leftFade ?? -1),
|
|
42
|
+
"outgoing opacity decreases monotonically",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const incomingStart = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS / 2);
|
|
46
|
+
const incomingMiddle = transition.resolve(undefined, undefined, holdEndsAt + 550);
|
|
47
|
+
const incomingEnd = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS - 1);
|
|
48
|
+
assert.equal(incomingStart.status, undefined);
|
|
49
|
+
assert.equal(incomingMiddle.status, undefined);
|
|
50
|
+
assert.equal(incomingEnd.status, undefined);
|
|
51
|
+
assert.ok(
|
|
52
|
+
(incomingStart.leftFade ?? 2) < (incomingMiddle.leftFade ?? 2) &&
|
|
53
|
+
(incomingMiddle.leftFade ?? 2) < (incomingEnd.leftFade ?? 2),
|
|
54
|
+
"incoming opacity increases monotonically",
|
|
55
|
+
);
|
|
56
|
+
assert.equal(transition.pending(holdEndsAt + WORKING_FADE_MS - 1), true);
|
|
57
|
+
|
|
58
|
+
assert.deepEqual(transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS), {
|
|
59
|
+
status: undefined,
|
|
60
|
+
kind: undefined,
|
|
61
|
+
pending: false,
|
|
62
|
+
});
|
|
63
|
+
assert.equal(transition.pending(holdEndsAt + WORKING_FADE_MS), false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("clearing message-style statuses holds them and then cuts without a fade", () => {
|
|
67
|
+
for (const kind of ["compaction", "retry"] as const) {
|
|
68
|
+
const transition = new StatusTransition();
|
|
69
|
+
transition.resolve(`⠙ ${kind}`, kind, 100);
|
|
70
|
+
assert.deepEqual(transition.resolve(undefined, undefined, 200), {
|
|
71
|
+
status: `⠙ ${kind}`,
|
|
72
|
+
kind,
|
|
73
|
+
pending: true,
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(transition.resolve(undefined, undefined, 200 + STATUS_HOLD_MS - 1), {
|
|
76
|
+
status: `⠙ ${kind}`,
|
|
77
|
+
kind,
|
|
78
|
+
pending: true,
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(transition.resolve(undefined, undefined, 200 + STATUS_HOLD_MS), {
|
|
81
|
+
status: undefined,
|
|
82
|
+
kind: undefined,
|
|
83
|
+
pending: false,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a new status during the hold cuts in immediately and cancels the exit", () => {
|
|
89
|
+
const transition = new StatusTransition();
|
|
90
|
+
transition.resolve("⠙ Working", "working", 1_000);
|
|
91
|
+
transition.resolve(undefined, undefined, 1_100);
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(transition.resolve("⠹ Retrying", "retry", 1_200), {
|
|
94
|
+
status: "⠹ Retrying",
|
|
95
|
+
kind: "retry",
|
|
96
|
+
pending: false,
|
|
97
|
+
});
|
|
98
|
+
assert.equal(transition.pending(2_000), false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a new status during the fade cuts in immediately and cancels the exit", () => {
|
|
102
|
+
const transition = new StatusTransition();
|
|
103
|
+
transition.resolve("⠙ Working", "working", 0);
|
|
104
|
+
transition.resolve(undefined, undefined, 100);
|
|
105
|
+
const fading = transition.resolve(undefined, undefined, 300);
|
|
106
|
+
assert.equal(fading.status, "⠙ Working");
|
|
107
|
+
assert.equal(fading.pending, true);
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(transition.resolve("⠸ Compacting", "compaction", 301), {
|
|
110
|
+
status: "⠸ Compacting",
|
|
111
|
+
kind: "compaction",
|
|
112
|
+
pending: false,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(transition.pending(1_000), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a working-to-compaction handoff across the hold never yields an empty status", () => {
|
|
118
|
+
const transition = new StatusTransition();
|
|
119
|
+
const frames = [
|
|
120
|
+
transition.resolve("⠙ Working", "working", 0),
|
|
121
|
+
transition.resolve(undefined, undefined, 100),
|
|
122
|
+
transition.resolve(undefined, undefined, 100 + STATUS_HOLD_MS - 1),
|
|
123
|
+
transition.resolve("⠸ Compacting", "compaction", 100 + STATUS_HOLD_MS),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
assert.ok(frames.every(frame => frame.status !== undefined));
|
|
127
|
+
assert.equal(frames.at(-1)?.kind, "compaction");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("an undefined kind preserves the pre-0.86 working fade", () => {
|
|
131
|
+
const transition = new StatusTransition();
|
|
132
|
+
transition.resolve("⠙ Working", undefined, 0);
|
|
133
|
+
transition.resolve(undefined, undefined, 10);
|
|
134
|
+
|
|
135
|
+
const fadeStarts = transition.resolve(undefined, undefined, 10 + STATUS_HOLD_MS);
|
|
136
|
+
assert.equal(fadeStarts.status, "⠙ Working");
|
|
137
|
+
assert.equal(fadeStarts.leftFade, 1);
|
|
138
|
+
assert.equal(fadeStarts.pending, true);
|
|
139
|
+
assert.deepEqual(transition.resolve(undefined, undefined, 10 + STATUS_HOLD_MS + WORKING_FADE_MS), {
|
|
140
|
+
status: undefined,
|
|
141
|
+
kind: undefined,
|
|
142
|
+
pending: false,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("reset clears live and exiting state", () => {
|
|
147
|
+
const transition = new StatusTransition();
|
|
148
|
+
transition.resolve("⠙ Working", "working", 10);
|
|
149
|
+
transition.resolve(undefined, undefined, 20);
|
|
150
|
+
transition.reset();
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(transition.resolve(undefined, undefined, 21), {
|
|
153
|
+
status: undefined,
|
|
154
|
+
kind: undefined,
|
|
155
|
+
pending: false,
|
|
156
|
+
});
|
|
157
|
+
assert.equal(transition.pending(21), false);
|
|
158
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contracts for the opt-in embedded
|
|
2
|
+
* Contracts for the opt-in embedded status indicator: the `working` segment
|
|
3
3
|
* passes pi's pre-rendered status through verbatim, the top-left group swaps
|
|
4
4
|
* everything but the Pi symbol for it while a stream is live, and the layout
|
|
5
|
-
* truncates it
|
|
5
|
+
* truncates it before any other left segment is dropped. The caller chooses
|
|
6
|
+
* whether truncated status text uses an ellipsis.
|
|
6
7
|
*/
|
|
7
8
|
import assert from "node:assert/strict";
|
|
8
9
|
import test from "node:test";
|
|
@@ -88,6 +89,26 @@ test("narrow widths truncate the status without an ellipsis and keep the pi symb
|
|
|
88
89
|
assert.equal(visibleWidth(bar), 20);
|
|
89
90
|
});
|
|
90
91
|
|
|
92
|
+
test("workingEllipsis marks truncated prose without changing the bar width", () => {
|
|
93
|
+
const status = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
|
|
94
|
+
for (const width of [12, 16, 24, 40, 60]) {
|
|
95
|
+
const bar = buildStatusLine(
|
|
96
|
+
width,
|
|
97
|
+
ctxWith(status),
|
|
98
|
+
EFFECTIVE,
|
|
99
|
+
noGap,
|
|
100
|
+
{ left: ["pi", "working"], right: [] },
|
|
101
|
+
{ col: 0, row: 0 },
|
|
102
|
+
{ workingEllipsis: "…" },
|
|
103
|
+
);
|
|
104
|
+
const plain = stripAnsi(bar);
|
|
105
|
+
assert.ok(plain.includes(theme.icon.pi), `pi symbol survives at width ${width}`);
|
|
106
|
+
assert.ok(plain.includes("…"), `truncated status ends with an ellipsis at width ${width}`);
|
|
107
|
+
assert.ok(!plain.includes("cancel"), `status tail is cut at width ${width}`);
|
|
108
|
+
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
91
112
|
test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
92
113
|
const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
|
|
93
114
|
for (const width of [12, 16, 24, 40, 60]) {
|
package/src/types.ts
CHANGED
|
@@ -74,7 +74,11 @@ export interface StatusLineSettings {
|
|
|
74
74
|
rainbowBorder?: boolean;
|
|
75
75
|
/** Animate the rainbow border when it is visible. */
|
|
76
76
|
rainbowAnimation?: boolean;
|
|
77
|
-
/**
|
|
77
|
+
/** NVIDIA-green gradient border while the model provider is Switchyard. */
|
|
78
|
+
nvidiaGreenBorder?: boolean;
|
|
79
|
+
/** Animate the Switchyard green border when it is visible. */
|
|
80
|
+
nvidiaGreenAnimation?: boolean;
|
|
81
|
+
/** Render pi's status spinners in the top bar instead of their own row. */
|
|
78
82
|
embedWorkingStatus?: boolean;
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -108,6 +112,8 @@ export interface EffectiveStatusLineSettings {
|
|
|
108
112
|
segmentOptions: StatusLineSegmentOptions;
|
|
109
113
|
rainbowBorder: boolean;
|
|
110
114
|
rainbowAnimation: boolean;
|
|
115
|
+
nvidiaGreenBorder: boolean;
|
|
116
|
+
nvidiaGreenAnimation: boolean;
|
|
111
117
|
embedWorkingStatus: boolean;
|
|
112
118
|
includes: SegmentIncludes;
|
|
113
119
|
}
|
|
@@ -138,8 +144,8 @@ export interface SegmentContext {
|
|
|
138
144
|
/** Pi's own footer stats, when this frame needs them. */
|
|
139
145
|
piStats: string | undefined;
|
|
140
146
|
/**
|
|
141
|
-
* Pi's embedded
|
|
142
|
-
*
|
|
147
|
+
* Pi's embedded status spinner, pre-rendered and pre-colored by the host for
|
|
148
|
+
* this frame. Absent while no status indicator is active.
|
|
143
149
|
*/
|
|
144
150
|
workingStatus?: string;
|
|
145
151
|
/** Live tok/s display state, when this frame needs it. */
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
/** Briefly retain a cleared status so handoffs between host indicators do not flash. */
|
|
7
|
+
export const STATUS_HOLD_MS = 150;
|
|
8
|
+
/** Cross-fade budget when the embedded working status disappears: half out, half in. */
|
|
9
|
+
export const WORKING_FADE_MS = 750;
|
|
10
|
+
|
|
11
|
+
/** True when a status kind is a message-style indicator rather than the working spinner. */
|
|
12
|
+
export function isMessageKind(kind: StatusIndicatorKind | undefined): boolean {
|
|
13
|
+
return kind !== undefined && kind !== "working";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Which status the top-left group shows this frame and how far it has faded. */
|
|
17
|
+
export interface StatusFrame {
|
|
18
|
+
status: string | undefined;
|
|
19
|
+
leftFade?: number;
|
|
20
|
+
/** Kind of the status this frame shows, for truncation styling. */
|
|
21
|
+
kind: StatusIndicatorKind | undefined;
|
|
22
|
+
/** True while a hold or fade still needs repaints. */
|
|
23
|
+
pending: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StatusExit {
|
|
27
|
+
holdUntil: number;
|
|
28
|
+
fadeMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve host status-indicator handoffs into stable top-bar frames.
|
|
33
|
+
*
|
|
34
|
+
* pi 0.86 routes working, retry, compaction, and branch-summary indicators
|
|
35
|
+
* through the editor border and may clear the slot between them. A cleared
|
|
36
|
+
* status is held briefly to bridge that gap. Only the working indicator — the
|
|
37
|
+
* one that genuinely ends a stream — then cross-fades back to user segments.
|
|
38
|
+
*/
|
|
39
|
+
export class StatusTransition {
|
|
40
|
+
#liveShown = false;
|
|
41
|
+
#lastStatus: string | undefined;
|
|
42
|
+
#lastKind: StatusIndicatorKind | undefined;
|
|
43
|
+
#exit: StatusExit | undefined;
|
|
44
|
+
|
|
45
|
+
resolve(live: string | undefined, kind: StatusIndicatorKind | undefined, now: number): StatusFrame {
|
|
46
|
+
if (live !== undefined) {
|
|
47
|
+
this.#liveShown = true;
|
|
48
|
+
this.#lastStatus = live;
|
|
49
|
+
this.#lastKind = kind;
|
|
50
|
+
this.#exit = undefined;
|
|
51
|
+
return { status: live, kind, pending: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.#liveShown) {
|
|
55
|
+
this.#liveShown = false;
|
|
56
|
+
this.#exit = {
|
|
57
|
+
holdUntil: now + STATUS_HOLD_MS,
|
|
58
|
+
fadeMs: isMessageKind(this.#lastKind) ? 0 : WORKING_FADE_MS,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const exit = this.#exit;
|
|
63
|
+
if (!exit) return { status: undefined, kind: undefined, pending: false };
|
|
64
|
+
if (now < exit.holdUntil) {
|
|
65
|
+
return { status: this.#lastStatus, kind: this.#lastKind, pending: true };
|
|
66
|
+
}
|
|
67
|
+
const elapsed = now - exit.holdUntil;
|
|
68
|
+
if (elapsed >= exit.fadeMs) {
|
|
69
|
+
this.#clearExit();
|
|
70
|
+
return { status: undefined, kind: undefined, pending: false };
|
|
71
|
+
}
|
|
72
|
+
const half = exit.fadeMs / 2;
|
|
73
|
+
const outgoing = elapsed < half;
|
|
74
|
+
const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
|
|
75
|
+
return {
|
|
76
|
+
status: outgoing ? this.#lastStatus : undefined,
|
|
77
|
+
kind: outgoing ? this.#lastKind : undefined,
|
|
78
|
+
leftFade,
|
|
79
|
+
pending: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pending(now: number): boolean {
|
|
84
|
+
const exit = this.#exit;
|
|
85
|
+
return exit !== undefined && now < exit.holdUntil + exit.fadeMs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
reset(): void {
|
|
89
|
+
this.#liveShown = false;
|
|
90
|
+
this.#clearExit();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#clearExit(): void {
|
|
94
|
+
this.#lastStatus = undefined;
|
|
95
|
+
this.#lastKind = undefined;
|
|
96
|
+
this.#exit = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|