@underactive/pi-topping-statusline 0.1.3 → 0.2.0
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 +19 -4
- package/index.ts +154 -20
- package/package.json +5 -4
- package/src/box.ts +15 -0
- package/src/context.ts +96 -8
- package/src/footer.ts +46 -0
- package/src/layout.ts +39 -8
- package/src/segments.ts +28 -4
- package/src/settings-menu.ts +13 -3
- package/src/settings.ts +12 -0
- package/src/tests/box.test.ts +14 -1
- package/src/tests/feeds.test.ts +165 -16
- package/src/tests/footer.test.ts +30 -1
- package/src/tests/working.test.ts +140 -0
- package/src/theme.ts +54 -11
- package/src/types.ts +23 -1
- package/src/utils.ts +7 -5
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ and apply live.
|
|
|
42
42
|
║ Border style ‹ rounded › ║
|
|
43
43
|
║ [■] Rainbow border on max thinking ON ║
|
|
44
44
|
║ [■] Animate rainbow border ON ║
|
|
45
|
+
║ [ ] Embed 'Working' indicator OFF ║
|
|
45
46
|
║ ║
|
|
46
47
|
╟─ Top Left Segment Group ───────────────────────────────────────╢
|
|
47
48
|
║ [■] Pi symbol ON ║
|
|
@@ -66,18 +67,18 @@ and apply live.
|
|
|
66
67
|
║ + add feed ║
|
|
67
68
|
║ ║
|
|
68
69
|
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
69
|
-
╚════════════════════════════════════════════════════════[ 1/
|
|
70
|
+
╚════════════════════════════════════════════════════════[ 1/30 ]╝
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
| Section | Settings |
|
|
73
74
|
| --- | --- |
|
|
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 |
|
|
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 'Working' indicator |
|
|
75
76
|
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
76
77
|
| Top Right Segment Group | Token rate · Session name |
|
|
77
78
|
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
78
79
|
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
79
80
|
| 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 |
|
|
81
|
+
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
|
|
81
82
|
|
|
82
83
|
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
83
84
|
replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
|
|
@@ -87,6 +88,17 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
|
|
|
87
88
|
phase too. Any other thinking level keeps the normal theme border color. Disable animation over
|
|
88
89
|
slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
|
|
89
90
|
|
|
91
|
+
**Embed 'Working' indicator** (off by default) moves pi's streaming status — spinner, message,
|
|
92
|
+
and any loader text a topping such as pi-topping supplies — out of its own row and into the
|
|
93
|
+
top-left group, right after the Pi symbol and its chevron. The remaining left segments (model,
|
|
94
|
+
path, git, PR) step aside while a response streams and return when it ends; the Pi symbol never
|
|
95
|
+
moves. The status appears instantly when a stream starts; when it ends, the swap back
|
|
96
|
+
cross-fades over 750ms (the status sinks into the bar background, then the user's segments
|
|
97
|
+
rise out of it) rather than cutting. A status too long for the bar is truncated without an ellipsis, as pi's own border does.
|
|
98
|
+
Requires pi 0.85 or later. Toggling the setting reinstalls the editor through pi, so it applies
|
|
99
|
+
immediately, even mid-response. Only the statusline's own editor opts in: when another
|
|
100
|
+
extension owns the editor slot and is wrapped, pi keeps its standalone working row.
|
|
101
|
+
|
|
90
102
|
## Segments
|
|
91
103
|
|
|
92
104
|
Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
|
|
@@ -129,7 +141,10 @@ early in a session, before the cache-write premium is amortized by reads. Above
|
|
|
129
141
|
|
|
130
142
|
A feed contributes nothing when its publisher is absent, has published nothing this run, or
|
|
131
143
|
reports a value its format suppresses; the segment disappears entirely once every feed is
|
|
132
|
-
quiet, so it costs nothing to leave switched on.
|
|
144
|
+
quiet, so it costs nothing to leave switched on. Feed values are shown in dim text; a value
|
|
145
|
+
that remains unchanged for five minutes fades into the statusline background over 500ms and
|
|
146
|
+
then hides. It reappears in dim text only when that configured field's value changes.
|
|
147
|
+
Publishers typically reset per process run,
|
|
133
148
|
so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
|
|
134
149
|
ignored rather than shown as a stale total. During steady-state rendering, the session is
|
|
135
150
|
re-read at most once every two seconds, because pi copies the whole entry list on each read
|
package/index.ts
CHANGED
|
@@ -25,17 +25,28 @@ import {
|
|
|
25
25
|
type ExtensionContext,
|
|
26
26
|
} from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
28
|
-
import { makeBoxPainters, renderBoxRow } from "./src/box.js";
|
|
28
|
+
import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "./src/box.js";
|
|
29
29
|
import { SegmentContextBuilder } from "./src/context.js";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
collapseFooterLayoutSlot,
|
|
32
|
+
filterVisibleRows,
|
|
33
|
+
isBorderRow,
|
|
34
|
+
stripAnsi,
|
|
35
|
+
stripRedundantStats,
|
|
36
|
+
} from "./src/footer.js";
|
|
31
37
|
import { buildStatusLine } from "./src/layout.js";
|
|
32
38
|
import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
|
|
33
39
|
import { registerSettingsCommand } from "./src/settings-menu.js";
|
|
34
|
-
import { createSettingsState } from "./src/settings.js";
|
|
40
|
+
import { createSettingsState, topLeftSegments } from "./src/settings.js";
|
|
41
|
+
import { easeFade } from "./src/theme.js";
|
|
35
42
|
import { theme } from "./src/theme.js";
|
|
36
43
|
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
37
44
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
38
45
|
|
|
46
|
+
/** Cross-fade budget when the embedded working status disappears: half out, half in. */
|
|
47
|
+
const WORKING_FADE_MS = 750;
|
|
48
|
+
const WORKING_FADE_FRAME_MS = 15;
|
|
49
|
+
|
|
39
50
|
export default function (pi: ExtensionAPI) {
|
|
40
51
|
const state = createSettingsState();
|
|
41
52
|
const builder = new SegmentContextBuilder(pi);
|
|
@@ -47,9 +58,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
47
58
|
let ensureTimer: ReturnType<typeof setInterval> | undefined;
|
|
48
59
|
let patchedFooter: Component | undefined;
|
|
49
60
|
let footerRenderOriginal: ((width: number) => string[]) | undefined;
|
|
61
|
+
let restoreFooterLayout: (() => void) | undefined;
|
|
62
|
+
// The CustomEditor this extension constructed itself (undefined when it wraps
|
|
63
|
+
// another extension's editor), and the host's working indicator captured
|
|
64
|
+
// from that editor while a response streams.
|
|
65
|
+
let activeEditor: CustomEditor | undefined;
|
|
66
|
+
let embeddedWorkingStatus: ((width: number) => string) | undefined;
|
|
67
|
+
// Fade from the working status back to the user's left segments when a
|
|
68
|
+
// stream ends. The last status text is kept so the outgoing half still has
|
|
69
|
+
// something to fade after pi has cleared its indicator.
|
|
70
|
+
let workingShown = false;
|
|
71
|
+
let lastWorkingText: string | undefined;
|
|
72
|
+
let workingFade: { start: number } | undefined;
|
|
73
|
+
let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
|
|
74
|
+
const stopWorkingFadeTimer = (): void => {
|
|
75
|
+
if (workingFadeTimer) clearInterval(workingFadeTimer);
|
|
76
|
+
workingFadeTimer = undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const syncEmbed = (): void => {
|
|
80
|
+
const ctx = activeCtx;
|
|
81
|
+
const factory = ourFactory;
|
|
82
|
+
if (!activeEditor || !ctx || !factory) return;
|
|
83
|
+
const next = state.effective.embedWorkingStatus;
|
|
84
|
+
if (activeEditor.embedWorkingStatus === next) return;
|
|
85
|
+
ctx.ui.setEditorComponent(factory);
|
|
86
|
+
};
|
|
50
87
|
|
|
51
88
|
const requestRender = () => activeTui?.requestRender();
|
|
52
89
|
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
|
+
/**
|
|
103
|
+
* Which left group this frame shows and how far it has faded. A stream
|
|
104
|
+
* starting shows the working status at once. A stream ending starts a
|
|
105
|
+
* 750ms clock: the status sinks into the bar background for the first
|
|
106
|
+
* half, then the user's segments rise out of it.
|
|
107
|
+
*/
|
|
108
|
+
const resolveWorkingFrame = (live: string | undefined): { working: string | undefined; leftFade?: number } => {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const liveShown = live !== undefined;
|
|
111
|
+
if (liveShown) lastWorkingText = live;
|
|
112
|
+
if (liveShown !== workingShown) {
|
|
113
|
+
workingShown = liveShown;
|
|
114
|
+
if (liveShown) {
|
|
115
|
+
// Instant cut in, and it cancels any fade-out still running.
|
|
116
|
+
workingFade = undefined;
|
|
117
|
+
} else {
|
|
118
|
+
workingFade = { start: now };
|
|
119
|
+
startWorkingFadeTimer();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!workingFade) return { working: live };
|
|
123
|
+
const half = WORKING_FADE_MS / 2;
|
|
124
|
+
const elapsed = now - workingFade.start;
|
|
125
|
+
if (elapsed >= WORKING_FADE_MS) {
|
|
126
|
+
workingFade = undefined;
|
|
127
|
+
return { working: live };
|
|
128
|
+
}
|
|
129
|
+
const outgoing = elapsed < half;
|
|
130
|
+
const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
|
|
131
|
+
return { working: outgoing ? lastWorkingText : undefined, leftFade };
|
|
132
|
+
};
|
|
53
133
|
|
|
54
134
|
const rateMonitor = new TokenRateMonitor(requestRender);
|
|
55
135
|
rateMonitor.attach(pi);
|
|
@@ -83,6 +163,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
163
|
|
|
84
164
|
registerSettingsCommand(pi, state, builder, () => {
|
|
85
165
|
syncRainbow();
|
|
166
|
+
syncEmbed();
|
|
86
167
|
requestRender();
|
|
87
168
|
});
|
|
88
169
|
|
|
@@ -120,16 +201,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
201
|
const rainbowOn = rainbowActive();
|
|
121
202
|
const box = theme.getBox(effective.borderStyle);
|
|
122
203
|
const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
|
|
204
|
+
// The layout truncates the status to fit, so it is rendered at full width here.
|
|
205
|
+
const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
|
|
206
|
+
const { working, leftFade } = resolveWorkingFrame(live);
|
|
123
207
|
const bar = buildStatusLine(
|
|
124
208
|
innerWidth,
|
|
125
|
-
segCtx,
|
|
209
|
+
{ ...segCtx, workingStatus: working },
|
|
126
210
|
effective,
|
|
127
211
|
painters.gapColor,
|
|
128
212
|
{
|
|
129
|
-
left: effective
|
|
213
|
+
left: topLeftSegments(effective, working !== undefined),
|
|
130
214
|
right: effective.rightSegments,
|
|
131
215
|
},
|
|
132
216
|
{ col: 3, row: 0 },
|
|
217
|
+
{ leftFade },
|
|
133
218
|
);
|
|
134
219
|
// Leading spacer row: keeps the transcript from sitting flush on the box.
|
|
135
220
|
const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
|
|
@@ -149,7 +234,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
234
|
},
|
|
150
235
|
{ col: 3, row: bottomIdx },
|
|
151
236
|
);
|
|
152
|
-
out.push(
|
|
237
|
+
out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
|
|
153
238
|
for (let i = bottomIdx + 1; i < lines.length; i++) {
|
|
154
239
|
out.push(` ${lines[i]}`);
|
|
155
240
|
}
|
|
@@ -159,11 +244,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
159
244
|
const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
|
|
160
245
|
return (tui, editorTheme, keybindings) => {
|
|
161
246
|
activeTui = tui;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
:
|
|
247
|
+
let editor: ReturnType<EditorFactory>;
|
|
248
|
+
if (inner) {
|
|
249
|
+
// A wrapped third-party editor is not opted in: pi keeps its
|
|
250
|
+
// standalone working row for it.
|
|
251
|
+
editor = inner(tui, editorTheme, keybindings);
|
|
252
|
+
activeEditor = undefined;
|
|
253
|
+
embeddedWorkingStatus = undefined;
|
|
254
|
+
} else {
|
|
255
|
+
const own = new CustomEditor(tui, editorTheme, keybindings, {
|
|
256
|
+
embedWorkingStatus: state.effective.embedWorkingStatus,
|
|
257
|
+
});
|
|
258
|
+
// Capture the host's indicator on its way in; renderBoxed draws it
|
|
259
|
+
// into the top bar since the inner top border (lines[0]) is discarded.
|
|
260
|
+
if (typeof own.setWorkingStatusIndicator === "function") {
|
|
261
|
+
const setIndicator = own.setWorkingStatusIndicator.bind(own);
|
|
262
|
+
own.setWorkingStatusIndicator = indicator => {
|
|
263
|
+
embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
|
|
264
|
+
setIndicator(indicator);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
activeEditor = own;
|
|
268
|
+
editor = own;
|
|
269
|
+
}
|
|
165
270
|
const innerRender = editor.render.bind(editor);
|
|
166
|
-
|
|
271
|
+
let boxRenderFailed = false;
|
|
272
|
+
editor.render = width => {
|
|
273
|
+
try {
|
|
274
|
+
return renderBoxed(innerRender, width, editor);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (!boxRenderFailed) {
|
|
277
|
+
boxRenderFailed = true;
|
|
278
|
+
activeCtx?.ui.notify(
|
|
279
|
+
`Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
|
|
280
|
+
"error",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return innerRender(width);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
167
286
|
return editor;
|
|
168
287
|
};
|
|
169
288
|
};
|
|
@@ -212,17 +331,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
212
331
|
// its cwd and stats lines, which the box now carries itself.
|
|
213
332
|
const ensureFooterPatched = () => {
|
|
214
333
|
const tui = activeTui;
|
|
215
|
-
if (!tui
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
334
|
+
if (!tui) return;
|
|
335
|
+
if (!patchedFooter) {
|
|
336
|
+
let footer: Component | undefined;
|
|
337
|
+
for (const child of tui.children) {
|
|
338
|
+
footer = findFooter(child);
|
|
339
|
+
if (footer) break;
|
|
340
|
+
}
|
|
341
|
+
if (!footer) return;
|
|
342
|
+
const orig = footer.render.bind(footer);
|
|
343
|
+
footerRenderOriginal = orig;
|
|
344
|
+
footer.render = width => filterVisibleRows(orig(width).slice(2));
|
|
345
|
+
patchedFooter = footer;
|
|
346
|
+
}
|
|
347
|
+
if (!restoreFooterLayout) {
|
|
348
|
+
const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
|
|
349
|
+
restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
|
|
220
350
|
}
|
|
221
|
-
if (!footer) return;
|
|
222
|
-
const orig = footer.render.bind(footer);
|
|
223
|
-
footerRenderOriginal = orig;
|
|
224
|
-
footer.render = width => orig(width).slice(2);
|
|
225
|
-
patchedFooter = footer;
|
|
226
351
|
};
|
|
227
352
|
|
|
228
353
|
pi.on("model_select", () => requestRender());
|
|
@@ -239,6 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
364
|
rateMonitor.enable();
|
|
240
365
|
syncRainbow();
|
|
241
366
|
ensureInstalled();
|
|
367
|
+
syncEmbed();
|
|
242
368
|
ensureFooterPatched();
|
|
243
369
|
ensureTimer ??= setInterval(() => {
|
|
244
370
|
ensureInstalled();
|
|
@@ -267,10 +393,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
393
|
if (patchedFooter && footerRenderOriginal) {
|
|
268
394
|
patchedFooter.render = footerRenderOriginal;
|
|
269
395
|
}
|
|
396
|
+
restoreFooterLayout?.();
|
|
270
397
|
patchedFooter = undefined;
|
|
271
398
|
footerRenderOriginal = undefined;
|
|
399
|
+
restoreFooterLayout = undefined;
|
|
272
400
|
ourFactory = undefined;
|
|
273
401
|
wrappedFactory = undefined;
|
|
402
|
+
activeEditor = undefined;
|
|
403
|
+
embeddedWorkingStatus = undefined;
|
|
404
|
+
stopWorkingFadeTimer();
|
|
405
|
+
workingFade = undefined;
|
|
406
|
+
workingShown = false;
|
|
407
|
+
lastWorkingText = undefined;
|
|
274
408
|
activeTui = undefined;
|
|
275
409
|
activeCtx = undefined;
|
|
276
410
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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,10 @@
|
|
|
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-
|
|
48
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
50
|
+
"@earendil-works/pi-server": "0.85.0",
|
|
51
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
51
52
|
"@types/node": "^22.0.0",
|
|
52
53
|
"typescript": "^5.7.0"
|
|
53
54
|
}
|
package/src/box.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* (host borderColor vs menu theme; shared stepped animator vs phase-from-
|
|
10
10
|
* elapsed instance).
|
|
11
11
|
*/
|
|
12
|
+
import { stripVTControlCharacters } from "node:util";
|
|
12
13
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
13
14
|
import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
|
|
14
15
|
import type { BoxGlyphs } from "./theme.js";
|
|
@@ -67,3 +68,17 @@ export function renderBoxRow(
|
|
|
67
68
|
painters.paint(rowIdx, boxWidth - 1, rightGlyph)
|
|
68
69
|
);
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
/** Render a box row only when its bar contains visible, non-whitespace content. */
|
|
73
|
+
export function renderBoxRowIfVisible(
|
|
74
|
+
painters: BoxPainters,
|
|
75
|
+
bar: string,
|
|
76
|
+
rowIdx: number,
|
|
77
|
+
boxWidth: number,
|
|
78
|
+
leftGlyph: string,
|
|
79
|
+
rightGlyph: string,
|
|
80
|
+
): string[] {
|
|
81
|
+
const plainBar = stripVTControlCharacters(bar);
|
|
82
|
+
if (plainBar.trim().length === 0) return [];
|
|
83
|
+
return [renderBoxRow(painters, bar, rowIdx, boxWidth, leftGlyph, rightGlyph)];
|
|
84
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
10
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import
|
|
12
|
+
import { FADE_SHADE_COUNT } from "./theme.js";
|
|
13
|
+
import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
|
|
14
|
+
import { feedKey } from "./utils.js";
|
|
12
15
|
|
|
13
16
|
const GIT_STATUS_TTL_MS = 1_000;
|
|
14
17
|
const BRANCH_POLL_TTL_MS = 5_000;
|
|
@@ -16,6 +19,10 @@ const EXEC_TIMEOUT_MS = 2_000;
|
|
|
16
19
|
|
|
17
20
|
/** getEntries() filters and copies the whole session, so rescans are rate-limited. */
|
|
18
21
|
const FEED_TTL_MS = 2_000;
|
|
22
|
+
/** Unchanged feed values remain fully visible for five minutes. */
|
|
23
|
+
export const FEED_HOLD_MS = 5 * 60 * 1_000;
|
|
24
|
+
/** Match the existing token-rate fade cadence. */
|
|
25
|
+
export const FEED_FADE_MS = 500;
|
|
19
26
|
|
|
20
27
|
/** Prototype-less map — feed keys are publisher-supplied customTypes. */
|
|
21
28
|
const emptyFeedData = (): Record<string, unknown> => Object.create(null);
|
|
@@ -32,6 +39,22 @@ interface RepoInfo {
|
|
|
32
39
|
toplevel: string;
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
interface FeedTracking {
|
|
43
|
+
value: unknown;
|
|
44
|
+
changedAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pure feed lifecycle snapshot, including a clock moving backwards. */
|
|
48
|
+
export function getFeedDisplayState(changedAt: number, now: number): FeedDisplayState {
|
|
49
|
+
const elapsed = Math.max(0, now - changedAt);
|
|
50
|
+
if (elapsed < FEED_HOLD_MS) return { phase: "active", fadeShade: 0 };
|
|
51
|
+
if (elapsed >= FEED_HOLD_MS + FEED_FADE_MS) return { phase: "hidden", fadeShade: FADE_SHADE_COUNT - 1 };
|
|
52
|
+
return {
|
|
53
|
+
phase: "fading",
|
|
54
|
+
fadeShade: Math.min(FADE_SHADE_COUNT - 1, Math.floor((elapsed - FEED_HOLD_MS) / (FEED_FADE_MS / FADE_SHADE_COUNT))),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
function deriveContextUsage(ctx: ExtensionContext | undefined): {
|
|
36
59
|
contextWindow: number;
|
|
37
60
|
tokens: number | undefined;
|
|
@@ -40,7 +63,7 @@ function deriveContextUsage(ctx: ExtensionContext | undefined): {
|
|
|
40
63
|
const usage = ctx?.getContextUsage();
|
|
41
64
|
const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
|
|
42
65
|
const tokens = usage?.tokens ?? undefined;
|
|
43
|
-
const percent = usage ? usage.percent :
|
|
66
|
+
const percent = usage ? usage.percent : null;
|
|
44
67
|
return { contextWindow, tokens, percent };
|
|
45
68
|
}
|
|
46
69
|
|
|
@@ -90,8 +113,11 @@ export class SegmentContextBuilder {
|
|
|
90
113
|
|
|
91
114
|
#feedData: Record<string, unknown> = emptyFeedData();
|
|
92
115
|
#feedsScannedAt = 0;
|
|
93
|
-
#
|
|
116
|
+
#feedsScannedRef: readonly string[] | undefined;
|
|
94
117
|
#runStartedAt = 0;
|
|
118
|
+
#feedTracking = new Map<string, FeedTracking>();
|
|
119
|
+
#feedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
#feedTimerAt: number | undefined;
|
|
95
121
|
|
|
96
122
|
constructor(pi: ExtensionAPI) {
|
|
97
123
|
this.#pi = pi;
|
|
@@ -118,7 +144,8 @@ export class SegmentContextBuilder {
|
|
|
118
144
|
this.#runStartedAt = Date.now();
|
|
119
145
|
this.#feedData = emptyFeedData();
|
|
120
146
|
this.#feedsScannedAt = 0;
|
|
121
|
-
this.#
|
|
147
|
+
this.#feedsScannedRef = undefined;
|
|
148
|
+
this.#clearFeedLifecycle();
|
|
122
149
|
if (this.#repoCwd !== ctx.cwd) {
|
|
123
150
|
this.#resetGitState();
|
|
124
151
|
this.#repoCwd = ctx.cwd;
|
|
@@ -131,6 +158,14 @@ export class SegmentContextBuilder {
|
|
|
131
158
|
this.#headWatcher = undefined;
|
|
132
159
|
this.#watcherOk = false;
|
|
133
160
|
this.#ctx = undefined;
|
|
161
|
+
this.#clearFeedLifecycle();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#clearFeedLifecycle(): void {
|
|
165
|
+
this.#feedTracking.clear();
|
|
166
|
+
if (this.#feedTimer) clearTimeout(this.#feedTimer);
|
|
167
|
+
this.#feedTimer = undefined;
|
|
168
|
+
this.#feedTimerAt = undefined;
|
|
134
169
|
}
|
|
135
170
|
|
|
136
171
|
// ── git plumbing ─────────────────────────────────────────────────────────
|
|
@@ -288,6 +323,55 @@ export class SegmentContextBuilder {
|
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
|
|
326
|
+
#syncFeedTracking(feeds: StatusLineSegmentOptions["feeds"], now: number): Record<string, FeedDisplayState> {
|
|
327
|
+
const configured = new Set<string>();
|
|
328
|
+
for (const feed of feeds) {
|
|
329
|
+
const key = feedKey(feed.customType, feed.field);
|
|
330
|
+
configured.add(key);
|
|
331
|
+
const payload = this.#feedData[feed.customType];
|
|
332
|
+
if (payload === null || typeof payload !== "object" || !Object.hasOwn(payload, feed.field)) {
|
|
333
|
+
this.#feedTracking.delete(key);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const value = (payload as Record<string, unknown>)[feed.field];
|
|
337
|
+
const prior = this.#feedTracking.get(key);
|
|
338
|
+
if (!prior || !isDeepStrictEqual(prior.value, value)) this.#feedTracking.set(key, { value, changedAt: now });
|
|
339
|
+
}
|
|
340
|
+
for (const key of this.#feedTracking.keys()) {
|
|
341
|
+
if (!configured.has(key)) this.#feedTracking.delete(key);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const states: Record<string, FeedDisplayState> = Object.create(null);
|
|
345
|
+
for (const [key, tracking] of this.#feedTracking) states[key] = getFeedDisplayState(tracking.changedAt, now);
|
|
346
|
+
this.#scheduleFeedTransition(now);
|
|
347
|
+
return states;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#scheduleFeedTransition(now: number): void {
|
|
351
|
+
let nextAt: number | undefined;
|
|
352
|
+
for (const { changedAt } of this.#feedTracking.values()) {
|
|
353
|
+
const display = getFeedDisplayState(changedAt, now);
|
|
354
|
+
if (display.phase === "hidden") continue;
|
|
355
|
+
const next =
|
|
356
|
+
display.phase === "active"
|
|
357
|
+
? changedAt + FEED_HOLD_MS
|
|
358
|
+
: changedAt + FEED_HOLD_MS + (display.fadeShade + 1) * (FEED_FADE_MS / FADE_SHADE_COUNT);
|
|
359
|
+
if (nextAt === undefined || next < nextAt) nextAt = next;
|
|
360
|
+
}
|
|
361
|
+
if (this.#feedTimer && this.#feedTimerAt === nextAt) return;
|
|
362
|
+
if (this.#feedTimer) clearTimeout(this.#feedTimer);
|
|
363
|
+
this.#feedTimer = undefined;
|
|
364
|
+
this.#feedTimerAt = nextAt;
|
|
365
|
+
if (nextAt === undefined) return;
|
|
366
|
+
this.#feedTimer = setTimeout(() => {
|
|
367
|
+
this.#feedTimer = undefined;
|
|
368
|
+
this.#feedTimerAt = undefined;
|
|
369
|
+
this.#requestRender();
|
|
370
|
+
this.#scheduleFeedTransition(Date.now());
|
|
371
|
+
}, Math.max(1, nextAt - now));
|
|
372
|
+
this.#feedTimer.unref?.();
|
|
373
|
+
}
|
|
374
|
+
|
|
291
375
|
async #refreshPr(): Promise<void> {
|
|
292
376
|
const cwd = this.#repoCwd;
|
|
293
377
|
const branch = this.#branch;
|
|
@@ -350,13 +434,16 @@ export class SegmentContextBuilder {
|
|
|
350
434
|
if (include.pr) void this.#refreshPr();
|
|
351
435
|
}
|
|
352
436
|
|
|
437
|
+
let feedDisplayState: Record<string, FeedDisplayState> | undefined;
|
|
353
438
|
if (include.feeds.length > 0) {
|
|
354
439
|
// Editing the subscription list mid-session must not wait out the TTL.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.#feedsScanned = key;
|
|
440
|
+
if (include.feeds !== this.#feedsScannedRef || now - this.#feedsScannedAt > FEED_TTL_MS) {
|
|
441
|
+
this.#feedsScannedRef = include.feeds;
|
|
358
442
|
this.#refreshFeeds(include.feeds, now);
|
|
359
443
|
}
|
|
444
|
+
feedDisplayState = this.#syncFeedTracking(options.feeds, now);
|
|
445
|
+
} else {
|
|
446
|
+
this.#clearFeedLifecycle();
|
|
360
447
|
}
|
|
361
448
|
|
|
362
449
|
const model = ctx?.model
|
|
@@ -388,7 +475,8 @@ export class SegmentContextBuilder {
|
|
|
388
475
|
scrollHint,
|
|
389
476
|
piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
|
|
390
477
|
tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
|
|
391
|
-
feedData: include.feeds.length > 0 ?
|
|
478
|
+
feedData: include.feeds.length > 0 ? this.#feedData : undefined,
|
|
479
|
+
feedDisplayState,
|
|
392
480
|
};
|
|
393
481
|
}
|
|
394
482
|
}
|
package/src/footer.ts
CHANGED
|
@@ -8,6 +8,52 @@ export function stripAnsi(str: string): string {
|
|
|
8
8
|
return str.replace(ANSI_RE, "");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Drop host footer rows that contain only whitespace or SGR control codes. */
|
|
12
|
+
export function filterVisibleRows(rows: readonly string[]): string[] {
|
|
13
|
+
return rows.filter(row => stripAnsi(row).trim().length > 0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LayoutEntry {
|
|
17
|
+
component: unknown;
|
|
18
|
+
minSize?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function containsComponent(node: unknown, target: unknown, seen: Set<unknown>): boolean {
|
|
22
|
+
if (node === target) return true;
|
|
23
|
+
if (!node || typeof node !== "object" || seen.has(node)) return false;
|
|
24
|
+
seen.add(node);
|
|
25
|
+
const children = (node as { children?: unknown }).children;
|
|
26
|
+
return Array.isArray(children) && children.some(child => containsComponent(child, target, seen));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Let Pi's fullscreen footer slot collapse when its component renders no rows.
|
|
31
|
+
* Pi 0.84 gives that VStack entry minSize=1, independently of render output.
|
|
32
|
+
*/
|
|
33
|
+
export function collapseFooterLayoutSlot(layoutRoot: unknown, footer: unknown): (() => void) | undefined {
|
|
34
|
+
const seen = new Set<unknown>();
|
|
35
|
+
const find = (node: unknown): LayoutEntry | undefined => {
|
|
36
|
+
if (!node || typeof node !== "object" || seen.has(node)) return undefined;
|
|
37
|
+
seen.add(node);
|
|
38
|
+
const entries = (node as { entries?: unknown }).entries;
|
|
39
|
+
if (!Array.isArray(entries)) return undefined;
|
|
40
|
+
for (const entry of entries as LayoutEntry[]) {
|
|
41
|
+
const nested = find(entry.component);
|
|
42
|
+
if (nested) return nested;
|
|
43
|
+
}
|
|
44
|
+
return (entries as LayoutEntry[]).find(entry => containsComponent(entry.component, footer, new Set()));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const entry = find(layoutRoot);
|
|
48
|
+
if (!entry) return undefined;
|
|
49
|
+
const previous = entry.minSize;
|
|
50
|
+
entry.minSize = 0;
|
|
51
|
+
return () => {
|
|
52
|
+
if (previous === undefined) delete entry.minSize;
|
|
53
|
+
else entry.minSize = previous;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
11
57
|
export function isBorderRow(row: string): boolean {
|
|
12
58
|
const text = stripAnsi(row);
|
|
13
59
|
return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
|