@underactive/pi-topping-statusline 0.1.3 → 0.2.0

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,140 @@
1
+ /**
2
+ * Contracts for the opt-in embedded working 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 (no ellipsis) before any other left segment is dropped.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { buildStatusLine } from "../layout.ts";
11
+ import { SEGMENTS } from "../segments.ts";
12
+ import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
13
+ import { easeFade, theme } from "../theme.ts";
14
+ import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
15
+
16
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
17
+ const noGap = (s: string) => s;
18
+
19
+ const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
20
+
21
+ function ctxWith(workingStatus: string | undefined): SegmentContext {
22
+ return {
23
+ options: EFFECTIVE.segmentOptions,
24
+ model: undefined,
25
+ thinkingLevel: "off",
26
+ cwd: "/tmp/pi-topping-statusline-tests",
27
+ sessionName: undefined,
28
+ contextPercent: null,
29
+ contextTokens: 0,
30
+ contextWindow: 0,
31
+ git: { branch: null, status: null, pr: null },
32
+ worktree: null,
33
+ scrollHint: undefined,
34
+ piStats: undefined,
35
+ tokenRate: undefined,
36
+ feedData: undefined,
37
+ workingStatus,
38
+ };
39
+ }
40
+
41
+ test("working segment renders the host text verbatim", () => {
42
+ const status = "\x1b[36m⠙ Working\x1b[39m";
43
+ assert.deepEqual(SEGMENTS.working.render(ctxWith(status)), { content: status, visible: true });
44
+ });
45
+
46
+ test("working segment is invisible when idle", () => {
47
+ assert.equal(SEGMENTS.working.render(ctxWith(undefined)).visible, false);
48
+ assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
49
+ });
50
+
51
+ test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
52
+ assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
53
+ assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
54
+ assert.ok(EFFECTIVE.leftSegments.length > 2, "default left group has more than pi + one segment");
55
+ });
56
+
57
+ test("topLeftSegments yields only working when the pi symbol is off", () => {
58
+ const noPi = resolveEffectiveSettings({ segments: { pi: false } });
59
+ assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
60
+ });
61
+
62
+ test("embedWorkingStatus resolves off by default and on when set", () => {
63
+ assert.equal(resolveEffectiveSettings({}).embedWorkingStatus, false);
64
+ assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
65
+ });
66
+
67
+ test("top bar renders pi symbol, separator, then the status", () => {
68
+ const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
69
+ left: ["pi", "working"],
70
+ right: [],
71
+ });
72
+ const plain = stripAnsi(bar);
73
+ const piAt = plain.indexOf(theme.icon.pi);
74
+ const statusAt = plain.indexOf("⠙ Mulling 28 tps");
75
+ assert.ok(piAt >= 0, "pi symbol present");
76
+ assert.ok(statusAt > piAt, "status follows the pi symbol");
77
+ assert.equal(visibleWidth(bar), 80);
78
+ });
79
+
80
+ test("narrow widths truncate the status without an ellipsis and keep the pi symbol", () => {
81
+ const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
82
+ const bar = buildStatusLine(20, ctxWith(status), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
83
+ const plain = stripAnsi(bar);
84
+ assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
85
+ assert.ok(plain.includes("⠙ Mull"), "status head is kept");
86
+ assert.ok(!plain.includes("…"), "no ellipsis");
87
+ assert.ok(!plain.includes("tokens"), "status tail is cut");
88
+ assert.equal(visibleWidth(bar), 20);
89
+ });
90
+
91
+ test("a long ANSI-styled status never exceeds the bar width", () => {
92
+ const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
93
+ for (const width of [12, 16, 24, 40, 60]) {
94
+ const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
95
+ left: ["pi", "working"],
96
+ right: [],
97
+ });
98
+ assert.equal(visibleWidth(bar), width, `width ${width}`);
99
+ assert.ok(stripAnsi(bar).includes(theme.icon.pi), `pi symbol at width ${width}`);
100
+ }
101
+ });
102
+
103
+ test("the status drops entirely rather than the pi symbol when there is no room", () => {
104
+ const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
105
+ const plain = stripAnsi(bar);
106
+ assert.ok(plain.includes(theme.icon.pi));
107
+ assert.ok(!plain.includes("⠙"));
108
+ });
109
+
110
+ test("fadeAnsi leaves text untouched at full opacity and sinks colors into the bar at zero", () => {
111
+ const styled = "\x1b[38;2;254;188;56mA\x1b[39m \x1b[48;5;70mB\x1b[49m";
112
+ assert.equal(theme.fadeAnsi(styled, 1), styled);
113
+ const sunk = theme.fadeAnsi(styled, 0);
114
+ assert.equal(
115
+ sunk,
116
+ `${theme.getFgAnsi("statusLineBg")}A${theme.getFgAnsi("statusLineBg")} ${theme.getBgAnsi()}B\x1b[49m`,
117
+ );
118
+ });
119
+
120
+ test("easeFade is monotonic and pinned at both ends", () => {
121
+ assert.equal(easeFade(0), 0);
122
+ assert.equal(easeFade(1), 1);
123
+ assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
124
+ });
125
+
126
+ test("leftFade recolors the tail but not the pi symbol or the bar width", () => {
127
+ const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
128
+ const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = { left: ["pi", "working"], right: [] };
129
+ const origin = { col: 0, row: 0 };
130
+ const build = (fade?: number) =>
131
+ buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
132
+ const solid = build();
133
+ const faded = build(0.2);
134
+ assert.equal(visibleWidth(faded), 60);
135
+ assert.equal(stripAnsi(faded), stripAnsi(solid));
136
+ assert.ok(!faded.includes("10;200;30"), "status color blended");
137
+ const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
138
+ assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
139
+ assert.equal(build(1), solid);
140
+ });
package/src/theme.ts CHANGED
@@ -178,7 +178,6 @@ type ColorValue = string | number;
178
178
 
179
179
  export type StatusColor =
180
180
  | "accent"
181
- | "success"
182
181
  | "error"
183
182
  | "warning"
184
183
  | "muted"
@@ -193,11 +192,13 @@ export type StatusColor =
193
192
  | "statusLineContextElevated"
194
193
  | "statusLineStaged"
195
194
  | "statusLineDirty"
196
- | "statusLineUntracked";
195
+ | "statusLineUntracked"
196
+ | "statusLineBg";
197
+
198
+ const STATUS_LINE_BG: ColorValue = "#121212";
197
199
 
198
200
  const FG_COLORS: Record<StatusColor, ColorValue> = {
199
201
  accent: "#febc38",
200
- success: "#89d281",
201
202
  error: "#fc3a4b",
202
203
  warning: "#e4c00f",
203
204
  muted: "#777d88",
@@ -213,10 +214,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
213
214
  statusLineStaged: 70,
214
215
  statusLineDirty: 178,
215
216
  statusLineUntracked: 39,
217
+ statusLineBg: STATUS_LINE_BG,
216
218
  };
217
219
 
218
- const STATUS_LINE_BG: ColorValue = "#121212";
219
-
220
220
  /** Dominant palette hexes the session accent must not collide with. */
