@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 +39 -9
- package/index.ts +146 -39
- package/package.json +1 -1
- package/src/compaction-embed.ts +62 -0
- package/src/context-graph.ts +5 -5
- package/src/context-thresholds.ts +7 -3
- package/src/context.ts +72 -13
- package/src/layout.ts +76 -27
- package/src/menu.ts +25 -36
- package/src/segments.ts +54 -0
- package/src/settings-menu.ts +66 -36
- package/src/settings.ts +25 -3
- package/src/tests/compaction-embed.test.ts +184 -0
- package/src/tests/context-estimate.test.ts +207 -0
- package/src/tests/context-thresholds.test.ts +6 -0
- package/src/tests/feeds.test.ts +2 -8
- package/src/tests/menu.test.ts +31 -2
- package/src/tests/token-rate.test.ts +2 -1
- package/src/tests/working-status-embed.test.ts +12 -0
- package/src/tests/working-status.test.ts +60 -9
- package/src/tests/working.test.ts +220 -25
- package/src/token-rate.ts +1 -1
- package/src/types.ts +28 -2
- package/src/working-status-embed.ts +15 -0
- package/src/working-status.ts +42 -15
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
|
-
║
|
|
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/
|
|
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
|
|
105
|
-
status is visible, and the Pi symbol never moves.
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
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
|
|
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
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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()))
|
|
90
|
-
},
|
|
91
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
};
|
package/src/context-graph.ts
CHANGED
|
@@ -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
|
|
20
|
+
opts: { usedTokens: number; labelColor: string; showBar: boolean; showStats: boolean; estimated: boolean },
|
|
21
21
|
): string {
|
|
22
22
|
const parts: string[] = [];
|
|
23
|
-
if (opts.showBar
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
63
|
+
interface ContextUsageFigures {
|
|
59
64
|
contextWindow: number;
|
|
60
|
-
tokens: number
|
|
65
|
+
tokens: number;
|
|
61
66
|
percent: number | null;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
523
|
+
sessionName: ctx?.sessionManager.getSessionName(),
|
|
466
524
|
contextPercent,
|
|
467
|
-
contextTokens: tokens
|
|
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,
|