@underactive/pi-topping-statusline 0.1.2 → 0.1.4
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 +18 -13
- package/index.ts +48 -18
- package/package.json +4 -4
- package/src/box.ts +15 -0
- package/src/context.ts +101 -9
- package/src/footer.ts +46 -0
- package/src/segments.ts +13 -4
- package/src/settings-menu.ts +13 -9
- package/src/settings.ts +2 -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/rainbow.test.ts +3 -1
- package/src/theme.ts +12 -10
- package/src/types.ts +17 -1
- package/src/utils.ts +7 -5
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ and apply live.
|
|
|
41
41
|
║ Symbols ‹ nerdfont › ║
|
|
42
42
|
║ Border style ‹ rounded › ║
|
|
43
43
|
║ [■] Rainbow border on max thinking ON ║
|
|
44
|
+
║ [■] Animate rainbow border ON ║
|
|
44
45
|
║ ║
|
|
45
46
|
╟─ Top Left Segment Group ───────────────────────────────────────╢
|
|
46
47
|
║ [■] Pi symbol ON ║
|
|
@@ -65,26 +66,26 @@ and apply live.
|
|
|
65
66
|
║ + add feed ║
|
|
66
67
|
║ ║
|
|
67
68
|
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
68
|
-
╚════════════════════════════════════════════════════════[ 1/
|
|
69
|
+
╚════════════════════════════════════════════════════════[ 1/29 ]╝
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
| Section | Settings |
|
|
72
73
|
| --- | --- |
|
|
73
|
-
| 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 |
|
|
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 |
|
|
74
75
|
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
75
76
|
| Top Right Segment Group | Token rate · Session name |
|
|
76
77
|
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
77
78
|
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
78
79
|
| Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
|
|
79
|
-
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on |
|
|
80
|
+
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
|
|
80
81
|
|
|
81
82
|
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
82
|
-
replaces the border's fixed theme color with
|
|
83
|
-
|
|
84
|
-
Intelligence's screen border.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
redraws.
|
|
83
|
+
replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
|
|
84
|
+
box perimeter. **Animate rainbow border** is also on by default, so the hues flow around the
|
|
85
|
+
border (~14s per rotation), like Apple Intelligence's screen border. Turn animation off to keep
|
|
86
|
+
the rainbow at a fixed color phase without its repaint timer; the settings preview uses a stable
|
|
87
|
+
phase too. Any other thinking level keeps the normal theme border color. Disable animation over
|
|
88
|
+
slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
|
|
88
89
|
|
|
89
90
|
## Segments
|
|
90
91
|
|
|
@@ -128,11 +129,15 @@ early in a session, before the cache-write premium is amortized by reads. Above
|
|
|
128
129
|
|
|
129
130
|
A feed contributes nothing when its publisher is absent, has published nothing this run, or
|
|
130
131
|
reports a value its format suppresses; the segment disappears entirely once every feed is
|
|
131
|
-
quiet, so it costs nothing to leave switched on.
|
|
132
|
+
quiet, so it costs nothing to leave switched on. Feed values are shown in dim text; a value
|
|
133
|
+
that remains unchanged for five minutes fades into the statusline background over 500ms and
|
|
134
|
+
then hides. It reappears in dim text only when that configured field's value changes.
|
|
135
|
+
Publishers typically reset per process run,
|
|
132
136
|
so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
|
|
133
|
-
ignored rather than shown as a stale total.
|
|
134
|
-
seconds, because pi copies the whole entry list on each read
|
|
135
|
-
every keystroke
|
|
137
|
+
ignored rather than shown as a stale total. During steady-state rendering, the session is
|
|
138
|
+
re-read at most once every two seconds, because pi copies the whole entry list on each read
|
|
139
|
+
while the bar re-renders on every keystroke; changing subscriptions or starting a new
|
|
140
|
+
session forces an immediate scan.
|
|
136
141
|
|
|
137
142
|
### Publishing a feed
|
|
138
143
|
|
package/index.ts
CHANGED
|
@@ -25,9 +25,15 @@ 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";
|
|
@@ -47,6 +53,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
47
53
|
let ensureTimer: ReturnType<typeof setInterval> | undefined;
|
|
48
54
|
let patchedFooter: Component | undefined;
|
|
49
55
|
let footerRenderOriginal: ((width: number) => string[]) | undefined;
|
|
56
|
+
let restoreFooterLayout: (() => void) | undefined;
|
|
50
57
|
|
|
51
58
|
const requestRender = () => activeTui?.requestRender();
|
|
52
59
|
builder.setRequestRender(requestRender);
|
|
@@ -55,17 +62,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
55
62
|
rateMonitor.attach(pi);
|
|
56
63
|
builder.setTokenRateProvider(() => rateMonitor.getDisplay());
|
|
57
64
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
65
|
+
// The rainbow is visible at max thinking independently of whether its hue
|
|
66
|
+
// phase is animated. Stopping the timer preserves the current live phase.
|
|
60
67
|
const rainbow = new RainbowBorder();
|
|
61
68
|
let rainbowTimer: ReturnType<typeof setInterval> | undefined;
|
|
62
69
|
const rainbowActive = (): boolean =>
|
|
63
70
|
state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
|
|
71
|
+
const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
|
|
64
72
|
const syncRainbow = (): void => {
|
|
65
|
-
if (
|
|
73
|
+
if (rainbowAnimationActive()) {
|
|
66
74
|
if (!rainbowTimer) {
|
|
67
75
|
rainbowTimer = setInterval(() => {
|
|
68
|
-
if (!
|
|
76
|
+
if (!rainbowAnimationActive()) {
|
|
69
77
|
syncRainbow();
|
|
70
78
|
return;
|
|
71
79
|
}
|
|
@@ -148,7 +156,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
156
|
},
|
|
149
157
|
{ col: 3, row: bottomIdx },
|
|
150
158
|
);
|
|
151
|
-
out.push(
|
|
159
|
+
out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
|
|
152
160
|
for (let i = bottomIdx + 1; i < lines.length; i++) {
|
|
153
161
|
out.push(` ${lines[i]}`);
|
|
154
162
|
}
|
|
@@ -162,7 +170,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
162
170
|
? inner(tui, editorTheme, keybindings)
|
|
163
171
|
: new CustomEditor(tui, editorTheme, keybindings);
|
|
164
172
|
const innerRender = editor.render.bind(editor);
|
|
165
|
-
|
|
173
|
+
let boxRenderFailed = false;
|
|
174
|
+
editor.render = width => {
|
|
175
|
+
try {
|
|
176
|
+
return renderBoxed(innerRender, width, editor);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (!boxRenderFailed) {
|
|
179
|
+
boxRenderFailed = true;
|
|
180
|
+
activeCtx?.ui.notify(
|
|
181
|
+
`Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
|
|
182
|
+
"error",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return innerRender(width);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
166
188
|
return editor;
|
|
167
189
|
};
|
|
168
190
|
};
|
|
@@ -211,17 +233,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
233
|
// its cwd and stats lines, which the box now carries itself.
|
|
212
234
|
const ensureFooterPatched = () => {
|
|
213
235
|
const tui = activeTui;
|
|
214
|
-
if (!tui
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
if (!tui) return;
|
|
237
|
+
if (!patchedFooter) {
|
|
238
|
+
let footer: Component | undefined;
|
|
239
|
+
for (const child of tui.children) {
|
|
240
|
+
footer = findFooter(child);
|
|
241
|
+
if (footer) break;
|
|
242
|
+
}
|
|
243
|
+
if (!footer) return;
|
|
244
|
+
const orig = footer.render.bind(footer);
|
|
245
|
+
footerRenderOriginal = orig;
|
|
246
|
+
footer.render = width => filterVisibleRows(orig(width).slice(2));
|
|
247
|
+
patchedFooter = footer;
|
|
248
|
+
}
|
|
249
|
+
if (!restoreFooterLayout) {
|
|
250
|
+
const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
|
|
251
|
+
restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
|
|
219
252
|
}
|
|
220
|
-
if (!footer) return;
|
|
221
|
-
const orig = footer.render.bind(footer);
|
|
222
|
-
footerRenderOriginal = orig;
|
|
223
|
-
footer.render = width => orig(width).slice(2);
|
|
224
|
-
patchedFooter = footer;
|
|
225
253
|
};
|
|
226
254
|
|
|
227
255
|
pi.on("model_select", () => requestRender());
|
|
@@ -266,8 +294,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
266
294
|
if (patchedFooter && footerRenderOriginal) {
|
|
267
295
|
patchedFooter.render = footerRenderOriginal;
|
|
268
296
|
}
|
|
297
|
+
restoreFooterLayout?.();
|
|
269
298
|
patchedFooter = undefined;
|
|
270
299
|
footerRenderOriginal = undefined;
|
|
300
|
+
restoreFooterLayout = undefined;
|
|
271
301
|
ourFactory = undefined;
|
|
272
302
|
wrappedFactory = undefined;
|
|
273
303
|
activeTui = undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-statusline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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.84.4",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
50
|
+
"@earendil-works/pi-tui": "0.84.4",
|
|
51
51
|
"@types/node": "^22.0.0",
|
|
52
52
|
"typescript": "^5.7.0"
|
|
53
53
|
}
|
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;
|
|
@@ -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 ─────────────────────────────────────────────────────────
|
|
@@ -191,6 +226,9 @@ export class SegmentContextBuilder {
|
|
|
191
226
|
this.#requestRender();
|
|
192
227
|
} finally {
|
|
193
228
|
this.#repoResolving = false;
|
|
229
|
+
if (this.#repoCwd && this.#repoCwd !== cwd) {
|
|
230
|
+
void this.#resolveRepo(this.#repoCwd);
|
|
231
|
+
}
|
|
194
232
|
}
|
|
195
233
|
}
|
|
196
234
|
|
|
@@ -278,12 +316,62 @@ export class SegmentContextBuilder {
|
|
|
278
316
|
// first: matching on customType alone lets one shadow a real entry.
|
|
279
317
|
if (entry?.type !== "custom" || !pending.has(entry.customType)) continue;
|
|
280
318
|
const ts = Date.parse(entry.timestamp);
|
|
281
|
-
if (!Number.isFinite(ts)
|
|
319
|
+
if (!Number.isFinite(ts)) continue;
|
|
320
|
+
if (ts < this.#runStartedAt) break;
|
|
282
321
|
pending.delete(entry.customType);
|
|
283
322
|
if (entry.data !== undefined) this.#feedData[entry.customType] = entry.data;
|
|
284
323
|
}
|
|
285
324
|
}
|
|
286
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
|
+
|
|
287
375
|
async #refreshPr(): Promise<void> {
|
|
288
376
|
const cwd = this.#repoCwd;
|
|
289
377
|
const branch = this.#branch;
|
|
@@ -307,7 +395,7 @@ export class SegmentContextBuilder {
|
|
|
307
395
|
const u = URL.parse(parsed.url);
|
|
308
396
|
if (
|
|
309
397
|
u &&
|
|
310
|
-
|
|
398
|
+
u.protocol === "https:" &&
|
|
311
399
|
u.hostname === "github.com" &&
|
|
312
400
|
!/[\u0000-\u001f\u007f]/.test(parsed.url)
|
|
313
401
|
) {
|
|
@@ -346,13 +434,16 @@ export class SegmentContextBuilder {
|
|
|
346
434
|
if (include.pr) void this.#refreshPr();
|
|
347
435
|
}
|
|
348
436
|
|
|
437
|
+
let feedDisplayState: Record<string, FeedDisplayState> | undefined;
|
|
349
438
|
if (include.feeds.length > 0) {
|
|
350
439
|
// Editing the subscription list mid-session must not wait out the TTL.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
this.#feedsScanned = key;
|
|
440
|
+
if (include.feeds !== this.#feedsScannedRef || now - this.#feedsScannedAt > FEED_TTL_MS) {
|
|
441
|
+
this.#feedsScannedRef = include.feeds;
|
|
354
442
|
this.#refreshFeeds(include.feeds, now);
|
|
355
443
|
}
|
|
444
|
+
feedDisplayState = this.#syncFeedTracking(options.feeds, now);
|
|
445
|
+
} else {
|
|
446
|
+
this.#clearFeedLifecycle();
|
|
356
447
|
}
|
|
357
448
|
|
|
358
449
|
const model = ctx?.model
|
|
@@ -384,7 +475,8 @@ export class SegmentContextBuilder {
|
|
|
384
475
|
scrollHint,
|
|
385
476
|
piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
|
|
386
477
|
tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
|
|
387
|
-
feedData: include.feeds.length > 0 ?
|
|
478
|
+
feedData: include.feeds.length > 0 ? this.#feedData : undefined,
|
|
479
|
+
feedDisplayState,
|
|
388
480
|
};
|
|
389
481
|
}
|
|
390
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);
|
package/src/segments.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { formatContextUsage } from "./context-thresholds.js";
|
|
|
13
13
|
import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
|
|
14
14
|
import {
|
|
15
15
|
clampPathLength,
|
|
16
|
+
feedKey,
|
|
16
17
|
getSessionAccentHex,
|
|
17
18
|
sanitizeLabel,
|
|
18
19
|
sanitizeStatusText,
|
|
@@ -256,8 +257,8 @@ function formatFeedValue(value: unknown, format: FeedFormat): string | undefined
|
|
|
256
257
|
/**
|
|
257
258
|
* Values published by other extensions as custom session entries, each shown
|
|
258
259
|
* as its configured prefix followed by the value. A feed contributes nothing
|
|
259
|
-
* when its publisher is absent, has published nothing this run,
|
|
260
|
-
* value the configured format suppresses.
|
|
260
|
+
* when its publisher is absent, has published nothing this run, reports a
|
|
261
|
+
* value the configured format suppresses, or has faded out after five minutes unchanged.
|
|
261
262
|
*/
|
|
262
263
|
const feedsSegment: StatusLineSegment = {
|
|
263
264
|
id: "feeds",
|
|
@@ -269,11 +270,19 @@ const feedsSegment: StatusLineSegment = {
|
|
|
269
270
|
const payload = data[feed.customType];
|
|
270
271
|
if (payload == null || typeof payload !== "object") continue;
|
|
271
272
|
if (!Object.hasOwn(payload, feed.field)) continue;
|
|
273
|
+
const display = ctx.feedDisplayState?.[feedKey(feed.customType, feed.field)];
|
|
274
|
+
if (display?.phase === "hidden") continue;
|
|
272
275
|
const text = formatFeedValue((payload as Record<string, unknown>)[feed.field], feed.format);
|
|
273
|
-
if (text
|
|
276
|
+
if (text === undefined) continue;
|
|
277
|
+
const content = `${sanitizeLabel(feed.prefix)}${text}`;
|
|
278
|
+
parts.push(
|
|
279
|
+
display?.phase === "fading"
|
|
280
|
+
? theme.fadeFg("dim", "statusLineBg", display.fadeShade, content)
|
|
281
|
+
: theme.fg("dim", content),
|
|
282
|
+
);
|
|
274
283
|
}
|
|
275
284
|
if (parts.length === 0) return INVISIBLE;
|
|
276
|
-
return { content:
|
|
285
|
+
return { content: parts.join(" "), visible: true };
|
|
277
286
|
},
|
|
278
287
|
};
|
|
279
288
|
|
package/src/settings-menu.ts
CHANGED
|
@@ -135,6 +135,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
135
135
|
label: "Rainbow border on max thinking",
|
|
136
136
|
value: settings.rainbowBorder ?? true,
|
|
137
137
|
},
|
|
138
|
+
{
|
|
139
|
+
id: "rainbowAnimation",
|
|
140
|
+
label: "Animate rainbow border",
|
|
141
|
+
value: settings.rainbowAnimation ?? true,
|
|
142
|
+
},
|
|
138
143
|
],
|
|
139
144
|
};
|
|
140
145
|
return [
|
|
@@ -164,6 +169,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
164
169
|
segments,
|
|
165
170
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
166
171
|
rainbowBorder: values.rainbowBorder === true,
|
|
172
|
+
rainbowAnimation: values.rainbowAnimation === true,
|
|
167
173
|
};
|
|
168
174
|
}
|
|
169
175
|
|
|
@@ -195,12 +201,10 @@ function cannedFeedData(
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
class StatusLinePreview {
|
|
198
|
-
readonly #state: SettingsState;
|
|
199
204
|
readonly #builder: SegmentContextBuilder;
|
|
200
205
|
readonly #uiTheme: Theme;
|
|
201
206
|
|
|
202
|
-
constructor(
|
|
203
|
-
this.#state = state;
|
|
207
|
+
constructor(builder: SegmentContextBuilder, uiTheme: Theme) {
|
|
204
208
|
this.#builder = builder;
|
|
205
209
|
this.#uiTheme = uiTheme;
|
|
206
210
|
}
|
|
@@ -217,6 +221,7 @@ class StatusLinePreview {
|
|
|
217
221
|
const boxWidth = Math.max(24, innerWidth - 1);
|
|
218
222
|
const barWidth = boxWidth - 6;
|
|
219
223
|
const rainbowOn = effective.rainbowBorder;
|
|
224
|
+
const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
|
|
220
225
|
|
|
221
226
|
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
222
227
|
// symbol preset for this synchronous render only; the live bar behind
|
|
@@ -255,7 +260,7 @@ class StatusLinePreview {
|
|
|
255
260
|
const bottomIdx = 2;
|
|
256
261
|
const painters = makeBoxPainters({
|
|
257
262
|
rainbowOn,
|
|
258
|
-
rainbow: new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS),
|
|
263
|
+
rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
|
|
259
264
|
box,
|
|
260
265
|
width: boxWidth,
|
|
261
266
|
bottomIdx,
|
|
@@ -291,9 +296,8 @@ class StatusLinePreview {
|
|
|
291
296
|
painters.paint(1, boxWidth - 1, box.vertical),
|
|
292
297
|
renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
|
|
293
298
|
],
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
nextRefreshInMs: rainbowOn ? RAINBOW_FRAME_MS : undefined,
|
|
299
|
+
// A static rainbow stays at phase zero and needs no repaint timer.
|
|
300
|
+
nextRefreshInMs: rainbowAnimationOn ? RAINBOW_FRAME_MS : undefined,
|
|
297
301
|
};
|
|
298
302
|
} finally {
|
|
299
303
|
theme.setSymbolPreset(applied);
|
|
@@ -308,13 +312,13 @@ export function registerSettingsCommand(
|
|
|
308
312
|
onChange: () => void,
|
|
309
313
|
): void {
|
|
310
314
|
pi.registerCommand("topping-statusline-settings", {
|
|
311
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border",
|
|
315
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border animation",
|
|
312
316
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
313
317
|
if (ctx.mode !== "tui") {
|
|
314
318
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
|
315
319
|
return;
|
|
316
320
|
}
|
|
317
|
-
const preview = new StatusLinePreview(
|
|
321
|
+
const preview = new StatusLinePreview(builder, ctx.ui.theme);
|
|
318
322
|
const result = await showMenu<Record<string, MenuValue>>(ctx, {
|
|
319
323
|
title: "Pi Topping Statusline: Settings",
|
|
320
324
|
sections: buildSections(state.settings),
|
package/src/settings.ts
CHANGED
|
@@ -92,6 +92,7 @@ function loadSettings(): StatusLineSettings {
|
|
|
92
92
|
}
|
|
93
93
|
if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
|
|
94
94
|
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
95
|
+
if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
|
|
95
96
|
return settings;
|
|
96
97
|
} catch {
|
|
97
98
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -153,6 +154,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
153
154
|
bottomLeftSegments,
|
|
154
155
|
bottomRightSegments,
|
|
155
156
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
157
|
+
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
156
158
|
includes,
|
|
157
159
|
segmentOptions: {
|
|
158
160
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
package/src/tests/box.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import test from "node:test";
|
|
10
10
|
import { stripVTControlCharacters } from "node:util";
|
|
11
11
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
-
import { makeBoxPainters, renderBoxRow } from "../box.ts";
|
|
12
|
+
import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "../box.ts";
|
|
13
13
|
import { RainbowBorder } from "../rainbow.ts";
|
|
14
14
|
import type { BoxGlyphs } from "../theme.ts";
|
|
15
15
|
|
|
@@ -44,6 +44,19 @@ test("an empty bar renders chrome only, still totalling boxWidth", () => {
|
|
|
44
44
|
assert.equal(visibleWidth(out), 20);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test("an empty footer bar reserves no row", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
renderBoxRowIfVisible(identityPainters(20), "\x1b[32m \x1b[0m", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight),
|
|
50
|
+
[],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a visible footer bar retains one row", () => {
|
|
55
|
+
const rows = renderBoxRowIfVisible(identityPainters(20), "X", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight);
|
|
56
|
+
assert.equal(rows.length, 1);
|
|
57
|
+
assert.equal(rows[0], "╰──X" + "─".repeat(15) + "╯");
|
|
58
|
+
});
|
|
59
|
+
|
|
47
60
|
test("wide characters in the bar consume pad cells, keeping the total fixed", () => {
|
|
48
61
|
const out = renderBoxRow(identityPainters(20), "日本", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
|
|
49
62
|
assert.equal(out, "╭──日本" + "─".repeat(12) + "╮");
|
package/src/tests/feeds.test.ts
CHANGED
|
@@ -12,16 +12,23 @@
|
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
13
|
import test from "node:test";
|
|
14
14
|
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
15
|
+
import { renderBoxRowIfVisible } from "../box.ts";
|
|
16
|
+
import { FEED_FADE_MS, FEED_HOLD_MS, SegmentContextBuilder, getFeedDisplayState } from "../context.ts";
|
|
17
|
+
import { buildStatusLine } from "../layout.ts";
|
|
16
18
|
import { SEGMENTS } from "../segments.ts";
|
|
17
19
|
import { DEFAULT_FEEDS, DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
|
|
18
|
-
import
|
|
20
|
+
import { theme } from "../theme.ts";
|
|
21
|
+
import type { FeedDisplayState, SegmentContext, StatusLineFeed } from "../types.ts";
|
|
22
|
+
import { feedKey } from "../utils.ts";
|
|
19
23
|
|
|
20
24
|
const SAVINGS = "pi-prompt-cache/savings";
|
|
21
25
|
const OPTIONS = resolveEffectiveSettings({}).segmentOptions;
|
|
22
26
|
const INCLUDE = { git: false, pr: false, piStats: false, tokenRate: false, feeds: [SAVINGS] };
|
|
23
27
|
/** Outside any repo, so the builder's git probes stay inert. */
|
|
24
28
|
const CWD = "/tmp/pi-topping-statusline-tests";
|
|
29
|
+
/** Match the builder's intentional prototype-less feed map in deep equality assertions. */
|
|
30
|
+
const feedMap = (entries: Record<string, unknown> = {}): Record<string, unknown> =>
|
|
31
|
+
Object.assign(Object.create(null) as Record<string, unknown>, entries);
|
|
25
32
|
|
|
26
33
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
27
34
|
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -60,8 +67,11 @@ async function contextAfterEmitting(
|
|
|
60
67
|
|
|
61
68
|
const BASE = await contextAfterEmitting(() => []);
|
|
62
69
|
/** Render the segment against explicit feed config and payloads. */
|
|
63
|
-
const renderFeeds = (
|
|
64
|
-
|
|
70
|
+
const renderFeeds = (
|
|
71
|
+
feeds: StatusLineFeed[],
|
|
72
|
+
feedData: Record<string, unknown> | undefined,
|
|
73
|
+
feedDisplayState?: Record<string, FeedDisplayState>,
|
|
74
|
+
) => SEGMENTS.feeds.render({ ...BASE, options: { ...BASE.options, feeds }, feedData, feedDisplayState });
|
|
65
75
|
const savingsFeed = (format: StatusLineFeed["format"] = "currency", prefix = "CS"): StatusLineFeed => ({
|
|
66
76
|
customType: SAVINGS,
|
|
67
77
|
field: "savedUsd",
|
|
@@ -71,14 +81,39 @@ const savingsFeed = (format: StatusLineFeed["format"] = "currency", prefix = "CS
|
|
|
71
81
|
const renderSaved = (value: unknown, format: StatusLineFeed["format"] = "currency") =>
|
|
72
82
|
renderFeeds([savingsFeed(format)], { [SAVINGS]: { savedUsd: value } });
|
|
73
83
|
|
|
84
|
+
const FOOTER_PAINTERS = {
|
|
85
|
+
paint: (_row: number, _col: number, ch: string) => ch,
|
|
86
|
+
horizRun: (_row: number, _startCol: number, count: number) => "─".repeat(count),
|
|
87
|
+
gapColor: (str: string, _startCol: number, _row: number) => str,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Build the same one-row bottom-bar path used by index.ts for feed-only cases. */
|
|
91
|
+
const renderFeedRows = (feedData: Record<string, unknown> | undefined, format: StatusLineFeed["format"] = "currency") => {
|
|
92
|
+
const feed = savingsFeed(format);
|
|
93
|
+
const settings = resolveEffectiveSettings({ feeds: [feed] });
|
|
94
|
+
const ctx: SegmentContext = { ...BASE, options: settings.segmentOptions, feedData };
|
|
95
|
+
const bar = buildStatusLine(74, ctx, settings, (str, _startCol, _row) => str, { left: [], right: ["feeds"] });
|
|
96
|
+
return renderBoxRowIfVisible(FOOTER_PAINTERS, bar, 2, 80, "╰", "╯");
|
|
97
|
+
};
|
|
98
|
+
|
|
74
99
|
// ── reading the feed ───────────────────────────────────────────────────────
|
|
75
100
|
|
|
76
101
|
test("hidden when no publisher has emitted", async () => {
|
|
77
102
|
const ctx = await contextAfterEmitting(() => []);
|
|
78
|
-
assert.deepEqual(ctx.feedData,
|
|
103
|
+
assert.deepEqual(ctx.feedData, feedMap());
|
|
79
104
|
assert.equal(SEGMENTS.feeds.render(ctx).visible, false);
|
|
80
105
|
});
|
|
81
106
|
|
|
107
|
+
test("configured but unavailable or empty feeds reserve no footer row", () => {
|
|
108
|
+
assert.equal(renderFeedRows(undefined).length, 0);
|
|
109
|
+
assert.equal(renderFeedRows({}).length, 0);
|
|
110
|
+
assert.equal(renderFeedRows({ [SAVINGS]: {} }).length, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a visible feed keeps the footer row", () => {
|
|
114
|
+
assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: 1.25 } }).length, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
82
117
|
test("a later custom_message sharing the customType cannot shadow a real entry", async () => {
|
|
83
118
|
// CustomMessageEntry carries customType too, but keeps its payload under
|
|
84
119
|
// details. Matching on customType alone would hit this newer entry first,
|
|
@@ -94,17 +129,17 @@ test("a later custom_message sharing the customType cannot shadow a real entry",
|
|
|
94
129
|
details: { savedUsd: 99 },
|
|
95
130
|
} as SessionEntry;
|
|
96
131
|
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 4.2 }), decoy]);
|
|
97
|
-
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 4.2 } });
|
|
132
|
+
assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 4.2 } }));
|
|
98
133
|
});
|
|
99
134
|
|
|
100
135
|
test("unsubscribed customTypes are ignored", async () => {
|
|
101
136
|
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 9 }, "other-ext/thing")]);
|
|
102
|
-
assert.deepEqual(ctx.feedData,
|
|
137
|
+
assert.deepEqual(ctx.feedData, feedMap());
|
|
103
138
|
});
|
|
104
139
|
|
|
105
140
|
test("the newest matching entry wins", async () => {
|
|
106
141
|
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 0.5 }), feedEntry({ savedUsd: 1.2345 })]);
|
|
107
|
-
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1.2345 } });
|
|
142
|
+
assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 1.2345 } }));
|
|
108
143
|
});
|
|
109
144
|
|
|
110
145
|
test("several feeds are collected in one pass and render together", async () => {
|
|
@@ -138,12 +173,121 @@ test("missing fields and malformed payloads do not surface", () => {
|
|
|
138
173
|
assert.equal(renderFeeds([savingsFeed()], { [SAVINGS]: 5 }).visible, false, "payload must be an object");
|
|
139
174
|
});
|
|
140
175
|
|
|
176
|
+
// ── stale-feed lifecycle ───────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
test("feed display state changes at exact hold and fade boundaries", () => {
|
|
179
|
+
const changedAt = 1_000;
|
|
180
|
+
assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS - 1), { phase: "active", fadeShade: 0 });
|
|
181
|
+
for (let shade = 0; shade < 5; shade++) {
|
|
182
|
+
assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS + shade * (FEED_FADE_MS / 5)), {
|
|
183
|
+
phase: "fading",
|
|
184
|
+
fadeShade: shade,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS + FEED_FADE_MS), {
|
|
188
|
+
phase: "hidden",
|
|
189
|
+
fadeShade: 4,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("missing display state remains active, while fading and hidden feeds render independently", () => {
|
|
194
|
+
const count: StatusLineFeed = { customType: "my-ext/metrics", field: "count", prefix: "N", format: "number" };
|
|
195
|
+
const feeds = [savingsFeed(), count];
|
|
196
|
+
const data = { [SAVINGS]: { savedUsd: 1.5 }, "my-ext/metrics": { count: 42 } };
|
|
197
|
+
assert.equal(stripAnsi(renderFeeds(feeds, data).content), "CS$1.50 N42");
|
|
198
|
+
|
|
199
|
+
const states = {
|
|
200
|
+
[feedKey(SAVINGS, "savedUsd")]: { phase: "hidden", fadeShade: 4 },
|
|
201
|
+
[feedKey("my-ext/metrics", "count")]: { phase: "fading", fadeShade: 2 },
|
|
202
|
+
} satisfies Record<string, FeedDisplayState>;
|
|
203
|
+
const rendered = renderFeeds(feeds, data, states);
|
|
204
|
+
assert.equal(stripAnsi(rendered.content), "N42");
|
|
205
|
+
assert.equal(rendered.visible, true);
|
|
206
|
+
assert.equal(rendered.content, theme.fadeFg("dim", "statusLineBg", 2, "N42"));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("tracking preserves equal values and resets changed values per field", () => {
|
|
210
|
+
const originalNow = Date.now;
|
|
211
|
+
let now = 1_000_000;
|
|
212
|
+
Date.now = () => now;
|
|
213
|
+
try {
|
|
214
|
+
const entries: SessionEntry[] = [];
|
|
215
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
216
|
+
builder.attach(fakeCtx(entries));
|
|
217
|
+
entries.push(feedEntry({ savedUsd: { total: 1 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
|
|
218
|
+
const feeds: StatusLineFeed[] = [
|
|
219
|
+
{ customType: SAVINGS, field: "savedUsd", prefix: "A", format: "text" },
|
|
220
|
+
{ customType: SAVINGS, field: "count", prefix: "B", format: "number" },
|
|
221
|
+
];
|
|
222
|
+
const options = { ...OPTIONS, feeds };
|
|
223
|
+
let ctx = builder.build(80, options, INCLUDE, undefined);
|
|
224
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "active");
|
|
225
|
+
|
|
226
|
+
now += FEED_HOLD_MS + 1;
|
|
227
|
+
entries.push(feedEntry({ savedUsd: { total: 1 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
|
|
228
|
+
ctx = builder.build(80, options, INCLUDE, undefined);
|
|
229
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "fading", "cloned equal object must not reset");
|
|
230
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")]?.phase, "fading", "equal primitive must not reset");
|
|
231
|
+
|
|
232
|
+
now += 2_001;
|
|
233
|
+
entries.push(feedEntry({ savedUsd: { total: 2 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
|
|
234
|
+
ctx = builder.build(80, options, INCLUDE, undefined);
|
|
235
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "active", "changed hidden/fading field reappears");
|
|
236
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")]?.phase, "hidden", "fields sharing a publisher age independently");
|
|
237
|
+
|
|
238
|
+
now += 2_001;
|
|
239
|
+
entries.push(feedEntry({ savedUsd: { total: 2 } }, SAVINGS, new Date(now).toISOString()));
|
|
240
|
+
ctx = builder.build(80, options, INCLUDE, undefined);
|
|
241
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")], undefined, "missing fields clear tracking");
|
|
242
|
+
ctx = builder.build(80, { ...options, feeds: [feeds[0]!] }, INCLUDE, undefined);
|
|
243
|
+
assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")], undefined, "removed subscriptions stay pruned");
|
|
244
|
+
builder.dispose();
|
|
245
|
+
} finally {
|
|
246
|
+
Date.now = originalNow;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("feed transition timers request a render and are cleared by attach and dispose", () => {
|
|
251
|
+
const originalNow = Date.now;
|
|
252
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
253
|
+
const originalClearTimeout = globalThis.clearTimeout;
|
|
254
|
+
let scheduled: (() => void) | undefined;
|
|
255
|
+
let cleared = 0;
|
|
256
|
+
Date.now = () => 1_000_000;
|
|
257
|
+
globalThis.setTimeout = ((callback: () => void) => {
|
|
258
|
+
scheduled = callback;
|
|
259
|
+
return { unref() {} } as unknown as ReturnType<typeof setTimeout>;
|
|
260
|
+
}) as typeof setTimeout;
|
|
261
|
+
globalThis.clearTimeout = (() => {
|
|
262
|
+
cleared++;
|
|
263
|
+
}) as typeof clearTimeout;
|
|
264
|
+
try {
|
|
265
|
+
const entries = [feedEntry({ savedUsd: 1 }, SAVINGS, new Date(Date.now()).toISOString())];
|
|
266
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
267
|
+
let renders = 0;
|
|
268
|
+
builder.setRequestRender(() => renders++);
|
|
269
|
+
builder.attach(fakeCtx(entries));
|
|
270
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
271
|
+
assert.ok(scheduled, "an active feed schedules its stale transition");
|
|
272
|
+
scheduled?.();
|
|
273
|
+
assert.equal(renders, 1, "the transition wakes the renderer");
|
|
274
|
+
builder.attach(fakeCtx(entries));
|
|
275
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
276
|
+
builder.dispose();
|
|
277
|
+
assert.ok(cleared >= 2, "attach and dispose clear pending feed timers");
|
|
278
|
+
} finally {
|
|
279
|
+
Date.now = originalNow;
|
|
280
|
+
globalThis.setTimeout = originalSetTimeout;
|
|
281
|
+
globalThis.clearTimeout = originalClearTimeout;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
141
285
|
// ── the per-run reset ──────────────────────────────────────────────────────
|
|
142
286
|
|
|
143
287
|
test("entries predating this run are stale and render nothing", async () => {
|
|
144
288
|
const priorRun = new Date(Date.now() - 60_000).toISOString();
|
|
145
289
|
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun)]);
|
|
146
|
-
assert.deepEqual(ctx.feedData,
|
|
290
|
+
assert.deepEqual(ctx.feedData, feedMap(), "a prior run's figure must not surface");
|
|
147
291
|
});
|
|
148
292
|
|
|
149
293
|
test("the first emission of the new run supersedes a stale one", async () => {
|
|
@@ -152,7 +296,7 @@ test("the first emission of the new run supersedes a stale one", async () => {
|
|
|
152
296
|
feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun),
|
|
153
297
|
feedEntry({ savedUsd: 0.42 }),
|
|
154
298
|
]);
|
|
155
|
-
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 0.42 } });
|
|
299
|
+
assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 0.42 } }));
|
|
156
300
|
});
|
|
157
301
|
|
|
158
302
|
test("session replacement inside the TTL window drops the cached payload", async () => {
|
|
@@ -162,7 +306,7 @@ test("session replacement inside the TTL window drops the cached payload", async
|
|
|
162
306
|
builder.attach(fakeCtx(entries));
|
|
163
307
|
await sleep(3);
|
|
164
308
|
entries.push(feedEntry({ savedUsd: 4.2 }));
|
|
165
|
-
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 4.2 } });
|
|
309
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap({ [SAVINGS]: { savedUsd: 4.2 } }));
|
|
166
310
|
|
|
167
311
|
// /new, /resume and /fork replace the session in-process; publishers reset,
|
|
168
312
|
// so the entries above now belong to a previous run.
|
|
@@ -170,7 +314,7 @@ test("session replacement inside the TTL window drops the cached payload", async
|
|
|
170
314
|
builder.attach(fakeCtx(entries));
|
|
171
315
|
assert.deepEqual(
|
|
172
316
|
builder.build(80, OPTIONS, INCLUDE, undefined).feedData,
|
|
173
|
-
|
|
317
|
+
feedMap(),
|
|
174
318
|
"must rescan on attach rather than serve the prior session's payload",
|
|
175
319
|
);
|
|
176
320
|
});
|
|
@@ -186,7 +330,7 @@ test("no stale frame is observable across the whole TTL window", async () => {
|
|
|
186
330
|
await sleep(3);
|
|
187
331
|
builder.attach(fakeCtx(entries));
|
|
188
332
|
for (let frame = 0; frame < 10; frame++) {
|
|
189
|
-
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData,
|
|
333
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap(), `frame ${frame} leaked`);
|
|
190
334
|
await sleep(30);
|
|
191
335
|
}
|
|
192
336
|
});
|
|
@@ -231,7 +375,7 @@ test("changing the subscription list rescans without waiting out the TTL", async
|
|
|
231
375
|
// Adding a feed in the settings menu must take effect on the next frame.
|
|
232
376
|
const ctx = builder.build(80, OPTIONS, { ...INCLUDE, feeds: [SAVINGS, "my-ext/tokens"] }, undefined);
|
|
233
377
|
assert.equal(scans, 2);
|
|
234
|
-
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1 }, "my-ext/tokens": { count: 7 } });
|
|
378
|
+
assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 1 }, "my-ext/tokens": { count: 7 } }));
|
|
235
379
|
});
|
|
236
380
|
|
|
237
381
|
test("a fresh emission appears once the TTL lapses", async () => {
|
|
@@ -242,9 +386,9 @@ test("a fresh emission appears once the TTL lapses", async () => {
|
|
|
242
386
|
|
|
243
387
|
await sleep(3);
|
|
244
388
|
entries.push(feedEntry({ savedUsd: 0.01 }));
|
|
245
|
-
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData,
|
|
389
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap(), "still inside the TTL");
|
|
246
390
|
await sleep(2_100);
|
|
247
|
-
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 0.01 } });
|
|
391
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap({ [SAVINGS]: { savedUsd: 0.01 } }));
|
|
248
392
|
});
|
|
249
393
|
|
|
250
394
|
// ── formats ────────────────────────────────────────────────────────────────
|
|
@@ -260,6 +404,11 @@ test("currency hides sub-cent and negative values, matching the publisher's own
|
|
|
260
404
|
}
|
|
261
405
|
});
|
|
262
406
|
|
|
407
|
+
test("format-suppressed feed values reserve no footer row", () => {
|
|
408
|
+
assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: 0.0049 } }).length, 0);
|
|
409
|
+
assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: "" } }, "text").length, 0);
|
|
410
|
+
});
|
|
411
|
+
|
|
263
412
|
test("no $0.00 artifact is reachable near the crossing", () => {
|
|
264
413
|
for (let value = -0.05; value <= 0.05; value += 0.0001) {
|
|
265
414
|
assert.ok(!stripAnsi(renderSaved(value).content).includes("$0.00"), `rendered $0.00 at ${value}`);
|
package/src/tests/footer.test.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import test from "node:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
collapseFooterLayoutSlot,
|
|
12
|
+
filterVisibleRows,
|
|
13
|
+
isBorderRow,
|
|
14
|
+
stripAnsi,
|
|
15
|
+
stripRedundantStats,
|
|
16
|
+
} from "../footer.ts";
|
|
11
17
|
|
|
12
18
|
test("isBorderRow accepts a plain border run", () => {
|
|
13
19
|
assert.equal(isBorderRow("─────"), true);
|
|
@@ -35,6 +41,29 @@ test("stripAnsi removes SGR sequences only", () => {
|
|
|
35
41
|
assert.equal(stripAnsi("\x1b[32mgreen\x1b[0m plain"), "green plain");
|
|
36
42
|
});
|
|
37
43
|
|
|
44
|
+
test("filterVisibleRows drops empty notifications but keeps visible rows", () => {
|
|
45
|
+
assert.deepEqual(filterVisibleRows(["", " ", "\x1b[32m\x1b[0m", "saved $1.23"]), ["saved $1.23"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("fullscreen footer slot can collapse and restores its minimum", () => {
|
|
49
|
+
const footer = { render: () => [] };
|
|
50
|
+
const footerContainer = { children: [footer] };
|
|
51
|
+
const footerEntry = { component: footerContainer, minSize: 1 };
|
|
52
|
+
const dock = { entries: [{ component: {} }, footerEntry] };
|
|
53
|
+
const root = { entries: [{ component: {} }, { component: dock, minSize: 1 }] };
|
|
54
|
+
|
|
55
|
+
const restore = collapseFooterLayoutSlot(root, footer);
|
|
56
|
+
assert.equal(footerEntry.minSize, 0);
|
|
57
|
+
assert.equal(root.entries[1]?.minSize, 1, "must not collapse the whole dock");
|
|
58
|
+
restore?.();
|
|
59
|
+
assert.equal(footerEntry.minSize, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("inline layouts without a footer slot are left untouched", () => {
|
|
63
|
+
assert.equal(collapseFooterLayoutSlot(undefined, {}), undefined);
|
|
64
|
+
assert.equal(collapseFooterLayoutSlot({ children: [] }, {}), undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
38
67
|
test("stripRedundantStats passes undefined through", () => {
|
|
39
68
|
assert.equal(stripRedundantStats(undefined), undefined);
|
|
40
69
|
});
|
|
@@ -113,9 +113,11 @@ test("a left group shifts the gap start by its width", () => {
|
|
|
113
113
|
assert.ok(calls[0]!.startCol > 3, "gap starts after the pi segment, not at the origin");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
test("
|
|
116
|
+
test("rainbow settings default to true and resolve explicit false", () => {
|
|
117
117
|
assert.equal(resolveEffectiveSettings({}).rainbowBorder, true);
|
|
118
|
+
assert.equal(resolveEffectiveSettings({}).rainbowAnimation, true);
|
|
118
119
|
assert.equal(resolveEffectiveSettings({ rainbowBorder: false }).rainbowBorder, false);
|
|
120
|
+
assert.equal(resolveEffectiveSettings({ rainbowAnimation: false }).rainbowAnimation, false);
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
test("borderStyle defaults to rounded and is loadable", () => {
|
package/src/theme.ts
CHANGED
|
@@ -178,7 +178,6 @@ type ColorValue = string | number;
|
|
|
178
178
|
|
|
179
179
|
export type StatusColor =
|
|
180
180
|
| "accent"
|
|
181
|
-
| "success"
|
|
182
181
|
| "error"
|
|
183
182
|
| "warning"
|
|
184
183
|
| "muted"
|
|
@@ -193,11 +192,13 @@ export type StatusColor =
|
|
|
193
192
|
| "statusLineContextElevated"
|
|
194
193
|
| "statusLineStaged"
|
|
195
194
|
| "statusLineDirty"
|
|
196
|
-
| "statusLineUntracked"
|
|
195
|
+
| "statusLineUntracked"
|
|
196
|
+
| "statusLineBg";
|
|
197
|
+
|
|
198
|
+
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
197
199
|
|
|
198
200
|
const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
199
201
|
accent: "#febc38",
|
|
200
|
-
success: "#89d281",
|
|
201
202
|
error: "#fc3a4b",
|
|
202
203
|
warning: "#e4c00f",
|
|
203
204
|
muted: "#777d88",
|
|
@@ -213,10 +214,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
213
214
|
statusLineStaged: 70,
|
|
214
215
|
statusLineDirty: 178,
|
|
215
216
|
statusLineUntracked: 39,
|
|
217
|
+
statusLineBg: STATUS_LINE_BG,
|
|
216
218
|
};
|
|
217
219
|
|
|
218
|
-
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
219
|
-
|
|
220
220
|
/** Dominant palette hexes the session accent must not collide with. */
|
|
221
221
|
export const MAJOR_COLOR_HEXES = [
|
|
222
222
|
"#febc38",
|
|
@@ -246,7 +246,7 @@ export function parseHex(hex: string): [number, number, number] | undefined {
|
|
|
246
246
|
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
/** Number of discrete, eased shades used for the token-rate
|
|
249
|
+
/** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
|
|
250
250
|
export const FADE_SHADE_COUNT = 5;
|
|
251
251
|
|
|
252
252
|
/** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
|
|
@@ -275,11 +275,14 @@ function rgbTo256(r: number, g: number, b: number): number {
|
|
|
275
275
|
return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
|
|
279
|
+
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
280
|
+
}
|
|
281
|
+
|
|
278
282
|
export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
|
|
279
283
|
const rgb = parseHex(hex);
|
|
280
284
|
if (!rgb) return "";
|
|
281
|
-
|
|
282
|
-
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
285
|
+
return rgbToFgAnsi(...rgb, mode);
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
function fgAnsi(color: ColorValue, mode: ColorMode): string {
|
|
@@ -361,8 +364,7 @@ class StatusTheme {
|
|
|
361
364
|
const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
|
|
362
365
|
const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
|
|
363
366
|
const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
|
|
364
|
-
const ansi =
|
|
365
|
-
this.#mode === "truecolor" ? `\x1b[38;2;${r};${g};${bl}m` : `\x1b[38;5;${rgbTo256(r, g, bl)}m`;
|
|
367
|
+
const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
|
|
366
368
|
return `${ansi}${text}\x1b[39m`;
|
|
367
369
|
}
|
|
368
370
|
}
|
package/src/types.ts
CHANGED
|
@@ -69,8 +69,10 @@ export interface StatusLineSettings {
|
|
|
69
69
|
borderStyle?: BorderStyle;
|
|
70
70
|
segments?: StatusLineSegmentToggles;
|
|
71
71
|
feeds?: StatusLineFeed[];
|
|
72
|
-
/**
|
|
72
|
+
/** Rainbow border while the thinking level is max. */
|
|
73
73
|
rainbowBorder?: boolean;
|
|
74
|
+
/** Animate the rainbow border when it is visible. */
|
|
75
|
+
rainbowAnimation?: boolean;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
/** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
|
|
@@ -102,6 +104,7 @@ export interface EffectiveStatusLineSettings {
|
|
|
102
104
|
bottomRightSegments: StatusLineSegmentId[];
|
|
103
105
|
segmentOptions: StatusLineSegmentOptions;
|
|
104
106
|
rainbowBorder: boolean;
|
|
107
|
+
rainbowAnimation: boolean;
|
|
105
108
|
includes: SegmentIncludes;
|
|
106
109
|
}
|
|
107
110
|
|
|
@@ -139,6 +142,19 @@ export interface SegmentContext {
|
|
|
139
142
|
* entries, and those must not be shown as current.
|
|
140
143
|
*/
|
|
141
144
|
feedData: Record<string, unknown> | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Lifecycle state per configured feed field. Missing state is treated as
|
|
147
|
+
* active so previews and manually constructed contexts retain full color.
|
|
148
|
+
*/
|
|
149
|
+
feedDisplayState?: Record<string, FeedDisplayState>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type FeedPhase = "active" | "fading" | "hidden";
|
|
153
|
+
|
|
154
|
+
export interface FeedDisplayState {
|
|
155
|
+
phase: FeedPhase;
|
|
156
|
+
/** Discrete fade step (0..FADE_SHADE_COUNT-1); meaningful while fading. */
|
|
157
|
+
fadeShade: number;
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
export type TokenRatePhase = "active" | "fading" | "idle";
|
package/src/utils.ts
CHANGED
|
@@ -65,10 +65,7 @@ export function clampPathLength(pwd: string, maxLen: number): string {
|
|
|
65
65
|
|
|
66
66
|
/** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
|
|
67
67
|
export function sanitizeStatusText(text: string): string {
|
|
68
|
-
return
|
|
69
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
70
|
-
.replace(/ +/g, " ")
|
|
71
|
-
.trim();
|
|
68
|
+
return sanitizeLabel(text).trim();
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
/**
|
|
@@ -77,7 +74,7 @@ export function sanitizeStatusText(text: string): string {
|
|
|
77
74
|
*/
|
|
78
75
|
export function sanitizeLabel(text: string): string {
|
|
79
76
|
return stripVTControlCharacters(text)
|
|
80
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
77
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, " ")
|
|
81
78
|
.replace(/ +/g, " ");
|
|
82
79
|
}
|
|
83
80
|
|
|
@@ -85,6 +82,11 @@ export function withIcon(icon: string, text: string): string {
|
|
|
85
82
|
return icon ? `${icon} ${text}` : text;
|
|
86
83
|
}
|
|
87
84
|
|
|
85
|
+
/** Unambiguous identity for one configured field of a feed publisher. */
|
|
86
|
+
export function feedKey(customType: string, field: string): string {
|
|
87
|
+
return `${customType}\u0000${field}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
88
90
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
89
91
|
// Session accent (djb2 hue hash, dark warm band, collision avoidance)
|
|
90
92
|
// ═══════════════════════════════════════════════════════════════════════════
|