@underactive/pi-topping-statusline 0.1.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/LICENSE +23 -0
- package/README.md +208 -0
- package/index.ts +355 -0
- package/package.json +54 -0
- package/src/context-graph.ts +48 -0
- package/src/context-thresholds.ts +84 -0
- package/src/context.ts +387 -0
- package/src/layout.ts +164 -0
- package/src/menu.ts +687 -0
- package/src/rainbow.ts +98 -0
- package/src/segments.ts +349 -0
- package/src/separators.ts +22 -0
- package/src/settings-menu.ts +370 -0
- package/src/settings.ts +169 -0
- package/src/tests/context-thresholds.test.ts +50 -0
- package/src/tests/feeds.test.ts +368 -0
- package/src/tests/menu.test.ts +109 -0
- package/src/tests/rainbow.test.ts +133 -0
- package/src/tests/token-rate-monitor.test.ts +87 -0
- package/src/tests/token-rate.test.ts +75 -0
- package/src/tests/ts-resolve.ts +20 -0
- package/src/theme.ts +454 -0
- package/src/token-rate.ts +242 -0
- package/src/types.ts +166 -0
- package/src/utils.ts +161 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts for the feeds segment, which surfaces values other extensions
|
|
3
|
+
* publish as custom session entries. The default subscription targets
|
|
4
|
+
* pi-prompt-cache's savings feed, whose author specified the currency rules.
|
|
5
|
+
*
|
|
6
|
+
* Ordering matters here: a publisher emits at turn end or after compaction,
|
|
7
|
+
* both of which require a completed model call, so a live entry is always
|
|
8
|
+
* written after the consumer's session_start. Fabricating a timestamp before
|
|
9
|
+
* attaching would exercise an ordering the real system cannot produce, and the
|
|
10
|
+
* stale-run guard would discard it.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { SegmentContextBuilder } from "../context.ts";
|
|
16
|
+
import { SEGMENTS } from "../segments.ts";
|
|
17
|
+
import { DEFAULT_FEEDS, DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
|
|
18
|
+
import { theme } from "../theme.ts";
|
|
19
|
+
import type { SegmentContext, StatusLineFeed } from "../types.ts";
|
|
20
|
+
|
|
21
|
+
const SAVINGS = "pi-prompt-cache/savings";
|
|
22
|
+
const OPTIONS = resolveEffectiveSettings({}).segmentOptions;
|
|
23
|
+
const INCLUDE = { git: false, pr: false, piStats: false, tokenRate: false, feeds: [SAVINGS] };
|
|
24
|
+
/** Outside any repo, so the builder's git probes stay inert. */
|
|
25
|
+
const CWD = "/tmp/pi-topping-statusline-tests";
|
|
26
|
+
|
|
27
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
28
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
29
|
+
|
|
30
|
+
// Partial stand-ins: the builder only reaches the members defined here.
|
|
31
|
+
const fakePi = { exec: async () => undefined, getThinkingLevel: () => "off" } as unknown as ExtensionAPI;
|
|
32
|
+
const fakeCtx = (entries: SessionEntry[], onScan?: () => void): ExtensionContext =>
|
|
33
|
+
({
|
|
34
|
+
cwd: CWD,
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getEntries: () => {
|
|
37
|
+
onScan?.();
|
|
38
|
+
return entries;
|
|
39
|
+
},
|
|
40
|
+
getSessionName: () => undefined,
|
|
41
|
+
},
|
|
42
|
+
getContextUsage: () => undefined,
|
|
43
|
+
model: undefined,
|
|
44
|
+
}) as unknown as ExtensionContext;
|
|
45
|
+
|
|
46
|
+
const feedEntry = (data: unknown, customType = SAVINGS, timestamp = new Date().toISOString()): SessionEntry =>
|
|
47
|
+
({ type: "custom", customType, id: "e", parentId: null, timestamp, data }) as SessionEntry;
|
|
48
|
+
|
|
49
|
+
/** Attach first, let the clock advance, then emit — a publisher's real ordering. */
|
|
50
|
+
async function contextAfterEmitting(
|
|
51
|
+
makeEntries: () => SessionEntry[],
|
|
52
|
+
include = INCLUDE,
|
|
53
|
+
): Promise<SegmentContext> {
|
|
54
|
+
const entries: SessionEntry[] = [];
|
|
55
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
56
|
+
builder.attach(fakeCtx(entries));
|
|
57
|
+
await sleep(3);
|
|
58
|
+
entries.push(...makeEntries());
|
|
59
|
+
return builder.build(80, OPTIONS, include, undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BASE = await contextAfterEmitting(() => []);
|
|
63
|
+
/** Render the segment against explicit feed config and payloads. */
|
|
64
|
+
const renderFeeds = (feeds: StatusLineFeed[], feedData: Record<string, unknown> | undefined) =>
|
|
65
|
+
SEGMENTS.feeds.render({ ...BASE, options: { ...BASE.options, feeds }, feedData });
|
|
66
|
+
const savingsFeed = (format: StatusLineFeed["format"] = "currency", prefix = "CS"): StatusLineFeed => ({
|
|
67
|
+
customType: SAVINGS,
|
|
68
|
+
field: "savedUsd",
|
|
69
|
+
prefix,
|
|
70
|
+
format,
|
|
71
|
+
});
|
|
72
|
+
const renderSaved = (value: unknown, format: StatusLineFeed["format"] = "currency") =>
|
|
73
|
+
renderFeeds([savingsFeed(format)], { [SAVINGS]: { savedUsd: value } });
|
|
74
|
+
|
|
75
|
+
// ── reading the feed ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
test("hidden when no publisher has emitted", async () => {
|
|
78
|
+
const ctx = await contextAfterEmitting(() => []);
|
|
79
|
+
assert.deepEqual(ctx.feedData, {});
|
|
80
|
+
assert.equal(SEGMENTS.feeds.render(ctx).visible, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a later custom_message sharing the customType cannot shadow a real entry", async () => {
|
|
84
|
+
// CustomMessageEntry carries customType too, but keeps its payload under
|
|
85
|
+
// details. Matching on customType alone would hit this newer entry first,
|
|
86
|
+
// find nothing usable, and suppress the real value behind it.
|
|
87
|
+
const decoy = {
|
|
88
|
+
type: "custom_message",
|
|
89
|
+
customType: SAVINGS,
|
|
90
|
+
id: "m",
|
|
91
|
+
parentId: null,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
content: "x",
|
|
94
|
+
display: false,
|
|
95
|
+
details: { savedUsd: 99 },
|
|
96
|
+
} as SessionEntry;
|
|
97
|
+
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 4.2 }), decoy]);
|
|
98
|
+
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 4.2 } });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("unsubscribed customTypes are ignored", async () => {
|
|
102
|
+
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 9 }, "other-ext/thing")]);
|
|
103
|
+
assert.deepEqual(ctx.feedData, {});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the newest matching entry wins", async () => {
|
|
107
|
+
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 0.5 }), feedEntry({ savedUsd: 1.2345 })]);
|
|
108
|
+
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1.2345 } });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("several feeds are collected in one pass and render together", async () => {
|
|
112
|
+
const include = { ...INCLUDE, feeds: [SAVINGS, "my-ext/tokens"] };
|
|
113
|
+
const ctx = await contextAfterEmitting(
|
|
114
|
+
() => [feedEntry({ savedUsd: 1.5 }), feedEntry({ count: 42 }, "my-ext/tokens")],
|
|
115
|
+
include,
|
|
116
|
+
);
|
|
117
|
+
const feeds: StatusLineFeed[] = [
|
|
118
|
+
savingsFeed(),
|
|
119
|
+
{ customType: "my-ext/tokens", field: "count", prefix: "TOK ", format: "number" },
|
|
120
|
+
];
|
|
121
|
+
assert.equal(stripAnsi(renderFeeds(feeds, ctx.feedData).content), "CS$1.50 TOK 42");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a subscribed feed with no publisher simply contributes nothing", async () => {
|
|
125
|
+
const include = { ...INCLUDE, feeds: [SAVINGS, "absent/feed"] };
|
|
126
|
+
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 2 })], include);
|
|
127
|
+
const feeds: StatusLineFeed[] = [
|
|
128
|
+
savingsFeed(),
|
|
129
|
+
{ customType: "absent/feed", field: "x", prefix: "AB", format: "number" },
|
|
130
|
+
];
|
|
131
|
+
assert.equal(stripAnsi(renderFeeds(feeds, ctx.feedData).content), "CS$2.00");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("missing fields and malformed payloads do not surface", () => {
|
|
135
|
+
assert.equal(renderSaved("1.23").visible, false, "a string is not a currency value");
|
|
136
|
+
assert.equal(renderSaved(Number.NaN).visible, false);
|
|
137
|
+
assert.equal(renderSaved(undefined).visible, false);
|
|
138
|
+
assert.equal(renderFeeds([savingsFeed()], { [SAVINGS]: { other: 1 } }).visible, false);
|
|
139
|
+
assert.equal(renderFeeds([savingsFeed()], { [SAVINGS]: 5 }).visible, false, "payload must be an object");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── the per-run reset ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test("entries predating this run are stale and render nothing", async () => {
|
|
145
|
+
const priorRun = new Date(Date.now() - 60_000).toISOString();
|
|
146
|
+
const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun)]);
|
|
147
|
+
assert.deepEqual(ctx.feedData, {}, "a prior run's figure must not surface");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the first emission of the new run supersedes a stale one", async () => {
|
|
151
|
+
const priorRun = new Date(Date.now() - 60_000).toISOString();
|
|
152
|
+
const ctx = await contextAfterEmitting(() => [
|
|
153
|
+
feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun),
|
|
154
|
+
feedEntry({ savedUsd: 0.42 }),
|
|
155
|
+
]);
|
|
156
|
+
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 0.42 } });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("session replacement inside the TTL window drops the cached payload", async () => {
|
|
160
|
+
const entries: SessionEntry[] = [];
|
|
161
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
162
|
+
|
|
163
|
+
builder.attach(fakeCtx(entries));
|
|
164
|
+
await sleep(3);
|
|
165
|
+
entries.push(feedEntry({ savedUsd: 4.2 }));
|
|
166
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 4.2 } });
|
|
167
|
+
|
|
168
|
+
// /new, /resume and /fork replace the session in-process; publishers reset,
|
|
169
|
+
// so the entries above now belong to a previous run.
|
|
170
|
+
await sleep(3);
|
|
171
|
+
builder.attach(fakeCtx(entries));
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
builder.build(80, OPTIONS, INCLUDE, undefined).feedData,
|
|
174
|
+
{},
|
|
175
|
+
"must rescan on attach rather than serve the prior session's payload",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("no stale frame is observable across the whole TTL window", async () => {
|
|
180
|
+
const entries: SessionEntry[] = [];
|
|
181
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
182
|
+
builder.attach(fakeCtx(entries));
|
|
183
|
+
await sleep(3);
|
|
184
|
+
entries.push(feedEntry({ savedUsd: 9.99 }));
|
|
185
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
186
|
+
|
|
187
|
+
await sleep(3);
|
|
188
|
+
builder.attach(fakeCtx(entries));
|
|
189
|
+
for (let frame = 0; frame < 10; frame++) {
|
|
190
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, {}, `frame ${frame} leaked`);
|
|
191
|
+
await sleep(30);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── scan cost ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
test("no subscriptions means the session is never read", () => {
|
|
198
|
+
let scans = 0;
|
|
199
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
200
|
+
builder.attach(fakeCtx([feedEntry({ savedUsd: 1 })], () => scans++));
|
|
201
|
+
const ctx = builder.build(80, OPTIONS, { ...INCLUDE, feeds: [] }, undefined);
|
|
202
|
+
assert.equal(scans, 0);
|
|
203
|
+
assert.equal(ctx.feedData, undefined);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("renders share one scan per TTL, and attach forces a fresh one", async () => {
|
|
207
|
+
let scans = 0;
|
|
208
|
+
const entries: SessionEntry[] = [];
|
|
209
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
210
|
+
builder.attach(fakeCtx(entries, () => scans++));
|
|
211
|
+
await sleep(3);
|
|
212
|
+
entries.push(feedEntry({ savedUsd: 1.5 }));
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < 50; i++) builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
215
|
+
assert.equal(scans, 1, `expected 1 scan across 50 renders, got ${scans}`);
|
|
216
|
+
|
|
217
|
+
builder.attach(fakeCtx(entries, () => scans++));
|
|
218
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
219
|
+
assert.equal(scans, 2, "attach must expire the cache rather than wait out the TTL");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("changing the subscription list rescans without waiting out the TTL", async () => {
|
|
223
|
+
let scans = 0;
|
|
224
|
+
const entries: SessionEntry[] = [];
|
|
225
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
226
|
+
builder.attach(fakeCtx(entries, () => scans++));
|
|
227
|
+
await sleep(3);
|
|
228
|
+
entries.push(feedEntry({ savedUsd: 1 }), feedEntry({ count: 7 }, "my-ext/tokens"));
|
|
229
|
+
|
|
230
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
231
|
+
assert.equal(scans, 1);
|
|
232
|
+
// Adding a feed in the settings menu must take effect on the next frame.
|
|
233
|
+
const ctx = builder.build(80, OPTIONS, { ...INCLUDE, feeds: [SAVINGS, "my-ext/tokens"] }, undefined);
|
|
234
|
+
assert.equal(scans, 2);
|
|
235
|
+
assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1 }, "my-ext/tokens": { count: 7 } });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("a fresh emission appears once the TTL lapses", async () => {
|
|
239
|
+
const entries: SessionEntry[] = [];
|
|
240
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
241
|
+
builder.attach(fakeCtx(entries));
|
|
242
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
243
|
+
|
|
244
|
+
await sleep(3);
|
|
245
|
+
entries.push(feedEntry({ savedUsd: 0.01 }));
|
|
246
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, {}, "still inside the TTL");
|
|
247
|
+
await sleep(2_100);
|
|
248
|
+
assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 0.01 } });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ── formats ────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
test("currency hides sub-cent and negative values, matching the publisher's own cutoff", () => {
|
|
254
|
+
// A cold 100K-token prefix bills cacheWrite at 1.25x against a 1x baseline,
|
|
255
|
+
// so the first turns really do publish a negative figure.
|
|
256
|
+
for (const value of [-12.5, -0.03, -0.025, -0.005, -0.0001, 0, 0.002, 0.0049]) {
|
|
257
|
+
assert.equal(renderSaved(value).visible, false, `${value} must be hidden`);
|
|
258
|
+
}
|
|
259
|
+
for (const value of [0.005, 0.0164, 1.235]) {
|
|
260
|
+
assert.equal(renderSaved(value).visible, true, `${value} must show`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("no $0.00 artifact is reachable near the crossing", () => {
|
|
265
|
+
for (let value = -0.05; value <= 0.05; value += 0.0001) {
|
|
266
|
+
assert.ok(!stripAnsi(renderSaved(value).content).includes("$0.00"), `rendered $0.00 at ${value}`);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("currency: two decimals below $100, whole dollars above", () => {
|
|
271
|
+
assert.equal(stripAnsi(renderSaved(0.0164).content), "CS$0.02");
|
|
272
|
+
assert.equal(stripAnsi(renderSaved(1.235).content), "CS$1.24");
|
|
273
|
+
assert.equal(stripAnsi(renderSaved(99.994).content), "CS$99.99");
|
|
274
|
+
assert.equal(stripAnsi(renderSaved(100).content), "CS$100");
|
|
275
|
+
assert.equal(stripAnsi(renderSaved(1234.56).content), "CS$1235");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("number prints plainly, keeping at most two decimals and no floor", () => {
|
|
279
|
+
assert.equal(stripAnsi(renderSaved(87, "number").content), "CS87");
|
|
280
|
+
assert.equal(stripAnsi(renderSaved(1.2345, "number").content), "CS1.23");
|
|
281
|
+
assert.equal(stripAnsi(renderSaved(1.5, "number").content), "CS1.5");
|
|
282
|
+
assert.equal(stripAnsi(renderSaved(-3, "number").content), "CS-3");
|
|
283
|
+
assert.equal(stripAnsi(renderSaved(0, "number").content), "CS0", "number has no currency floor");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("text prints any value as-is, sanitized", () => {
|
|
287
|
+
assert.equal(stripAnsi(renderSaved("ready", "text").content), "CSready");
|
|
288
|
+
assert.equal(stripAnsi(renderSaved(42, "text").content), "CS42");
|
|
289
|
+
assert.equal(renderSaved("", "text").visible, false);
|
|
290
|
+
assert.equal(stripAnsi(renderSaved("a\x1b[31mb", "text").content), "CSab", "escapes are stripped");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a prefix keeps deliberate spacing but never raw escapes", () => {
|
|
294
|
+
const withSpace: StatusLineFeed = { customType: SAVINGS, field: "n", prefix: "TOK ", format: "number" };
|
|
295
|
+
assert.equal(stripAnsi(renderFeeds([withSpace], { [SAVINGS]: { n: 42 } }).content), "TOK 42");
|
|
296
|
+
const prefixed = (prefix: string) =>
|
|
297
|
+
stripAnsi(renderFeeds([{ customType: SAVINGS, field: "n", prefix, format: "number" }], { [SAVINGS]: { n: 1 } }).content);
|
|
298
|
+
assert.equal(prefixed("a\x1b[31mb"), "ab1", "colour escapes are removed, not rendered");
|
|
299
|
+
assert.equal(prefixed("a\x07b"), "a b1", "control characters degrade to a space");
|
|
300
|
+
assert.equal(prefixed("a\tb"), "a b1");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("the prefix is literal, so the currency symbol can live in either place", () => {
|
|
304
|
+
assert.equal(stripAnsi(renderFeeds([savingsFeed("currency", "CS")], { [SAVINGS]: { savedUsd: 1.44 } }).content), "CS$1.44");
|
|
305
|
+
assert.equal(stripAnsi(renderFeeds([savingsFeed("number", "CS$")], { [SAVINGS]: { savedUsd: 1.44 } }).content), "CS$1.44");
|
|
306
|
+
assert.equal(stripAnsi(renderFeeds([savingsFeed("currency", "")], { [SAVINGS]: { savedUsd: 1.44 } }).content), "$1.44");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── settings ───────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
test("defaults: segment off, seeded with the pi-prompt-cache subscription", () => {
|
|
312
|
+
assert.equal(DEFAULT_SEGMENTS.feeds, false);
|
|
313
|
+
assert.ok(!resolveEffectiveSettings({}).bottomRightSegments.includes("feeds"));
|
|
314
|
+
assert.deepEqual(resolveEffectiveSettings({}).segmentOptions.feeds, [
|
|
315
|
+
{ customType: SAVINGS, field: "savedUsd", prefix: "CS", format: "currency" },
|
|
316
|
+
]);
|
|
317
|
+
assert.deepEqual(resolveEffectiveSettings({ segments: { feeds: true } }).bottomRightSegments, [
|
|
318
|
+
"feeds",
|
|
319
|
+
"pi_stats",
|
|
320
|
+
"context_graph",
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("feeds lead the bottom-right group", () => {
|
|
325
|
+
assert.deepEqual(
|
|
326
|
+
resolveEffectiveSettings({ segments: { feeds: true, tokenRate: true } }).bottomRightSegments,
|
|
327
|
+
["feeds", "token_rate", "pi_stats", "context_graph"],
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("the bottom-left group runs scroll hint, feeds, then the rate", () => {
|
|
332
|
+
assert.equal(DEFAULT_SEGMENTS.feedsBottomLeft, false);
|
|
333
|
+
assert.deepEqual(resolveEffectiveSettings({}).bottomLeftSegments, ["scroll_hint"]);
|
|
334
|
+
assert.deepEqual(
|
|
335
|
+
resolveEffectiveSettings({ segments: { feedsBottomLeft: true, tokenRateBottomLeft: true } })
|
|
336
|
+
.bottomLeftSegments,
|
|
337
|
+
["scroll_hint", "feeds", "token_rate"],
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("sanitizeFeeds drops unusable rows and normalizes the rest", () => {
|
|
342
|
+
assert.deepEqual(
|
|
343
|
+
sanitizeFeeds([
|
|
344
|
+
{ customType: " a/b ", field: " f ", prefix: "P", format: "number" },
|
|
345
|
+
{ customType: "", field: "f", prefix: "", format: "number" },
|
|
346
|
+
{ customType: "c/d", field: "", prefix: "", format: "number" },
|
|
347
|
+
{ customType: "e/f", field: "g", prefix: "", format: "bogus" },
|
|
348
|
+
"nonsense",
|
|
349
|
+
null,
|
|
350
|
+
]),
|
|
351
|
+
[
|
|
352
|
+
{ customType: "a/b", field: "f", prefix: "P", format: "number" },
|
|
353
|
+
{ customType: "e/f", field: "g", prefix: "", format: "text" },
|
|
354
|
+
],
|
|
355
|
+
);
|
|
356
|
+
assert.deepEqual(sanitizeFeeds(undefined), []);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("an explicitly empty feed list is honoured rather than reseeded", () => {
|
|
360
|
+
assert.deepEqual(resolveEffectiveSettings({ feeds: [] }).segmentOptions.feeds, []);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("the default subscription matches what pi-prompt-cache publishes", () => {
|
|
364
|
+
assert.equal(DEFAULT_FEEDS[0]?.customType, SAVINGS);
|
|
365
|
+
assert.equal(DEFAULT_FEEDS[0]?.field, "savedUsd");
|
|
366
|
+
assert.equal(DEFAULT_FEEDS[0]?.format, "currency");
|
|
367
|
+
void theme;
|
|
368
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { MenuComponent } from "../menu.ts";
|
|
5
|
+
import type { MenuSection } from "../menu.ts";
|
|
6
|
+
|
|
7
|
+
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s } as unknown as Theme;
|
|
8
|
+
const plain = (lines: string[]) => lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
9
|
+
|
|
10
|
+
function makeSections(feeds: { customType: string; prefix: string }[]): MenuSection[] {
|
|
11
|
+
const items: any[] = [];
|
|
12
|
+
feeds.forEach((f, i) => {
|
|
13
|
+
items.push({ id: `feed.${i}.customType`, label: `${i + 1}. type`, value: f.customType, text: true, placeholder: "ext/type" });
|
|
14
|
+
items.push({ id: `feed.${i}.prefix`, label: `${i + 1}. prefix`, value: f.prefix, text: true, placeholder: "(none)" });
|
|
15
|
+
items.push({ id: `feed.${i}.remove`, label: `${i + 1}. remove`, value: false, action: true });
|
|
16
|
+
});
|
|
17
|
+
items.push({ id: "feed.add", label: "+ add feed", value: false, action: true });
|
|
18
|
+
return [{ title: "Feeds", items }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function drive() {
|
|
22
|
+
let result: any;
|
|
23
|
+
const feeds = [{ customType: "pi-prompt-cache/savings", prefix: "CS" }];
|
|
24
|
+
const menu = new MenuComponent(
|
|
25
|
+
{
|
|
26
|
+
title: "T",
|
|
27
|
+
sections: makeSections(feeds),
|
|
28
|
+
onAction: (id, values) => {
|
|
29
|
+
const list: { customType: string; prefix: string }[] = [];
|
|
30
|
+
for (let i = 0; values[`feed.${i}.customType`] !== undefined; i++) {
|
|
31
|
+
list.push({ customType: String(values[`feed.${i}.customType`]), prefix: String(values[`feed.${i}.prefix`]) });
|
|
32
|
+
}
|
|
33
|
+
if (id === "feed.add") list.push({ customType: "", prefix: "" });
|
|
34
|
+
else {
|
|
35
|
+
const m = /^feed\.(\d+)\.remove$/.exec(id);
|
|
36
|
+
if (!m) return undefined;
|
|
37
|
+
list.splice(Number(m[1]), 1);
|
|
38
|
+
}
|
|
39
|
+
return makeSections(list);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
theme,
|
|
43
|
+
r => { result = r; },
|
|
44
|
+
);
|
|
45
|
+
return { menu, get result() { return result; } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const type = (menu: any, s: string) => { for (const ch of s) menu.handleInput(ch); };
|
|
49
|
+
|
|
50
|
+
test("typing into a text row edits only on commit", () => {
|
|
51
|
+
const { menu } = drive();
|
|
52
|
+
menu.handleInput("\r"); // Enter on row 0 -> begin editing
|
|
53
|
+
type(menu, "-x");
|
|
54
|
+
assert.match(plain(menu.render(60)).join("\n"), /pi-prompt-cache\/savings-x/, "buffer shown live");
|
|
55
|
+
menu.handleInput("\x7f"); // backspace
|
|
56
|
+
menu.handleInput("\r"); // commit
|
|
57
|
+
menu.handleInput("\x1b"); // escape closes the menu (cancel)
|
|
58
|
+
assert.equal((menu as any).values["feed.0.customType"], "pi-prompt-cache/savings-");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("escape during editing discards the buffer, not the menu", () => {
|
|
62
|
+
const { menu, } = drive();
|
|
63
|
+
menu.handleInput("\r");
|
|
64
|
+
type(menu, "ZZZ");
|
|
65
|
+
menu.handleInput("\x1b"); // cancel the edit
|
|
66
|
+
assert.equal((menu as any).values["feed.0.customType"], "pi-prompt-cache/savings");
|
|
67
|
+
assert.equal((menu as any).editing, undefined);
|
|
68
|
+
assert.equal(drive().result, undefined);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("escape sequences never enter the buffer", () => {
|
|
72
|
+
const { menu } = drive();
|
|
73
|
+
menu.handleInput("\r");
|
|
74
|
+
menu.handleInput("\x1b[A"); // an arrow key, mid-edit
|
|
75
|
+
menu.handleInput("\r");
|
|
76
|
+
assert.equal((menu as any).values["feed.0.customType"], "pi-prompt-cache/savings");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("add feed appends a blank row; remove drops the right one", () => {
|
|
80
|
+
const { menu } = drive();
|
|
81
|
+
const rowsBefore = (menu as any).flat.length;
|
|
82
|
+
// cursor to "+ add feed" (last row)
|
|
83
|
+
for (let i = 0; i < rowsBefore - 1; i++) menu.handleInput("\x1b[B");
|
|
84
|
+
menu.handleInput("\r");
|
|
85
|
+
assert.equal((menu as any).flat.length, rowsBefore + 3, "one feed = 3 rows");
|
|
86
|
+
assert.equal((menu as any).values["feed.1.customType"], "");
|
|
87
|
+
|
|
88
|
+
// remove feed 1 -> back to the original row count
|
|
89
|
+
(menu as any).cursor = 5; // "2. remove"
|
|
90
|
+
menu.handleInput("\r");
|
|
91
|
+
assert.equal((menu as any).flat.length, rowsBefore);
|
|
92
|
+
assert.equal((menu as any).values["feed.1.customType"], undefined, "removed values do not linger");
|
|
93
|
+
assert.equal((menu as any).values["feed.0.customType"], "pi-prompt-cache/savings", "survivor kept");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("cursor stays in range after a removal at the end", () => {
|
|
97
|
+
const { menu } = drive();
|
|
98
|
+
(menu as any).cursor = 2; // "1. remove"
|
|
99
|
+
menu.handleInput("\r");
|
|
100
|
+
assert.ok((menu as any).cursor < (menu as any).flat.length);
|
|
101
|
+
assert.doesNotThrow(() => menu.render(60));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("placeholder shows for empty text rows", () => {
|
|
105
|
+
const { menu } = drive();
|
|
106
|
+
for (let i = 0; i < (menu as any).flat.length - 1; i++) menu.handleInput("\x1b[B");
|
|
107
|
+
menu.handleInput("\r"); // add feed
|
|
108
|
+
assert.match(plain(menu.render(60)).join("\n"), /ext\/type/, "placeholder visible");
|
|
109
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rainbow border: perimeter walk math, per-glyph colouring, the gap
|
|
3
|
+
* geometry buildStatusLine hands the border callback, and the setting
|
|
4
|
+
* default/plumbing.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { SegmentContextBuilder } from "../context.ts";
|
|
10
|
+
import { buildStatusLine } from "../layout.ts";
|
|
11
|
+
import { perimeterLength, perimeterPosition, RainbowBorder } from "../rainbow.ts";
|
|
12
|
+
import { resolveEffectiveSettings } from "../settings.ts";
|
|
13
|
+
import { theme } from "../theme.ts";
|
|
14
|
+
|
|
15
|
+
const fakePi = { exec: async () => undefined, getThinkingLevel: () => "off" } as unknown as ExtensionAPI;
|
|
16
|
+
const fakeCtx = {
|
|
17
|
+
cwd: "/tmp/pi-topping-statusline-tests",
|
|
18
|
+
sessionManager: { getEntries: () => [], getSessionName: () => undefined },
|
|
19
|
+
getContextUsage: () => undefined,
|
|
20
|
+
model: undefined,
|
|
21
|
+
} as unknown as ExtensionContext;
|
|
22
|
+
|
|
23
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
24
|
+
builder.attach(fakeCtx);
|
|
25
|
+
const BASE = builder.build(
|
|
26
|
+
80,
|
|
27
|
+
resolveEffectiveSettings({}).segmentOptions,
|
|
28
|
+
{ git: false, pr: false, piStats: false, tokenRate: false, feeds: [] },
|
|
29
|
+
undefined,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Border cells in clockwise walk order, starting at the top-left corner. */
|
|
33
|
+
function walkPositions(width: number, bottomIdx: number): number[] {
|
|
34
|
+
const positions: number[] = [];
|
|
35
|
+
for (let col = 0; col < width; col++) positions.push(perimeterPosition(0, col, width, bottomIdx));
|
|
36
|
+
for (let row = 1; row < bottomIdx; row++) positions.push(perimeterPosition(row, width - 1, width, bottomIdx));
|
|
37
|
+
for (let col = width - 1; col >= 0; col--) positions.push(perimeterPosition(bottomIdx, col, width, bottomIdx));
|
|
38
|
+
for (let row = bottomIdx - 1; row >= 1; row--) positions.push(perimeterPosition(row, 0, width, bottomIdx));
|
|
39
|
+
return positions;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("the perimeter walk covers every border cell exactly once, in order", () => {
|
|
43
|
+
for (const [width, bottomIdx] of [
|
|
44
|
+
[10, 5],
|
|
45
|
+
[12, 2],
|
|
46
|
+
[5, 5],
|
|
47
|
+
] as const) {
|
|
48
|
+
const P = perimeterLength(width, bottomIdx);
|
|
49
|
+
const positions = walkPositions(width, bottomIdx);
|
|
50
|
+
assert.equal(positions.length, P, `expected ${P} border cells`);
|
|
51
|
+
assert.equal(new Set(positions).size, P, "walk visits a position twice");
|
|
52
|
+
for (const [i, p] of positions.entries()) {
|
|
53
|
+
const prev = positions[(i - 1 + P) % P]!;
|
|
54
|
+
assert.equal((p - prev + P) % P, 1, `step ${i} is not consecutive with its neighbour`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("colorChar wraps the glyph in per-char ANSI and resets", () => {
|
|
60
|
+
const rainbow = new RainbowBorder(0);
|
|
61
|
+
assert.match(rainbow.colorChar("─", 0, 28), /^\x1b\[38;(2;\d+;\d+;\d+|5;\d+)m─\x1b\[39m$/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("far-apart perimeter positions get different hues", () => {
|
|
65
|
+
const rainbow = new RainbowBorder(0);
|
|
66
|
+
// Opposite sides of a 28-cell perimeter are 180° of hue apart.
|
|
67
|
+
assert.notEqual(rainbow.colorChar("─", 0, 28), rainbow.colorChar("─", 14, 28));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("step wraps the phase modulo 360", () => {
|
|
71
|
+
const a = new RainbowBorder(0);
|
|
72
|
+
const b = new RainbowBorder(0);
|
|
73
|
+
a.step(361);
|
|
74
|
+
b.step(1);
|
|
75
|
+
assert.equal(a.colorChar("─", 0, 28), b.colorChar("─", 0, 28));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the gap callback receives the absolute gap column and row", () => {
|
|
79
|
+
const calls: Array<{ str: string; startCol: number; row: number }> = [];
|
|
80
|
+
buildStatusLine(
|
|
81
|
+
80,
|
|
82
|
+
BASE,
|
|
83
|
+
resolveEffectiveSettings({}),
|
|
84
|
+
(str, startCol, row) => {
|
|
85
|
+
calls.push({ str, startCol, row });
|
|
86
|
+
return str;
|
|
87
|
+
},
|
|
88
|
+
{ left: [], right: ["pi"] },
|
|
89
|
+
{ col: 3, row: 7 },
|
|
90
|
+
);
|
|
91
|
+
assert.equal(calls.length, 1);
|
|
92
|
+
const call = calls[0]!;
|
|
93
|
+
assert.equal(call.row, 7);
|
|
94
|
+
// No left group: the gap starts at the bar origin.
|
|
95
|
+
assert.equal(call.startCol, 3);
|
|
96
|
+
assert.ok(call.str.includes(theme.getBox("rounded").horizontal));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a left group shifts the gap start by its width", () => {
|
|
100
|
+
const calls: Array<{ startCol: number }> = [];
|
|
101
|
+
buildStatusLine(
|
|
102
|
+
80,
|
|
103
|
+
BASE,
|
|
104
|
+
resolveEffectiveSettings({}),
|
|
105
|
+
(str, startCol) => {
|
|
106
|
+
calls.push({ startCol });
|
|
107
|
+
return str;
|
|
108
|
+
},
|
|
109
|
+
{ left: ["pi"], right: [] },
|
|
110
|
+
{ col: 3, row: 0 },
|
|
111
|
+
);
|
|
112
|
+
assert.equal(calls.length, 1);
|
|
113
|
+
assert.ok(calls[0]!.startCol > 3, "gap starts after the pi segment, not at the origin");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("rainbowBorder defaults to true and is loadable", () => {
|
|
117
|
+
assert.equal(resolveEffectiveSettings({}).rainbowBorder, true);
|
|
118
|
+
assert.equal(resolveEffectiveSettings({ rainbowBorder: false }).rainbowBorder, false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("borderStyle defaults to rounded and is loadable", () => {
|
|
122
|
+
assert.equal(resolveEffectiveSettings({}).borderStyle, "rounded");
|
|
123
|
+
assert.equal(resolveEffectiveSettings({ borderStyle: "heavy" }).borderStyle, "heavy");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("getBox falls back to the ascii box regardless of style under the ascii preset", () => {
|
|
127
|
+
theme.setSymbolPreset("ascii");
|
|
128
|
+
try {
|
|
129
|
+
assert.equal(theme.getBox("heavy").topLeft, "+");
|
|
130
|
+
} finally {
|
|
131
|
+
theme.setSymbolPreset("nerd");
|
|
132
|
+
}
|
|
133
|
+
});
|