@underactive/pi-topping-statusline 0.1.4 → 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 +32 -4
- package/index.ts +127 -32
- package/package.json +4 -4
- package/src/box.ts +33 -11
- package/src/context.ts +1 -1
- package/src/footer.ts +5 -2
- package/src/layout.ts +41 -8
- package/src/nvidia-green.ts +66 -0
- package/src/rainbow.ts +18 -4
- package/src/segments.ts +35 -2
- package/src/settings-menu.ts +47 -12
- package/src/settings.ts +16 -0
- 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 +161 -0
- package/src/theme.ts +42 -1
- package/src/types.ts +16 -1
- package/src/working-status.ts +98 -0
package/src/rainbow.ts
CHANGED
|
@@ -24,6 +24,9 @@ export const RAINBOW_DEG_PER_FRAME = (360 * RAINBOW_FRAME_MS) / RAINBOW_CYCLE_MS
|
|
|
24
24
|
const SATURATION = 1;
|
|
25
25
|
const LIGHTNESS = 0.62;
|
|
26
26
|
|
|
27
|
+
/** Normalize a degree value into the [0, 360) range. */
|
|
28
|
+
export const wrapDeg = (deg: number): number => ((deg % 360) + 360) % 360;
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Number of border cells in the box (rows 0..bottomIdx, cols 0..width-1):
|
|
29
32
|
* two horizontal edges of `width` cells plus two vertical edges of
|
|
@@ -64,27 +67,38 @@ export function rgbToHex([r, g, b]: [number, number, number]): string {
|
|
|
64
67
|
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
export interface BorderColorizer {
|
|
71
|
+
colorChar(ch: string, perimeterPos: number, perimeter: number): string;
|
|
72
|
+
/** ANSI foreground-color escape for a perimeter position, with no glyph or reset. */
|
|
73
|
+
prefix(perimeterPos: number, perimeter: number): string;
|
|
74
|
+
step(deltaDeg: number): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
/**
|
|
68
78
|
* Colors box glyphs with a hue that travels around the border perimeter as
|
|
69
79
|
* the phase advances — the spectrum appears to flow around the box.
|
|
70
80
|
*/
|
|
71
|
-
export class RainbowBorder {
|
|
81
|
+
export class RainbowBorder implements BorderColorizer {
|
|
72
82
|
#phaseDeg: number;
|
|
73
83
|
readonly #mode: ColorMode;
|
|
74
84
|
readonly #ansiByHue: string[] = [];
|
|
75
85
|
|
|
76
86
|
constructor(phaseDeg = 0) {
|
|
77
|
-
this.#phaseDeg = (
|
|
87
|
+
this.#phaseDeg = wrapDeg(phaseDeg);
|
|
78
88
|
this.#mode = detectColorMode();
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/** Advance the whole spectrum by `deltaDeg` (mod 360). */
|
|
82
92
|
step(deltaDeg: number): void {
|
|
83
|
-
this.#phaseDeg = (
|
|
93
|
+
this.#phaseDeg = wrapDeg(this.#phaseDeg + deltaDeg);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** Color one border glyph whose walk position is `perimeterPos` of `perimeter`. */
|
|
87
97
|
colorChar(char: string, perimeterPos: number, perimeter: number): string {
|
|
98
|
+
return `${this.prefix(perimeterPos, perimeter)}${char}\x1b[39m`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
prefix(perimeterPos: number, perimeter: number): string {
|
|
88
102
|
const hue = (this.#phaseDeg + (360 * perimeterPos) / perimeter) % 360;
|
|
89
103
|
const bucket = Math.round(hue) % 360;
|
|
90
104
|
let ansi = this.#ansiByHue[bucket];
|
|
@@ -93,6 +107,6 @@ export class RainbowBorder {
|
|
|
93
107
|
ansi = hexToFgAnsi(hex, this.#mode);
|
|
94
108
|
this.#ansiByHue[bucket] = ansi;
|
|
95
109
|
}
|
|
96
|
-
return
|
|
110
|
+
return ansi;
|
|
97
111
|
}
|
|
98
112
|
}
|
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) {
|
|
@@ -295,6 +313,20 @@ const scrollHintSegment: StatusLineSegment = {
|
|
|
295
313
|
},
|
|
296
314
|
};
|
|
297
315
|
|
|
316
|
+
/**
|
|
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.
|
|
320
|
+
*/
|
|
321
|
+
const workingSegment: StatusLineSegment = {
|
|
322
|
+
id: "working",
|
|
323
|
+
render(ctx) {
|
|
324
|
+
const status = ctx.workingStatus;
|
|
325
|
+
if (!status) return INVISIBLE;
|
|
326
|
+
return { content: status, visible: true };
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
298
330
|
/** Context bar + stats — related parts joined by a space, not the group separator. */
|
|
299
331
|
const contextGraphSegment: StatusLineSegment = {
|
|
300
332
|
id: "context_graph",
|
|
@@ -351,6 +383,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
351
383
|
feeds: feedsSegment,
|
|
352
384
|
context_graph: contextGraphSegment,
|
|
353
385
|
scroll_hint: scrollHintSegment,
|
|
386
|
+
working: workingSegment,
|
|
354
387
|
};
|
|
355
388
|
|
|
356
389
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
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,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
saveSettings,
|
|
20
21
|
sanitizeFeeds,
|
|
21
22
|
SEPARATORS,
|
|
23
|
+
topLeftSegments,
|
|
22
24
|
type SettingsState,
|
|
23
25
|
} from "./settings.js";
|
|
24
26
|
import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
@@ -33,8 +35,10 @@ import type {
|
|
|
33
35
|
} from "./types.js";
|
|
34
36
|
|
|
35
37
|
const SYMBOL_LABELS: Record<SymbolPreset, string> = { nerd: "nerdfont", unicode: "unicode", ascii: "ascii" };
|
|
36
|
-
const
|
|
37
|
-
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
|
+
);
|
|
38
42
|
|
|
39
43
|
const SECTION_TITLES = [
|
|
40
44
|
"Top Left Segment Group",
|
|
@@ -140,6 +144,21 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
140
144
|
label: "Animate rainbow border",
|
|
141
145
|
value: settings.rainbowAnimation ?? true,
|
|
142
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
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "embedWorkingStatus",
|
|
159
|
+
label: "Embed status spinners",
|
|
160
|
+
value: settings.embedWorkingStatus ?? false,
|
|
161
|
+
},
|
|
143
162
|
],
|
|
144
163
|
};
|
|
145
164
|
return [
|
|
@@ -170,6 +189,9 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
170
189
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
171
190
|
rainbowBorder: values.rainbowBorder === true,
|
|
172
191
|
rainbowAnimation: values.rainbowAnimation === true,
|
|
192
|
+
nvidiaGreenBorder: values.nvidiaGreenBorder === true,
|
|
193
|
+
nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
|
|
194
|
+
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
173
195
|
};
|
|
174
196
|
}
|
|
175
197
|
|
|
@@ -184,6 +206,10 @@ const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
|
|
|
184
206
|
const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
|
|
185
207
|
const CANNED_PERCENT = 42;
|
|
186
208
|
const CANNED_WINDOW = 200_000;
|
|
209
|
+
/** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
|
|
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)";
|
|
187
213
|
|
|
188
214
|
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
189
215
|
function cannedFeedData(
|
|
@@ -212,7 +238,7 @@ class StatusLinePreview {
|
|
|
212
238
|
render(
|
|
213
239
|
values: Record<string, MenuValue>,
|
|
214
240
|
elapsedMs: number,
|
|
215
|
-
|
|
241
|
+
activeItemId: string | undefined,
|
|
216
242
|
innerWidth: number,
|
|
217
243
|
): PreviewResult {
|
|
218
244
|
const effective = resolveEffectiveSettings(valuesToSettings(values));
|
|
@@ -220,8 +246,6 @@ class StatusLinePreview {
|
|
|
220
246
|
// 6-cell chrome budget for the bars themselves.
|
|
221
247
|
const boxWidth = Math.max(24, innerWidth - 1);
|
|
222
248
|
const barWidth = boxWidth - 6;
|
|
223
|
-
const rainbowOn = effective.rainbowBorder;
|
|
224
|
-
const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
|
|
225
249
|
|
|
226
250
|
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
227
251
|
// symbol preset for this synchronous render only; the live bar behind
|
|
@@ -236,6 +260,10 @@ class StatusLinePreview {
|
|
|
236
260
|
feeds: effective.segmentOptions.feeds.map(f => f.customType),
|
|
237
261
|
};
|
|
238
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;
|
|
239
267
|
const contextWindow = base.contextWindow || CANNED_WINDOW;
|
|
240
268
|
const ctx: SegmentContext = {
|
|
241
269
|
...base,
|
|
@@ -258,24 +286,31 @@ class StatusLinePreview {
|
|
|
258
286
|
const box = theme.getBox(effective.borderStyle);
|
|
259
287
|
// The preview box is three rows tall: top bar, side verticals, bottom bar.
|
|
260
288
|
const bottomIdx = 2;
|
|
289
|
+
const colorizer = greenOn ? new NvidiaGreenBorder(phase) : new RainbowBorder(phase);
|
|
261
290
|
const painters = makeBoxPainters({
|
|
262
|
-
rainbowOn,
|
|
263
|
-
|
|
291
|
+
colorizerOn: greenOn || rainbowOn,
|
|
292
|
+
colorizer,
|
|
264
293
|
box,
|
|
265
294
|
width: boxWidth,
|
|
266
295
|
bottomIdx,
|
|
267
296
|
flat: s => this.#uiTheme.fg("border", s),
|
|
268
297
|
});
|
|
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;
|
|
269
303
|
const top = buildStatusLine(
|
|
270
304
|
barWidth,
|
|
271
|
-
ctx,
|
|
305
|
+
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", sample) } : ctx,
|
|
272
306
|
effective,
|
|
273
307
|
painters.gapColor,
|
|
274
308
|
{
|
|
275
|
-
left: effective
|
|
309
|
+
left: topLeftSegments(effective, embedOn),
|
|
276
310
|
right: effective.rightSegments,
|
|
277
311
|
},
|
|
278
312
|
{ col: 3, row: 0 },
|
|
313
|
+
{ workingEllipsis: compactionDemo ? "…" : "" },
|
|
279
314
|
);
|
|
280
315
|
const bottom = buildStatusLine(
|
|
281
316
|
barWidth,
|
|
@@ -296,8 +331,8 @@ class StatusLinePreview {
|
|
|
296
331
|
painters.paint(1, boxWidth - 1, box.vertical),
|
|
297
332
|
renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
|
|
298
333
|
],
|
|
299
|
-
// A static
|
|
300
|
-
nextRefreshInMs:
|
|
334
|
+
// A static colorizer stays at phase zero and needs no repaint timer.
|
|
335
|
+
nextRefreshInMs: animationOn ? RAINBOW_FRAME_MS : undefined,
|
|
301
336
|
};
|
|
302
337
|
} finally {
|
|
303
338
|
theme.setSymbolPreset(applied);
|
|
@@ -312,7 +347,7 @@ export function registerSettingsCommand(
|
|
|
312
347
|
onChange: () => void,
|
|
313
348
|
): void {
|
|
314
349
|
pi.registerCommand("topping-statusline-settings", {
|
|
315
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds,
|
|
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",
|
|
316
351
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
317
352
|
if (ctx.mode !== "tui") {
|
|
318
353
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
package/src/settings.ts
CHANGED
|
@@ -93,6 +93,9 @@ 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;
|
|
98
|
+
if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
|
|
96
99
|
return settings;
|
|
97
100
|
} catch {
|
|
98
101
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -155,6 +158,9 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
155
158
|
bottomRightSegments,
|
|
156
159
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
157
160
|
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
161
|
+
nvidiaGreenBorder: settings.nvidiaGreenBorder ?? true,
|
|
162
|
+
nvidiaGreenAnimation: settings.nvidiaGreenAnimation ?? true,
|
|
163
|
+
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
158
164
|
includes,
|
|
159
165
|
segmentOptions: {
|
|
160
166
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
@@ -165,6 +171,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
165
171
|
};
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
/**
|
|
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.
|
|
178
|
+
*/
|
|
179
|
+
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
180
|
+
if (!working) return effective.leftSegments;
|
|
181
|
+
return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
export interface SettingsState {
|
|
169
185
|
settings: StatusLineSettings;
|
|
170
186
|
effective: EffectiveStatusLineSettings;
|
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
|
+
});
|