@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/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) {
@@ -296,9 +314,9 @@ const scrollHintSegment: StatusLineSegment = {
296
314
  };
297
315
 
298
316
  /**
299
- * Pi's embedded working indicator (spinner + message), already colored by the
300
- * host, so it is passed through verbatim. The layout truncates it, without an
301
- * ellipsis, when it overflows, mirroring pi's own border rendering.
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",
@@ -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 SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = { nerdfont: "nerd", unicode: "unicode", ascii: "ascii" };
38
- 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
+ );
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 'Working' indicator",
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
- _activeItemId: string | undefined,
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
- rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
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", CANNED_WORKING) } : ctx,
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 rainbow stays at phase zero and needs no repaint timer.
310
- 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,
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 working indicator",
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's working status is embedded,
172
- * it stands in for every left segment except the leading Pi symbol, so the
173
- * symbol and its chevron never move when a stream starts or ends.
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;
@@ -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
+ });
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Contracts for the opt-in embedded working indicator: the `working` segment
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 (no ellipsis) before any other left segment is dropped.
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
- /** Render pi's streaming working status in the top bar instead of its own row. */
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 working indicator, pre-rendered and pre-colored by the host
142
- * for this frame. Absent while no response is streaming.
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
+ }