@underactive/pi-topping-statusline 0.1.4 → 0.2.1
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 +32 -4
- package/index.ts +127 -32
- package/package.json +4 -4
- package/src/box.ts +33 -11
- package/src/context.ts +1 -1
- package/src/footer.ts +5 -2
- package/src/layout.ts +41 -8
- package/src/nvidia-green.ts +66 -0
- package/src/rainbow.ts +18 -4
- package/src/segments.ts +35 -2
- package/src/settings-menu.ts +47 -12
- package/src/settings.ts +16 -0
- package/src/tests/box.test.ts +6 -6
- package/src/tests/footer.test.ts +1 -0
- package/src/tests/nvidia-green.test.ts +51 -0
- package/src/tests/working-status.test.ts +158 -0
- package/src/tests/working.test.ts +161 -0
- package/src/theme.ts +42 -1
- package/src/types.ts +16 -1
- package/src/working-status.ts +98 -0
package/README.md
CHANGED
|
@@ -42,6 +42,9 @@ and apply live.
|
|
|
42
42
|
║ Border style ‹ rounded › ║
|
|
43
43
|
║ [■] Rainbow border on max thinking ON ║
|
|
44
44
|
║ [■] Animate rainbow border ON ║
|
|
45
|
+
║ [■] NVIDIA-green when using Switchyard ON ║
|
|
46
|
+
║ [■] Animate Switchyard green border ON ║
|
|
47
|
+
║ [ ] Embed status spinners OFF ║
|
|
45
48
|
║ ║
|
|
46
49
|
╟─ Top Left Segment Group ───────────────────────────────────────╢
|
|
47
50
|
║ [■] Pi symbol ON ║
|
|
@@ -66,18 +69,18 @@ and apply live.
|
|
|
66
69
|
║ + add feed ║
|
|
67
70
|
║ ║
|
|
68
71
|
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
69
|
-
╚════════════════════════════════════════════════════════[ 1/
|
|
72
|
+
╚════════════════════════════════════════════════════════[ 1/32 ]╝
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
| Section | Settings |
|
|
73
76
|
| --- | --- |
|
|
74
|
-
| 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 |
|
|
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 |
|
|
75
78
|
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
76
79
|
| Top Right Segment Group | Token rate · Session name |
|
|
77
80
|
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
78
81
|
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
79
82
|
| Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
|
|
80
|
-
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
|
|
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 |
|
|
81
84
|
|
|
82
85
|
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
83
86
|
replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
|
|
@@ -87,6 +90,29 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
|
|
|
87
90
|
phase too. Any other thinking level keeps the normal theme border color. Disable animation over
|
|
88
91
|
slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
|
|
89
92
|
|
|
93
|
+
When the active model provider is `switchyard` and **NVIDIA-green when using Switchyard** is on (the
|
|
94
|
+
default), the border uses an NVIDIA-green gradient from `#84c51a` to `#0b3d20`. **Animate Switchyard
|
|
95
|
+
green border** is also on by default, so it sweeps around the box on the same ~14s cycle as the
|
|
96
|
+
max-thinking rainbow, independently of **Animate rainbow border**. Turn the animation setting off to
|
|
97
|
+
keep the green gradient at a fixed color phase without its repaint timer. When enabled, green takes
|
|
98
|
+
precedence over the max-thinking rainbow and ignores thinking level; turn the NVIDIA-green setting off
|
|
99
|
+
to restore the normal theme or rainbow border.
|
|
100
|
+
|
|
101
|
+
**Embed status spinners** (off by default) moves pi's status indicators out of their own row
|
|
102
|
+
and into the top-left group, right after the Pi symbol and its chevron. On pi 0.86 and later
|
|
103
|
+
this covers the working, retry, compaction, and branch-summary spinners; pi 0.85 embeds only
|
|
104
|
+
the working indicator. The remaining left segments (model, path, git, PR) step aside while a
|
|
105
|
+
status is visible, and the Pi symbol never moves. A cleared status is held briefly so handoffs
|
|
106
|
+
between spinners do not flash the left segments. Only the end of a working spinner then
|
|
107
|
+
cross-fades back over 750ms; the message-style spinners cut after the hold.
|
|
108
|
+
|
|
109
|
+
A status too long for the bar is truncated. The working spinner keeps pi's bare cut, while the
|
|
110
|
+
message-style spinners use an ellipsis. Focusing the embed row in the settings preview shows
|
|
111
|
+
the longest compaction sample. Requires pi 0.85 or later. Toggling the setting reinstalls the
|
|
112
|
+
editor through pi, so it applies immediately, even mid-response. Only the statusline's own
|
|
113
|
+
editor opts in: when another extension owns the editor slot and is wrapped, pi keeps its
|
|
114
|
+
standalone status row.
|
|
115
|
+
|
|
90
116
|
## Segments
|
|
91
117
|
|
|
92
118
|
Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
|
|
@@ -96,7 +122,9 @@ fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (liv
|
|
|
96
122
|
accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
|
|
97
123
|
pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
|
|
98
124
|
bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
|
|
99
|
-
(documented below).
|
|
125
|
+
(documented below). On pi 0.86 and later, `pi_stats` includes cache-warming refreshes folded
|
|
126
|
+
into pi's own footer totals, so cache R/W and cost can increase while idle; this segment matches
|
|
127
|
+
the host footer by construction.
|
|
100
128
|
|
|
101
129
|
When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
|
|
102
130
|
then left segments drop end-first — the path is always the last to go.
|
package/index.ts
CHANGED
|
@@ -35,13 +35,17 @@ import {
|
|
|
35
35
|
stripRedundantStats,
|
|
36
36
|
} from "./src/footer.js";
|
|
37
37
|
import { buildStatusLine } from "./src/layout.js";
|
|
38
|
-
import {
|
|
38
|
+
import { NvidiaGreenBorder, isSwitchyardProvider } from "./src/nvidia-green.js";
|
|
39
|
+
import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder, type BorderColorizer } from "./src/rainbow.js";
|
|
39
40
|
import { registerSettingsCommand } from "./src/settings-menu.js";
|
|
40
|
-
import { createSettingsState } from "./src/settings.js";
|
|
41
|
+
import { createSettingsState, topLeftSegments } from "./src/settings.js";
|
|
41
42
|
import { theme } from "./src/theme.js";
|
|
42
43
|
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
44
|
+
import { isMessageKind, type StatusIndicatorKind, StatusTransition } from "./src/working-status.js";
|
|
43
45
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
44
46
|
|
|
47
|
+
const WORKING_FADE_FRAME_MS = 30;
|
|
48
|
+
|
|
45
49
|
export default function (pi: ExtensionAPI) {
|
|
46
50
|
const state = createSettingsState();
|
|
47
51
|
const builder = new SegmentContextBuilder(pi);
|
|
@@ -54,42 +58,81 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
58
|
let patchedFooter: Component | undefined;
|
|
55
59
|
let footerRenderOriginal: ((width: number) => string[]) | undefined;
|
|
56
60
|
let restoreFooterLayout: (() => void) | undefined;
|
|
61
|
+
// The CustomEditor this extension constructed itself (undefined when it wraps
|
|
62
|
+
// another extension's editor), and the host's status spinner captured from
|
|
63
|
+
// that editor. pi 0.85 sends only working; 0.86 sends all four kinds.
|
|
64
|
+
let activeEditor: CustomEditor | undefined;
|
|
65
|
+
let embeddedWorkingStatus: ((width: number) => string) | undefined;
|
|
66
|
+
let embeddedStatusKind: StatusIndicatorKind | undefined;
|
|
67
|
+
const statusTransition = new StatusTransition();
|
|
68
|
+
let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
|
|
69
|
+
const stopWorkingFadeTimer = (): void => {
|
|
70
|
+
if (workingFadeTimer) clearInterval(workingFadeTimer);
|
|
71
|
+
workingFadeTimer = undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const syncEmbed = (): void => {
|
|
75
|
+
const ctx = activeCtx;
|
|
76
|
+
const factory = ourFactory;
|
|
77
|
+
if (!activeEditor || !ctx || !factory) return;
|
|
78
|
+
const next = state.effective.embedWorkingStatus;
|
|
79
|
+
if (activeEditor.embedWorkingStatus === next) return;
|
|
80
|
+
ctx.ui.setEditorComponent(factory);
|
|
81
|
+
};
|
|
57
82
|
|
|
58
83
|
const requestRender = () => activeTui?.requestRender();
|
|
59
84
|
builder.setRequestRender(requestRender);
|
|
85
|
+
const startWorkingFadeTimer = (): void => {
|
|
86
|
+
if (workingFadeTimer) return;
|
|
87
|
+
workingFadeTimer = setInterval(() => {
|
|
88
|
+
requestRender();
|
|
89
|
+
if (!statusTransition.pending(Date.now())) stopWorkingFadeTimer();
|
|
90
|
+
}, WORKING_FADE_FRAME_MS);
|
|
91
|
+
workingFadeTimer.unref?.();
|
|
92
|
+
};
|
|
60
93
|
|
|
61
94
|
const rateMonitor = new TokenRateMonitor(requestRender);
|
|
62
95
|
rateMonitor.attach(pi);
|
|
63
96
|
builder.setTokenRateProvider(() => rateMonitor.getDisplay());
|
|
64
97
|
|
|
65
|
-
// The
|
|
66
|
-
//
|
|
98
|
+
// The active border colorizer is visible independently of whether its phase
|
|
99
|
+
// is animated. Stopping the timer preserves the current live phase.
|
|
67
100
|
const rainbow = new RainbowBorder();
|
|
68
|
-
|
|
101
|
+
const green = new NvidiaGreenBorder();
|
|
102
|
+
let borderTimer: ReturnType<typeof setInterval> | undefined;
|
|
69
103
|
const rainbowActive = (): boolean =>
|
|
70
104
|
state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
const greenActive = (): boolean =>
|
|
106
|
+
state.effective.nvidiaGreenBorder && activeCtx !== undefined && isSwitchyardProvider(activeCtx.model?.provider);
|
|
107
|
+
const borderColorizer = (): BorderColorizer | undefined => (greenActive() ? green : rainbowActive() ? rainbow : undefined);
|
|
108
|
+
const borderAnimationActive = (): boolean => {
|
|
109
|
+
if (greenActive()) return state.effective.nvidiaGreenAnimation;
|
|
110
|
+
if (rainbowActive()) return state.effective.rainbowAnimation;
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
const syncBorderAnimation = (): void => {
|
|
114
|
+
if (borderAnimationActive()) {
|
|
115
|
+
if (!borderTimer) {
|
|
116
|
+
borderTimer = setInterval(() => {
|
|
117
|
+
const colorizer = borderColorizer();
|
|
118
|
+
if (!borderAnimationActive() || colorizer === undefined) {
|
|
119
|
+
syncBorderAnimation();
|
|
78
120
|
return;
|
|
79
121
|
}
|
|
80
|
-
|
|
122
|
+
colorizer.step(RAINBOW_DEG_PER_FRAME);
|
|
81
123
|
requestRender();
|
|
82
124
|
}, RAINBOW_FRAME_MS);
|
|
83
|
-
|
|
125
|
+
borderTimer.unref?.();
|
|
84
126
|
}
|
|
85
|
-
} else if (
|
|
86
|
-
clearInterval(
|
|
87
|
-
|
|
127
|
+
} else if (borderTimer) {
|
|
128
|
+
clearInterval(borderTimer);
|
|
129
|
+
borderTimer = undefined;
|
|
88
130
|
}
|
|
89
131
|
};
|
|
90
132
|
|
|
91
133
|
registerSettingsCommand(pi, state, builder, () => {
|
|
92
|
-
|
|
134
|
+
syncBorderAnimation();
|
|
135
|
+
syncEmbed();
|
|
93
136
|
requestRender();
|
|
94
137
|
});
|
|
95
138
|
|
|
@@ -124,19 +167,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
167
|
const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
|
|
125
168
|
// borderColor is assigned by the host after the factory returns — read late.
|
|
126
169
|
const border = editor.borderColor ?? ((s: string) => s);
|
|
127
|
-
const
|
|
170
|
+
const colorizer = borderColorizer();
|
|
128
171
|
const box = theme.getBox(effective.borderStyle);
|
|
129
|
-
const painters = makeBoxPainters({
|
|
172
|
+
const painters = makeBoxPainters({
|
|
173
|
+
colorizerOn: colorizer !== undefined,
|
|
174
|
+
colorizer: colorizer ?? rainbow,
|
|
175
|
+
box,
|
|
176
|
+
width,
|
|
177
|
+
bottomIdx,
|
|
178
|
+
flat: border,
|
|
179
|
+
});
|
|
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();
|
|
130
184
|
const bar = buildStatusLine(
|
|
131
185
|
innerWidth,
|
|
132
|
-
segCtx,
|
|
186
|
+
{ ...segCtx, workingStatus: frame.status },
|
|
133
187
|
effective,
|
|
134
188
|
painters.gapColor,
|
|
135
189
|
{
|
|
136
|
-
left: effective.
|
|
190
|
+
left: topLeftSegments(effective, frame.status !== undefined),
|
|
137
191
|
right: effective.rightSegments,
|
|
138
192
|
},
|
|
139
193
|
{ col: 3, row: 0 },
|
|
194
|
+
{
|
|
195
|
+
leftFade: frame.leftFade,
|
|
196
|
+
workingEllipsis: isMessageKind(frame.kind) ? "…" : "",
|
|
197
|
+
},
|
|
140
198
|
);
|
|
141
199
|
// Leading spacer row: keeps the transcript from sitting flush on the box.
|
|
142
200
|
const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
|
|
@@ -166,12 +224,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
166
224
|
const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
|
|
167
225
|
return (tui, editorTheme, keybindings) => {
|
|
168
226
|
activeTui = tui;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
:
|
|
227
|
+
let editor: ReturnType<EditorFactory>;
|
|
228
|
+
if (inner) {
|
|
229
|
+
// A wrapped third-party editor is not opted in: pi keeps its
|
|
230
|
+
// standalone status-spinner row for it.
|
|
231
|
+
editor = inner(tui, editorTheme, keybindings);
|
|
232
|
+
activeEditor = undefined;
|
|
233
|
+
embeddedWorkingStatus = undefined;
|
|
234
|
+
embeddedStatusKind = undefined;
|
|
235
|
+
} else {
|
|
236
|
+
const own = new CustomEditor(tui, editorTheme, keybindings, {
|
|
237
|
+
embedWorkingStatus: state.effective.embedWorkingStatus,
|
|
238
|
+
});
|
|
239
|
+
// Capture the host's status indicator on its way in; renderBoxed
|
|
240
|
+
// draws it in the top bar because the inner border is discarded.
|
|
241
|
+
if (typeof own.setWorkingStatusIndicator === "function") {
|
|
242
|
+
const setIndicator = own.setWorkingStatusIndicator.bind(own);
|
|
243
|
+
own.setWorkingStatusIndicator = indicator => {
|
|
244
|
+
// pi 0.86 sends retry, compaction, and branch-summary indicators
|
|
245
|
+
// through this setter too; renderInBorder now lives on the base class.
|
|
246
|
+
embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
|
|
247
|
+
embeddedStatusKind = indicator?.kind;
|
|
248
|
+
setIndicator(indicator);
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
activeEditor = own;
|
|
252
|
+
editor = own;
|
|
253
|
+
}
|
|
172
254
|
const innerRender = editor.render.bind(editor);
|
|
173
255
|
let boxRenderFailed = false;
|
|
174
256
|
editor.render = width => {
|
|
257
|
+
if (boxRenderFailed) return innerRender(width);
|
|
175
258
|
try {
|
|
176
259
|
return renderBoxed(innerRender, width, editor);
|
|
177
260
|
} catch (err) {
|
|
@@ -201,6 +284,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
284
|
|
|
202
285
|
const PI_STATS_CACHE_MS = 250;
|
|
203
286
|
let piStatsCache: { at: number; value: string | undefined } | undefined;
|
|
287
|
+
// From pi 0.86 these host totals include cache_warm usage entries, so R/W
|
|
288
|
+
// and cost can change while idle; mirroring the footer keeps this exact.
|
|
204
289
|
builder.setPiStatsProvider(width => {
|
|
205
290
|
if (!footerRenderOriginal) return undefined;
|
|
206
291
|
const now = Date.now();
|
|
@@ -248,13 +333,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
248
333
|
}
|
|
249
334
|
if (!restoreFooterLayout) {
|
|
250
335
|
const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
|
|
336
|
+
// No-op on pi >= 0.86; retained for supported 0.84/0.85 hosts.
|
|
251
337
|
restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
|
|
252
338
|
}
|
|
253
339
|
};
|
|
254
340
|
|
|
255
|
-
pi.on("model_select", () =>
|
|
341
|
+
pi.on("model_select", () => {
|
|
342
|
+
syncBorderAnimation();
|
|
343
|
+
requestRender();
|
|
344
|
+
});
|
|
256
345
|
pi.on("thinking_level_select", () => {
|
|
257
|
-
|
|
346
|
+
syncBorderAnimation();
|
|
258
347
|
requestRender();
|
|
259
348
|
});
|
|
260
349
|
pi.on("session_info_changed", () => requestRender());
|
|
@@ -264,15 +353,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
264
353
|
activeCtx = ctx;
|
|
265
354
|
builder.attach(ctx);
|
|
266
355
|
rateMonitor.enable();
|
|
267
|
-
|
|
356
|
+
syncBorderAnimation();
|
|
268
357
|
ensureInstalled();
|
|
358
|
+
syncEmbed();
|
|
269
359
|
ensureFooterPatched();
|
|
270
360
|
ensureTimer ??= setInterval(() => {
|
|
271
361
|
ensureInstalled();
|
|
272
362
|
ensureFooterPatched();
|
|
273
363
|
// The host's /settings dialog can change the thinking level without
|
|
274
364
|
// firing thinking_level_select, so reconcile the animator periodically.
|
|
275
|
-
|
|
365
|
+
syncBorderAnimation();
|
|
276
366
|
}, 1000);
|
|
277
367
|
ensureTimer.unref?.();
|
|
278
368
|
});
|
|
@@ -280,9 +370,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
370
|
pi.on("session_shutdown", () => {
|
|
281
371
|
rateMonitor.disable();
|
|
282
372
|
builder.dispose();
|
|
283
|
-
if (
|
|
284
|
-
clearInterval(
|
|
285
|
-
|
|
373
|
+
if (borderTimer) {
|
|
374
|
+
clearInterval(borderTimer);
|
|
375
|
+
borderTimer = undefined;
|
|
286
376
|
}
|
|
287
377
|
if (ensureTimer) {
|
|
288
378
|
clearInterval(ensureTimer);
|
|
@@ -300,6 +390,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
300
390
|
restoreFooterLayout = undefined;
|
|
301
391
|
ourFactory = undefined;
|
|
302
392
|
wrappedFactory = undefined;
|
|
393
|
+
activeEditor = undefined;
|
|
394
|
+
embeddedWorkingStatus = undefined;
|
|
395
|
+
embeddedStatusKind = undefined;
|
|
396
|
+
stopWorkingFadeTimer();
|
|
397
|
+
statusTransition.reset();
|
|
303
398
|
activeTui = undefined;
|
|
304
399
|
activeCtx = undefined;
|
|
305
400
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-statusline",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": "0.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
50
|
-
"@earendil-works/pi-tui": "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",
|
|
51
51
|
"@types/node": "^22.0.0",
|
|
52
52
|
"typescript": "^5.7.0"
|
|
53
53
|
}
|
package/src/box.ts
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
* Shared box-chrome painting for the live editor box (index.ts) and the
|
|
3
3
|
* settings-menu preview, so the 3/2/+2 row geometry has one definition.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Gradient mode colors each glyph by its perimeter-walk position; otherwise
|
|
6
6
|
* the caller's flat painter wraps whole runs in one escape sequence, which
|
|
7
7
|
* renders identically to per-glyph wraps with fewer bytes. The flat painter
|
|
8
|
-
* and the
|
|
8
|
+
* and the border colorizer are injected because they differ per caller
|
|
9
9
|
* (host borderColor vs menu theme; shared stepped animator vs phase-from-
|
|
10
10
|
* elapsed instance).
|
|
11
11
|
*/
|
|
12
12
|
import { stripVTControlCharacters } from "node:util";
|
|
13
13
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
-
import { perimeterLength, perimeterPosition, type
|
|
14
|
+
import { perimeterLength, perimeterPosition, type BorderColorizer } from "./rainbow.js";
|
|
15
15
|
import type { BoxGlyphs } from "./theme.js";
|
|
16
16
|
|
|
17
17
|
export interface BoxPainters {
|
|
@@ -22,28 +22,50 @@ export interface BoxPainters {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function makeBoxPainters(opts: {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
colorizerOn: boolean;
|
|
26
|
+
colorizer: BorderColorizer;
|
|
27
27
|
box: BoxGlyphs;
|
|
28
28
|
width: number;
|
|
29
29
|
bottomIdx: number;
|
|
30
30
|
flat: (str: string) => string;
|
|
31
31
|
}): BoxPainters {
|
|
32
|
-
const {
|
|
32
|
+
const { colorizerOn, colorizer, box, width, bottomIdx, flat } = opts;
|
|
33
33
|
const perimeter = perimeterLength(width, bottomIdx);
|
|
34
34
|
const paint = (row: number, col: number, ch: string): string =>
|
|
35
|
-
|
|
35
|
+
colorizerOn ? colorizer.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
|
|
36
36
|
const horizRun = (row: number, startCol: number, count: number): string => {
|
|
37
|
-
if (!
|
|
37
|
+
if (!colorizerOn) return flat(box.horizontal.repeat(count));
|
|
38
38
|
let out = "";
|
|
39
|
-
|
|
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`;
|
|
40
51
|
return out;
|
|
41
52
|
};
|
|
42
53
|
const gapColor = (str: string, startCol: number, row: number): string => {
|
|
43
|
-
if (!
|
|
54
|
+
if (!colorizerOn) return flat(str);
|
|
44
55
|
let out = "";
|
|
56
|
+
let runPrefix: string | undefined;
|
|
57
|
+
let runChars = "";
|
|
45
58
|
let k = 0;
|
|
46
|
-
for (const ch of str)
|
|
59
|
+
for (const ch of str) {
|
|
60
|
+
const prefix = colorizer.prefix(perimeterPosition(row, startCol + k++, width, bottomIdx), perimeter);
|
|
61
|
+
if (runPrefix !== undefined && prefix !== runPrefix) {
|
|
62
|
+
out += `${runPrefix}${runChars}\x1b[39m`;
|
|
63
|
+
runChars = "";
|
|
64
|
+
}
|
|
65
|
+
runPrefix = prefix;
|
|
66
|
+
runChars += ch;
|
|
67
|
+
}
|
|
68
|
+
if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
|
|
47
69
|
return out;
|
|
48
70
|
};
|
|
49
71
|
return { paint, horizRun, gapColor };
|
package/src/context.ts
CHANGED
|
@@ -63,7 +63,7 @@ function deriveContextUsage(ctx: ExtensionContext | undefined): {
|
|
|
63
63
|
const usage = ctx?.getContextUsage();
|
|
64
64
|
const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
|
|
65
65
|
const tokens = usage?.tokens ?? undefined;
|
|
66
|
-
const percent = usage ? usage.percent :
|
|
66
|
+
const percent = usage ? usage.percent : null;
|
|
67
67
|
return { contextWindow, tokens, percent };
|
|
68
68
|
}
|
|
69
69
|
|
package/src/footer.ts
CHANGED
|
@@ -27,8 +27,11 @@ function containsComponent(node: unknown, target: unknown, seen: Set<unknown>):
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Let
|
|
31
|
-
*
|
|
30
|
+
* Let pi's fullscreen footer slot collapse when its component renders no rows.
|
|
31
|
+
* Hosts before 0.86 give that VStack entry minSize=1 regardless of render
|
|
32
|
+
* output; 0.86 fixed it (#8919), where this writes 0 over 0. Delete this
|
|
33
|
+
* helper, its index.ts call/restore path, and its test once the minimum
|
|
34
|
+
* supported host is 0.86.
|
|
32
35
|
*/
|
|
33
36
|
export function collapseFooterLayoutSlot(layoutRoot: unknown, footer: unknown): (() => void) | undefined {
|
|
34
37
|
const seen = new Set<unknown>();
|
package/src/layout.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
|
|
3
3
|
*
|
|
4
|
-
* Overflow strategy (in order): drop right segments right-to-left,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Overflow strategy (in order): drop right segments right-to-left, truncate
|
|
5
|
+
* the embedded `working` status with the caller's chosen ellipsis policy,
|
|
6
|
+
* shrink the elastic `path` segment down to ~8 cells, drop left segments
|
|
7
|
+
* end-first while protecting `path`. Working indicators retain pi's bare cut;
|
|
8
|
+
* message-style indicators use an ellipsis so truncated prose is apparent.
|
|
9
|
+
* The gap between the two groups is filled with the
|
|
7
10
|
* box-horizontal glyph colored like the editor border, so it tracks the
|
|
8
11
|
* thinking-level border color; the callback receives the gap's absolute
|
|
9
12
|
* column and row in the box, so a caller can paint it as part of a
|
|
10
13
|
* continuous border gradient (the rainbow effect).
|
|
14
|
+
*
|
|
15
|
+
* `options.leftFade` (0..1) blends every left segment after a leading `pi`
|
|
16
|
+
* symbol toward the bar background, which the working-indicator transition
|
|
17
|
+
* uses to cross-fade the group without moving the symbol or its chevron.
|
|
11
18
|
*/
|
|
12
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
19
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
20
|
import { renderSegment } from "./segments.js";
|
|
14
21
|
import { getSeparator } from "./separators.js";
|
|
15
22
|
import { theme } from "./theme.js";
|
|
@@ -58,6 +65,7 @@ export function buildStatusLine(
|
|
|
58
65
|
gapBorderColor: (str: string, startCol: number, row: number) => string,
|
|
59
66
|
segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
|
|
60
67
|
barOrigin: { col: number; row: number } = { col: 0, row: 0 },
|
|
68
|
+
options: { leftFade?: number; workingEllipsis?: string } = {},
|
|
61
69
|
): string {
|
|
62
70
|
const separatorDef = getSeparator(settings.separator);
|
|
63
71
|
|
|
@@ -112,6 +120,19 @@ export function buildStatusLine(
|
|
|
112
120
|
right.pop();
|
|
113
121
|
rightWidth = rightGroupWidth(right);
|
|
114
122
|
}
|
|
123
|
+
// The working status absorbs overflow first so the Pi symbol beside it
|
|
124
|
+
// survives on narrow terminals.
|
|
125
|
+
const workingIdx = leftSegIds.indexOf("working");
|
|
126
|
+
if (workingIdx >= 0 && totalWidth() > width) {
|
|
127
|
+
const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
|
|
128
|
+
if (available >= 1) {
|
|
129
|
+
left[workingIdx] = truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "");
|
|
130
|
+
} else {
|
|
131
|
+
left.splice(workingIdx, 1);
|
|
132
|
+
leftSegIds.splice(workingIdx, 1);
|
|
133
|
+
}
|
|
134
|
+
leftWidth = leftGroupWidth(left);
|
|
135
|
+
}
|
|
115
136
|
// Shrink path before dropping left segments — path is the only elastic segment
|
|
116
137
|
const pathIdx = leftSegIds.indexOf("path");
|
|
117
138
|
if (pathIdx >= 0 && totalWidth() > width) {
|
|
@@ -137,13 +158,25 @@ export function buildStatusLine(
|
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
160
|
|
|
140
|
-
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
|
161
|
+
const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
|
|
141
162
|
if (parts.length === 0) return "";
|
|
142
163
|
const sep = direction === "left" ? separatorDef.left : separatorDef.right;
|
|
143
164
|
const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
|
|
144
|
-
|
|
165
|
+
const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
|
|
166
|
+
|
|
167
|
+
let body: string;
|
|
168
|
+
const fade = options.leftFade;
|
|
169
|
+
if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
|
|
170
|
+
// The faded tail opens with an explicit default-fg reset so plain text
|
|
171
|
+
// inside it is recolored too, not just segments that set their own color.
|
|
172
|
+
const head = parts.slice(0, fadeFrom).join(sepText);
|
|
173
|
+
const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
|
|
174
|
+
body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
|
|
175
|
+
} else {
|
|
176
|
+
body = parts.join(sepText);
|
|
177
|
+
}
|
|
145
178
|
let content = bgAnsi + fgAnsi;
|
|
146
|
-
content += ` ${
|
|
179
|
+
content += ` ${body} `;
|
|
147
180
|
content += "\x1b[0m";
|
|
148
181
|
|
|
149
182
|
if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
|
|
@@ -151,7 +184,7 @@ export function buildStatusLine(
|
|
|
151
184
|
return content;
|
|
152
185
|
};
|
|
153
186
|
|
|
154
|
-
const leftGroup = renderGroup(left, "left");
|
|
187
|
+
const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
|
|
155
188
|
const rightGroup = renderGroup(right, "right");
|
|
156
189
|
if (!leftGroup && !rightGroup) return "";
|
|
157
190
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Provider-specific NVIDIA green border colorizer for Switchyard. */
|
|
2
|
+
import { rgbToHex, wrapDeg, type BorderColorizer } from "./rainbow.js";
|
|
3
|
+
import { detectColorMode, hexToFgAnsi, parseHex, type ColorMode } from "./theme.js";
|
|
4
|
+
|
|
5
|
+
export const SWITCHYARD_PROVIDER = "switchyard";
|
|
6
|
+
export const NVIDIA_GREEN_HEX = "#84c51a";
|
|
7
|
+
export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
|
|
8
|
+
const GREEN_SHADE_COUNT = 256;
|
|
9
|
+
const GREEN_BIAS = 0.65;
|
|
10
|
+
|
|
11
|
+
export function isSwitchyardProvider(provider: string | undefined): boolean {
|
|
12
|
+
return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ansiByShadeByMode = new Map<ColorMode, string[]>();
|
|
16
|
+
|
|
17
|
+
function buildAnsiByShade(mode: ColorMode): string[] {
|
|
18
|
+
const cached = ansiByShadeByMode.get(mode);
|
|
19
|
+
if (cached !== undefined) return cached;
|
|
20
|
+
|
|
21
|
+
const dark = parseHex(NVIDIA_GREEN_DARK_HEX);
|
|
22
|
+
const bright = parseHex(NVIDIA_GREEN_HEX);
|
|
23
|
+
if (dark === undefined || bright === undefined) {
|
|
24
|
+
throw new Error("Invalid NVIDIA green palette");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ansiByShade: string[] = [];
|
|
28
|
+
for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
|
|
29
|
+
const progress = i / (GREEN_SHADE_COUNT - 1);
|
|
30
|
+
const rgb: [number, number, number] = [
|
|
31
|
+
Math.round(dark[0] + (bright[0] - dark[0]) * progress),
|
|
32
|
+
Math.round(dark[1] + (bright[1] - dark[1]) * progress),
|
|
33
|
+
Math.round(dark[2] + (bright[2] - dark[2]) * progress),
|
|
34
|
+
];
|
|
35
|
+
ansiByShade.push(hexToFgAnsi(rgbToHex(rgb), mode));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ansiByShadeByMode.set(mode, ansiByShade);
|
|
39
|
+
return ansiByShade;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class NvidiaGreenBorder implements BorderColorizer {
|
|
43
|
+
#phaseDeg: number;
|
|
44
|
+
readonly #ansiByShade: string[];
|
|
45
|
+
|
|
46
|
+
constructor(phaseDeg = 0) {
|
|
47
|
+
this.#phaseDeg = wrapDeg(phaseDeg);
|
|
48
|
+
this.#ansiByShade = buildAnsiByShade(detectColorMode());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
step(deltaDeg: number): void {
|
|
52
|
+
this.#phaseDeg = wrapDeg(this.#phaseDeg + deltaDeg);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
colorChar(char: string, perimeterPos: number, perimeter: number): string {
|
|
56
|
+
return `${this.prefix(perimeterPos, perimeter)}${char}\x1b[39m`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prefix(perimeterPos: number, perimeter: number): string {
|
|
60
|
+
const t = (perimeterPos / perimeter + this.#phaseDeg / 360) % 1;
|
|
61
|
+
const u = (1 - Math.cos(2 * Math.PI * t)) / 2;
|
|
62
|
+
const eased = u ** GREEN_BIAS;
|
|
63
|
+
const shadeIndex = Math.round((1 - eased) * (GREEN_SHADE_COUNT - 1));
|
|
64
|
+
return this.#ansiByShade[shadeIndex];
|
|
65
|
+
}
|
|
66
|
+
}
|