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