@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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Contracts for the opt-in embedded status indicator: the `working` segment
3
+ * passes pi's pre-rendered status through verbatim, the top-left group swaps
4
+ * everything but the Pi symbol for it while a stream is live, and the layout
5
+ * truncates it before any other left segment is dropped. The caller chooses
6
+ * whether truncated status text uses an ellipsis.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import test from "node:test";
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+ import { buildStatusLine } from "../layout.ts";
12
+ import { SEGMENTS } from "../segments.ts";
13
+ import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
14
+ import { easeFade, theme } from "../theme.ts";
15
+ import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
16
+
17
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
18
+ const noGap = (s: string) => s;
19
+
20
+ const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
21
+
22
+ function ctxWith(workingStatus: string | undefined): SegmentContext {
23
+ return {
24
+ options: EFFECTIVE.segmentOptions,
25
+ model: undefined,
26
+ thinkingLevel: "off",
27
+ cwd: "/tmp/pi-topping-statusline-tests",
28
+ sessionName: undefined,
29
+ contextPercent: null,
30
+ contextTokens: 0,
31
+ contextWindow: 0,
32
+ git: { branch: null, status: null, pr: null },
33
+ worktree: null,
34
+ scrollHint: undefined,
35
+ piStats: undefined,
36
+ tokenRate: undefined,
37
+ feedData: undefined,
38
+ workingStatus,
39
+ };
40
+ }
41
+
42
+ test("working segment renders the host text verbatim", () => {
43
+ const status = "\x1b[36m⠙ Working\x1b[39m";
44
+ assert.deepEqual(SEGMENTS.working.render(ctxWith(status)), { content: status, visible: true });
45
+ });
46
+
47
+ test("working segment is invisible when idle", () => {
48
+ assert.equal(SEGMENTS.working.render(ctxWith(undefined)).visible, false);
49
+ assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
50
+ });
51
+
52
+ test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
53
+ assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
54
+ assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
55
+ assert.ok(EFFECTIVE.leftSegments.length > 2, "default left group has more than pi + one segment");
56
+ });
57
+
58
+ test("topLeftSegments yields only working when the pi symbol is off", () => {
59
+ const noPi = resolveEffectiveSettings({ segments: { pi: false } });
60
+ assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
61
+ });
62
+
63
+ test("embedWorkingStatus resolves off by default and on when set", () => {
64
+ assert.equal(resolveEffectiveSettings({}).embedWorkingStatus, false);
65
+ assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
66
+ });
67
+
68
+ test("top bar renders pi symbol, separator, then the status", () => {
69
+ const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
70
+ left: ["pi", "working"],
71
+ right: [],
72
+ });
73
+ const plain = stripAnsi(bar);
74
+ const piAt = plain.indexOf(theme.icon.pi);
75
+ const statusAt = plain.indexOf("⠙ Mulling 28 tps");
76
+ assert.ok(piAt >= 0, "pi symbol present");
77
+ assert.ok(statusAt > piAt, "status follows the pi symbol");
78
+ assert.equal(visibleWidth(bar), 80);
79
+ });
80
+
81
+ test("narrow widths truncate the status without an ellipsis and keep the pi symbol", () => {
82
+ const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
83
+ const bar = buildStatusLine(20, ctxWith(status), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
84
+ const plain = stripAnsi(bar);
85
+ assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
86
+ assert.ok(plain.includes("⠙ Mull"), "status head is kept");
87
+ assert.ok(!plain.includes("…"), "no ellipsis");
88
+ assert.ok(!plain.includes("tokens"), "status tail is cut");
89
+ assert.equal(visibleWidth(bar), 20);
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
+
112
+ test("a long ANSI-styled status never exceeds the bar width", () => {
113
+ const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
114
+ for (const width of [12, 16, 24, 40, 60]) {
115
+ const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
116
+ left: ["pi", "working"],
117
+ right: [],
118
+ });
119
+ assert.equal(visibleWidth(bar), width, `width ${width}`);
120
+ assert.ok(stripAnsi(bar).includes(theme.icon.pi), `pi symbol at width ${width}`);
121
+ }
122
+ });
123
+
124
+ test("the status drops entirely rather than the pi symbol when there is no room", () => {
125
+ const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
126
+ const plain = stripAnsi(bar);
127
+ assert.ok(plain.includes(theme.icon.pi));
128
+ assert.ok(!plain.includes("⠙"));
129
+ });
130
+
131
+ test("fadeAnsi leaves text untouched at full opacity and sinks colors into the bar at zero", () => {
132
+ const styled = "\x1b[38;2;254;188;56mA\x1b[39m \x1b[48;5;70mB\x1b[49m";
133
+ assert.equal(theme.fadeAnsi(styled, 1), styled);
134
+ const sunk = theme.fadeAnsi(styled, 0);
135
+ assert.equal(
136
+ sunk,
137
+ `${theme.getFgAnsi("statusLineBg")}A${theme.getFgAnsi("statusLineBg")} ${theme.getBgAnsi()}B\x1b[49m`,
138
+ );
139
+ });
140
+
141
+ test("easeFade is monotonic and pinned at both ends", () => {
142
+ assert.equal(easeFade(0), 0);
143
+ assert.equal(easeFade(1), 1);
144
+ assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
145
+ });
146
+
147
+ test("leftFade recolors the tail but not the pi symbol or the bar width", () => {
148
+ const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
149
+ const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = { left: ["pi", "working"], right: [] };
150
+ const origin = { col: 0, row: 0 };
151
+ const build = (fade?: number) =>
152
+ buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
153
+ const solid = build();
154
+ const faded = build(0.2);
155
+ assert.equal(visibleWidth(faded), 60);
156
+ assert.equal(stripAnsi(faded), stripAnsi(solid));
157
+ assert.ok(!faded.includes("10;200;30"), "status color blended");
158
+ const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
159
+ assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
160
+ assert.equal(build(1), solid);
161
+ });
package/src/theme.ts CHANGED
@@ -249,6 +249,18 @@ export function parseHex(hex: string): [number, number, number] | undefined {
249
249
  /** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
250
250
  export const FADE_SHADE_COUNT = 5;
251
251
 
252
+ /** Stand-in for the terminal's default foreground when a fade must recolor it. */
253
+ const DEFAULT_FG_RGB: [number, number, number] = [204, 204, 204];
254
+
255
+ /** Every SGR color the layout or a segment can emit: truecolor, 256-color, or the default-fg reset. */
256
+ const SGR_COLOR_RE = /\x1b\[(38|48);2;(\d+);(\d+);(\d+)m|\x1b\[(38|48);5;(\d+)m|\x1b\[39m/g;
257
+
258
+ /** Raised-cosine ease shared by the discrete and continuous fades. */
259
+ export function easeFade(progress: number): number {
260
+ const clamped = Math.max(0, Math.min(1, progress));
261
+ return 0.5 * (1 - Math.cos(Math.PI * clamped));
262
+ }
263
+
252
264
  /** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
253
265
  function xterm256ToRgb(n: number): [number, number, number] {
254
266
  if (n >= 232) {
@@ -358,7 +370,7 @@ class StatusTheme {
358
370
  fadeFg(from: StatusColor, to: StatusColor, shade: number, text: string): string {
359
371
  const clamped = Math.max(0, Math.min(FADE_SHADE_COUNT - 1, Math.floor(shade)));
360
372
  const progress = (clamped + 1) / FADE_SHADE_COUNT;
361
- const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
373
+ const eased = easeFade(progress);
362
374
  const a = colorValueToRgb(FG_COLORS[from]);
363
375
  const b = colorValueToRgb(FG_COLORS[to]);
364
376
  const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
@@ -367,6 +379,35 @@ class StatusTheme {
367
379
  const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
368
380
  return `${ansi}${text}\x1b[39m`;
369
381
  }
382
+
383
+ /**
384
+ * Blend every color already present in `text` toward the bar background
385
+ * by `opacity` (1 = untouched, 0 = fully sunk into the background), so a
386
+ * fully styled group can fade as a whole without re-rendering its segments.
387
+ * Default-foreground resets are recolored from a neutral stand-in so plain
388
+ * labels fade alongside their colored neighbors; background resets (49)
389
+ * stay put so transparent bars keep the terminal's own background.
390
+ */
391
+ fadeAnsi(text: string, opacity: number): string {
392
+ const alpha = Math.max(0, Math.min(1, opacity));
393
+ if (alpha >= 1) return text;
394
+ const target = colorValueToRgb(STATUS_LINE_BG);
395
+ const mode = this.#mode;
396
+ return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
397
+ let plane = "38";
398
+ let rgb: [number, number, number] = DEFAULT_FG_RGB;
399
+ if (tcPlane) {
400
+ plane = tcPlane;
401
+ rgb = [Number(r), Number(g), Number(b)];
402
+ } else if (ixPlane) {
403
+ plane = ixPlane;
404
+ rgb = xterm256ToRgb(Number(index));
405
+ }
406
+ const mix = (i: number) => Math.round(rgb[i] * alpha + target[i] * (1 - alpha));
407
+ const fg = rgbToFgAnsi(mix(0), mix(1), mix(2), mode);
408
+ return plane === "38" ? fg : fg.replace("\x1b[38;", "\x1b[48;");
409
+ });
410
+ }
370
411
  }
371
412
 
372
413
  export const theme = new StatusTheme();
package/src/types.ts CHANGED
@@ -18,7 +18,8 @@ export type StatusLineSegmentId =
18
18
  | "token_rate"
19
19
  | "feeds"
20
20
  | "context_graph"
21
- | "scroll_hint";
21
+ | "scroll_hint"
22
+ | "working";
22
23
 
23
24
  /**
24
25
  * A feed the statusline subscribes to: custom session entries published by
@@ -73,6 +74,12 @@ export interface StatusLineSettings {
73
74
  rainbowBorder?: boolean;
74
75
  /** Animate the rainbow border when it is visible. */
75
76
  rainbowAnimation?: boolean;
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. */
82
+ embedWorkingStatus?: boolean;
76
83
  }
77
84
 
78
85
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -105,6 +112,9 @@ export interface EffectiveStatusLineSettings {
105
112
  segmentOptions: StatusLineSegmentOptions;
106
113
  rainbowBorder: boolean;
107
114
  rainbowAnimation: boolean;
115
+ nvidiaGreenBorder: boolean;
116
+ nvidiaGreenAnimation: boolean;
117
+ embedWorkingStatus: boolean;
108
118
  includes: SegmentIncludes;
109
119
  }
110
120
 
@@ -133,6 +143,11 @@ export interface SegmentContext {
133
143
  scrollHint: string | undefined;
134
144
  /** Pi's own footer stats, when this frame needs them. */
135
145
  piStats: string | undefined;
146
+ /**
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.
149
+ */
150
+ workingStatus?: string;
136
151
  /** Live tok/s display state, when this frame needs it. */
137
152
  tokenRate: TokenRateDisplay | undefined;
138
153
  /**
@@ -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
+ }