@underactive/pi-topping-statusline 0.1.4 → 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.
package/README.md CHANGED
@@ -42,6 +42,7 @@ 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
46
  ║ ║
46
47
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
47
48
  ║ [■] Pi symbol ON ║
@@ -66,18 +67,18 @@ and apply live.
66
67
  ║ + add feed ║
67
68
  ║ ║
68
69
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
69
- ╚════════════════════════════════════════════════════════[ 1/29 ]╝
70
+ ╚════════════════════════════════════════════════════════[ 1/30 ]╝
70
71
  ```
71
72
 
72
73
  | Section | Settings |
73
74
  | --- | --- |
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 |
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 |
75
76
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
76
77
  | Top Right Segment Group | Token rate · Session name |
77
78
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
78
79
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
79
80
  | 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 |
81
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
81
82
 
82
83
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
83
84
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -87,6 +88,17 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
87
88
  phase too. Any other thinking level keeps the normal theme border color. Disable animation over
88
89
  slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
89
90
 
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.
101
+
90
102
  ## Segments
91
103
 
92
104
  Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
package/index.ts CHANGED
@@ -37,11 +37,16 @@ import {
37
37
  import { buildStatusLine } from "./src/layout.js";
38
38
  import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
39
39
  import { registerSettingsCommand } from "./src/settings-menu.js";
40
- import { createSettingsState } from "./src/settings.js";
40
+ import { createSettingsState, topLeftSegments } from "./src/settings.js";
41
+ import { easeFade } from "./src/theme.js";
41
42
  import { theme } from "./src/theme.js";
42
43
  import { TokenRateMonitor } from "./src/token-rate.js";
43
44
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
44
45
 
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;
49
+
45
50
  export default function (pi: ExtensionAPI) {
46
51
  const state = createSettingsState();
47
52
  const builder = new SegmentContextBuilder(pi);
@@ -54,9 +59,77 @@ export default function (pi: ExtensionAPI) {
54
59
  let patchedFooter: Component | undefined;
55
60
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
56
61
  let restoreFooterLayout: (() => void) | undefined;
62
+ // 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.
65
+ let activeEditor: CustomEditor | undefined;
66
+ 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;
73
+ let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
74
+ const stopWorkingFadeTimer = (): void => {
75
+ if (workingFadeTimer) clearInterval(workingFadeTimer);
76
+ workingFadeTimer = undefined;
77
+ };
78
+
79
+ const syncEmbed = (): void => {
80
+ const ctx = activeCtx;
81
+ const factory = ourFactory;
82
+ if (!activeEditor || !ctx || !factory) return;
83
+ const next = state.effective.embedWorkingStatus;
84
+ if (activeEditor.embedWorkingStatus === next) return;
85
+ ctx.ui.setEditorComponent(factory);
86
+ };
57
87
 
58
88
  const requestRender = () => activeTui?.requestRender();
59
89
  builder.setRequestRender(requestRender);
90
+ const startWorkingFadeTimer = (): void => {
91
+ if (workingFadeTimer) return;
92
+ workingFadeTimer = setInterval(() => {
93
+ if (!workingFade || Date.now() - workingFade.start >= WORKING_FADE_MS) {
94
+ workingFade = undefined;
95
+ stopWorkingFadeTimer();
96
+ }
97
+ requestRender();
98
+ }, WORKING_FADE_FRAME_MS);
99
+ workingFadeTimer.unref?.();
100
+ };
101
+
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
+ };
60
133
 
61
134
  const rateMonitor = new TokenRateMonitor(requestRender);
62
135
  rateMonitor.attach(pi);
@@ -90,6 +163,7 @@ export default function (pi: ExtensionAPI) {
90
163
 
91
164
  registerSettingsCommand(pi, state, builder, () => {
92
165
  syncRainbow();
166
+ syncEmbed();
93
167
  requestRender();
94
168
  });
95
169
 
@@ -127,16 +201,20 @@ export default function (pi: ExtensionAPI) {
127
201
  const rainbowOn = rainbowActive();
128
202
  const box = theme.getBox(effective.borderStyle);
129
203
  const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
204
+ // The layout truncates the status to fit, so it is rendered at full width here.
205
+ const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
206
+ const { working, leftFade } = resolveWorkingFrame(live);
130
207
  const bar = buildStatusLine(
131
208
  innerWidth,
132
- segCtx,
209
+ { ...segCtx, workingStatus: working },
133
210
  effective,
134
211
  painters.gapColor,
135
212
  {
136
- left: effective.leftSegments,
213
+ left: topLeftSegments(effective, working !== undefined),
137
214
  right: effective.rightSegments,
138
215
  },
139
216
  { col: 3, row: 0 },
217
+ { leftFade },
140
218
  );
141
219
  // Leading spacer row: keeps the transcript from sitting flush on the box.
142
220
  const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
@@ -166,9 +244,29 @@ export default function (pi: ExtensionAPI) {
166
244
  const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
167
245
  return (tui, editorTheme, keybindings) => {
168
246
  activeTui = tui;
169
- const editor = inner
170
- ? inner(tui, editorTheme, keybindings)
171
- : new CustomEditor(tui, editorTheme, keybindings);
247
+ let editor: ReturnType<EditorFactory>;
248
+ if (inner) {
249
+ // A wrapped third-party editor is not opted in: pi keeps its
250
+ // standalone working row for it.
251
+ editor = inner(tui, editorTheme, keybindings);
252
+ activeEditor = undefined;
253
+ embeddedWorkingStatus = undefined;
254
+ } else {
255
+ const own = new CustomEditor(tui, editorTheme, keybindings, {
256
+ embedWorkingStatus: state.effective.embedWorkingStatus,
257
+ });
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.
260
+ if (typeof own.setWorkingStatusIndicator === "function") {
261
+ const setIndicator = own.setWorkingStatusIndicator.bind(own);
262
+ own.setWorkingStatusIndicator = indicator => {
263
+ embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
264
+ setIndicator(indicator);
265
+ };
266
+ }
267
+ activeEditor = own;
268
+ editor = own;
269
+ }
172
270
  const innerRender = editor.render.bind(editor);
173
271
  let boxRenderFailed = false;
174
272
  editor.render = width => {
@@ -266,6 +364,7 @@ export default function (pi: ExtensionAPI) {
266
364
  rateMonitor.enable();
267
365
  syncRainbow();
268
366
  ensureInstalled();
367
+ syncEmbed();
269
368
  ensureFooterPatched();
270
369
  ensureTimer ??= setInterval(() => {
271
370
  ensureInstalled();
@@ -300,6 +399,12 @@ export default function (pi: ExtensionAPI) {
300
399
  restoreFooterLayout = undefined;
301
400
  ourFactory = undefined;
302
401
  wrappedFactory = undefined;
402
+ activeEditor = undefined;
403
+ embeddedWorkingStatus = undefined;
404
+ stopWorkingFadeTimer();
405
+ workingFade = undefined;
406
+ workingShown = false;
407
+ lastWorkingText = undefined;
303
408
  activeTui = undefined;
304
409
  activeCtx = undefined;
305
410
  });
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.0",
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,10 @@
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.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",
51
52
  "@types/node": "^22.0.0",
52
53
  "typescript": "^5.7.0"
53
54
  }
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/layout.ts CHANGED
@@ -1,15 +1,20 @@
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 (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
7
8
  * box-horizontal glyph colored like the editor border, so it tracks the
8
9
  * thinking-level border color; the callback receives the gap's absolute
9
10
  * column and row in the box, so a caller can paint it as part of a
10
11
  * continuous border gradient (the rainbow effect).
12
+ *
13
+ * `options.leftFade` (0..1) blends every left segment after a leading `pi`
14
+ * symbol toward the bar background, which the working-indicator transition
15
+ * uses to cross-fade the group without moving the symbol or its chevron.
11
16
  */
12
- import { visibleWidth } from "@earendil-works/pi-tui";
17
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
18
  import { renderSegment } from "./segments.js";
14
19
  import { getSeparator } from "./separators.js";
15
20
  import { theme } from "./theme.js";
@@ -58,6 +63,7 @@ export function buildStatusLine(
58
63
  gapBorderColor: (str: string, startCol: number, row: number) => string,
59
64
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
60
65
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
66
+ options: { leftFade?: number } = {},
61
67
  ): string {
62
68
  const separatorDef = getSeparator(settings.separator);
63
69
 
@@ -112,6 +118,19 @@ export function buildStatusLine(
112
118
  right.pop();
113
119
  rightWidth = rightGroupWidth(right);
114
120
  }
121
+ // The working status absorbs overflow first so the Pi symbol beside it
122
+ // survives on narrow terminals.
123
+ const workingIdx = leftSegIds.indexOf("working");
124
+ if (workingIdx >= 0 && totalWidth() > width) {
125
+ const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
126
+ if (available >= 1) {
127
+ left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
128
+ } else {
129
+ left.splice(workingIdx, 1);
130
+ leftSegIds.splice(workingIdx, 1);
131
+ }
132
+ leftWidth = leftGroupWidth(left);
133
+ }
115
134
  // Shrink path before dropping left segments — path is the only elastic segment
116
135
  const pathIdx = leftSegIds.indexOf("path");
117
136
  if (pathIdx >= 0 && totalWidth() > width) {
@@ -137,13 +156,25 @@ export function buildStatusLine(
137
156
  }
138
157
  }
139
158
 
140
- const renderGroup = (parts: string[], direction: "left" | "right"): string => {
159
+ const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
141
160
  if (parts.length === 0) return "";
142
161
  const sep = direction === "left" ? separatorDef.left : separatorDef.right;
143
162
  const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
144
-
163
+ const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
164
+
165
+ let body: string;
166
+ const fade = options.leftFade;
167
+ if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
168
+ // The faded tail opens with an explicit default-fg reset so plain text
169
+ // inside it is recolored too, not just segments that set their own color.
170
+ const head = parts.slice(0, fadeFrom).join(sepText);
171
+ const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
172
+ body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
173
+ } else {
174
+ body = parts.join(sepText);
175
+ }
145
176
  let content = bgAnsi + fgAnsi;
146
- content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
177
+ content += ` ${body} `;
147
178
  content += "\x1b[0m";
148
179
 
149
180
  if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
@@ -151,7 +182,7 @@ export function buildStatusLine(
151
182
  return content;
152
183
  };
153
184
 
154
- const leftGroup = renderGroup(left, "left");
185
+ const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
155
186
  const rightGroup = renderGroup(right, "right");
156
187
  if (!leftGroup && !rightGroup) return "";
157
188
 
package/src/segments.ts CHANGED
@@ -295,6 +295,20 @@ const scrollHintSegment: StatusLineSegment = {
295
295
  },
296
296
  };
297
297
 
298
+ /**
299
+ * Pi's embedded working indicator (spinner + message), already colored by the
300
+ * host, so it is passed through verbatim. The layout truncates it, without an
301
+ * ellipsis, when it overflows, mirroring pi's own border rendering.
302
+ */
303
+ const workingSegment: StatusLineSegment = {
304
+ id: "working",
305
+ render(ctx) {
306
+ const status = ctx.workingStatus;
307
+ if (!status) return INVISIBLE;
308
+ return { content: status, visible: true };
309
+ },
310
+ };
311
+
298
312
  /** Context bar + stats — related parts joined by a space, not the group separator. */
299
313
  const contextGraphSegment: StatusLineSegment = {
300
314
  id: "context_graph",
@@ -351,6 +365,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
351
365
  feeds: feedsSegment,
352
366
  context_graph: contextGraphSegment,
353
367
  scroll_hint: scrollHintSegment,
368
+ working: workingSegment,
354
369
  };
355
370
 
356
371
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -19,6 +19,7 @@ import {
19
19
  saveSettings,
20
20
  sanitizeFeeds,
21
21
  SEPARATORS,
22
+ topLeftSegments,
22
23
  type SettingsState,
23
24
  } from "./settings.js";
24
25
  import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
@@ -140,6 +141,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
140
141
  label: "Animate rainbow border",
141
142
  value: settings.rainbowAnimation ?? true,
142
143
  },
144
+ {
145
+ id: "embedWorkingStatus",
146
+ label: "Embed 'Working' indicator",
147
+ value: settings.embedWorkingStatus ?? false,
148
+ },
143
149
  ],
144
150
  };
145
151
  return [
@@ -170,6 +176,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
170
176
  feeds: sanitizeFeeds(feedsFromValues(values)),
171
177
  rainbowBorder: values.rainbowBorder === true,
172
178
  rainbowAnimation: values.rainbowAnimation === true,
179
+ embedWorkingStatus: values.embedWorkingStatus === true,
173
180
  };
174
181
  }
175
182
 
@@ -184,6 +191,8 @@ const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
184
191
  const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
185
192
  const CANNED_PERCENT = 42;
186
193
  const CANNED_WINDOW = 200_000;
194
+ /** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
195
+ const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
187
196
 
188
197
  /** Fill any feed the live session has no entry for, so the preview stays legible. */
189
198
  function cannedFeedData(
@@ -266,13 +275,14 @@ class StatusLinePreview {
266
275
  bottomIdx,
267
276
  flat: s => this.#uiTheme.fg("border", s),
268
277
  });
278
+ const embedOn = effective.embedWorkingStatus;
269
279
  const top = buildStatusLine(
270
280
  barWidth,
271
- ctx,
281
+ embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", CANNED_WORKING) } : ctx,
272
282
  effective,
273
283
  painters.gapColor,
274
284
  {
275
- left: effective.leftSegments,
285
+ left: topLeftSegments(effective, embedOn),
276
286
  right: effective.rightSegments,
277
287
  },
278
288
  { col: 3, row: 0 },
@@ -312,7 +322,7 @@ export function registerSettingsCommand(
312
322
  onChange: () => void,
313
323
  ): void {
314
324
  pi.registerCommand("topping-statusline-settings", {
315
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border animation",
325
+ description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded working indicator",
316
326
  handler: async (_args, ctx: ExtensionCommandContext) => {
317
327
  if (ctx.mode !== "tui") {
318
328
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
package/src/settings.ts CHANGED
@@ -93,6 +93,7 @@ function loadSettings(): StatusLineSettings {
93
93
  if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
94
94
  if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
95
95
  if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
96
+ if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
96
97
  return settings;
97
98
  } catch {
98
99
  // Missing or corrupt settings.json falls back to defaults.
@@ -155,6 +156,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
155
156
  bottomRightSegments,
156
157
  rainbowBorder: settings.rainbowBorder ?? true,
157
158
  rainbowAnimation: settings.rainbowAnimation ?? true,
159
+ embedWorkingStatus: settings.embedWorkingStatus ?? false,
158
160
  includes,
159
161
  segmentOptions: {
160
162
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
@@ -165,6 +167,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
165
167
  };
166
168
  }
167
169
 
170
+ /**
171
+ * The top-left group for one frame. While pi's working status is embedded,
172
+ * it stands in for every left segment except the leading Pi symbol, so the
173
+ * symbol and its chevron never move when a stream starts or ends.
174
+ */
175
+ export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
176
+ if (!working) return effective.leftSegments;
177
+ return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
178
+ }
179
+
168
180
  export interface SettingsState {
169
181
  settings: StatusLineSettings;
170
182
  effective: EffectiveStatusLineSettings;
@@ -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
@@ -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,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
  /**