@underactive/pi-topping-statusline 0.2.1 → 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,7 +36,7 @@ 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 › ║
@@ -45,6 +45,7 @@ and apply live.
45
45
  ║ [■] NVIDIA-green when using Switchyard ON ║
46
46
  ║ [■] Animate Switchyard green border ON ║
47
47
  ║ [ ] Embed status spinners OFF ║
48
+ ║ [■] Embed compaction progress ON ║
48
49
  ║ ║
49
50
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
50
51
  ║ [■] Pi symbol ON ║
@@ -69,18 +70,18 @@ and apply live.
69
70
  ║ + add feed ║
70
71
  ║ ║
71
72
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
72
- ╚════════════════════════════════════════════════════════[ 1/32 ]╝
73
+ ╚════════════════════════════════════════════════════════[ 1/33 ]╝
73
74
  ```
74
75
 
75
76
  | Section | Settings |
76
77
  | --- | --- |
77
- | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · NVIDIA-green when using Switchyard · Animate Switchyard green border · Embed status spinners |
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 |
78
79
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
79
80
  | Top Right Segment Group | Token rate · Session name |
80
81
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
81
82
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
82
83
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
83
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · NVIDIA-green when using Switchyard on · Animate Switchyard green border on · Embed status spinners off |
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 |
84
85
 
85
86
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
86
87
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -101,10 +102,15 @@ 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
102
103
  and into the top-left group, right after the Pi symbol and its chevron. On pi 0.86 and later
103
104
  this covers the working, retry, compaction, and branch-summary spinners; pi 0.85 embeds only
104
- the working indicator. The remaining left segments (model, path, git, PR) step aside while a
105
- status is visible, and the Pi symbol never moves. A cleared status is held briefly so handoffs
106
- between spinners do not flash the left segments. Only the end of a working spinner then
107
- cross-fades back over 750ms; the message-style spinners cut after the hold.
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.
108
114
 
109
115
  A status too long for the bar is truncated. The working spinner keeps pi's bare cut, while the
110
116
  message-style spinners use an ellipsis. Focusing the embed row in the settings preview shows
@@ -113,6 +119,22 @@ editor through pi, so it applies immediately, even mid-response. Only the status
113
119
  editor opts in: when another extension owns the editor slot and is wrapped, pi keeps its
114
120
  standalone status row.
115
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.
137
+
116
138
  ## Segments
117
139
 
118
140
  Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
@@ -122,10 +144,18 @@ fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (liv
122
144
  accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
123
145
  pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
124
146
  bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
125
- (documented below). On pi 0.86 and later, `pi_stats` includes cache-warming refreshes folded
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
126
150
  into pi's own footer totals, so cache R/W and cost can increase while idle; this segment matches
127
151
  the host footer by construction.
128
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.
158
+
129
159
  When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
130
160
  then left segments drop end-first — the path is always the last to go.
131
161
 
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,
@@ -38,13 +45,20 @@ import { buildStatusLine } from "./src/layout.js";
38
45
  import { NvidiaGreenBorder, isSwitchyardProvider } from "./src/nvidia-green.js";
39
46
  import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder, type BorderColorizer } from "./src/rainbow.js";
40
47
  import { registerSettingsCommand } from "./src/settings-menu.js";
41
- import { createSettingsState, topLeftSegments } from "./src/settings.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";
44
57
  import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
58
+ import { hostsWorkingStatus, WORKING_STATUS_EMBED_CHANNEL } from "./src/working-status-embed.js";
45
59
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
46
60
 
47
- const WORKING_FADE_FRAME_MS = 30;
61
+ const STATUS_ANIMATION_FRAME_MS = 30;
48
62
 
49
63
  export default function (pi: ExtensionAPI) {
50
64
  const state = createSettingsState();
@@ -65,10 +79,10 @@ export default function (pi: ExtensionAPI) {
65
79
  let embeddedWorkingStatus: ((width: number) => string) | undefined;
66
80
  let embeddedStatusKind: StatusIndicatorKind | undefined;
67
81
  const statusTransition = new StatusTransition();
68
- let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
69
- const stopWorkingFadeTimer = (): void => {
70
- if (workingFadeTimer) clearInterval(workingFadeTimer);
71
- workingFadeTimer = undefined;
82
+ let statusAnimationTimer: ReturnType<typeof setInterval> | undefined;
83
+ const stopStatusAnimationTimer = (): void => {
84
+ if (statusAnimationTimer) clearInterval(statusAnimationTimer);
85
+ statusAnimationTimer = undefined;
72
86
  };
73
87
 
74
88
  const syncEmbed = (): void => {
@@ -80,15 +94,69 @@ export default function (pi: ExtensionAPI) {
80
94
  ctx.ui.setEditorComponent(factory);
81
95
  };
82
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
+
83
100
  const requestRender = () => activeTui?.requestRender();
84
101
  builder.setRequestRender(requestRender);
85
- const startWorkingFadeTimer = (): void => {
86
- if (workingFadeTimer) return;
87
- workingFadeTimer = setInterval(() => {
102
+
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;
129
+ }
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);
150
+ }
151
+ requestRender();
152
+ });
153
+ const startStatusAnimationTimer = (): void => {
154
+ if (statusAnimationTimer) return;
155
+ statusAnimationTimer = setInterval(() => {
88
156
  requestRender();
89
- if (!statusTransition.pending(Date.now())) stopWorkingFadeTimer();
90
- }, WORKING_FADE_FRAME_MS);
91
- workingFadeTimer.unref?.();
157
+ if (!statusTransition.pending(Date.now())) stopStatusAnimationTimer();
158
+ }, STATUS_ANIMATION_FRAME_MS);
159
+ statusAnimationTimer.unref?.();
92
160
  };
93
161
 
94
162
  const rateMonitor = new TokenRateMonitor(requestRender);
@@ -144,7 +212,10 @@ export default function (pi: ExtensionAPI) {
144
212
  editor: { borderColor?: (str: string) => string },
145
213
  ): string[] => {
146
214
  const ctx = activeCtx;
147
- if (!ctx || width < MIN_BOXED_WIDTH) return innerRender(width);
215
+ if (!ctx || width < MIN_BOXED_WIDTH) {
216
+ announceCompactionEmbed(false);
217
+ return innerRender(width);
218
+ }
148
219
 
149
220
  // Chrome totals 6 cells on every row, so the bar and the inner text rows
150
221
  // share one width budget. Border rows split it 3/3 (╭── … ──╮); text rows
@@ -159,11 +230,20 @@ export default function (pi: ExtensionAPI) {
159
230
  if ((editor as unknown as { autocompleteState?: unknown }).autocompleteState) {
160
231
  while (bottomIdx > 0 && !isBorderRow(lines[bottomIdx] ?? "")) bottomIdx--;
161
232
  }
162
- if (lines.length < 3 || bottomIdx < 2) return innerRender(width);
233
+ if (lines.length < 3 || bottomIdx < 2) {
234
+ announceCompactionEmbed(false);
235
+ return innerRender(width);
236
+ }
163
237
 
164
238
  const hint = stripAnsi(lines[bottomIdx] ?? "").match(/[↑↓] \d+ more/)?.[0] ?? "";
165
239
  const effective = state.effective;
166
- 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 };
167
247
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
168
248
  // borderColor is assigned by the host after the factory returns — read late.
169
249
  const border = editor.borderColor ?? ((s: string) => s);
@@ -177,10 +257,6 @@ export default function (pi: ExtensionAPI) {
177
257
  bottomIdx,
178
258
  flat: border,
179
259
  });
180
- // The layout truncates the status to fit, so it is rendered at full width here.
181
- const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
182
- const frame = statusTransition.resolve(live, embeddedStatusKind, Date.now());
183
- if (frame.pending) startWorkingFadeTimer();
184
260
  const bar = buildStatusLine(
185
261
  innerWidth,
186
262
  { ...segCtx, workingStatus: frame.status },
@@ -193,6 +269,7 @@ export default function (pi: ExtensionAPI) {
193
269
  { col: 3, row: 0 },
194
270
  {
195
271
  leftFade: frame.leftFade,
272
+ workingReveal: frame.reveal,
196
273
  workingEllipsis: isMessageKind(frame.kind) ? "…" : "",
197
274
  },
198
275
  );
@@ -203,17 +280,43 @@ export default function (pi: ExtensionAPI) {
203
280
  painters.paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + painters.paint(i, width - 1, box.vertical),
204
281
  );
205
282
  }
206
- const bottomBar = buildStatusLine(
207
- innerWidth,
208
- segCtx,
209
- effective,
210
- painters.gapColor,
211
- {
212
- left: effective.bottomLeftSegments,
213
- right: effective.bottomRightSegments,
214
- },
215
- { col: 3, row: bottomIdx },
216
- );
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);
217
320
  out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
218
321
  for (let i = bottomIdx + 1; i < lines.length; i++) {
219
322
  out.push(` ${lines[i]}`);
@@ -251,20 +354,21 @@ export default function (pi: ExtensionAPI) {
251
354
  activeEditor = own;
252
355
  editor = own;
253
356
  }
357
+ announceEmbed(hostsWorkingStatus(activeEditor));
358
+ // A fresh editor retries the boxed path.
359
+ boxRenderFailed = false;
254
360
  const innerRender = editor.render.bind(editor);
255
- let boxRenderFailed = false;
256
361
  editor.render = width => {
257
362
  if (boxRenderFailed) return innerRender(width);
258
363
  try {
259
364
  return renderBoxed(innerRender, width, editor);
260
365
  } catch (err) {
261
- if (!boxRenderFailed) {
262
- boxRenderFailed = true;
263
- activeCtx?.ui.notify(
264
- `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
265
- "error",
266
- );
267
- }
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
+ );
268
372
  return innerRender(width);
