@underactive/pi-topping-statusline 0.2.0 → 0.2.2

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
@@ -36,13 +36,16 @@ and apply live.
36
36
  ║ ╰── ↑ 3 more ── ↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42 42%──╯ ║
37
37
  ║ ║
38
38
  ╟─ Global ───────────────────────────────────────────────────────╢
39
- ║ ▸ [■] Transparent Segments ON ║
39
+ ║ > [■] Transparent Segments ON ║
40
40
  ║ Separator ‹ powerline-thin › ║
41
41
  ║ Symbols ‹ nerdfont › ║
42
42
  ║ Border style ‹ rounded › ║
43
43
  ║ [■] Rainbow border on max thinking ON ║
44
44
  ║ [■] Animate rainbow border ON ║
45
- ║ [ ] Embed 'Working' indicator OFF ║
45
+ ║ [■] NVIDIA-green when using Switchyard ON ║
46
+ ║ [■] Animate Switchyard green border ON ║
47
+ ║ [ ] Embed status spinners OFF ║
48
+ ║ [■] Embed compaction progress ON ║
46
49
  ║ ║
47
50
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
48
51
  ║ [■] Pi symbol ON ║
@@ -67,18 +70,18 @@ and apply live.
67
70
  ║ + add feed ║
68
71
  ║ ║