221
221
  export const MAJOR_COLOR_HEXES = [
222
222
  "#febc38",
@@ -246,9 +246,21 @@ export function parseHex(hex: string): [number, number, number] | undefined {
246
246
  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
247
247
  }
248
248
 
249
- /** Number of discrete, eased shades used for the token-rate fade (pi-topping). */
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) {
@@ -275,11 +287,14 @@ function rgbTo256(r: number, g: number, b: number): number {
275
287
  return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
276
288
  }
277
289
 
290
+ function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
291
+ return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
292
+ }
293
+
278
294
  export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
279
295
  const rgb = parseHex(hex);
280
296
  if (!rgb) return "";
281
- const [r, g, b] = rgb;
282
- return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
297
+ return rgbToFgAnsi(...rgb, mode);
283
298
  }
284
299
 
285
300
  function fgAnsi(color: ColorValue, mode: ColorMode): string {
@@ -355,16 +370,44 @@ class StatusTheme {
355
370
  fadeFg(from: StatusColor, to: StatusColor, shade: number, text: string): string {
356
371
  const clamped = Math.max(0, Math.min(FADE_SHADE_COUNT - 1, Math.floor(shade)));
357
372
  const progress = (clamped + 1) / FADE_SHADE_COUNT;
358
- const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
373
+ const eased = easeFade(progress);
359
374
  const a = colorValueToRgb(FG_COLORS[from]);
360
375
  const b = colorValueToRgb(FG_COLORS[to]);
361
376
  const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
362
377
  const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
363
378
  const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
364
- const ansi =
365
- this.#mode === "truecolor" ? `\x1b[38;2;${r};${g};${bl}m` : `\x1b[38;5;${rgbTo256(r, g, bl)}m`;
379
+ const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
366
380
  return `${ansi}${text}\x1b[39m`;
367
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
+ }
368
411
  }
369
412
 
370
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,8 @@ export interface StatusLineSettings {
73
74
  rainbowBorder?: boolean;
74
75
  /** Animate the rainbow border when it is visible. */
75
76
  rainbowAnimation?: boolean;
77
+ /** Render pi's streaming working status in the top bar instead of its own row. */
78
+ embedWorkingStatus?: boolean;
76
79
  }
77
80
 
78
81
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -105,6 +108,7 @@ export interface EffectiveStatusLineSettings {
105
108
  segmentOptions: StatusLineSegmentOptions;
106
109
  rainbowBorder: boolean;
107
110
  rainbowAnimation: boolean;
111
+ embedWorkingStatus: boolean;
108
112
  includes: SegmentIncludes;
109
113
  }
110
114
 
@@ -133,6 +137,11 @@ export interface SegmentContext {
133
137
  scrollHint: string | undefined;
134
138
  /** Pi's own footer stats, when this frame needs them. */
135
139
  piStats: string | undefined;
140
+ /**
141
+ * Pi's embedded working indicator, pre-rendered and pre-colored by the host
142
+ * for this frame. Absent while no response is streaming.
143
+ */
144
+ workingStatus?: string;
136
145
  /** Live tok/s display state, when this frame needs it. */
137
146
  tokenRate: TokenRateDisplay | undefined;
138
147
  /**
@@ -142,6 +151,19 @@ export interface SegmentContext {
142
151
  * entries, and those must not be shown as current.
143
152
  */
144
153
  feedData: Record<string, unknown> | undefined;
154
+ /**
155
+ * Lifecycle state per configured feed field. Missing state is treated as
156
+ * active so previews and manually constructed contexts retain full color.
157
+ */
158
+ feedDisplayState?: Record<string, FeedDisplayState>;
159
+ }
160
+
161
+ export type FeedPhase = "active" | "fading" | "hidden";
162
+
163
+ export interface FeedDisplayState {
164
+ phase: FeedPhase;
165
+ /** Discrete fade step (0..FADE_SHADE_COUNT-1); meaningful while fading. */
166
+ fadeShade: number;
145
167
  }
146
168
 
147
169
  export type TokenRatePhase = "active" | "fading" | "idle";
package/src/utils.ts CHANGED
@@ -65,10 +65,7 @@ export function clampPathLength(pwd: string, maxLen: number): string {
65
65
 
66
66
  /** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
67
67
  export function sanitizeStatusText(text: string): string {
68
- return stripVTControlCharacters(text)
69
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
70
- .replace(/ +/g, " ")
71
- .trim();
68
+ return sanitizeLabel(text).trim();
72
69
  }
73
70
 
74
71
  /**
@@ -77,7 +74,7 @@ export function sanitizeStatusText(text: string): string {
77
74
  */
78
75
  export function sanitizeLabel(text: string): string {
79
76
  return stripVTControlCharacters(text)
80
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
77
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, " ")
81
78
  .replace(/ +/g, " ");
82
79
  }
83
80
 
@@ -85,6 +82,11 @@ export function withIcon(icon: string, text: string): string {
85
82
  return icon ? `${icon} ${text}` : text;
86
83
  }
87
84
 
85
+ /** Unambiguous identity for one configured field of a feed publisher. */
86
+ export function feedKey(customType: string, field: string): string {
87
+ return `${customType}\u0000${field}`;
88
+ }
89
+
88
90
  // ═══════════════════════════════════════════════════════════════════════════
89
91
  // Session accent (djb2 hue hash, dark warm band, collision avoidance)
90
92
  // ═══════════════════════════════════════════════════════════════════════════