269
373
  }
270
374
  };
@@ -393,9 +497,12 @@ export default function (pi: ExtensionAPI) {
393
497
  activeEditor = undefined;
394
498
  embeddedWorkingStatus = undefined;
395
499
  embeddedStatusKind = undefined;
396
- stopWorkingFadeTimer();
500
+ announceEmbed(false);
501
+ stopStatusAnimationTimer();
397
502
  statusTransition.reset();
503
+ compaction = undefined;
398
504
  activeTui = undefined;
399
505
  activeCtx = undefined;
506
+ announceCompactionEmbed(false);
400
507
  });
401
508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.1",
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",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * pi-topping-compact's compaction progress, hosted in the bottom bar.
3
+ *
4
+ * The two extensions talk over pi's extension event bus. This extension
5
+ * announces on COMPACTION_EMBED_CHANNEL whether its bottom bar has room for the
6
+ * progress; pi-topping-compact broadcasts snapshots on COMPACTION_PROGRESS_CHANNEL
7
+ * only while it does, and ends with `{ active: false }` so pi's stats and the
8
+ * context graph return the moment the compaction finishes. Its completion
9
+ * result still appears above the editor, in pi-topping-compact's own widget.
10
+ */
11
+ import { COMPACTION_STAND_INS } from "./settings.js";
12
+ import { hexToFgAnsi } from "./theme.js";
13
+ import type { CompactionProgressView, EffectiveStatusLineSettings } from "./types.js";
14
+
15
+ /** Announced as `{ embedded: boolean }`. Shared with pi-topping-compact. */
16
+ export const COMPACTION_EMBED_CHANNEL = "pi-topping-statusline:compaction-progress-embedded";
17
+
18
+ /** Carries a CompactionProgressView plus `active: true`, or `{ active: false }`. Shared with pi-topping-compact. */
19
+ export const COMPACTION_PROGRESS_CHANNEL = "pi-topping-compact:compaction-progress";
20
+
21
+ const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
22
+
23
+ /** The view in a broadcast, or undefined when it reports no active compaction or is malformed. */
24
+ export function parseCompactionProgress(data: unknown): CompactionProgressView | undefined {
25
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
26
+ const { active, bar, percent, tokensBefore, contextWindow, elapsedMs } = data as Record<string, unknown>;
27
+ if (active !== true || typeof bar !== "string") return undefined;
28
+ if (
29
+ !isFiniteNumber(percent) ||
30
+ !isFiniteNumber(tokensBefore) ||
31
+ !isFiniteNumber(contextWindow) ||
32
+ !isFiniteNumber(elapsedMs)
33
+ ) {
34
+ return undefined;
35
+ }
36
+ return { bar, percent, tokensBefore, contextWindow, elapsedMs };
37
+ }
38
+
39
+ /** Whether the bottom bar has somewhere to put the progress: the setting is on and a segment it stands in for is shown. */
40
+ export function hostsCompactionProgress(effective: EffectiveStatusLineSettings): boolean {
41
+ return (
42
+ effective.embedCompactionProgress &&
43
+ effective.bottomRightSegments.some((id) => COMPACTION_STAND_INS[id] !== undefined)
44
+ );
45
+ }
46
+
47
+ /** pi-topping-compact's phosphor bar as it looks with a third of the context left to compact. */
48
+ const FIT_PROBE_BAR = `${hexToFgAnsi("#72f1b8")}${"\u258b".repeat(7)}\x1b[39m\x1b[2m${hexToFgAnsi("#266446")}${"\u2591".repeat(13)}\x1b[22m\x1b[39m`;
49
+
50
+ /**
51
+ * A representative hosted compaction (66% usage, 35% still to go, so 47% summarized), used to
52
+ * measure whether the stand-in segments have room before any real progress has broadcast, and
53
+ * shown in the settings preview. Real figures vary in digit count, but this stands in for a
54
+ * typical session without committing to a worst case that would starve the rest of the bar.
55
+ */
56
+ export const COMPACTION_FIT_PROBE: CompactionProgressView = {
57
+ bar: FIT_PROBE_BAR,
58
+ percent: 47,
59
+ tokensBefore: 86_000,
60
+ contextWindow: 131_072,
61
+ elapsedMs: 16_300,
62
+ };
@@ -17,10 +17,10 @@ const cellColor = (percent: number, contextWindow: number): string =>
17
17
  export function renderContextGraph(
18
18
  percent: number,
19
19
  contextWindow: number,
20
- opts: { usedTokens?: number; labelColor?: string; showBar?: boolean; showStats?: boolean } = {},
20
+ opts: { usedTokens: number; labelColor: string; showBar: boolean; showStats: boolean; estimated: boolean },
21
21
  ): string {
22
22
  const parts: string[] = [];
23
- if (opts.showBar !== false) {
23
+ if (opts.showBar) {
24
24
  const filled = Math.min(CELLS, Math.max(0, Math.round((percent * CELLS) / 100)));
25
25
  let bar = "";
26
26
  let prevColor = "";
@@ -38,11 +38,11 @@ export function renderContextGraph(
38
38
  bar += "\x1b[39m";
39
39
  parts.push(bar);
40
40
  }
41
- if (opts.showStats !== false) {
41
+ if (opts.showStats) {
42
42
  // Label wears pi's own footer-stats color so it reads as part of that run;
43
43
  // the threshold gradient still lives in the cells.
44
- const text = formatContextUsage(percent, contextWindow, opts.usedTokens);
45
- parts.push(opts.labelColor ? `${opts.labelColor}${text}\x1b[39m` : theme.fg("muted", text));
44
+ const text = formatContextUsage(percent, contextWindow, opts.usedTokens, opts.estimated);
45
+ parts.push(`${opts.labelColor}${text}\x1b[39m`);
46
46
  }
47
47
  return parts.join(" ");
48
48
  }
@@ -1,4 +1,4 @@
1
- /** Ported verbatim from oh-my-pi status-line/context-thresholds.ts. */
1
+ /** Ported from oh-my-pi status-line/context-thresholds.ts; formatContextUsage adds the `~` estimate marker. */
2
2
  import type { StatusColor } from "./theme.js";
3
3
  import { formatNumber } from "./utils.js";
4
4
 
@@ -62,11 +62,15 @@ export function formatContextUsage(
62
62
  contextPercent: number | null | undefined,
63
63
  contextWindow: number,
64
64
  usedTokens?: number,
65
+ estimated = false,
65
66
  ): string {
67
+ // An estimate wears "~" so it reads as one until pi's own figure arrives.
68
+ const about = estimated ? "~" : "";
66
69
  if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
67
- return `${formatNumber(usedTokens ?? 0)}/?`;
70
+ return `${about}${formatNumber(usedTokens ?? 0)}/?`;
68
71
  }
69
- const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
72
+ const pct =
73
+ contextPercent === null || contextPercent === undefined ? "?" : `${about}${contextPercent.toFixed(1)}%`;
70
74
  return `${pct}/${formatNumber(contextWindow)}`;
71
75
  }
72
76
 
package/src/context.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { isDeepStrictEqual } from "node:util";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ buildSessionContext,
13
+ estimateTokens,
14
+ type ExtensionAPI,
15
+ type ExtensionContext,
16
+ } from "@earendil-works/pi-coding-agent";
12
17
  import { FADE_SHADE_COUNT } from "./theme.js";
13
18
  import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
14
19
  import { feedKey } from "./utils.js";
@@ -55,16 +60,32 @@ export function getFeedDisplayState(changedAt: number, now: number): FeedDisplay
55
60
  };