69
72
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
70
- ╚════════════════════════════════════════════════════════[ 1/30 ]╝
73
+ ╚════════════════════════════════════════════════════════[ 1/33 ]╝
71
74
  ```
72
75
 
73
76
  | Section | Settings |
74
77
  | --- | --- |
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 |
78
+ | 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 · Embed compaction progress |
76
79
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
77
80
  | Top Right Segment Group | Token rate · Session name |
78
81
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
79
82
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
80
83
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
81
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
84
+ | 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 · Embed compaction progress on |
82
85
 
83
86
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
84
87
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -88,16 +91,49 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
88
91
  phase too. Any other thinking level keeps the normal theme border color. Disable animation over
89
92
  slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
90
93
 
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.
94
+ When the active model provider is `switchyard` and **NVIDIA-green when using Switchyard** is on (the
95
+ default), the border uses an NVIDIA-green gradient from `#84c51a` to `#0b3d20`. **Animate Switchyard
96
+ green border** is also on by default, so it sweeps around the box on the same ~14s cycle as the
97
+ max-thinking rainbow, independently of **Animate rainbow border**. Turn the animation setting off to
98
+ keep the green gradient at a fixed color phase without its repaint timer. When enabled, green takes
99
+ precedence over the max-thinking rainbow and ignores thinking level; turn the NVIDIA-green setting off
100
+ to restore the normal theme or rainbow border.
101
+
102
+ **Embed status spinners** (off by default) moves pi's status indicators out of their own row
103
+ and into the top-left group, right after the Pi symbol and its chevron. On pi 0.86 and later
104
+ this covers the working, retry, compaction, and branch-summary spinners; pi 0.85 embeds only
105
+ the working indicator. The configured model, provider, and thinking details remain visible after
106
+ the status; path, git, and PR step aside while a status is visible, and the Pi symbol never moves.
107
+ An appearing status slides out from behind the Pi symbol's chevron over 300ms, pushing the model
108
+ details right; one that replaces a status still on screen swaps in place without sliding.
109
+ A cleared status is held briefly so handoffs between spinners do not flash the left segments.
110
+ Only the working status cross-fades over 750ms; stable model details remain solid, and the
111
+ message-style spinners cut after the hold. While the bar hosts the working status, it announces
112
+ that on pi's extension event bus, and pi-topping's loader slides its response model out the same
113
+ way; anywhere else that model appears at once.
114
+
115
+ A status too long for the bar is truncated. The working spinner keeps pi's bare cut, while the
116
+ message-style spinners use an ellipsis. Focusing the embed row in the settings preview shows
117
+ the longest compaction sample. Requires pi 0.85 or later. Toggling the setting reinstalls the
118
+ editor through pi, so it applies immediately, even mid-response. Only the statusline's own
119
+ editor opts in: when another extension owns the editor slot and is wrapped, pi keeps its
120
+ standalone status row.
121
+
122
+ **Embed compaction progress** (on by default) hosts
123
+ [pi-topping-compact](https://github.com/underactive/pi-topping-compact)'s compaction progress in
124
+ the box's bottom border while a compaction runs. Its phosphor bar stands in for the context
125
+ graph, followed by the tokens being compacted over the window and as a share of it
126
+ (`86K/131K (66%)`), and `57% summarized · 16.3s` — how much has been summarized so far, and the
127
+ time taken — stands in for pi's stats. The bar and its label follow the Context bar and Context
128
+ stats toggles. The moment the compaction ends, pi's stats and the context graph return, the
129
+ graph sizing the compacted context as an estimate until the next response (see Segments), and
130
+ pi-topping-compact shows its completion result above the editor as it always has. Nothing
131
+ changes without pi-topping-compact installed: the two talk over pi's extension event bus, this
132
+ bar announcing whether it hosts the progress and pi-topping-compact broadcasting it only while
133
+ it does, otherwise keeping its own above-editor widget. With the setting off, with Pi stats,
134
+ Context bar, and Context stats all off, or with the terminal too narrow for the stand-ins, that
135
+ widget is used instead. Focusing the setting's row in the settings preview shows the bottom bar
136
+ mid-compaction.
101
137
 
102
138
  ## Segments
103
139
 
@@ -108,7 +144,17 @@ fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (liv
108
144
  accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
109
145
  pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
110
146
  bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
111
- (documented below).
147
+ (documented below), with `compaction_info` and `compaction_graph` standing in for `pi_stats` and
148
+ `context_graph` while pi-topping-compact's progress is hosted. On pi 0.86 and later, `pi_stats`
149
+ includes cache-warming refreshes folded
150
+ into pi's own footer totals, so cache R/W and cost can increase while idle; this segment matches
151
+ the host footer by construction.
152
+
153
+ Between a compaction and the next response, pi reports context usage as unknown. Rather than
154
+ vanish for that stretch, `context_graph` shows pi's own estimate of the compacted context, the
155
+ summary plus the kept messages sized by pi's estimator, marked with `~` (`~9.2%/131K`). It is
156
+ the same figure pi-topping-compact reports in its result. pi's measured figure replaces it after
157
+ the next response.
112
158
 
113
159
  When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
114
160
  then left segments drop end-first — the path is always the last to go.
package/index.ts CHANGED
@@ -26,6 +26,13 @@ import {
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import type { Component, TUI } from "@earendil-works/pi-tui";
28
28
  import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "./src/box.js";
29
+ import {
30
+ COMPACTION_EMBED_CHANNEL,
31
+ COMPACTION_FIT_PROBE,
32
+ COMPACTION_PROGRESS_CHANNEL,
33
+ hostsCompactionProgress,
34
+ parseCompactionProgress,
35
+ } from "./src/compaction-embed.js";
29
36
  import { SegmentContextBuilder } from "./src/context.js";
30
37
  import {
31
38
  collapseFooterLayoutSlot,
@@ -35,17 +42,23 @@ import {
35
42
  stripRedundantStats,
36
43
  } from "./src/footer.js";
37
44
  import { buildStatusLine } from "./src/layout.js";
38
- import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
45
+ import { NvidiaGreenBorder, isSwitchyardProvider } from "./src/nvidia-green.js";
46
+ import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder, type BorderColorizer } from "./src/rainbow.js";
39
47
  import { registerSettingsCommand } from "./src/settings-menu.js";
40
- import { createSettingsState, topLeftSegments } from "./src/settings.js";
41
- import { easeFade } from "./src/theme.js";
48
+ import { bottomRightSegments, COMPACTION_STAND_INS, createSettingsState, topLeftSegments } from "./src/settings.js";
42
49
  import { theme } from "./src/theme.js";
43
50
  import { TokenRateMonitor } from "./src/token-rate.js";
51
+ import type {
52
+ CompactionProgressView,
53
+ EffectiveStatusLineSettings,
54
+ SegmentContext,
55
+ StatusLineSegmentId,
56
+ } from "./src/types.js";
57
+ import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
58
+ import { hostsWorkingStatus, WORKING_STATUS_EMBED_CHANNEL } from "./src/working-status-embed.js";
44
59
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
45
60
 
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;
61
+ const STATUS_ANIMATION_FRAME_MS = 30;
49
62
 
50
63
  export default function (pi: ExtensionAPI) {
51
64
  const state = createSettingsState();
@@ -60,20 +73,16 @@ export default function (pi: ExtensionAPI) {
60
73
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
61
74
  let restoreFooterLayout: (() => void) | undefined;
62
75
  // 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.
76
+ // another extension's editor), and the host's status spinner captured from
77
+ // that editor. pi 0.85 sends only working; 0.86 sends all four kinds.
65
78
  let activeEditor: CustomEditor | undefined;
66
79
  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;
80
+ let embeddedStatusKind: StatusIndicatorKind | undefined;
81
+ const statusTransition = new StatusTransition();
82
+ let statusAnimationTimer: ReturnType<typeof setInterval> | undefined;
83
+ const stopStatusAnimationTimer = (): void => {
84
+ if (statusAnimationTimer) clearInterval(statusAnimationTimer);
85
+ statusAnimationTimer = undefined;
77
86
  };
78
87
 
79
88
  const syncEmbed = (): void => {
@@ -85,84 +94,112 @@ export default function (pi: ExtensionAPI) {
85
94
  ctx.ui.setEditorComponent(factory);
86
95
  };
87
96
 
97
+ // pi-topping slides its loader's response model out only while this bar hosts the loader.
98
+ const announceEmbed = (embedded: boolean): void => pi.events.emit(WORKING_STATUS_EMBED_CHANNEL, { embedded });
99
+
88
100
  const requestRender = () => activeTui?.requestRender();
89
101
  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
 
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
- }
103
+ // pi-topping-compact's latest progress broadcast, while one is active.
104
+ let compaction: CompactionProgressView | undefined;
105
+ // The boxed render failed and the plain editor is showing instead.
106
+ let boxRenderFailed = false;
107
+ // A COMPACTION_PROGRESS_CHANNEL payload with active: true that parseCompactionProgress
108
+ // could not parse: pi-topping-compact and this bar have drifted out of sync. Latched, not
109
+ // reset, since the mismatch persists until the extensions reload.
110
+ let compactionPayloadRejected = false;
111
+ const hostingCompaction = (): boolean =>
112
+ activeCtx !== undefined && !boxRenderFailed && !compactionPayloadRejected && hostsCompactionProgress(state.effective);
113
+ // The last verdict sent on COMPACTION_EMBED_CHANNEL, so the channel only fires on change.
114
+ let announcedCompactionEmbed: boolean | undefined;
115
+ const announceCompactionEmbed = (hosts: boolean): void => {
116
+ if (hosts === announcedCompactionEmbed) return;
117
+ announcedCompactionEmbed = hosts;
118
+ pi.events.emit(COMPACTION_EMBED_CHANNEL, { embedded: hosts });
119
+ };
120
+ // Whether the compaction stand-ins would have room, cached by (innerWidth, effective) since
121
+ // it only changes on a resize or a settings change, not every frame: measured against a
122
+ // representative view rather than a fixed cell count, so it tracks the border/symbol/
123
+ // transparency settings and which segments are toggled on instead of assuming defaults.
124
+ const noGapColor = (s: string) => s;
125
+ let compactionFitCache: { innerWidth: number; effective: EffectiveStatusLineSettings; fits: boolean } | undefined;
126
+ const compactionWouldFit = (innerWidth: number, ctx: SegmentContext, effective: EffectiveStatusLineSettings): boolean => {
127
+ if (compactionFitCache && compactionFitCache.innerWidth === innerWidth && compactionFitCache.effective === effective) {
128
+ return compactionFitCache.fits;
121
129
  }
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 };
130
+ const compactionRightIds = bottomRightSegments(effective, true);
131
+ let fitRight: readonly StatusLineSegmentId[] = compactionRightIds;
132
+ buildStatusLine(
133
+ innerWidth,
134
+ { ...ctx, compaction: COMPACTION_FIT_PROBE },
135
+ effective,
136
+ noGapColor,
137
+ { left: effective.bottomLeftSegments, right: compactionRightIds },
138
+ { col: 3, row: 0 },
139
+ { onFit: fit => { fitRight = fit.right; } },
140
+ );
141
+ const fits = Object.values(COMPACTION_STAND_INS).some(id => fitRight.includes(id));
142
+ compactionFitCache = { innerWidth, effective, fits };
143
+ return fits;
144
+ };
145
+ pi.events.on(COMPACTION_PROGRESS_CHANNEL, data => {
146
+ compaction = parseCompactionProgress(data);
147
+ if (compaction === undefined && typeof data === "object" && data !== null && (data as { active?: unknown }).active === true) {
148
+ compactionPayloadRejected = true;
149
+ announceCompactionEmbed(false);
128
150
  }
129
- const outgoing = elapsed < half;
130
- const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
131
- return { working: outgoing ? lastWorkingText : undefined, leftFade };
151
+ requestRender();
152
+ });
153
+ const startStatusAnimationTimer = (): void => {
154
+ if (statusAnimationTimer) return;
155
+ statusAnimationTimer = setInterval(() => {
156
+ requestRender();
157
+ if (!statusTransition.pending(Date.now())) stopStatusAnimationTimer();
158
+ }, STATUS_ANIMATION_FRAME_MS);
159
+ statusAnimationTimer.unref?.();
132
160
  };
133
161
 
134
162
  const rateMonitor = new TokenRateMonitor(requestRender);
135
163
  rateMonitor.attach(pi);
136
164
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
137
165
 
138
- // The rainbow is visible at max thinking independently of whether its hue
139
- // phase is animated. Stopping the timer preserves the current live phase.
166
+ // The active border colorizer is visible independently of whether its phase
167
+ // is animated. Stopping the timer preserves the current live phase.
140
168
  const rainbow = new RainbowBorder();
141
- let rainbowTimer: ReturnType<typeof setInterval> | undefined;
169
+ const green = new NvidiaGreenBorder();
170
+ let borderTimer: ReturnType<typeof setInterval> | undefined;
142
171
  const rainbowActive = (): boolean =>
143
172
  state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
144
- const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
145
- const syncRainbow = (): void => {
146
- if (rainbowAnimationActive()) {
147
- if (!rainbowTimer) {
148
- rainbowTimer = setInterval(() => {
149
- if (!rainbowAnimationActive()) {
150
- syncRainbow();
173
+ const greenActive = (): boolean =>
174
+ state.effective.nvidiaGreenBorder && activeCtx !== undefined && isSwitchyardProvider(activeCtx.model?.provider);
175
+ const borderColorizer = (): BorderColorizer | undefined => (greenActive() ? green : rainbowActive() ? rainbow : undefined);
176
+ const borderAnimationActive = (): boolean => {
177
+ if (greenActive()) return state.effective.nvidiaGreenAnimation;
178
+ if (rainbowActive()) return state.effective.rainbowAnimation;
179
+ return false;
180
+ };
181
+ const syncBorderAnimation = (): void => {
182
+ if (borderAnimationActive()) {
183
+ if (!borderTimer) {
184
+ borderTimer = setInterval(() => {
185
+ const colorizer = borderColorizer();
186
+ if (!borderAnimationActive() || colorizer === undefined) {
187
+ syncBorderAnimation();
151
188
  return;
152
189
  }
153
- rainbow.step(RAINBOW_DEG_PER_FRAME);
190
+ colorizer.step(RAINBOW_DEG_PER_FRAME);
154
191
  requestRender();
155
192
  }, RAINBOW_FRAME_MS);
156
- rainbowTimer.unref?.();
193
+ borderTimer.unref?.();
157
194
  }
158
- } else if (rainbowTimer) {
159
- clearInterval(rainbowTimer);
160
- rainbowTimer = undefined;
195
+ } else if (borderTimer) {
196
+ clearInterval(borderTimer);
197
+ borderTimer = undefined;
161
198
  }
162
199
  };
163
200
 
164
201
  registerSettingsCommand(pi, state, builder, () => {
165
- syncRainbow();
202
+ syncBorderAnimation();
166
203
  syncEmbed();
167
204
  requestRender();
168
205
  });
@@ -175,7 +212,10 @@ export default function (pi: ExtensionAPI) {
175
212
  editor: { borderColor?: (str: string) => string },
176
213
  ): string[] => {
177
214
  const ctx = activeCtx;
178
- if (!ctx || width < MIN_BOXED_WIDTH) return innerRender(width);
215
+ if (!ctx || width < MIN_BOXED_WIDTH) {
216
+ announceCompactionEmbed(false);
217
+ return innerRender(width);
218
+ }
179
219
 
180
220
  // Chrome totals 6 cells on every row, so the bar and the inner text rows
181
221
  // share one width budget. Border rows split it 3/3 (╭── … ──╮); text rows
@@ -190,31 +230,48 @@ export default function (pi: ExtensionAPI) {
190
230
  if ((editor as unknown as { autocompleteState?: unknown }).autocompleteState) {
191
231
  while (bottomIdx > 0 && !isBorderRow(lines[bottomIdx] ?? "")) bottomIdx--;
192
232
  }
193
- if (lines.length < 3 || bottomIdx < 2) return innerRender(width);
233
+ if (lines.length < 3 || bottomIdx < 2) {
234
+ announceCompactionEmbed(false);
235
+ return innerRender(width);
236
+ }
194
237
 
195
238
  const hint = stripAnsi(lines[bottomIdx] ?? "").match(/[↑↓] \d+ more/)?.[0] ?? "";
196
239
  const effective = state.effective;
197
- const include = effective.includes;
240
+ // The layout truncates the status to fit, so it is rendered at full width here.
241
+ const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
242
+ const frame = statusTransition.resolve(live, embeddedStatusKind, Date.now());
243
+ if (frame.pending) startStatusAnimationTimer();
244
+ const compactionCandidate = hostingCompaction();
245
+ let include = effective.includes;
246
+ if (frame.status !== undefined) include = { ...include, git: false, pr: false };
198
247
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
199
248
  // borderColor is assigned by the host after the factory returns — read late.
200
249
  const border = editor.borderColor ?? ((s: string) => s);
201
- const rainbowOn = rainbowActive();
250
+ const colorizer = borderColorizer();
202
251
  const box = theme.getBox(effective.borderStyle);
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);
252
+ const painters = makeBoxPainters({
253
+ colorizerOn: colorizer !== undefined,
254
+ colorizer: colorizer ?? rainbow,
255
+ box,
256
+ width,
257
+ bottomIdx,
258
+ flat: border,
259
+ });
207
260
  const bar = buildStatusLine(
208
261
  innerWidth,
209
- { ...segCtx, workingStatus: working },
262
+ { ...segCtx, workingStatus: frame.status },
210
263
  effective,
211
264
  painters.gapColor,
212
265
  {
213
- left: topLeftSegments(effective, working !== undefined),
266
+ left: topLeftSegments(effective, frame.status !== undefined),
214
267
  right: effective.rightSegments,
215
268
  },
216
269
  { col: 3, row: 0 },
217
- { leftFade },
270
+ {
271
+ leftFade: frame.leftFade,
272
+ workingReveal: frame.reveal,
273
+ workingEllipsis: isMessageKind(frame.kind) ? "…" : "",
274
+ },
218
275
  );
219
276
  // Leading spacer row: keeps the transcript from sitting flush on the box.
220
277
  const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
@@ -223,17 +280,43 @@ export default function (pi: ExtensionAPI) {
223
280
  painters.paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + painters.paint(i, width - 1, box.vertical),
224
281
  );
225
282
  }
226
- const bottomBar = buildStatusLine(
227
- innerWidth,
228
- segCtx,
229
- effective,
230
- painters.gapColor,
231
- {
232
- left: effective.bottomLeftSegments,
233
- right: effective.bottomRightSegments,
234
- },
235
- { col: 3, row: bottomIdx },
236
- );
283
+ // Swap in the stand-ins only once there is live progress to show, and only once they
284
+ // actually fit; otherwise pi's own stats and the context graph stay put, same as when
285
+ // the setting is off. Fit is checked against the real bar while a compaction is running,
286
+ // so the fallback below never trims further than that render already decided; before one
287
+ // starts, the cached probe stands in so the handshake doesn't wait on real progress.
288
+ const plainBottomBar = (): string =>
289
+ buildStatusLine(
290
+ innerWidth,
291
+ segCtx,
292
+ effective,
293
+ painters.gapColor,
294
+ { left: effective.bottomLeftSegments, right: effective.bottomRightSegments },
295
+ { col: 3, row: bottomIdx },
296
+ );
297
+ const activeCompaction = compactionCandidate ? compaction : undefined;
298
+ let compactionHostable: boolean;
299
+ let bottomBar: string;
300
+ if (activeCompaction === undefined) {
301
+ compactionHostable = compactionCandidate && compactionWouldFit(innerWidth, segCtx, effective);
302
+ bottomBar = plainBottomBar();
303
+ } else {
304
+ const compactionRightIds = bottomRightSegments(effective, true);
305
+ let fitRight: readonly StatusLineSegmentId[] = compactionRightIds;
306
+ const hostedBar = buildStatusLine(
307
+ innerWidth,
308
+ { ...segCtx, compaction: activeCompaction },
309
+ effective,
310
+ painters.gapColor,
311
+ { left: effective.bottomLeftSegments, right: compactionRightIds },
312
+ { col: 3, row: bottomIdx },
313
+ { onFit: fit => { fitRight = fit.right; } },
314
+ );
315
+ const shown = Object.values(COMPACTION_STAND_INS).some(id => fitRight.includes(id));
316
+ compactionHostable = shown;
317
+ bottomBar = shown ? hostedBar : plainBottomBar();
318
+ }
319
+ announceCompactionEmbed(compactionHostable);
237
320
  out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
238
321
  for (let i = bottomIdx + 1; i < lines.length; i++) {
239
322
  out.push(` ${lines[i]}`);
@@ -247,39 +330,45 @@ export default function (pi: ExtensionAPI) {
247
330
  let editor: ReturnType<EditorFactory>;
248
331
  if (inner) {
249
332
  // A wrapped third-party editor is not opted in: pi keeps its
250
- // standalone working row for it.
333
+ // standalone status-spinner row for it.
251
334
  editor = inner(tui, editorTheme, keybindings);
252
335
  activeEditor = undefined;
253
336
  embeddedWorkingStatus = undefined;
337
+ embeddedStatusKind = undefined;
254
338
  } else {
255
339
  const own = new CustomEditor(tui, editorTheme, keybindings, {
256
340
  embedWorkingStatus: state.effective.embedWorkingStatus,
257
341
  });
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.
342
+ // Capture the host's status indicator on its way in; renderBoxed
343
+ // draws it in the top bar because the inner border is discarded.
260
344
  if (typeof own.setWorkingStatusIndicator === "function") {
261
345
  const setIndicator = own.setWorkingStatusIndicator.bind(own);
262
346
  own.setWorkingStatusIndicator = indicator => {
347
+ // pi 0.86 sends retry, compaction, and branch-summary indicators
348
+ // through this setter too; renderInBorder now lives on the base class.
263
349
  embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
350
+ embeddedStatusKind = indicator?.kind;
264
351
  setIndicator(indicator);
265
352
  };
266
353
  }
267
354
  activeEditor = own;
268
355
  editor = own;
269
356
  }
357
+ announceEmbed(hostsWorkingStatus(activeEditor));
358
+ // A fresh editor retries the boxed path.
359
+ boxRenderFailed = false;
270
360
  const innerRender = editor.render.bind(editor);
271
- let boxRenderFailed = false;
272
361
  editor.render = width => {
362
+ if (boxRenderFailed) return innerRender(width);
273
363
  try {
274
364
  return renderBoxed(innerRender, width, editor);
275
365
  } catch (err) {
276
- if (!boxRenderFailed) {
277
- boxRenderFailed = true;
278
- activeCtx?.ui.notify(
279
- `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
280
- "error",
281
- );
282
- }
366
+ boxRenderFailed = true;
367
+ announceCompactionEmbed(false);
368
+ activeCtx?.ui.notify(
369
+ `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
370
+ "error",
371
+ );
283
372
  return innerRender(width);
284
373
  }
285
374
  };
@@ -299,6 +388,8 @@ export default function (pi: ExtensionAPI) {
299
388
 
300
389
  const PI_STATS_CACHE_MS = 250;
301
390
  let piStatsCache: { at: number; value: string | undefined } | undefined;
391
+ // From pi 0.86 these host totals include cache_warm usage entries, so R/W
392
+ // and cost can change while idle; mirroring the footer keeps this exact.
302
393
  builder.setPiStatsProvider(width => {
303
394
  if (!footerRenderOriginal) return undefined;
304
395
  const now = Date.now();
@@ -346,13 +437,17 @@ export default function (pi: ExtensionAPI) {
346
437
  }
347
438
  if (!restoreFooterLayout) {
348
439
  const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
440
+ // No-op on pi >= 0.86; retained for supported 0.84/0.85 hosts.
349
441
  restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
350
442
  }
351
443
  };
352
444
 
353
- pi.on("model_select", () => requestRender());
445
+ pi.on("model_select", () => {
446
+ syncBorderAnimation();
447
+ requestRender();
448
+ });
354
449
  pi.on("thinking_level_select", () => {
355
- syncRainbow();
450
+ syncBorderAnimation();
356
451
  requestRender();
357
452
  });
358
453
  pi.on("session_info_changed", () => requestRender());
@@ -362,7 +457,7 @@ export default function (pi: ExtensionAPI) {
362
457
  activeCtx = ctx;
363
458
  builder.attach(ctx);
364
459
  rateMonitor.enable();
365
- syncRainbow();
460
+ syncBorderAnimation();
366
461
  ensureInstalled();
367
462
  syncEmbed();
368
463
  ensureFooterPatched();
@@ -371,7 +466,7 @@ export default function (pi: ExtensionAPI) {
371
466
  ensureFooterPatched();
372
467
  // The host's /settings dialog can change the thinking level without
373
468
  // firing thinking_level_select, so reconcile the animator periodically.
374
- syncRainbow();
469
+ syncBorderAnimation();
375
470
  }, 1000);
376
471
  ensureTimer.unref?.();
377
472
  });
@@ -379,9 +474,9 @@ export default function (pi: ExtensionAPI) {
379
474
  pi.on("session_shutdown", () => {
380
475
  rateMonitor.disable();
381
476
  builder.dispose();
382
- if (rainbowTimer) {
383
- clearInterval(rainbowTimer);
384
- rainbowTimer = undefined;
477
+ if (borderTimer) {
478
+ clearInterval(borderTimer);
479
+ borderTimer = undefined;
385
480
  }
386
481
  if (ensureTimer) {
387
482
  clearInterval(ensureTimer);
@@ -401,11 +496,13 @@ export default function (pi: ExtensionAPI) {
401
496
  wrappedFactory = undefined;
402
497
  activeEditor = undefined;
403
498
  embeddedWorkingStatus = undefined;
404
- stopWorkingFadeTimer();
405
- workingFade = undefined;
406
- workingShown = false;
407
- lastWorkingText = undefined;
499
+ embeddedStatusKind = undefined;
500
+ announceEmbed(false);
501
+ stopStatusAnimationTimer();
502
+ statusTransition.reset();
503
+ compaction = undefined;
408
504
  activeTui = undefined;
409
505
  activeCtx = undefined;
506
+ announceCompactionEmbed(false);
410
507
  });
411
508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,10 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "0.85.0",
49
- "@earendil-works/pi-coding-agent": "0.85.0",
50
- "@earendil-works/pi-server": "0.85.0",
51
- "@earendil-works/pi-tui": "0.85.0",
48
+ "@earendil-works/pi-ai": "0.86.0",
49
+ "@earendil-works/pi-coding-agent": "0.86.0",
50
+ "@earendil-works/pi-tui": "0.86.0",
52
51
  "@types/node": "^22.0.0",
53
52
  "typescript": "^5.7.0"
54
53
  }