@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 CHANGED
@@ -42,7 +42,9 @@ and apply live.
42
42
  ║ Border style ‹ rounded › ║
43
43
  ║ [■] Rainbow border on max thinking ON ║
44
44
  ║ [■] Animate rainbow border ON ║
45
- ║ [ ] Embed 'Working' indicator OFF ║
45
+ ║ [■] NVIDIA-green when using Switchyard ON ║
46
+ ║ [■] Animate Switchyard green border ON ║
47
+ ║ [ ] Embed status spinners OFF ║
46
48
  ║ ║
47
49
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
48
50
  ║ [■] Pi symbol ON ║
@@ -67,18 +69,18 @@ and apply live.
67
69
  ║ + add feed ║
68
70
  ║ ║
69
71
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
70
- ╚════════════════════════════════════════════════════════[ 1/30 ]╝
72
+ ╚════════════════════════════════════════════════════════[ 1/32 ]╝
71
73
  ```
72
74
 
73
75
  | Section | Settings |
74
76
  | --- | --- |
75
- | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · Embed 'Working' indicator |
77
+ | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · NVIDIA-green when using Switchyard · Animate Switchyard green border · Embed status spinners |
76
78
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
77
79
  | Top Right Segment Group | Token rate · Session name |
78
80
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
79
81
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
80
82
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
81
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
83
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off |
82
84
 
83
85
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
84
86
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -88,16 +90,28 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
88
90
  phase too. Any other thinking level keeps the normal theme border color. Disable animation over
89
91
  slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
90
92
 
91
- **Embed 'Working' indicator** (off by default) moves pi's streaming status — spinner, message,
92
- and any loader text a topping such as pi-topping supplies — out of its own row and into the
93
- top-left group, right after the Pi symbol and its chevron. The remaining left segments (model,
94
- path, git, PR) step aside while a response streams and return when it ends; the Pi symbol never
95
- moves. The status appears instantly when a stream starts; when it ends, the swap back
96
- cross-fades over 750ms (the status sinks into the bar background, then the user's segments
97
- rise out of it) rather than cutting. A status too long for the bar is truncated without an ellipsis, as pi's own border does.
98
- Requires pi 0.85 or later. Toggling the setting reinstalls the editor through pi, so it applies
99
- immediately, even mid-response. Only the statusline's own editor opts in: when another
100
- extension owns the editor slot and is wrapped, pi keeps its standalone working row.
93
+ When the active model provider is `switchyard` and **NVIDIA-green when using Switchyard** is on (the
94
+ default), the border uses an NVIDIA-green gradient from `#84c51a` to `#0b3d20`. **Animate Switchyard
95
+ green border** is also on by default, so it sweeps around the box on the same ~14s cycle as the
96
+ max-thinking rainbow, independently of **Animate rainbow border**. Turn the animation setting off to
97
+ keep the green gradient at a fixed color phase without its repaint timer. When enabled, green takes
98
+ precedence over the max-thinking rainbow and ignores thinking level; turn the NVIDIA-green setting off
99
+ to restore the normal theme or rainbow border.
100
+
101
+ **Embed status spinners** (off by default) moves pi's status indicators out of their own row
102
+ and into the top-left group, right after the Pi symbol and its chevron. On pi 0.86 and later
103
+ this covers the working, retry, compaction, and branch-summary spinners; pi 0.85 embeds only
104
+ the working indicator. The remaining left segments (model, path, git, PR) step aside while a
105
+ status is visible, and the Pi symbol never moves. A cleared status is held briefly so handoffs
106
+ between spinners do not flash the left segments. Only the end of a working spinner then
107
+ cross-fades back over 750ms; the message-style spinners cut after the hold.
108
+
109
+ A status too long for the bar is truncated. The working spinner keeps pi's bare cut, while the
110
+ message-style spinners use an ellipsis. Focusing the embed row in the settings preview shows
111
+ the longest compaction sample. Requires pi 0.85 or later. Toggling the setting reinstalls the
112
+ editor through pi, so it applies immediately, even mid-response. Only the statusline's own
113
+ editor opts in: when another extension owns the editor slot and is wrapped, pi keeps its
114
+ standalone status row.
101
115
 
