@underactive/pi-topping-statusline 0.2.6 → 0.2.7
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 +28 -23
- package/index.ts +18 -79
- package/package.json +4 -4
- package/src/box.ts +4 -18
- package/src/compaction-bar.ts +58 -0
- package/src/compaction-embed.ts +16 -47
- package/src/compaction-progress.ts +129 -0
- package/src/context-graph.ts +3 -3
- package/src/context.ts +9 -8
- package/src/nvidia-green.ts +5 -9
- package/src/rainbow.ts +9 -10
- package/src/segments.ts +8 -5
- package/src/settings-menu.ts +3 -4
- package/src/settings.ts +2 -2
- package/src/tests/compaction-bar.test.ts +67 -0
- package/src/tests/compaction-embed.test.ts +11 -21
- package/src/tests/compaction-progress.test.ts +183 -0
- package/src/tests/context-estimate.test.ts +1 -2
- package/src/tests/feeds.test.ts +1 -1
- package/src/tests/git-status.test.ts +62 -0
- package/src/theme.ts +14 -1
- package/src/types.ts +9 -6
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
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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`).
|
|
159
|
-
|
|
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
|
|
163
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
* bottom border carrying configurable
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
284
|
-
//
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
50
|
-
"@earendil-works/pi-tui": "1.0
|
|
48
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
50
|
+
"@earendil-works/pi-tui": "1.1.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
|
+
* 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
|
+
}
|
package/src/compaction-embed.ts
CHANGED
|
@@ -1,41 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 (
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|
package/src/context-graph.ts
CHANGED
|
@@ -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)));
|