56
61
  }
57
62
 
58
- function deriveContextUsage(ctx: ExtensionContext | undefined): {
63
+ interface ContextUsageFigures {
59
64
  contextWindow: number;
60
- tokens: number | undefined;
65
+ tokens: number;
61
66
  percent: number | null;
62
- } {
63
- const usage = ctx?.getContextUsage();
64
- const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
65
- const tokens = usage?.tokens ?? undefined;
66
- const percent = usage ? usage.percent : null;
67
- return { contextWindow, tokens, percent };
67
+ /** The figures are pi's estimate of a freshly compacted context, not a measurement. */
68
+ estimated: boolean;
69
+ }
70
+
71
+ /** One post-compaction estimate, keyed by session and leaf so it is rebuilt only when the branch grows. */
72
+ interface ContextEstimate {
73
+ key: string;
74
+ tokens: number | undefined;
75
+ }
76
+
77
+ /**
78
+ * pi's own estimate of the context after a compaction: the summary plus the
79
+ * kept and later messages, sized by its per-message estimator. This is the
80
+ * figure pi hands compaction callers as estimatedTokensAfter, so it agrees with
81
+ * what pi-topping-compact reports in its result.
82
+ */
83
+ export function estimateCompactedContextTokens(sessionManager: ExtensionContext["sessionManager"]): number {
84
+ let tokens = 0;
85
+ for (const message of buildSessionContext(sessionManager.getBranch(), sessionManager.getLeafId()).messages) {
86
+ tokens += estimateTokens(message);
87
+ }
88
+ return tokens;
68
89
  }
69
90
 
70
91
  function parseGitStatus(porcelain: string): GitStatusCounts {
@@ -119,6 +140,8 @@ export class SegmentContextBuilder {
119
140
  #feedTimer: ReturnType<typeof setTimeout> | undefined;
120
141
  #feedTimerAt: number | undefined;
121
142
 
143
+ #contextEstimate: ContextEstimate | undefined;
144
+
122
145
  constructor(pi: ExtensionAPI) {
123
146
  this.#pi = pi;
124
147
  }
@@ -146,6 +169,7 @@ export class SegmentContextBuilder {
146
169
  this.#feedsScannedAt = 0;
147
170
  this.#feedsScannedRef = undefined;
148
171
  this.#clearFeedLifecycle();
172
+ this.#contextEstimate = undefined;
149
173
  if (this.#repoCwd !== ctx.cwd) {
150
174
  this.#resetGitState();
151
175
  this.#repoCwd = ctx.cwd;
@@ -307,7 +331,7 @@ export class SegmentContextBuilder {
307
331
  #refreshFeeds(customTypes: readonly string[], now: number): void {
308
332
  this.#feedsScannedAt = now;
309
333
  this.#feedData = emptyFeedData();
310
- const entries = this.#ctx?.sessionManager?.getEntries();
334
+ const entries = this.#ctx?.sessionManager.getEntries();
311
335
  if (!entries) return;
312
336
  const pending = new Set(customTypes);
313
337
  for (let i = entries.length - 1; i >= 0 && pending.size > 0; i--) {
@@ -414,6 +438,40 @@ export class SegmentContextBuilder {
414
438
  }
415
439
  }
416
440
 
441
+ // ── context usage ────────────────────────────────────────────────────────
442
+
443
+ /**
444
+ * pi reports usage as unknown between a compaction and the next assistant
445
+ * response. Rather than let the graph vanish for that stretch, this fills it
446
+ * with pi's own estimate of the compacted context, flagged as such, until
447
+ * pi's measured figure arrives.
448
+ */
449
+ #deriveContextUsage(ctx: ExtensionContext | undefined): ContextUsageFigures {
450
+ const usage = ctx?.getContextUsage();
451
+ const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
452
+ if (!ctx || !usage || usage.tokens !== null || contextWindow <= 0) {
453
+ return { contextWindow, tokens: usage?.tokens ?? 0, percent: usage ? usage.percent : null, estimated: false };
454
+ }
455
+ const tokens = this.#estimateContextTokens(ctx);
456
+ if (tokens === undefined) return { contextWindow, tokens: 0, percent: null, estimated: false };
457
+ return { contextWindow, tokens, percent: (tokens / contextWindow) * 100, estimated: true };
458
+ }
459
+
460
+ #estimateContextTokens(ctx: ExtensionContext): number | undefined {
461
+ const sessionManager = ctx.sessionManager;
462
+ try {
463
+ const key = `${sessionManager.getSessionId()}\0${sessionManager.getLeafId() ?? ""}`;
464
+ if (this.#contextEstimate?.key !== key) {
465
+ this.#contextEstimate = { key, tokens: undefined };
466
+ this.#contextEstimate.tokens = estimateCompactedContextTokens(sessionManager);
467
+ }
468
+ return this.#contextEstimate.tokens;
469
+ } catch {
470
+ // The session plumbing is pi's; a surprise there costs the estimate, not the frame.
471
+ return undefined;
472
+ }
473
+ }
474
+
417
475
  // ── snapshot ─────────────────────────────────────────────────────────────
418
476
 
419
477
  build(
@@ -455,17 +513,18 @@ export class SegmentContextBuilder {
455
513
  }
456
514
  : undefined;
457
515
 
458
- const { contextWindow, tokens, percent: contextPercent } = deriveContextUsage(ctx);
516
+ const { contextWindow, tokens, percent: contextPercent, estimated } = this.#deriveContextUsage(ctx);
459
517
 
460
518
  return {
461
519
  options,
462
520
  model,
463
521
  thinkingLevel: this.#pi.getThinkingLevel(),
464
522
  cwd: ctx?.cwd ?? process.cwd(),
465
- sessionName: ctx?.sessionManager?.getSessionName(),
523
+ sessionName: ctx?.sessionManager.getSessionName(),
466
524
  contextPercent,
467
- contextTokens: tokens ?? 0,
525
+ contextTokens: tokens,
468
526
  contextWindow,
527
+ contextEstimated: estimated,
469
528
  git: {
470
529
  branch: include.git || include.pr ? this.#branch : null,
471
530
  status: include.git ? this.#gitStatus : null,