@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/src/layout.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
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 (no ellipsis, as pi's own border does), shrink
|
|
6
|
+
* the elastic `path` segment down to ~8 cells, drop left segments end-first
|
|
7
|
+
* while protecting `path`. The gap between the two groups is filled with the
|
|
7
8
|
* box-horizontal glyph colored like the editor border, so it tracks the
|
|
8
9
|
* thinking-level border color; the callback receives the gap's absolute
|
|
9
10
|
* column and row in the box, so a caller can paint it as part of a
|
|
10
11
|
* continuous border gradient (the rainbow effect).
|
|
12
|
+
*
|
|
13
|
+
* `options.leftFade` (0..1) blends every left segment after a leading `pi`
|
|
14
|
+
* symbol toward the bar background, which the working-indicator transition
|
|
15
|
+
* uses to cross-fade the group without moving the symbol or its chevron.
|
|
11
16
|
*/
|
|
12
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
18
|
import { renderSegment } from "./segments.js";
|
|
14
19
|
import { getSeparator } from "./separators.js";
|
|
15
20
|
import { theme } from "./theme.js";
|
|
@@ -58,6 +63,7 @@ export function buildStatusLine(
|
|
|
58
63
|
gapBorderColor: (str: string, startCol: number, row: number) => string,
|
|
59
64
|
segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
|
|
60
65
|
barOrigin: { col: number; row: number } = { col: 0, row: 0 },
|
|
66
|
+
options: { leftFade?: number } = {},
|
|
61
67
|
): string {
|
|
62
68
|
const separatorDef = getSeparator(settings.separator);
|
|
63
69
|
|
|
@@ -112,6 +118,19 @@ export function buildStatusLine(
|
|
|
112
118
|
right.pop();
|
|
113
119
|
rightWidth = rightGroupWidth(right);
|
|
114
120
|
}
|
|
121
|
+
// The working status absorbs overflow first so the Pi symbol beside it
|
|
122
|
+
// survives on narrow terminals.
|
|
123
|
+
const workingIdx = leftSegIds.indexOf("working");
|
|
124
|
+
if (workingIdx >= 0 && totalWidth() > width) {
|
|
125
|
+
const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
|
|
126
|
+
if (available >= 1) {
|
|
127
|
+
left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
|
|
128
|
+
} else {
|
|
129
|
+
left.splice(workingIdx, 1);
|
|
130
|
+
leftSegIds.splice(workingIdx, 1);
|
|
131
|
+
}
|
|
132
|
+
leftWidth = leftGroupWidth(left);
|
|
133
|
+
}
|
|
115
134
|
// Shrink path before dropping left segments — path is the only elastic segment
|
|
116
135
|
const pathIdx = leftSegIds.indexOf("path");
|
|
117
136
|
if (pathIdx >= 0 && totalWidth() > width) {
|
|
@@ -137,13 +156,25 @@ export function buildStatusLine(
|
|
|
137
156
|
}
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
|
159
|
+
const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
|
|
141
160
|
if (parts.length === 0) return "";
|
|
142
161
|
const sep = direction === "left" ? separatorDef.left : separatorDef.right;
|
|
143
162
|
const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
|
|
144
|
-
|
|
163
|
+
const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
|
|
164
|
+
|
|
165
|
+
let body: string;
|
|
166
|
+
const fade = options.leftFade;
|
|
167
|
+
if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
|
|
168
|
+
// The faded tail opens with an explicit default-fg reset so plain text
|
|
169
|
+
// inside it is recolored too, not just segments that set their own color.
|
|
170
|
+
const head = parts.slice(0, fadeFrom).join(sepText);
|
|
171
|
+
const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
|
|
172
|
+
body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
|
|
173
|
+
} else {
|
|
174
|
+
body = parts.join(sepText);
|
|
175
|
+
}
|
|
145
176
|
let content = bgAnsi + fgAnsi;
|
|
146
|
-
content += ` ${
|
|
177
|
+
content += ` ${body} `;
|
|
147
178
|
content += "\x1b[0m";
|
|
148
179
|
|
|
149
180
|
if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
|
|
@@ -151,7 +182,7 @@ export function buildStatusLine(
|
|
|
151
182
|
return content;
|
|
152
183
|
};
|
|
153
184
|
|
|
154
|
-
const leftGroup = renderGroup(left, "left");
|
|
185
|
+
const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
|
|
155
186
|
const rightGroup = renderGroup(right, "right");
|
|
156
187
|
if (!leftGroup && !rightGroup) return "";
|
|
157
188
|
|
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
|
|
|
@@ -286,6 +295,20 @@ const scrollHintSegment: StatusLineSegment = {
|
|
|
286
295
|
},
|
|
287
296
|
};
|
|
288
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Pi's embedded working indicator (spinner + message), already colored by the
|
|
300
|
+
* host, so it is passed through verbatim. The layout truncates it, without an
|
|
301
|
+
* ellipsis, when it overflows, mirroring pi's own border rendering.
|
|
302
|
+
*/
|
|
303
|
+
const workingSegment: StatusLineSegment = {
|
|
304
|
+
id: "working",
|
|
305
|
+
render(ctx) {
|
|
306
|
+
const status = ctx.workingStatus;
|
|
307
|
+
if (!status) return INVISIBLE;
|
|
308
|
+
return { content: status, visible: true };
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
289
312
|
/** Context bar + stats — related parts joined by a space, not the group separator. */
|
|
290
313
|
const contextGraphSegment: StatusLineSegment = {
|
|
291
314
|
id: "context_graph",
|
|
@@ -342,6 +365,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
342
365
|
feeds: feedsSegment,
|
|
343
366
|
context_graph: contextGraphSegment,
|
|
344
367
|
scroll_hint: scrollHintSegment,
|
|
368
|
+
working: workingSegment,
|
|
345
369
|
};
|
|
346
370
|
|
|
347
371
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
package/src/settings-menu.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
saveSettings,
|
|
20
20
|
sanitizeFeeds,
|
|
21
21
|
SEPARATORS,
|
|
22
|
+
topLeftSegments,
|
|
22
23
|
type SettingsState,
|
|
23
24
|
} from "./settings.js";
|
|
24
25
|
import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
@@ -140,6 +141,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
140
141
|
label: "Animate rainbow border",
|
|
141
142
|
value: settings.rainbowAnimation ?? true,
|
|
142
143
|
},
|
|
144
|
+
{
|
|
145
|
+
id: "embedWorkingStatus",
|
|
146
|
+
label: "Embed 'Working' indicator",
|
|
147
|
+
value: settings.embedWorkingStatus ?? false,
|
|
148
|
+
},
|
|
143
149
|
],
|
|
144
150
|
};
|
|
145
151
|
return [
|
|
@@ -170,6 +176,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
170
176
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
171
177
|
rainbowBorder: values.rainbowBorder === true,
|
|
172
178
|
rainbowAnimation: values.rainbowAnimation === true,
|
|
179
|
+
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
173
180
|
};
|
|
174
181
|
}
|
|
175
182
|
|
|
@@ -184,6 +191,8 @@ const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
|
|
|
184
191
|
const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
|
|
185
192
|
const CANNED_PERCENT = 42;
|
|
186
193
|
const CANNED_WINDOW = 200_000;
|
|
194
|
+
/** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
|
|
195
|
+
const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
|
|
187
196
|
|
|
188
197
|
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
189
198
|
function cannedFeedData(
|
|
@@ -266,13 +275,14 @@ class StatusLinePreview {
|
|
|
266
275
|
bottomIdx,
|
|
267
276
|
flat: s => this.#uiTheme.fg("border", s),
|
|
268
277
|
});
|
|
278
|
+
const embedOn = effective.embedWorkingStatus;
|
|
269
279
|
const top = buildStatusLine(
|
|
270
280
|
barWidth,
|
|
271
|
-
ctx,
|
|
281
|
+
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", CANNED_WORKING) } : ctx,
|
|
272
282
|
effective,
|
|
273
283
|
painters.gapColor,
|
|
274
284
|
{
|
|
275
|
-
left: effective
|
|
285
|
+
left: topLeftSegments(effective, embedOn),
|
|
276
286
|
right: effective.rightSegments,
|
|
277
287
|
},
|
|
278
288
|
{ col: 3, row: 0 },
|
|
@@ -312,7 +322,7 @@ export function registerSettingsCommand(
|
|
|
312
322
|
onChange: () => void,
|
|
313
323
|
): void {
|
|
314
324
|
pi.registerCommand("topping-statusline-settings", {
|
|
315
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds,
|
|
325
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded working indicator",
|
|
316
326
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
317
327
|
if (ctx.mode !== "tui") {
|
|
318
328
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
package/src/settings.ts
CHANGED
|
@@ -93,6 +93,7 @@ function loadSettings(): StatusLineSettings {
|
|
|
93
93
|
if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
|
|
94
94
|
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
95
95
|
if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
|
|
96
|
+
if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
|
|
96
97
|
return settings;
|
|
97
98
|
} catch {
|
|
98
99
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -155,6 +156,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
155
156
|
bottomRightSegments,
|
|
156
157
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
157
158
|
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
159
|
+
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
158
160
|
includes,
|
|
159
161
|
segmentOptions: {
|
|
160
162
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
@@ -165,6 +167,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
165
167
|
};
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The top-left group for one frame. While pi's working status is embedded,
|
|
172
|
+
* it stands in for every left segment except the leading Pi symbol, so the
|
|
173
|
+
* symbol and its chevron never move when a stream starts or ends.
|
|
174
|
+
*/
|
|
175
|
+
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
176
|
+
if (!working) return effective.leftSegments;
|
|
177
|
+
return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
export interface SettingsState {
|
|
169
181
|
settings: StatusLineSettings;
|
|
170
182
|
effective: EffectiveStatusLineSettings;
|
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
|
});
|