102
116
  ## Segments
103
117
 
@@ -108,7 +122,9 @@ fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (liv
108
122
  accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
109
123
  pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
110
124
  bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
111
- (documented below).
125
+ (documented below). On pi 0.86 and later, `pi_stats` includes cache-warming refreshes folded
126
+ into pi's own footer totals, so cache R/W and cost can increase while idle; this segment matches
127
+ the host footer by construction.
112
128
 
113
129
  When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
114
130
  then left segments drop end-first — the path is always the last to go.
package/index.ts CHANGED
@@ -35,17 +35,16 @@ import {
35
35
  stripRedundantStats,
36
36
  } from "./src/footer.js";
37
37
  import { buildStatusLine } from "./src/layout.js";
38
- import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
38
+ import { NvidiaGreenBorder, isSwitchyardProvider } from "./src/nvidia-green.js";
39
+ import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder, type BorderColorizer } from "./src/rainbow.js";
39
40
  import { registerSettingsCommand } from "./src/settings-menu.js";
40
41
  import { createSettingsState, topLeftSegments } from "./src/settings.js";
41
- import { easeFade } from "./src/theme.js";
42
42
  import { theme } from "./src/theme.js";
43
43
  import { TokenRateMonitor } from "./src/token-rate.js";
44
+ import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
44
45
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
45
46
 
46
- /** Cross-fade budget when the embedded working status disappears: half out, half in. */
47
- const WORKING_FADE_MS = 750;
48
- const WORKING_FADE_FRAME_MS = 15;
47
+ const WORKING_FADE_FRAME_MS = 30;
49
48
 
