@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/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 = ((phaseDeg % 360) + 360) % 360;
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 = ((this.#phaseDeg + deltaDeg) % 360 + 360) % 360;
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 `${ansi}${char}\x1b[39m`;
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 } = classifyProjectDir(ctx.cwd);
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 = stripDisplayRoot(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 {
@@ -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 SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = { nerdfont: "nerd", unicode: "unicode", ascii: "ascii" };
37
- const SYMBOL_VALUES: readonly string[] = ["nerdfont", "unicode", "ascii"];
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
- _activeItemId: string | undefined,
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
- rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
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.leftSegments,
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 rainbow stays at phase zero and needs no repaint timer.
300
- nextRefreshInMs: rainbowAnimationOn ? RAINBOW_FRAME_MS : undefined,
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, and rainbow border animation",
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;
@@ -24,8 +24,8 @@ const ROUNDED: BoxGlyphs = {
24
24
 
25
25
  const identityPainters = (boxWidth: number) =>
26
26
  makeBoxPainters({
27
- rainbowOn: false,
28
- rainbow: new RainbowBorder(0),
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
- rainbowOn: false,
75
- rainbow: new RainbowBorder(0),
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
- rainbowOn: true,
89
- rainbow: new RainbowBorder(0),
88
+ colorizerOn: true,
89
+ colorizer: new RainbowBorder(0),
90
90
  box: ROUNDED,
91
91
  width: 20,
92
92
  bottomIdx: 2,
@@ -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
+ });