@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 CHANGED
@@ -42,6 +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
+ ║ [■] NVIDIA-green when using Switchyard ON ║
46
+ ║ [■] Animate Switchyard green border ON ║
47
+ ║ [ ] Embed status spinners OFF ║
45
48
  ║ ║
46
49
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
47
50
  ║ [■] Pi symbol ON ║
@@ -66,18 +69,18 @@ and apply live.
66
69
  ║ + add feed ║
67
70
  ║ ║
68
71
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
69
- ╚════════════════════════════════════════════════════════[ 1/29 ]╝
72
+ ╚════════════════════════════════════════════════════════[ 1/32 ]╝
70
73
  ```
71
74
 
72
75
  | Section | Settings |
73
76
  | --- | --- |
74
- | 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 |
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 |
75
78
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
76
79
  | Top Right Segment Group | Token rate · Session name |
77
80
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
78
81
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
79
82
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
80
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
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 |
81
84
 
82
85
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
83
86
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -87,6 +90,29 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
87
90
  phase too. Any other thinking level keeps the normal theme border color. Disable animation over
88
91
  slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
89
92
 
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.
115
+
90
116
  ## Segments
91
117
 
92
118
  Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
@@ -96,7 +122,9 @@ fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (liv
96
122
  accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
97
123
  pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
98
124
  bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
99
- (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.
100
128
 
101
129
  When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
102
130
  then left segments drop end-first — the path is always the last to go.
package/index.ts CHANGED
@@ -35,13 +35,17 @@ 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
- import { createSettingsState } from "./src/settings.js";
41
+ import { createSettingsState, topLeftSegments } from "./src/settings.js";
41
42
  import { theme } from "./src/theme.js";
42
43
  import { TokenRateMonitor } from "./src/token-rate.js";
44
+ import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
43
45
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
44
46
 
47
+ const WORKING_FADE_FRAME_MS = 30;
48
+
45
49
  export default function (pi: ExtensionAPI) {
46
50
  const state = createSettingsState();
47
51
  const builder = new SegmentContextBuilder(pi);
@@ -54,42 +58,81 @@ export default function (pi: ExtensionAPI) {
54
58
  let patchedFooter: Component | undefined;
55
59
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
56
60
  let restoreFooterLayout: (() => void) | undefined;
61
+ // The CustomEditor this extension constructed itself (undefined when it wraps
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.
64
+ let activeEditor: CustomEditor | undefined;
65
+ let embeddedWorkingStatus: ((width: number) => string) | undefined;
66
+ let embeddedStatusKind: StatusIndicatorKind | undefined;
67
+ const statusTransition = new StatusTransition();
68
+ let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
69
+ const stopWorkingFadeTimer = (): void => {
70
+ if (workingFadeTimer) clearInterval(workingFadeTimer);
71
+ workingFadeTimer = undefined;
72
+ };
73
+
74
+ const syncEmbed = (): void => {
75
+ const ctx = activeCtx;
76
+ const factory = ourFactory;
77
+ if (!activeEditor || !ctx || !factory) return;
78
+ const next = state.effective.embedWorkingStatus;
79
+ if (activeEditor.embedWorkingStatus === next) return;
80
+ ctx.ui.setEditorComponent(factory);
81
+ };
57
82
 
58
83
  const requestRender = () => activeTui?.requestRender();
59
84
  builder.setRequestRender(requestRender);
85
+ const startWorkingFadeTimer = (): void => {
86
+ if (workingFadeTimer) return;
87
+ workingFadeTimer = setInterval(() => {
88
+ requestRender();
89
+ if (!statusTransition.pending(Date.now())) stopWorkingFadeTimer();
90
+ }, WORKING_FADE_FRAME_MS);
91
+ workingFadeTimer.unref?.();
92
+ };
60
93
 
61
94
  const rateMonitor = new TokenRateMonitor(requestRender);
62
95
  rateMonitor.attach(pi);
63
96
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
64
97
 
65
- // The rainbow is visible at max thinking independently of whether its hue
66
- // 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.
67
100
  const rainbow = new RainbowBorder();
68
- let rainbowTimer: ReturnType<typeof setInterval> | undefined;
101
+ const green = new NvidiaGreenBorder();
102
+ let borderTimer: ReturnType<typeof setInterval> | undefined;
69
103
  const rainbowActive = (): boolean =>
70
104
  state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
71
- const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
72
- const syncRainbow = (): void => {
73
- if (rainbowAnimationActive()) {
74
- if (!rainbowTimer) {
75
- rainbowTimer = setInterval(() => {
76
- if (!rainbowAnimationActive()) {
77
- 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();
78
120
  return;
79
121
  }
80
- rainbow.step(RAINBOW_DEG_PER_FRAME);
122
+ colorizer.step(RAINBOW_DEG_PER_FRAME);
81
123
  requestRender();
82
124
  }, RAINBOW_FRAME_MS);
83
- rainbowTimer.unref?.();
125
+ borderTimer.unref?.();
84
126
  }
85
- } else if (rainbowTimer) {
86
- clearInterval(rainbowTimer);
87
- rainbowTimer = undefined;
127
+ } else if (borderTimer) {
128
+ clearInterval(borderTimer);
129
+ borderTimer = undefined;
88
130
  }
89
131
  };
90
132
 
91
133
  registerSettingsCommand(pi, state, builder, () => {
92
- syncRainbow();
134
+ syncBorderAnimation();
135
+ syncEmbed();
93
136
  requestRender();
94
137
  });
95
138
 
@@ -124,19 +167,34 @@ export default function (pi: ExtensionAPI) {
124
167
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
125
168
  // borderColor is assigned by the host after the factory returns — read late.
126
169
  const border = editor.borderColor ?? ((s: string) => s);
127
- const rainbowOn = rainbowActive();
170
+ const colorizer = borderColorizer();
128
171
  const box = theme.getBox(effective.borderStyle);
129
- 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
+ });
180
+ // The layout truncates the status to fit, so it is rendered at full width here.
181
+ const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
182
+ const frame = statusTransition.resolve(live, embeddedStatusKind, Date.now());
183
+ if (frame.pending) startWorkingFadeTimer();
130
184
  const bar = buildStatusLine(
131
185
  innerWidth,
132
- segCtx,
186
+ { ...segCtx, workingStatus: frame.status },
133
187
  effective,
134
188
  painters.gapColor,
135
189
  {
136
- left: effective.leftSegments,
190
+ left: topLeftSegments(effective, frame.status !== undefined),
137
191
  right: effective.rightSegments,
138
192
  },
139
193
  { col: 3, row: 0 },
194
+ {
195
+ leftFade: frame.leftFade,
196
+ workingEllipsis: isMessageKind(frame.kind) ? "…" : "",
197
+ },
140
198
  );
141
199
  // Leading spacer row: keeps the transcript from sitting flush on the box.
142
200
  const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
@@ -166,12 +224,37 @@ export default function (pi: ExtensionAPI) {
166
224
  const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
167
225
  return (tui, editorTheme, keybindings) => {
168
226
  activeTui = tui;
169
- const editor = inner
170
- ? inner(tui, editorTheme, keybindings)
171
- : new CustomEditor(tui, editorTheme, keybindings);
227
+ let editor: ReturnType<EditorFactory>;
228
+ if (inner) {
229
+ // A wrapped third-party editor is not opted in: pi keeps its
230
+ // standalone status-spinner row for it.
231
+ editor = inner(tui, editorTheme, keybindings);
232
+ activeEditor = undefined;
233
+ embeddedWorkingStatus = undefined;
234
+ embeddedStatusKind = undefined;
235
+ } else {
236
+ const own = new CustomEditor(tui, editorTheme, keybindings, {
237
+ embedWorkingStatus: state.effective.embedWorkingStatus,
238
+ });
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.
241
+ if (typeof own.setWorkingStatusIndicator === "function") {
242
+ const setIndicator = own.setWorkingStatusIndicator.bind(own);
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.
246
+ embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
247
+ embeddedStatusKind = indicator?.kind;
248
+ setIndicator(indicator);
249
+ };
250
+ }
251
+ activeEditor = own;
252
+ editor = own;
253
+ }
172
254
  const innerRender = editor.render.bind(editor);
173
255
  let boxRenderFailed = false;
174
256
  editor.render = width => {
257
+ if (boxRenderFailed) return innerRender(width);
175
258
  try {
176
259
  return renderBoxed(innerRender, width, editor);
177
260
  } catch (err) {
@@ -201,6 +284,8 @@ export default function (pi: ExtensionAPI) {
201
284
 
202
285
  const PI_STATS_CACHE_MS = 250;
203
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.
204
289
  builder.setPiStatsProvider(width => {
205
290
  if (!footerRenderOriginal) return undefined;
206
291
  const now = Date.now();
@@ -248,13 +333,17 @@ export default function (pi: ExtensionAPI) {
248
333
  }
249
334
  if (!restoreFooterLayout) {
250
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.
251
337
  restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
252
338
  }
253
339
  };
254
340
 
255
- pi.on("model_select", () => requestRender());
341
+ pi.on("model_select", () => {
342
+ syncBorderAnimation();
343
+ requestRender();
344
+ });
256
345
  pi.on("thinking_level_select", () => {
257
- syncRainbow();
346
+ syncBorderAnimation();
258
347
  requestRender();
259
348
  });
260
349
  pi.on("session_info_changed", () => requestRender());
@@ -264,15 +353,16 @@ export default function (pi: ExtensionAPI) {
264
353
  activeCtx = ctx;
265
354
  builder.attach(ctx);
266
355
  rateMonitor.enable();
267
- syncRainbow();
356
+ syncBorderAnimation();
268
357
  ensureInstalled();
358
+ syncEmbed();
269
359
  ensureFooterPatched();
270
360
  ensureTimer ??= setInterval(() => {
271
361
  ensureInstalled();
272
362
  ensureFooterPatched();
273
363
  // The host's /settings dialog can change the thinking level without
274
364
  // firing thinking_level_select, so reconcile the animator periodically.
275
- syncRainbow();
365
+ syncBorderAnimation();
276
366
  }, 1000);
277
367
  ensureTimer.unref?.();
278
368
  });
@@ -280,9 +370,9 @@ export default function (pi: ExtensionAPI) {
280
370
  pi.on("session_shutdown", () => {
281
371
  rateMonitor.disable();
282
372
  builder.dispose();
283
- if (rainbowTimer) {
284
- clearInterval(rainbowTimer);
285
- rainbowTimer = undefined;
373
+ if (borderTimer) {
374
+ clearInterval(borderTimer);
375
+ borderTimer = undefined;
286
376
  }
287
377
  if (ensureTimer) {
288
378
  clearInterval(ensureTimer);
@@ -300,6 +390,11 @@ export default function (pi: ExtensionAPI) {
300
390
  restoreFooterLayout = undefined;
301
391
  ourFactory = undefined;
302
392
  wrappedFactory = undefined;
393
+ activeEditor = undefined;
394
+ embeddedWorkingStatus = undefined;
395
+ embeddedStatusKind = undefined;
396
+ stopWorkingFadeTimer();
397
+ statusTransition.reset();
303
398
  activeTui = undefined;
304
399
  activeCtx = undefined;
305
400
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.1.4",
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,9 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "0.84.4",
49
- "@earendil-works/pi-coding-agent": "0.84.4",
50
- "@earendil-works/pi-tui": "0.84.4",
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",
51
51
  "@types/node": "^22.0.0",
52
52
  "typescript": "^5.7.0"
53
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/context.ts CHANGED
@@ -63,7 +63,7 @@ function deriveContextUsage(ctx: ExtensionContext | undefined): {
63
63
  const usage = ctx?.getContextUsage();
64
64
  const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
65
65
  const tokens = usage?.tokens ?? undefined;
66
- const percent = usage ? usage.percent : contextWindow > 0 && tokens != null ? (tokens / contextWindow) * 100 : null;
66
+ const percent = usage ? usage.percent : null;
67
67
  return { contextWindow, tokens, percent };
68
68
  }
69
69
 
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
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
3
3
  *
4
- * Overflow strategy (in order): drop right segments right-to-left, shrink the
5
- * elastic `path` segment down to ~8 cells, drop left segments end-first while
6
- * protecting `path`. The gap between the two groups is filled with the
4
+ * Overflow strategy (in order): drop right segments right-to-left, truncate
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
7
10
  * box-horizontal glyph colored like the editor border, so it tracks the
8
11
  * thinking-level border color; the callback receives the gap's absolute
9
12
  * column and row in the box, so a caller can paint it as part of a
10
13
  * continuous border gradient (the rainbow effect).
14
+ *
15
+ * `options.leftFade` (0..1) blends every left segment after a leading `pi`
16
+ * symbol toward the bar background, which the working-indicator transition
17
+ * uses to cross-fade the group without moving the symbol or its chevron.
11
18
  */
12
- import { visibleWidth } from "@earendil-works/pi-tui";
19
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
20
  import { renderSegment } from "./segments.js";
14
21
  import { getSeparator } from "./separators.js";
15
22
  import { theme } from "./theme.js";
@@ -58,6 +65,7 @@ export function buildStatusLine(
58
65
  gapBorderColor: (str: string, startCol: number, row: number) => string,
59
66
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
60
67
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
68
+ options: { leftFade?: number; workingEllipsis?: string } = {},
61
69
  ): string {
62
70
  const separatorDef = getSeparator(settings.separator);
63
71
 
@@ -112,6 +120,19 @@ export function buildStatusLine(
112
120
  right.pop();
113
121
  rightWidth = rightGroupWidth(right);
114
122
  }
123
+ // The working status absorbs overflow first so the Pi symbol beside it
124
+ // survives on narrow terminals.
125
+ const workingIdx = leftSegIds.indexOf("working");
126
+ if (workingIdx >= 0 && totalWidth() > width) {
127
+ const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
128
+ if (available >= 1) {
129
+ left[workingIdx] = truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "");
130
+ } else {
131
+ left.splice(workingIdx, 1);
132
+ leftSegIds.splice(workingIdx, 1);
133
+ }
134
+ leftWidth = leftGroupWidth(left);
135
+ }
115
136
  // Shrink path before dropping left segments — path is the only elastic segment
116
137
  const pathIdx = leftSegIds.indexOf("path");
117
138
  if (pathIdx >= 0 && totalWidth() > width) {
@@ -137,13 +158,25 @@ export function buildStatusLine(
137
158
  }
138
159
  }
139
160
 
140
- const renderGroup = (parts: string[], direction: "left" | "right"): string => {
161
+ const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
141
162
  if (parts.length === 0) return "";
142
163
  const sep = direction === "left" ? separatorDef.left : separatorDef.right;
143
164
  const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
144
-
165
+ const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
166
+
167
+ let body: string;
168
+ const fade = options.leftFade;
169
+ if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
170
+ // The faded tail opens with an explicit default-fg reset so plain text
171
+ // inside it is recolored too, not just segments that set their own color.
172
+ const head = parts.slice(0, fadeFrom).join(sepText);
173
+ const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
174
+ body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
175
+ } else {
176
+ body = parts.join(sepText);
177
+ }
145
178
  let content = bgAnsi + fgAnsi;
146
- content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
179
+ content += ` ${body} `;
147
180
  content += "\x1b[0m";
148
181
 
149
182
  if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
@@ -151,7 +184,7 @@ export function buildStatusLine(
151
184
  return content;
152
185
  };
153
186
 
154
- const leftGroup = renderGroup(left, "left");
187
+ const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
155
188
  const rightGroup = renderGroup(right, "right");
156
189
  if (!leftGroup && !rightGroup) return "";
157
190
 
@@ -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
+ }