50
49
  export default function (pi: ExtensionAPI) {
51
50
  const state = createSettingsState();
@@ -60,16 +59,12 @@ export default function (pi: ExtensionAPI) {
60
59
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
61
60
  let restoreFooterLayout: (() => void) | undefined;
62
61
  // The CustomEditor this extension constructed itself (undefined when it wraps
63
- // another extension's editor), and the host's working indicator captured
64
- // from that editor while a response streams.
62
+ // another extension's editor), and the host's status spinner captured from
63
+ // that editor. pi 0.85 sends only working; 0.86 sends all four kinds.
65
64
  let activeEditor: CustomEditor | undefined;
66
65
  let embeddedWorkingStatus: ((width: number) => string) | undefined;
67
- // Fade from the working status back to the user's left segments when a
68
- // stream ends. The last status text is kept so the outgoing half still has
69
- // something to fade after pi has cleared its indicator.
70
- let workingShown = false;
71
- let lastWorkingText: string | undefined;
72
- let workingFade: { start: number } | undefined;
66
+ let embeddedStatusKind: StatusIndicatorKind | undefined;
67
+ const statusTransition = new StatusTransition();
73
68
  let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
74
69
  const stopWorkingFadeTimer = (): void => {
75
70
  if (workingFadeTimer) clearInterval(workingFadeTimer);
@@ -90,79 +85,53 @@ export default function (pi: ExtensionAPI) {
90
85
  const startWorkingFadeTimer = (): void => {
91
86
  if (workingFadeTimer) return;
92
87
  workingFadeTimer = setInterval(() => {
93
- if (!workingFade || Date.now() - workingFade.start >= WORKING_FADE_MS) {
94
- workingFade = undefined;
95
- stopWorkingFadeTimer();
96
- }
97
88
  requestRender();
89
+ if (!statusTransition.pending(Date.now())) stopWorkingFadeTimer();
98
90
  }, WORKING_FADE_FRAME_MS);
99
91
  workingFadeTimer.unref?.();
100
92
  };
101
93
 
102
- /**
103
- * Which left group this frame shows and how far it has faded. A stream
104
- * starting shows the working status at once. A stream ending starts a
105
- * 750ms clock: the status sinks into the bar background for the first
106
- * half, then the user's segments rise out of it.
107
- */
108
- const resolveWorkingFrame = (live: string | undefined): { working: string | undefined; leftFade?: number } => {
109
- const now = Date.now();
110
- const liveShown = live !== undefined;
111
- if (liveShown) lastWorkingText = live;
112
- if (liveShown !== workingShown) {
113
- workingShown = liveShown;
114
- if (liveShown) {
115
- // Instant cut in, and it cancels any fade-out still running.
116
- workingFade = undefined;
117
- } else {
118
- workingFade = { start: now };
119
- startWorkingFadeTimer();
120
- }
121
- }
122
- if (!workingFade) return { working: live };
123
- const half = WORKING_FADE_MS / 2;
124
- const elapsed = now - workingFade.start;
125
- if (elapsed >= WORKING_FADE_MS) {
126
- workingFade = undefined;
127
- return { working: live };
128
- }
129
- const outgoing = elapsed < half;
130
- const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
131
- return { working: outgoing ? lastWorkingText : undefined, leftFade };
132
- };
133
-
134
94
  const rateMonitor = new TokenRateMonitor(requestRender);
135
95
  rateMonitor.attach(pi);
136
96
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
137
97
 
138
- // The rainbow is visible at max thinking independently of whether its hue
139
- // phase is animated. Stopping the timer preserves the current live phase.
98
+ // The active border colorizer is visible independently of whether its phase
99
+ // is animated. Stopping the timer preserves the current live phase.
140
100
  const rainbow = new RainbowBorder();
141
- let rainbowTimer: ReturnType<typeof setInterval> | undefined;
101
+ const green = new NvidiaGreenBorder();
102
+ let borderTimer: ReturnType<typeof setInterval> | undefined;
142
103
  const rainbowActive = (): boolean =>
143
104
  state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
144
- const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
145
- const syncRainbow = (): void => {
146
- if (rainbowAnimationActive()) {
147
- if (!rainbowTimer) {
148
- rainbowTimer = setInterval(() => {
149
- if (!rainbowAnimationActive()) {
150
- syncRainbow();
105
+ const greenActive = (): boolean =>
106
+ state.effective.nvidiaGreenBorder && activeCtx !== undefined && isSwitchyardProvider(activeCtx.model?.provider);
107
+ const borderColorizer = (): BorderColorizer | undefined => (greenActive() ? green : rainbowActive() ? rainbow : undefined);
108
+ const borderAnimationActive = (): boolean => {
109
+ if (greenActive()) return state.effective.nvidiaGreenAnimation;
110
+ if (rainbowActive()) return state.effective.rainbowAnimation;
111
+ return false;
112
+ };
113
+ const syncBorderAnimation = (): void => {
114
+ if (borderAnimationActive()) {
115
+ if (!borderTimer) {
116
+ borderTimer = setInterval(() => {
117
+ const colorizer = borderColorizer();
118
+ if (!borderAnimationActive() || colorizer === undefined) {
119
+ syncBorderAnimation();
151
120
  return;
152
121
  }
153
- rainbow.step(RAINBOW_DEG_PER_FRAME);
122
+ colorizer.step(RAINBOW_DEG_PER_FRAME);
154
123
  requestRender();
155
124
  }, RAINBOW_FRAME_MS);
156
- rainbowTimer.unref?.();
125
+ borderTimer.unref?.();
157
126
  }
158
- } else if (rainbowTimer) {
159
- clearInterval(rainbowTimer);
160
- rainbowTimer = undefined;
127
+ } else if (borderTimer) {
128
+ clearInterval(borderTimer);
129
+ borderTimer = undefined;
161
130
  }
162
131
  };
163
132
 
164
133
  registerSettingsCommand(pi, state, builder, () => {
165
- syncRainbow();
134
+ syncBorderAnimation();
166
135
  syncEmbed();
167
136
  requestRender();
168
137
  });
@@ -198,23 +167,34 @@ export default function (pi: ExtensionAPI) {
198
167
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
199
168
  // borderColor is assigned by the host after the factory returns — read late.
200
169
  const border = editor.borderColor ?? ((s: string) => s);
201
- const rainbowOn = rainbowActive();
170
+ const colorizer = borderColorizer();
202
171
  const box = theme.getBox(effective.borderStyle);
203
- const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
172
+ const painters = makeBoxPainters({
173
+ colorizerOn: colorizer !== undefined,
174
+ colorizer: colorizer ?? rainbow,
175
+ box,
176
+ width,
177
+ bottomIdx,
178
+ flat: border,
179
+ });
204
180
  // The layout truncates the status to fit, so it is rendered at full width here.
205
181
  const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
206
- const { working, leftFade } = resolveWorkingFrame(live);
182
+ const frame = statusTransition.resolve(live, embeddedStatusKind, Date.now());
183
+ if (frame.pending) startWorkingFadeTimer();
207
184
  const bar = buildStatusLine(
208
185
  innerWidth,
209
- { ...segCtx, workingStatus: working },
186
+ { ...segCtx, workingStatus: frame.status },
210
187
  effective,
211
188
  painters.gapColor,
212
189
  {
213
- left: topLeftSegments(effective, working !== undefined),
190
+ left: topLeftSegments(effective, frame.status !== undefined),
214
191
  right: effective.rightSegments,
215
192
  },
216
193
  { col: 3, row: 0 },
217
- { leftFade },
194
+ {
195
+ leftFade: frame.leftFade,
196
+ workingEllipsis: isMessageKind(frame.kind) ? "…" : "",
197
+ },
218
198
  );
219
199
  // Leading spacer row: keeps the transcript from sitting flush on the box.
220
200
  const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
@@ -247,20 +227,24 @@ export default function (pi: ExtensionAPI) {
247
227
  let editor: ReturnType<EditorFactory>;
248
228
  if (inner) {
249
229
  // A wrapped third-party editor is not opted in: pi keeps its
250
- // standalone working row for it.
230
+ // standalone status-spinner row for it.
251
231
  editor = inner(tui, editorTheme, keybindings);
252
232
  activeEditor = undefined;
253
233
  embeddedWorkingStatus = undefined;
234
+ embeddedStatusKind = undefined;
254
235
  } else {
255
236
  const own = new CustomEditor(tui, editorTheme, keybindings, {
256
237
  embedWorkingStatus: state.effective.embedWorkingStatus,
257
238
  });
258
- // Capture the host's indicator on its way in; renderBoxed draws it
259
- // into the top bar since the inner top border (lines[0]) is discarded.
239
+ // Capture the host's status indicator on its way in; renderBoxed
240
+ // draws it in the top bar because the inner border is discarded.
260
241
  if (typeof own.setWorkingStatusIndicator === "function") {
261
242
  const setIndicator = own.setWorkingStatusIndicator.bind(own);
262
243
  own.setWorkingStatusIndicator = indicator => {
244
+ // pi 0.86 sends retry, compaction, and branch-summary indicators
245
+ // through this setter too; renderInBorder now lives on the base class.
263
246
  embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
247
+ embeddedStatusKind = indicator?.kind;
264
248
  setIndicator(indicator);
265
249
  };
266
250
  }
@@ -270,6 +254,7 @@ export default function (pi: ExtensionAPI) {
270
254
  const innerRender = editor.render.bind(editor);
271
255
  let boxRenderFailed = false;
272
256
  editor.render = width => {
257
+ if (boxRenderFailed) return innerRender(width);
273
258
  try {
274
259
  return renderBoxed(innerRender, width, editor);
275
260
  } catch (err) {
@@ -299,6 +284,8 @@ export default function (pi: ExtensionAPI) {
299
284
 
300
285
  const PI_STATS_CACHE_MS = 250;
301
286
  let piStatsCache: { at: number; value: string | undefined } | undefined;
287
+ // From pi 0.86 these host totals include cache_warm usage entries, so R/W
288
+ // and cost can change while idle; mirroring the footer keeps this exact.
302
289
  builder.setPiStatsProvider(width => {
303
290
  if (!footerRenderOriginal) return undefined;
304
291
  const now = Date.now();
@@ -346,13 +333,17 @@ export default function (pi: ExtensionAPI) {
346
333
  }
347
334
  if (!restoreFooterLayout) {
348
335
  const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
336
+ // No-op on pi >= 0.86; retained for supported 0.84/0.85 hosts.
349
337
  restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
350
338
  }
351
339
  };
352
340
 
353
- pi.on("model_select", () => requestRender());
341
+ pi.on("model_select", () => {
342
+ syncBorderAnimation();
343
+ requestRender();
344
+ });
354
345
  pi.on("thinking_level_select", () => {
355
- syncRainbow();
346
+ syncBorderAnimation();
356
347
  requestRender();
357
348
  });
358
349
  pi.on("session_info_changed", () => requestRender());
@@ -362,7 +353,7 @@ export default function (pi: ExtensionAPI) {
362
353
  activeCtx = ctx;
363
354
  builder.attach(ctx);
364
355
  rateMonitor.enable();
365
- syncRainbow();
356
+ syncBorderAnimation();
366
357
  ensureInstalled();
367
358
  syncEmbed();
368
359
  ensureFooterPatched();
@@ -371,7 +362,7 @@ export default function (pi: ExtensionAPI) {
371
362
  ensureFooterPatched();
372
363
  // The host's /settings dialog can change the thinking level without
373
364
  // firing thinking_level_select, so reconcile the animator periodically.
374
- syncRainbow();
365
+ syncBorderAnimation();
375
366
  }, 1000);
376
367
  ensureTimer.unref?.();
377
368
  });
@@ -379,9 +370,9 @@ export default function (pi: ExtensionAPI) {
379
370
  pi.on("session_shutdown", () => {
380
371
  rateMonitor.disable();
381
372
  builder.dispose();
382
- if (rainbowTimer) {
383
- clearInterval(rainbowTimer);
384
- rainbowTimer = undefined;
373
+ if (borderTimer) {
374
+ clearInterval(borderTimer);
375
+ borderTimer = undefined;
385
376
  }
386
377
  if (ensureTimer) {
387
378
  clearInterval(ensureTimer);
@@ -401,10 +392,9 @@ export default function (pi: ExtensionAPI) {
401
392
  wrappedFactory = undefined;
402
393
  activeEditor = undefined;
403
394
  embeddedWorkingStatus = undefined;
395
+ embeddedStatusKind = undefined;
404
396
  stopWorkingFadeTimer();
405
- workingFade = undefined;
406
- workingShown = false;
407
- lastWorkingText = undefined;
397
+ statusTransition.reset();
408
398
  activeTui = undefined;
409
399
  activeCtx = undefined;
410
400
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,10 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "0.85.0",
49
- "@earendil-works/pi-coding-agent": "0.85.0",
50
- "@earendil-works/pi-server": "0.85.0",
51
- "@earendil-works/pi-tui": "0.85.0",
48
+ "@earendil-works/pi-ai": "0.86.0",
49
+ "@earendil-works/pi-coding-agent": "0.86.0",
50
+ "@earendil-works/pi-tui": "0.86.0",
52
51
  "@types/node": "^22.0.0",
53
52
  "typescript": "^5.7.0"
54
53
  }
package/src/box.ts CHANGED
@@ -2,16 +2,16 @@
2
2
  * Shared box-chrome painting for the live editor box (index.ts) and the
3
3
  * settings-menu preview, so the 3/2/+2 row geometry has one definition.
4
4
  *
5
- * Rainbow mode colors each glyph by its perimeter-walk position; otherwise
5
+ * Gradient mode colors each glyph by its perimeter-walk position; otherwise
6
6
  * the caller's flat painter wraps whole runs in one escape sequence, which
7
7
  * renders identically to per-glyph wraps with fewer bytes. The flat painter
8
- * and the RainbowBorder instance are injected because they differ per caller
8
+ * and the border colorizer are injected because they differ per caller
9
9
  * (host borderColor vs menu theme; shared stepped animator vs phase-from-
10
10
  * elapsed instance).
11
11
  */
12
12
  import { stripVTControlCharacters } from "node:util";
13
13
  import { visibleWidth } from "@earendil-works/pi-tui";
14
- import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
14
+ import { perimeterLength, perimeterPosition, type BorderColorizer } from "./rainbow.js";
15
15
  import type { BoxGlyphs } from "./theme.js";
16
16
 
17
17
  export interface BoxPainters {
@@ -22,28 +22,50 @@ export interface BoxPainters {
22
22
  }
23
23
 
24
24
  export function makeBoxPainters(opts: {
25
- rainbowOn: boolean;
26
- rainbow: RainbowBorder;
25
+ colorizerOn: boolean;
26
+ colorizer: BorderColorizer;
27
27
  box: BoxGlyphs;
28
28
  width: number;
29
29
  bottomIdx: number;
30
30
  flat: (str: string) => string;
31
31
  }): BoxPainters {
32
- const { rainbowOn, rainbow, box, width, bottomIdx, flat } = opts;
32
+ const { colorizerOn, colorizer, box, width, bottomIdx, flat } = opts;
33
33
  const perimeter = perimeterLength(width, bottomIdx);
34
34
  const paint = (row: number, col: number, ch: string): string =>
35
- rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
35
+ colorizerOn ? colorizer.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
36
36
  const horizRun = (row: number, startCol: number, count: number): string => {
37
- if (!rainbowOn) return flat(box.horizontal.repeat(count));
37
+ if (!colorizerOn) return flat(box.horizontal.repeat(count));
38
38
  let out = "";
39
- for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
39
+ let runPrefix: string | undefined;
40
+ let runChars = "";
41
+ for (let k = 0; k < count; k++) {
42
+ const prefix = colorizer.prefix(perimeterPosition(row, startCol + k, width, bottomIdx), perimeter);
43
+ if (runPrefix !== undefined && prefix !== runPrefix) {
44
+ out += `${runPrefix}${runChars}\x1b[39m`;
45
+ runChars = "";
46
+ }
47
+ runPrefix = prefix;
48
+ runChars += box.horizontal;
49
+ }
50
+ if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
40
51
  return out;
41
52
  };
42
53
  const gapColor = (str: string, startCol: number, row: number): string => {
43
- if (!rainbowOn) return flat(str);
54
+ if (!colorizerOn) return flat(str);
44
55
  let out = "";
56
+ let runPrefix: string | undefined;
57
+ let runChars = "";
45
58
  let k = 0;
46
- for (const ch of str) out += paint(row, startCol + k++, ch);
59
+ for (const ch of str) {
60
+ const prefix = colorizer.prefix(perimeterPosition(row, startCol + k++, width, bottomIdx), perimeter);
61
+ if (runPrefix !== undefined && prefix !== runPrefix) {
62
+ out += `${runPrefix}${runChars}\x1b[39m`;
63
+ runChars = "";
64
+ }
65
+ runPrefix = prefix;
66
+ runChars += ch;
67
+ }
68
+ if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
47
69
  return out;
48
70
  };
49
71
  return { paint, horizRun, gapColor };
package/src/footer.ts CHANGED
@@ -27,8 +27,11 @@ function containsComponent(node: unknown, target: unknown, seen: Set<unknown>):
27
27
  }
28
28
 
29
29
  /**
30
- * Let Pi's fullscreen footer slot collapse when its component renders no rows.
31
- * Pi 0.84 gives that VStack entry minSize=1, independently of render output.
30
+ * Let pi's fullscreen footer slot collapse when its component renders no rows.
31
+ * Hosts before 0.86 give that VStack entry minSize=1 regardless of render
32
+ * output; 0.86 fixed it (#8919), where this writes 0 over 0. Delete this
33
+ * helper, its index.ts call/restore path, and its test once the minimum
34
+ * supported host is 0.86.
32
35
  */
33
36
  export function collapseFooterLayoutSlot(layoutRoot: unknown, footer: unknown): (() => void) | undefined {
34
37
  const seen = new Set<unknown>();
package/src/layout.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
3
3
  *
4
4
  * Overflow strategy (in order): drop right segments right-to-left, truncate
5
- * the embedded `working` status (no ellipsis, as pi's own border does), shrink
6
- * the elastic `path` segment down to ~8 cells, drop left segments end-first
7
- * while protecting `path`. The gap between the two groups is filled with the
5
+ * the embedded `working` status with the caller's chosen ellipsis policy,
6
+ * shrink the elastic `path` segment down to ~8 cells, drop left segments
7
+ * end-first while protecting `path`. Working indicators retain pi's bare cut;
8
+ * message-style indicators use an ellipsis so truncated prose is apparent.
9
+ * The gap between the two groups is filled with the
8
10
  * box-horizontal glyph colored like the editor border, so it tracks the
9
11
  * thinking-level border color; the callback receives the gap's absolute
10
12
  * column and row in the box, so a caller can paint it as part of a
@@ -63,7 +65,7 @@ export function buildStatusLine(
63
65
  gapBorderColor: (str: string, startCol: number, row: number) => string,
64
66
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
65
67
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
66
- options: { leftFade?: number } = {},
68
+ options: { leftFade?: number; workingEllipsis?: string } = {},
67
69
  ): string {
68
70
  const separatorDef = getSeparator(settings.separator);
69
71
 
@@ -124,7 +126,7 @@ export function buildStatusLine(
124
126
  if (workingIdx >= 0 && totalWidth() > width) {
125
127
  const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
126
128
  if (available >= 1) {
127
- left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
129
+ left[workingIdx] = truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "");
128
130
  } else {
129
131
  left.splice(workingIdx, 1);
130
132
  leftSegIds.splice(workingIdx, 1);
@@ -0,0 +1,66 @@
1
+ /** Provider-specific NVIDIA green border colorizer for Switchyard. */
2
+ import { rgbToHex, wrapDeg, type BorderColorizer } from "./rainbow.js";
3
+ import { detectColorMode, hexToFgAnsi, parseHex, type ColorMode } from "./theme.js";
4
+
5
+ export const SWITCHYARD_PROVIDER = "switchyard";
6
+ export const NVIDIA_GREEN_HEX = "#84c51a";
7
+ export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
8
+ const GREEN_SHADE_COUNT = 256;
9
+ const GREEN_BIAS = 0.65;
10
+
11
+ export function isSwitchyardProvider(provider: string | undefined): boolean {
12
+ return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
13
+ }
14
+
15
+ const ansiByShadeByMode = new Map<ColorMode, string[]>();
16
+
17
+ function buildAnsiByShade(mode: ColorMode): string[] {
18
+ const cached = ansiByShadeByMode.get(mode);
19
+ if (cached !== undefined) return cached;
20
+
21
+ const dark = parseHex(NVIDIA_GREEN_DARK_HEX);
22
+ const bright = parseHex(NVIDIA_GREEN_HEX);
23
+ if (dark === undefined || bright === undefined) {
24
+ throw new Error("Invalid NVIDIA green palette");
25
+ }
26
+
27
+ const ansiByShade: string[] = [];
28
+ for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
29
+ const progress = i / (GREEN_SHADE_COUNT - 1);
30
+ const rgb: [number, number, number] = [
31
+ Math.round(dark[0] + (bright[0] - dark[0]) * progress),
32
+ Math.round(dark[1] + (bright[1] - dark[1]) * progress),
33
+ Math.round(dark[2] + (bright[2] - dark[2]) * progress),
34
+ ];
35
+ ansiByShade.push(hexToFgAnsi(rgbToHex(rgb), mode));
36
+ }
37
+
38
+ ansiByShadeByMode.set(mode, ansiByShade);
39
+ return ansiByShade;
40
+ }
41
+
42
+ export class NvidiaGreenBorder implements BorderColorizer {
43
+ #phaseDeg: number;
44
+ readonly #ansiByShade: string[];
45
+
46
+ constructor(phaseDeg = 0) {
47
+ this.#phaseDeg = wrapDeg(phaseDeg);
48
+ this.#ansiByShade = buildAnsiByShade(detectColorMode());
49
+ }
50
+
51
+ step(deltaDeg: number): void {
52
+ this.#phaseDeg = wrapDeg(this.#phaseDeg + deltaDeg);
53
+ }
54
+
55
+ colorChar(char: string, perimeterPos: number, perimeter: number): string {
56
+ return `${this.prefix(perimeterPos, perimeter)}${char}\x1b[39m`;
57
+ }
58
+
59
+ prefix(perimeterPos: number, perimeter: number): string {
60
+ const t = (perimeterPos / perimeter + this.#phaseDeg / 360) % 1;
61
+ const u = (1 - Math.cos(2 * Math.PI * t)) / 2;
62
+ const eased = u ** GREEN_BIAS;
63
+ const shadeIndex = Math.round((1 - eased) * (GREEN_SHADE_COUNT - 1));
64
+ return this.#ansiByShade[shadeIndex];
65
+ }
66
+ }
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
  }