@underactive/pi-topping-statusline 0.2.6 → 0.2.8

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
@@ -111,9 +111,9 @@ An appearing status slides out from behind the Pi symbol's chevron over 300ms, p
111
111
  details right; one that replaces a status still on screen swaps in place without sliding.
112
112
  A cleared status is held briefly so handoffs between spinners do not flash the left segments.
113
113
  Only the working status cross-fades over 750ms; stable model details remain solid, and the
114
- message-style spinners cut after the hold. While the bar hosts the working status, it announces
115
- that on pi's extension event bus, and pi-topping's loader slides its response model out the same
116
- way; anywhere else that model appears at once.
114
+ message-style spinners cut after the hold. While the bar hosts the working status, it notifies
115
+ pi-topping. Pi-topping's loader slides its response model out the same way; elsewhere, that model
116
+ appears at once.
117
117
 
118
118
  A status too long for the bar is truncated. The working spinner keeps pi's bare cut, while the
119
119
  message-style spinners use an ellipsis. Focusing the embed row in the settings preview shows
@@ -122,20 +122,24 @@ editor through pi, so it applies immediately, even mid-response. Only the status
122
122
  editor opts in: when another extension owns the editor slot and is wrapped, pi keeps its
123
123
  standalone status row.
124
124
 
125
- **Embed compaction progress** (on by default) hosts
126
- [pi-topping-compact](https://github.com/underactive/pi-topping-compact)'s compaction progress in
127
- the box's bottom border while a compaction runs. Its phosphor bar stands in for the context
128
- graph, followed by the tokens being compacted over the window and as a share of it
129
- (`86K/131K (66%)`), and `57% summarized · 16.3s` — how much has been summarized so far, and the
130
- time taken — stands in for pi's stats. The bar and its label follow the Context bar and Context
131
- stats toggles. The moment the compaction ends, pi's stats and the context graph return, the
132
- graph sizing the compacted context as an estimate until the next response (see Segments), and
133
- pi-topping-compact shows its completion result above the editor as it always has. Nothing
134
- changes without pi-topping-compact installed: the two talk over pi's extension event bus, this
135
- bar announcing whether it hosts the progress and pi-topping-compact broadcasting it only while
136
- it does, otherwise keeping its own above-editor widget. With the setting off, with Pi stats,
137
- Context bar, and Context stats all off, or with the terminal too narrow for the stand-ins, that
138
- widget is used instead. Focusing the setting's row in the settings preview shows the bottom bar
125
+ **Embed compaction progress** (on by default) tracks pi's built-in summarizer for `/compact`,
126
+ threshold, overflow, and extension-triggered compactions that use it. Pi runs
127
+ `session_before_compact` handlers in sequence, so if an earlier-loaded extension computes the
128
+ summary in its own handler, the bar appears only after that handler returns. In the bottom-right
129
+ group, a green progress bar takes the place of the context graph, followed
130
+ by the tokens being compacted against the context window, `86K/131K (66%)`. The bar starts just
131
+ below that usage and drains as the summary progresses. In place of pi's stats, `47% summarized ·
132
+ 16.3s` shows the share summarized so far and the time elapsed. The bar and its label follow the
133
+ Context bar and Context stats toggles. The summarized share is a time-based estimate: pi does not
134
+ expose the summarizer's progress, so it starts at 10%, eases up to 86%, and reaches 100% only
135
+ when pi completes. At completion, the empty bar and `100% summarized` hold for a moment. Then pi's stats and the
136
+ context graph return, and the graph shows the compacted context as a `~` estimate until the next
137
+ response. If the compaction fails or is cancelled, the usual segments return at once on pi 0.84.3
138
+ and later; earlier hosts keep the bar until the next prompt. The bottom bar does not change when
139
+ the setting is off, when Pi stats, Context bar, and Context
140
+ stats are all off, or when the terminal is too narrow for the stand-ins. In every case pi's own
141
+ compaction indicator still shows, in its own row or, with Embed status spinners on, in the top
142
+ bar. While the setting is on, focusing its row in the settings preview shows the bottom bar
139
143
  mid-compaction.
140
144
 
141
145
  ## Segments
@@ -148,19 +152,19 @@ accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder;
148
152
  pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
149
153
  bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
150
154
  (documented below), with `compaction_info` and `compaction_graph` standing in for `pi_stats` and
151
- `context_graph` while pi-topping-compact's progress is hosted. On pi 0.86 and later, `pi_stats`
155
+ `context_graph` while a compaction is hosted. On pi 0.86 and later, `pi_stats`
152
156
  includes cache-warming refreshes folded
153
157
  into pi's own footer totals, so cache R/W and cost can increase while idle; this segment matches
154
158
  the host footer by construction.
155
159
 
156
160
  Between a compaction and the next response, pi reports context usage as unknown. Rather than
157
161
  vanish for that stretch, `context_graph` shows pi's own estimate of the compacted context, the
158
- summary plus the kept messages sized by pi's estimator, marked with `~` (`~9.2%/131K`). It is
159
- the same figure pi-topping-compact reports in its result. pi's measured figure replaces it after
160
- the next response.
162
+ summary plus the kept messages sized by pi's estimator, marked with `~` (`~9.2%/131K`). pi's
163
+ measured figure replaces it after the next response.
161
164
 
162
- When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
163
- then left segments drop end-first — the path is always the last to go.
165
+ When the terminal narrows: right segments drop first, then an embedded status is truncated
166
+ (the model details after it drop only if the status would otherwise vanish), then the path
167
+ shrinks (to ~8 cells), then left segments drop end-first — the path is always the last to go.
164
168
 
165
169
  ## Feeds
166
170
 
@@ -257,6 +261,7 @@ settings TUI.
257
261
 
258
262
  - Other editor-replacing extensions are wrapped rather than raced — their editor renders inside this box — but ones that draw their own bar or chrome will clash visually.
259
263
  - The palette is OMP's **dark** theme; light terminal themes will look off.
264
+ - pi-topping-compact is no longer needed: the bar tracks compactions from pi's own events. If it is still installed, its above-editor widget shows alongside the bar, so uninstall it.
260
265
 
261
266
  ## Development
262
267
 
package/index.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  * packages/coding-agent/src/modes/components/status-line/, MIT). OMP mounts
6
6
  * the bar via its editor's setTopBorderProvider. Upstream pi's editor draws
7
7
  * plain ─── rows with no corners or sides, so the wrapper re-renders the inner
8
- * editor 6 columns narrower and rebuilds a full rounded box around its rows:
9
- * the bar in ╭─ … ─╮, │ sides on every text row, and a dedicated ╰─ … ─╯
10
- * bottom border carrying configurable left/right segment groups. Upstream's
8
+ * editor 6 columns narrower and rebuilds a full box around its rows in the
9
+ * configured border style (╭─ … ─╮ when rounded): the bar in the top border, side
10
+ * glyphs on every text row, and a dedicated bottom border carrying configurable
11
+ * left/right segment groups. Upstream's
11
12
  * own bottom border row is dropped.
12
13
  *
13
14
  * ctx.ui.setEditorComponent is a single last-writer-wins slot that other
@@ -26,13 +27,8 @@ import {
26
27
  } from "@earendil-works/pi-coding-agent";
27
28
  import type { Component, TUI } from "@earendil-works/pi-tui";
28
29
  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";
30
+ import { hostsCompactionProgress } from "./src/compaction-embed.js";
31
+ import { CompactionMonitor } from "./src/compaction-progress.js";
36
32
  import { SegmentContextBuilder } from "./src/context.js";
37
33
  import {
38
34
  collapseFooterLayoutSlot,
@@ -48,12 +44,7 @@ import { registerSettingsCommand } from "./src/settings-menu.js";
48
44
  import { bottomRightSegments, COMPACTION_STAND_INS, createSettingsState, topLeftSegments } from "./src/settings.js";
49
45
  import { theme } from "./src/theme.js";
50
46
  import { TokenRateMonitor } from "./src/token-rate.js";
51
- import type {
52
- CompactionProgressView,
53
- EffectiveStatusLineSettings,
54
- SegmentContext,
55
- StatusLineSegmentId,
56
- } from "./src/types.js";
47
+ import type { StatusLineSegmentId } from "./src/types.js";
57
48
  import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
58
49
  import { hostsWorkingStatus, WORKING_STATUS_EMBED_CHANNEL } from "./src/working-status-embed.js";
59
50
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
@@ -100,56 +91,10 @@ export default function (pi: ExtensionAPI) {
100
91
  const requestRender = () => activeTui?.requestRender();
101
92
  builder.setRequestRender(requestRender);
102
93
 
103
- // pi-topping-compact's latest progress broadcast, while one is active.
104
- let compaction: CompactionProgressView | undefined;
105
94
  // The boxed render failed and the plain editor is showing instead.
106
95
  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
96
  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
- });
97
+ activeCtx !== undefined && !boxRenderFailed && hostsCompactionProgress(state.effective);
153
98
  const startStatusAnimationTimer = (): void => {
154
99
  if (statusAnimationTimer) return;
155
100
  statusAnimationTimer = setInterval(() => {
@@ -163,6 +108,11 @@ export default function (pi: ExtensionAPI) {
163
108
  rateMonitor.attach(pi);
164
109
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
165
110
 
111
+ const compactionMonitor = new CompactionMonitor(() => {
112
+ if (hostingCompaction()) requestRender();
113
+ });
114
+ compactionMonitor.attach(pi);
115
+
166
116
  // The active border colorizer is visible independently of whether its phase
167
117
  // is animated. Stopping the timer preserves the current live phase.
168
118
  const rainbow = new RainbowBorder();
@@ -213,7 +163,6 @@ export default function (pi: ExtensionAPI) {
213
163
  ): string[] => {
214
164
  const ctx = activeCtx;
215
165
  if (!ctx || width < MIN_BOXED_WIDTH) {
216
- announceCompactionEmbed(false);
217
166
  return innerRender(width);
218
167
  }
219
168
 
@@ -231,7 +180,6 @@ export default function (pi: ExtensionAPI) {
231
180
  while (bottomIdx > 0 && !isBorderRow(lines[bottomIdx] ?? "")) bottomIdx--;
232
181
  }
233
182
  if (lines.length < 3 || bottomIdx < 2) {
234
- announceCompactionEmbed(false);
235
183
  return innerRender(width);
236
184
  }
237
185
 
@@ -241,7 +189,6 @@ export default function (pi: ExtensionAPI) {
241
189
  const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
242
190
  const frame = statusTransition.resolve(live, embeddedStatusKind, Date.now());
243
191
  if (frame.pending) startStatusAnimationTimer();
244
- const compactionCandidate = hostingCompaction();
245
192
  let include = effective.includes;
246
193
  if (frame.status !== undefined) include = { ...include, git: false, pr: false };
247
194
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
@@ -280,11 +227,8 @@ export default function (pi: ExtensionAPI) {
280
227
  painters.paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + painters.paint(i, width - 1, box.vertical),
281
228
  );
282
229
  }
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.
230
+ // Swap in the stand-ins only while a compaction runs, and only when they actually fit;
231
+ // otherwise pi's own stats and the context graph stay put, same as when the setting is off.
288
232
  const plainBottomBar = (): string =>
289
233
  buildStatusLine(
290
234
  innerWidth,
@@ -294,11 +238,9 @@ export default function (pi: ExtensionAPI) {
294
238
  { left: effective.bottomLeftSegments, right: effective.bottomRightSegments },
295
239
  { col: 3, row: bottomIdx },
296
240
  );
297
- const activeCompaction = compactionCandidate ? compaction : undefined;
298
- let compactionHostable: boolean;
241
+ const activeCompaction = hostingCompaction() ? compactionMonitor.view() : undefined;
299
242
  let bottomBar: string;
300
243
  if (activeCompaction === undefined) {
301
- compactionHostable = compactionCandidate && compactionWouldFit(innerWidth, segCtx, effective);
302
244
  bottomBar = plainBottomBar();
303
245
  } else {
304
246
  const compactionRightIds = bottomRightSegments(effective, true);
@@ -313,10 +255,8 @@ export default function (pi: ExtensionAPI) {
313
255
  { onFit: fit => { fitRight = fit.right; } },
314
256
  );
315
257
  const shown = Object.values(COMPACTION_STAND_INS).some(id => fitRight.includes(id));
316
- compactionHostable = shown;
317
258
  bottomBar = shown ? hostedBar : plainBottomBar();
318
259
  }
319
- announceCompactionEmbed(compactionHostable);
320
260
  out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
321
261
  for (let i = bottomIdx + 1; i < lines.length; i++) {
322
262
  out.push(` ${lines[i]}`);
@@ -364,7 +304,6 @@ export default function (pi: ExtensionAPI) {
364
304
  return renderBoxed(innerRender, width, editor);
365
305
  } catch (err) {
366
306
  boxRenderFailed = true;
367
- announceCompactionEmbed(false);
368
307
  activeCtx?.ui.notify(
369
308
  `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
370
309
  "error",
@@ -457,6 +396,7 @@ export default function (pi: ExtensionAPI) {
457
396
  activeCtx = ctx;
458
397
  builder.attach(ctx);
459
398
  rateMonitor.enable();
399
+ compactionMonitor.enable();
460
400
  syncBorderAnimation();
461
401
  ensureInstalled();
462
402
  syncEmbed();
@@ -473,6 +413,7 @@ export default function (pi: ExtensionAPI) {
473
413
 
474
414
  pi.on("session_shutdown", () => {
475
415
  rateMonitor.disable();
416
+ compactionMonitor.disable();
476
417
  builder.dispose();
477
418
  if (borderTimer) {
478
419
  clearInterval(borderTimer);
@@ -500,9 +441,7 @@ export default function (pi: ExtensionAPI) {
500
441
  announceEmbed(false);
501
442
  stopStatusAnimationTimer();
502
443
  statusTransition.reset();
503
- compaction = undefined;
504
444
  activeTui = undefined;
505
445
  activeCtx = undefined;
506
- announceCompactionEmbed(false);
507
446
  });
508
447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "1.0.3",
49
- "@earendil-works/pi-coding-agent": "1.0.3",
50
- "@earendil-works/pi-tui": "1.0.3",
48
+ "@earendil-works/pi-ai": "1.3.0",
49
+ "@earendil-works/pi-coding-agent": "1.3.0",
50
+ "@earendil-works/pi-tui": "1.3.0",
51
51
  "@types/node": "^22.0.0",
52
52
  "typescript": "^5.7.0"
53
53
  }
package/src/box.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shared box-chrome painting for the live editor box (index.ts) and the
3
- * settings-menu preview, so the 3/2/+2 row geometry has one definition.
3
+ * settings-menu preview, so the 6-cell border-row chrome (corner + 2-cell run,
4
+ * bar, pad + 2-cell run + corner) has one definition.
4
5
  *
5
6
  * Gradient mode colors each glyph by its perimeter-walk position; otherwise
6
7
  * the caller's flat painter wraps whole runs in one escape sequence, which
@@ -33,23 +34,6 @@ export function makeBoxPainters(opts: {
33
34
  const perimeter = perimeterLength(width, bottomIdx);
34
35
  const paint = (row: number, col: number, ch: string): string =>
35
36
  colorizerOn ? colorizer.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
36
- const horizRun = (row: number, startCol: number, count: number): string => {
37
- if (!colorizerOn) return flat(box.horizontal.repeat(count));
38
- let out = "";
39
- let runPrefix: string | undefined;
40
- let runChars = "";
41
- for (let k = 0; k < count; k++) {
42
- const prefix = colorizer.prefix(perimeterPosition(row, startCol + k, width, bottomIdx), perimeter);
43
- if (runPrefix !== undefined && prefix !== runPrefix) {
44
- out += `${runPrefix}${runChars}\x1b[39m`;
45
- runChars = "";
46
- }
47
- runPrefix = prefix;
48
- runChars += box.horizontal;
49
- }
50
- if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
51
- return out;
52
- };
53
37
  const gapColor = (str: string, startCol: number, row: number): string => {
54
38
  if (!colorizerOn) return flat(str);
55
39
  let out = "";
@@ -68,6 +52,8 @@ export function makeBoxPainters(opts: {
68
52
  if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
69
53
  return out;
70
54
  };
55
+ const horizRun = (row: number, startCol: number, count: number): string =>
56
+ gapColor(box.horizontal.repeat(count), startCol, row);
71
57
  return { paint, horizRun, gapColor };
72
58
  }
73
59
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The compaction bar, drawn with the same cells and trough glyphs as the context graph.
3
+ */
4
+ import { CELLS, FILLED, TROUGH } from "./context-graph.js";
5
+ import { detectColorMode, mixRgb, rgbToFgAnsi } from "./theme.js";
6
+ import type { ColorMode } from "./theme.js";
7
+
8
+ const COMET_SWEEP_MS = 2_000;
9
+ const COMET_START_PADDING_CELLS = 2;
10
+ const COMET_END_PADDING_CELLS = 6;
11
+ const COMET_HALF_WIDTH_CELLS = 1.5;
12
+ const COMET_TRAIL_DECAY_CELLS = 3.2;
13
+
14
+ const DEEP_GREEN: readonly [number, number, number] = [11, 61, 32];
15
+ const BASE_GREEN: readonly [number, number, number] = [114, 241, 184];
16
+ const LIGHT_GREEN: readonly [number, number, number] = [185, 248, 217];
17
+ const TROUGH_GREEN: readonly [number, number, number] = [38, 100, 70];
18
+
19
+ function cosineFalloff(distance: number, halfWidth: number): number {
20
+ if (distance >= halfWidth) return 0;
21
+ return 0.5 * (1 + Math.cos((Math.PI * distance) / halfWidth));
22
+ }
23
+
24
+ function normalizeElapsedMs(elapsedMs: number | undefined): number {
25
+ return Number.isFinite(elapsedMs) && elapsedMs !== undefined && elapsedMs > 0 ? elapsedMs : 0;
26
+ }
27
+
28
+ function renderCometCell(position: number, filled: number, elapsedMs: number, mode: ColorMode): string {
29
+ const phase = (elapsedMs % COMET_SWEEP_MS) / COMET_SWEEP_MS;
30
+ const head = filled + COMET_START_PADDING_CELLS - phase * (filled + COMET_END_PADDING_CELLS);
31
+ const behind = position - head;
32
+ const trail = behind >= 0 ? Math.exp(-behind / COMET_TRAIL_DECAY_CELLS) : 0;
33
+ const crest = cosineFalloff(Math.abs(position - head), COMET_HALF_WIDTH_CELLS);
34
+ const intensity = Math.min(1, Math.max(trail * 0.82, crest));
35
+ const trailBase = mixRgb(DEEP_GREEN, BASE_GREEN, 0.62 + 0.38 * trail);
36
+ const color = mixRgb(trailBase, LIGHT_GREEN, intensity * 0.9);
37
+ const emphasized = crest > 0.25;
38
+ return `${emphasized ? "\x1b[1m" : ""}${rgbToFgAnsi(...color, mode)}${FILLED}${emphasized ? "\x1b[22m" : ""}`;
39
+ }
40
+
41
+ export function renderCompactionBar(
42
+ percent: number,
43
+ opts: { mode?: ColorMode; shimmerElapsedMs?: number } = {},
44
+ ): string {
45
+ const mode = opts.mode ?? detectColorMode();
46
+ const clamped = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0;
47
+ const filled = Math.min(CELLS, Math.max(0, Math.round((clamped * CELLS) / 100)));
48
+ const elapsedMs = normalizeElapsedMs(opts.shimmerElapsedMs);
49
+ let bar = "";
50
+ for (let i = 0; i < filled; i++) {
51
+ bar += renderCometCell(i, filled, elapsedMs, mode);
52
+ }
53
+ if (filled < CELLS) {
54
+ bar += `\x1b[39m\x1b[2m${rgbToFgAnsi(...TROUGH_GREEN, mode)}${TROUGH.repeat(CELLS - filled)}\x1b[22m`;
55
+ }
56
+ bar += "\x1b[39m";
57
+ return bar;
58
+ }
@@ -1,41 +1,10 @@
1
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.
2
+ * Whether the bottom bar can host compaction progress, and the sample view the settings preview shows.
10
3
  */
4
+ import { renderCompactionBar } from "./compaction-bar.js";
11
5
  import { COMPACTION_STAND_INS } from "./settings.js";
12
- import { hexToFgAnsi } from "./theme.js";
13
6
  import type { CompactionProgressView, EffectiveStatusLineSettings } from "./types.js";
14
7
 
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
8
  /** Whether the bottom bar has somewhere to put the progress: the setting is on and a segment it stands in for is shown. */
40
9
  export function hostsCompactionProgress(effective: EffectiveStatusLineSettings): boolean {
41
10
  return (
@@ -44,19 +13,19 @@ export function hostsCompactionProgress(effective: EffectiveStatusLineSettings):
44
13
  );
45
14
  }
46
15
 
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
16
  /**
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.
17
+ * A representative hosted compaction (86K of a 131K window is 66% usage; at 47% summarized the
18
+ * bar still holds 35% of the window), shown in the settings preview. Real figures vary in digit
19
+ * count, but this stands in for a typical session without committing to a worst case that would
20
+ * starve the rest of the bar.
21
+ * The bar renders when this function is called, so it uses pi's current color depth.
55
22
  */
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
- };
23
+ export function compactionPreviewView(): CompactionProgressView {
24
+ return {
25
+ bar: renderCompactionBar(35),
26
+ percent: 47,
27
+ tokensBefore: 86_000,
28
+ contextWindow: 131_072,
29
+ elapsedMs: 16_300,
30
+ };
31
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tracks pi's built-in summarizer from pi's session events. The percent is a time-based estimate,
3
+ * because pi does not expose the summarizer's stream.
4
+ */
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { renderCompactionBar } from "./compaction-bar.js";
7
+ import type { CompactionProgressView } from "./types.js";
8
+
9
+ const COMPACTION_TICK_MS = 100;
10
+ const COMPACTION_COMPLETE_HOLD_MS = 900;
11
+ const SUMMARIZING_START_PERCENT = 10;
12
+ const SUMMARIZING_SPAN_PERCENT = 80;
13
+ const SUMMARIZING_TAU_MS = 25_000;
14
+ const SUMMARIZING_RATIO_CAP = 0.95;
15
+
16
+ /**
17
+ * Starts at 10% and eases toward 90%, capped at 86% (START + SPAN × RATIO_CAP)
18
+ * after about 75s; README.md and CHANGELOG.md quote the 10% and 86% figures.
19
+ */
20
+ export function estimateCompactionPercent(elapsedMs: number): number {
21
+ const elapsed = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs : 0;
22
+ const ratio = Math.min(SUMMARIZING_RATIO_CAP, 1 - Math.exp(-elapsed / SUMMARIZING_TAU_MS));
23
+ return SUMMARIZING_START_PERCENT + SUMMARIZING_SPAN_PERCENT * ratio;
24
+ }
25
+
26
+ type CompactionRun = {
27
+ startedAt: number;
28
+ tokensBefore: number;
29
+ contextWindow: number;
30
+ completedAt: number | undefined;
31
+ };
32
+
33
+ export class CompactionMonitor {
34
+ #requestRender: () => void;
35
+ #now: () => number;
36
+ #holdMs: number;
37
+ #enabled = false;
38
+ #run: CompactionRun | undefined;
39
+ #tickTimer: ReturnType<typeof setInterval> | undefined;
40
+ #holdTimer: ReturnType<typeof setTimeout> | undefined;
41
+
42
+ constructor(requestRender: () => void, options: { now?: () => number; holdMs?: number } = {}) {
43
+ this.#requestRender = requestRender;
44
+ this.#now = options.now ?? (() => performance.now());
45
+ this.#holdMs = options.holdMs ?? COMPACTION_COMPLETE_HOLD_MS;
46
+ }
47
+
48
+ attach(pi: ExtensionAPI): void {
49
+ pi.on("session_before_compact", (event, ctx) => {
50
+ if (!this.#enabled) return;
51
+ this.#clearTimers();
52
+ this.#run = {
53
+ startedAt: this.#now(),
54
+ tokensBefore: event.preparation.tokensBefore,
55
+ contextWindow: ctx.getContextUsage()?.contextWindow ?? ctx.model?.contextWindow ?? 0,
56
+ completedAt: undefined,
57
+ };
58
+ this.#tickTimer = setInterval(this.#requestRender, COMPACTION_TICK_MS);
59
+ this.#tickTimer.unref?.();
60
+ this.#requestRender();
61
+ });
62
+ pi.on("session_compact", () => {
63
+ if (!this.#run || this.#run.completedAt !== undefined) return;
64
+ this.#run.completedAt = this.#now();
65
+ this.#stopTickTimer();
66
+ this.#holdTimer = setTimeout(() => this.#endRun(), this.#holdMs);
67
+ this.#holdTimer.unref?.();
68
+ this.#requestRender();
69
+ });
70
+ pi.on("session_compact_failed", () => this.#endRun());
71
+ // pi < 0.84.3 has no session_compact_failed; the next agent run is the only sign a run died.
72
+ pi.on("agent_start", () => {
73
+ if (this.#run && this.#run.completedAt === undefined) this.#endRun();
74
+ });
75
+ }
76
+
77
+ enable(): void {
78
+ this.#enabled = true;
79
+ }
80
+
81
+ disable(): void {
82
+ this.#enabled = false;
83
+ this.#endRun();
84
+ }
85
+
86
+ view(): CompactionProgressView | undefined {
87
+ const run = this.#run;
88
+ if (!run) return undefined;
89
+ const elapsed = (run.completedAt ?? this.#now()) - run.startedAt;
90
+ const percent = run.completedAt !== undefined ? 100 : estimateCompactionPercent(elapsed);
91
+ const usage =
92
+ Number.isFinite(run.tokensBefore) && run.tokensBefore > 0 &&
93
+ Number.isFinite(run.contextWindow) && run.contextWindow > 0
94
+ ? Math.min(100, Math.max(0, (run.tokensBefore / run.contextWindow) * 100))
95
+ : 0;
96
+ const remaining = usage * (1 - percent / 100);
97
+ return {
98
+ bar: renderCompactionBar(remaining, {
99
+ shimmerElapsedMs: run.completedAt === undefined ? elapsed : undefined,
100
+ }),
101
+ percent,
102
+ tokensBefore: run.tokensBefore,
103
+ contextWindow: run.contextWindow,
104
+ elapsedMs: elapsed,
105
+ };
106
+ }
107
+
108
+ #endRun(): void {
109
+ this.#clearTimers();
110
+ if (!this.#run) return;
111
+ this.#run = undefined;
112
+ this.#requestRender();
113
+ }
114
+
115
+ #clearTimers(): void {
116
+ this.#stopTickTimer();
117
+ if (this.#holdTimer) {
118
+ clearTimeout(this.#holdTimer);
119
+ this.#holdTimer = undefined;
120
+ }
121
+ }
122
+
123
+ #stopTickTimer(): void {
124
+ if (this.#tickTimer) {
125
+ clearInterval(this.#tickTimer);
126
+ this.#tickTimer = undefined;
127
+ }
128
+ }
129
+ }
@@ -7,9 +7,9 @@
7
7
  import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds.js";
8
8
  import { theme } from "./theme.js";
9
9
 
10
- const CELLS = 20;
11
- const FILLED = "\u258b";
12
- const TROUGH = "\u2591";
10
+ export const CELLS = 20;
11
+ export const FILLED = "\u258b";
12
+ export const TROUGH = "\u2591";
13
13
 
14
14
  const cellColor = (percent: number, contextWindow: number): string =>
15
15
  theme.getFgAnsi(getContextUsageThemeColor(getContextUsageLevel(percent, contextWindow)));