@underactive/pi-topping-statusline 0.2.1 → 0.2.3
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 +39 -9
- package/index.ts +146 -39
- package/package.json +4 -4
- package/src/compaction-embed.ts +62 -0
- package/src/context-graph.ts +5 -5
- package/src/context-thresholds.ts +7 -3
- package/src/context.ts +72 -13
- package/src/layout.ts +76 -27
- package/src/menu.ts +25 -36
- package/src/segments.ts +54 -0
- package/src/settings-menu.ts +66 -36
- package/src/settings.ts +25 -3
- package/src/tests/compaction-embed.test.ts +184 -0
- package/src/tests/context-estimate.test.ts +207 -0
- package/src/tests/context-thresholds.test.ts +6 -0
- package/src/tests/feeds.test.ts +2 -8
- package/src/tests/menu.test.ts +31 -2
- package/src/tests/token-rate.test.ts +2 -1
- package/src/tests/working-status-embed.test.ts +12 -0
- package/src/tests/working-status.test.ts +60 -9
- package/src/tests/working.test.ts +220 -25
- package/src/token-rate.ts +1 -1
- package/src/types.ts +28 -2
- package/src/working-status-embed.ts +15 -0
- package/src/working-status.ts +42 -15
package/src/settings.ts
CHANGED
|
@@ -96,6 +96,7 @@ function loadSettings(): StatusLineSettings {
|
|
|
96
96
|
if (typeof raw.nvidiaGreenBorder === "boolean") settings.nvidiaGreenBorder = raw.nvidiaGreenBorder;
|
|
97
97
|
if (typeof raw.nvidiaGreenAnimation === "boolean") settings.nvidiaGreenAnimation = raw.nvidiaGreenAnimation;
|
|
98
98
|
if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
|
|
99
|
+
if (typeof raw.embedCompactionProgress === "boolean") settings.embedCompactionProgress = raw.embedCompactionProgress;
|
|
99
100
|
return settings;
|
|
100
101
|
} catch {
|
|
101
102
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -161,6 +162,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
161
162
|
nvidiaGreenBorder: settings.nvidiaGreenBorder ?? true,
|
|
162
163
|
nvidiaGreenAnimation: settings.nvidiaGreenAnimation ?? true,
|
|
163
164
|
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
165
|
+
embedCompactionProgress: settings.embedCompactionProgress ?? true,
|
|
164
166
|
includes,
|
|
165
167
|
segmentOptions: {
|
|
166
168
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
@@ -171,14 +173,34 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
171
173
|
};
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/** Segments that remain visible, unfaded, while a pi status spinner is embedded. */
|
|
177
|
+
export const STATUS_STABLE_SEGMENTS: ReadonlySet<StatusLineSegmentId> = new Set(["pi", "model"]);
|
|
178
|
+
|
|
174
179
|
/**
|
|
175
180
|
* The top-left group for one frame. While a pi status spinner is embedded, it
|
|
176
|
-
* stands in for
|
|
177
|
-
* and
|
|
181
|
+
* stands in for the path and repository segments but keeps the leading Pi
|
|
182
|
+
* symbol and configured model details visible.
|
|
178
183
|
*/
|
|
179
184
|
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
180
185
|
if (!working) return effective.leftSegments;
|
|
181
|
-
|
|
186
|
+
const segments = effective.leftSegments.filter((id) => STATUS_STABLE_SEGMENTS.has(id));
|
|
187
|
+
segments.splice(segments.indexOf("pi") + 1, 0, "working");
|
|
188
|
+
return segments;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Bottom-right segments a hosted compaction stands in for, and their stand-ins. */
|
|
192
|
+
export const COMPACTION_STAND_INS: Partial<Record<StatusLineSegmentId, StatusLineSegmentId>> = {
|
|
193
|
+
pi_stats: "compaction_info",
|
|
194
|
+
context_graph: "compaction_graph",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The bottom-right group for one frame. While pi-topping-compact's progress is
|
|
199
|
+
* hosted, its figures stand in for pi's stats and its bar for the context graph.
|
|
200
|
+
*/
|
|
201
|
+
export function bottomRightSegments(effective: EffectiveStatusLineSettings, compacting: boolean): StatusLineSegmentId[] {
|
|
202
|
+
if (!compacting) return effective.bottomRightSegments;
|
|
203
|
+
return effective.bottomRightSegments.map((id) => COMPACTION_STAND_INS[id] ?? id);
|
|
182
204
|
}
|
|
183
205
|
|
|
184
206
|
export interface SettingsState {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts for hosting pi-topping-compact's compaction progress: the broadcast
|
|
3
|
+
* parser, the preconditions for announcing that the bar hosts it, the
|
|
4
|
+
* bottom-right stand-ins, and the two segments that draw the bar and figures.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { hostsCompactionProgress, parseCompactionProgress } from "../compaction-embed.ts";
|
|
10
|
+
import { stripAnsi } from "../footer.ts";
|
|
11
|
+
import { buildStatusLine } from "../layout.ts";
|
|
12
|
+
import { formatCompactionInfo, formatCompactionTokens, SEGMENTS } from "../segments.ts";
|
|
13
|
+
import { bottomRightSegments, resolveEffectiveSettings } from "../settings.ts";
|
|
14
|
+
import { theme } from "../theme.ts";
|
|
15
|
+
import type { CompactionProgressView, SegmentContext } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
const noGap = (s: string) => s;
|
|
18
|
+
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
19
|
+
|
|
20
|
+
const BAR = `\x1b[38;2;114;241;184m${"▋".repeat(7)}\x1b[39m\x1b[2m${"░".repeat(13)}\x1b[22m\x1b[39m`;
|
|
21
|
+
const VIEW: CompactionProgressView = {
|
|
22
|
+
bar: BAR,
|
|
23
|
+
percent: 47,
|
|
24
|
+
tokensBefore: 86_000,
|
|
25
|
+
contextWindow: 131_072,
|
|
26
|
+
elapsedMs: 16_300,
|
|
27
|
+
};
|
|
28
|
+
const DOT = theme.sep.dot;
|
|
29
|
+
|
|
30
|
+
function ctxWith(compaction: CompactionProgressView | undefined): SegmentContext {
|
|
31
|
+
return {
|
|
32
|
+
options: EFFECTIVE.segmentOptions,
|
|
33
|
+
model: undefined,
|
|
34
|
+
thinkingLevel: "off",
|
|
35
|
+
cwd: "/tmp/pi-topping-statusline-tests",
|
|
36
|
+
sessionName: undefined,
|
|
37
|
+
contextPercent: 41,
|
|
38
|
+
contextTokens: 53_740,
|
|
39
|
+
contextWindow: 131_072,
|
|
40
|
+
git: { branch: null, status: null, pr: null },
|
|
41
|
+
worktree: null,
|
|
42
|
+
scrollHint: undefined,
|
|
43
|
+
piStats: "↑ 142K ↓ 16K R 4.2M",
|
|
44
|
+
tokenRate: undefined,
|
|
45
|
+
feedData: undefined,
|
|
46
|
+
compaction,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("parseCompactionProgress accepts an active broadcast and rejects everything else", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
parseCompactionProgress({ active: true, ...VIEW, phase: "summarizing", remainingPercent: 35 }),
|
|
53
|
+
VIEW,
|
|
54
|
+
"the rest of the snapshot is ignored",
|
|
55
|
+
);
|
|
56
|
+
assert.equal(parseCompactionProgress({ active: false }), undefined);
|
|
57
|
+
assert.equal(parseCompactionProgress({ ...VIEW, active: "true" }), undefined);
|
|
58
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, bar: 7 }), undefined);
|
|
59
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: undefined }), undefined);
|
|
60
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: Number.NaN }), undefined);
|
|
61
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, tokensBefore: "86000" }), undefined);
|
|
62
|
+
assert.equal(parseCompactionProgress({ active: true, ...VIEW, elapsedMs: "16.3" }), undefined);
|
|
63
|
+
assert.equal(parseCompactionProgress(undefined), undefined);
|
|
64
|
+
assert.equal(parseCompactionProgress(null), undefined);
|
|
65
|
+
assert.equal(parseCompactionProgress([{ active: true, ...VIEW }]), undefined);
|
|
66
|
+
assert.equal(parseCompactionProgress("active"), undefined);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("embedCompactionProgress resolves on by default and off when set", () => {
|
|
70
|
+
assert.equal(resolveEffectiveSettings({}).embedCompactionProgress, true);
|
|
71
|
+
assert.equal(resolveEffectiveSettings({ embedCompactionProgress: false }).embedCompactionProgress, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the bar hosts the progress only with the setting on and a stand-in slot shown", () => {
|
|
75
|
+
assert.equal(hostsCompactionProgress(EFFECTIVE), true);
|
|
76
|
+
assert.equal(hostsCompactionProgress(resolveEffectiveSettings({ embedCompactionProgress: false })), false);
|
|
77
|
+
const graphOnly = resolveEffectiveSettings({ segments: { piStats: false } });
|
|
78
|
+
assert.equal(hostsCompactionProgress(graphOnly), true, "the context graph alone hosts the bar");
|
|
79
|
+
const statsOnly = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } });
|
|
80
|
+
assert.equal(hostsCompactionProgress(statsOnly), true, "pi stats alone host the figures");
|
|
81
|
+
const nowhere = resolveEffectiveSettings({ segments: { piStats: false, contextBar: false, contextStats: false } });
|
|
82
|
+
assert.equal(hostsCompactionProgress(nowhere), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("bottomRightSegments swaps in the stand-ins in place while compacting", () => {
|
|
86
|
+
const effective = resolveEffectiveSettings({ segments: { feeds: true, tokenRate: true } });
|
|
87
|
+
assert.deepEqual(effective.bottomRightSegments, ["feeds", "token_rate", "pi_stats", "context_graph"]);
|
|
88
|
+
assert.deepEqual(bottomRightSegments(effective, true), [
|
|
89
|
+
"feeds",
|
|
90
|
+
"token_rate",
|
|
91
|
+
"compaction_info",
|
|
92
|
+
"compaction_graph",
|
|
93
|
+
]);
|
|
94
|
+
assert.equal(bottomRightSegments(effective, false), effective.bottomRightSegments);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("formatCompactionInfo reads how much is summarized and the time taken", () => {
|
|
98
|
+
assert.equal(formatCompactionInfo(VIEW), `47% summarized${DOT}16.3s`);
|
|
99
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 0, elapsedMs: 0 }), `0% summarized${DOT}0.0s`);
|
|
100
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 33.4, elapsedMs: 1_234_567 }), `33% summarized${DOT}1234.6s`);
|
|
101
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: 140 }), `100% summarized${DOT}16.3s`, "clamped high");
|
|
102
|
+
assert.equal(formatCompactionInfo({ ...VIEW, percent: -3, elapsedMs: -5 }), `0% summarized${DOT}0.0s`, "clamped low");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("formatCompactionTokens uses the context label's number style", () => {
|
|
106
|
+
assert.equal(formatCompactionTokens(VIEW), "86K/131K (66%)");
|
|
107
|
+
assert.equal(
|
|
108
|
+
formatCompactionTokens({ ...VIEW, tokensBefore: 1_250_000, contextWindow: 2_000_000 }),
|
|
109
|
+
"1.3M/2.0M (63%)",
|
|
110
|
+
);
|
|
111
|
+
assert.equal(formatCompactionTokens({ ...VIEW, contextWindow: 0 }), "86K/?", "no share of an unknown window");
|
|
112
|
+
assert.equal(formatCompactionTokens({ ...VIEW, tokensBefore: 200_000 }), "200K/131K (100%)", "clamped");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("compaction segments are invisible without a hosted compaction", () => {
|
|
116
|
+
assert.equal(SEGMENTS.compaction_info.render(ctxWith(undefined)).visible, false);
|
|
117
|
+
assert.equal(SEGMENTS.compaction_graph.render(ctxWith(undefined)).visible, false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("compaction_info renders the figures in dim text", () => {
|
|
121
|
+
assert.deepEqual(SEGMENTS.compaction_info.render(ctxWith(VIEW)), {
|
|
122
|
+
content: theme.fg("dim", formatCompactionInfo(VIEW)),
|
|
123
|
+
visible: true,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("compaction_graph passes the bar through verbatim and honors the graph toggles", () => {
|
|
128
|
+
const both = SEGMENTS.compaction_graph.render(ctxWith(VIEW));
|
|
129
|
+
assert.ok(both.visible && both.content.startsWith(BAR), "bar first, untouched");
|
|
130
|
+
assert.equal(stripAnsi(both.content), `${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`);
|
|
131
|
+
|
|
132
|
+
const barOnly = ctxWith(VIEW);
|
|
133
|
+
barOnly.options = resolveEffectiveSettings({ segments: { contextStats: false } }).segmentOptions;
|
|
134
|
+
assert.equal(SEGMENTS.compaction_graph.render(barOnly).content, BAR);
|
|
135
|
+
|
|
136
|
+
const statsOnly = ctxWith(VIEW);
|
|
137
|
+
statsOnly.options = resolveEffectiveSettings({ segments: { contextBar: false } }).segmentOptions;
|
|
138
|
+
assert.equal(stripAnsi(SEGMENTS.compaction_graph.render(statsOnly).content), "86K/131K (66%)");
|
|
139
|
+
|
|
140
|
+
const neither = ctxWith(VIEW);
|
|
141
|
+
neither.options = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } }).segmentOptions;
|
|
142
|
+
assert.equal(SEGMENTS.compaction_graph.render(neither).visible, false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the bottom bar swaps pi's stats and the context graph for the compaction while hosted", () => {
|
|
146
|
+
const groups = (compacting: boolean) => ({
|
|
147
|
+
left: EFFECTIVE.bottomLeftSegments,
|
|
148
|
+
right: bottomRightSegments(EFFECTIVE, compacting),
|
|
149
|
+
});
|
|
150
|
+
const idle = stripAnsi(buildStatusLine(100, ctxWith(undefined), EFFECTIVE, noGap, groups(false)));
|
|
151
|
+
assert.ok(idle.includes("↑ 142K"), "pi stats show while idle");
|
|
152
|
+
assert.ok(idle.includes("41.0%/131K"), "the context label shows while idle");
|
|
153
|
+
|
|
154
|
+
const bar = buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups(true));
|
|
155
|
+
const plain = stripAnsi(bar);
|
|
156
|
+
assert.equal(visibleWidth(bar), 100);
|
|
157
|
+
assert.ok(plain.includes(`47% summarized${DOT}16.3s`), "the compaction figures replace pi stats");
|
|
158
|
+
assert.ok(plain.includes(`${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`), "the compaction bar replaces the graph");
|
|
159
|
+
assert.ok(!plain.includes("↑ 142K"), "pi stats are gone");
|
|
160
|
+
assert.ok(!plain.includes("41.0%"), "the context label is gone");
|
|
161
|
+
assert.ok(bar.includes(BAR), "the bar's own colors survive the layout");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("onFit reports which segments actually survived overflow trimming, not just what was requested", () => {
|
|
165
|
+
const groups = { left: [], right: bottomRightSegments(EFFECTIVE, true) };
|
|
166
|
+
let wide: { left: readonly string[]; right: readonly string[] } | undefined;
|
|
167
|
+
buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
|
|
168
|
+
onFit: fit => {
|
|
169
|
+
wide = fit;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
assert.deepEqual(wide?.right, ["compaction_info", "compaction_graph"], "both stand-ins fit at full width");
|
|
173
|
+
|
|
174
|
+
let narrow: { left: readonly string[]; right: readonly string[] } | undefined;
|
|
175
|
+
buildStatusLine(20, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
|
|
176
|
+
onFit: fit => {
|
|
177
|
+
narrow = fit;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
assert.ok(
|
|
181
|
+
!narrow?.right.includes("compaction_info") && !narrow?.right.includes("compaction_graph"),
|
|
182
|
+
"too narrow for either stand-in, so a caller checking onFit knows not to claim it hosts the progress",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context graph between a compaction and the next response. pi reports
|
|
3
|
+
* usage as unknown for that stretch; the builder fills it with pi's own
|
|
4
|
+
* estimate of the compacted context, the figure pi hands compaction callers as
|
|
5
|
+
* estimatedTokensAfter, so the graph never vanishes and agrees with the result
|
|
6
|
+
* pi-topping-compact reports.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
buildSessionContext,
|
|
12
|
+
estimateTokens,
|
|
13
|
+
type ExtensionAPI,
|
|
14
|
+
type ExtensionContext,
|
|
15
|
+
type SessionEntry,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { SegmentContextBuilder, estimateCompactedContextTokens } from "../context.ts";
|
|
18
|
+
import { stripAnsi } from "../footer.ts";
|
|
19
|
+
import { SEGMENTS } from "../segments.ts";
|
|
20
|
+
import { resolveEffectiveSettings } from "../settings.ts";
|
|
21
|
+
import type { SegmentContext } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
const WINDOW = 131_072;
|
|
24
|
+
const OPTIONS = resolveEffectiveSettings({}).segmentOptions;
|
|
25
|
+
const INCLUDE = { git: false, pr: false, piStats: false, tokenRate: false, feeds: [] };
|
|
26
|
+
/** Outside any repo, so the builder's git probes stay inert. */
|
|
27
|
+
const CWD = "/tmp/pi-topping-statusline-tests";
|
|
28
|
+
const fakePi = { exec: async () => undefined, getThinkingLevel: () => "off" } as unknown as ExtensionAPI;
|
|
29
|
+
|
|
30
|
+
type Usage = { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
|
|
31
|
+
const UNKNOWN: Usage = { tokens: null, contextWindow: WINDOW, percent: null };
|
|
32
|
+
|
|
33
|
+
const EPOCH = 1_700_000_000_000;
|
|
34
|
+
const at = (i: number) => new Date(EPOCH + i * 1000).toISOString();
|
|
35
|
+
const user = (id: string, parentId: string | null, text: string, i: number): SessionEntry =>
|
|
36
|
+
({
|
|
37
|
+
type: "message",
|
|
38
|
+
id,
|
|
39
|
+
parentId,
|
|
40
|
+
timestamp: at(i),
|
|
41
|
+
message: { role: "user", content: text, timestamp: EPOCH + i * 1000 },
|
|
42
|
+
}) as unknown as SessionEntry;
|
|
43
|
+
const assistant = (id: string, parentId: string, text: string, i: number): SessionEntry =>
|
|
44
|
+
({
|
|
45
|
+
type: "message",
|
|
46
|
+
id,
|
|
47
|
+
parentId,
|
|
48
|
+
timestamp: at(i),
|
|
49
|
+
message: {
|
|
50
|
+
role: "assistant",
|
|
51
|
+
content: [{ type: "text", text }],
|
|
52
|
+
api: "anthropic-messages",
|
|
53
|
+
provider: "anthropic",
|
|
54
|
+
model: "m",
|
|
55
|
+
usage: { input: 80_000, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 80_500 },
|
|
56
|
+
stopReason: "stop",
|
|
57
|
+
timestamp: EPOCH + i * 1000,
|
|
58
|
+
},
|
|
59
|
+
}) as unknown as SessionEntry;
|
|
60
|
+
|
|
61
|
+
/** A branch just after compaction: the first exchange summarized, the second kept, the leaf on the compaction. */
|
|
62
|
+
function compactedBranch(): SessionEntry[] {
|
|
63
|
+
return [
|
|
64
|
+
user("e1", null, "Please review the repository layout.", 1),
|
|
65
|
+
assistant("e2", "e1", "x".repeat(60_000), 2),
|
|
66
|
+
user("e3", "e2", "Now write the tests.", 3),
|
|
67
|
+
assistant("e4", "e3", "Done, all pass.", 4),
|
|
68
|
+
{
|
|
69
|
+
type: "compaction",
|
|
70
|
+
id: "e5",
|
|
71
|
+
parentId: "e4",
|
|
72
|
+
timestamp: at(5),
|
|
73
|
+
summary: "The layout was reviewed. ".repeat(80),
|
|
74
|
+
firstKeptEntryId: "e3",
|
|
75
|
+
tokensBefore: 86_000,
|
|
76
|
+
} as unknown as SessionEntry,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fakeCtx(branch: SessionEntry[], usage: Usage, counters = { branch: 0 }): ExtensionContext {
|
|
81
|
+
return {
|
|
82
|
+
cwd: CWD,
|
|
83
|
+
model: { id: "m", provider: "anthropic", name: "M", contextWindow: WINDOW },
|
|
84
|
+
getContextUsage: () => usage,
|
|
85
|
+
sessionManager: {
|
|
86
|
+
getSessionId: () => "session-1",
|
|
87
|
+
getSessionName: () => undefined,
|
|
88
|
+
getLeafId: () => branch.at(-1)?.id ?? null,
|
|
89
|
+
getBranch: () => {
|
|
90
|
+
counters.branch++;
|
|
91
|
+
return [...branch];
|
|
92
|
+
},
|
|
93
|
+
getEntries: () => branch,
|
|
94
|
+
},
|
|
95
|
+
} as unknown as ExtensionContext;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What pi itself computes as estimatedTokensAfter for this branch. */
|
|
99
|
+
function piEstimate(branch: SessionEntry[]): number {
|
|
100
|
+
let tokens = 0;
|
|
101
|
+
for (const message of buildSessionContext(branch, branch.at(-1)?.id).messages) tokens += estimateTokens(message);
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function segmentCtx(overrides: Partial<SegmentContext>): SegmentContext {
|
|
106
|
+
return {
|
|
107
|
+
options: OPTIONS,
|
|
108
|
+
model: undefined,
|
|
109
|
+
thinkingLevel: "off",
|
|
110
|
+
cwd: CWD,
|
|
111
|
+
sessionName: undefined,
|
|
112
|
+
contextPercent: null,
|
|
113
|
+
contextTokens: 0,
|
|
114
|
+
contextWindow: WINDOW,
|
|
115
|
+
git: { branch: null, status: null, pr: null },
|
|
116
|
+
worktree: null,
|
|
117
|
+
scrollHint: undefined,
|
|
118
|
+
piStats: undefined,
|
|
119
|
+
tokenRate: undefined,
|
|
120
|
+
feedData: undefined,
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("after a compaction the builder sizes the context as pi does, leaving out what was summarized", () => {
|
|
126
|
+
const branch = compactedBranch();
|
|
127
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
128
|
+
builder.attach(fakeCtx(branch, UNKNOWN));
|
|
129
|
+
const ctx = builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
130
|
+
|
|
131
|
+
const expected = piEstimate(branch);
|
|
132
|
+
assert.equal(ctx.contextTokens, expected);
|
|
133
|
+
assert.equal(ctx.contextPercent, (expected / WINDOW) * 100);
|
|
134
|
+
assert.equal(ctx.contextEstimated, true);
|
|
135
|
+
assert.equal(ctx.contextWindow, WINDOW);
|
|
136
|
+
assert.ok(expected > 0);
|
|
137
|
+
const summarized = branch[1] as SessionEntry & { message: Parameters<typeof estimateTokens>[0] };
|
|
138
|
+
assert.ok(expected < estimateTokens(summarized.message), "the summarized reply no longer counts");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the estimate is cached per leaf and rebuilt when the branch grows", () => {
|
|
142
|
+
const branch = compactedBranch();
|
|
143
|
+
const counters = { branch: 0 };
|
|
144
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
145
|
+
builder.attach(fakeCtx(branch, UNKNOWN, counters));
|
|
146
|
+
|
|
147
|
+
const first = builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
148
|
+
builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
149
|
+
assert.equal(counters.branch, 1, "the same leaf reuses the estimate");
|
|
150
|
+
|
|
151
|
+
const followUp = user("e6", "e5", "And now the docs, please.", 6);
|
|
152
|
+
branch.push(followUp);
|
|
153
|
+
const grown = builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
154
|
+
assert.equal(counters.branch, 2, "a new leaf rebuilds it");
|
|
155
|
+
const added = estimateTokens((followUp as SessionEntry & { message: Parameters<typeof estimateTokens>[0] }).message);
|
|
156
|
+
assert.equal(grown.contextTokens, first.contextTokens + added);
|
|
157
|
+
assert.equal(grown.contextTokens, piEstimate(branch));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("pi's measured figure takes over as soon as it has one", () => {
|
|
161
|
+
const counters = { branch: 0 };
|
|
162
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
163
|
+
builder.attach(fakeCtx(compactedBranch(), { tokens: 12_345, contextWindow: WINDOW, percent: 9.4 }, counters));
|
|
164
|
+
const ctx = builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
165
|
+
assert.equal(ctx.contextTokens, 12_345);
|
|
166
|
+
assert.equal(ctx.contextPercent, 9.4);
|
|
167
|
+
assert.equal(ctx.contextEstimated, false);
|
|
168
|
+
assert.equal(counters.branch, 0, "no estimate is computed");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a broken session manager costs the estimate, not the frame", () => {
|
|
172
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
173
|
+
const ctx = fakeCtx(compactedBranch(), UNKNOWN);
|
|
174
|
+
(ctx as { sessionManager: Partial<ExtensionContext["sessionManager"]> }).sessionManager = {
|
|
175
|
+
getSessionName: () => undefined,
|
|
176
|
+
getEntries: () => [],
|
|
177
|
+
};
|
|
178
|
+
builder.attach(ctx);
|
|
179
|
+
const built = builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
180
|
+
assert.equal(built.contextPercent, null);
|
|
181
|
+
assert.equal(built.contextTokens, 0);
|
|
182
|
+
assert.equal(built.contextEstimated, false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an empty branch estimates zero", () => {
|
|
186
|
+
assert.equal(
|
|
187
|
+
estimateCompactedContextTokens({
|
|
188
|
+
getBranch: () => [],
|
|
189
|
+
getLeafId: () => null,
|
|
190
|
+
} as unknown as ExtensionContext["sessionManager"]),
|
|
191
|
+
0,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the graph draws an estimate with a ~ on its label", () => {
|
|
196
|
+
const estimated = SEGMENTS.context_graph.render(
|
|
197
|
+
segmentCtx({ contextPercent: 9.2, contextTokens: 12_000, contextEstimated: true }),
|
|
198
|
+
);
|
|
199
|
+
assert.ok(estimated.visible);
|
|
200
|
+
assert.equal(stripAnsi(estimated.content), `${"▋".repeat(2)}${"░".repeat(18)} ~9.2%/131K`);
|
|
201
|
+
|
|
202
|
+
const measured = SEGMENTS.context_graph.render(segmentCtx({ contextPercent: 9.2, contextTokens: 12_000 }));
|
|
203
|
+
assert.equal(stripAnsi(measured.content), `${"▋".repeat(2)}${"░".repeat(18)} 9.2%/131K`);
|
|
204
|
+
|
|
205
|
+
const unknown = SEGMENTS.context_graph.render(segmentCtx({}));
|
|
206
|
+
assert.equal(unknown.visible, false, "with nothing to size against, the graph still stays out");
|
|
207
|
+
});
|
|
@@ -48,3 +48,9 @@ test("a null or undefined percent against a known window renders '?'", () => {
|
|
|
48
48
|
test("a known percent renders with one decimal", () => {
|
|
49
49
|
assert.equal(formatContextUsage(42.567, 200_000), "42.6%/200K");
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("an estimate wears ~ wherever it shows a figure", () => {
|
|
53
|
+
assert.equal(formatContextUsage(9.2, 131_072, 12_000, true), "~9.2%/131K");
|
|
54
|
+
assert.equal(formatContextUsage(9.2, 0, 12_000, true), "~12K/?");
|
|
55
|
+
assert.equal(formatContextUsage(null, 131_072, 0, true), "?/131K", "nothing to mark");
|
|
56
|
+
});
|
package/src/tests/feeds.test.ts
CHANGED
|
@@ -14,9 +14,10 @@ import test from "node:test";
|
|
|
14
14
|
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { renderBoxRowIfVisible } from "../box.ts";
|
|
16
16
|
import { FEED_FADE_MS, FEED_HOLD_MS, SegmentContextBuilder, getFeedDisplayState } from "../context.ts";
|
|
17
|
+
import { stripAnsi } from "../footer.ts";
|
|
17
18
|
import { buildStatusLine } from "../layout.ts";
|
|
18
19
|
import { SEGMENTS } from "../segments.ts";
|
|
19
|
-
import {
|
|
20
|
+
import { DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
|
|
20
21
|
import { theme } from "../theme.ts";
|
|
21
22
|
import type { FeedDisplayState, SegmentContext, StatusLineFeed } from "../types.ts";
|
|
22
23
|
import { feedKey } from "../utils.ts";
|
|
@@ -31,7 +32,6 @@ const feedMap = (entries: Record<string, unknown> = {}): Record<string, unknown>
|
|
|
31
32
|
Object.assign(Object.create(null) as Record<string, unknown>, entries);
|
|
32
33
|
|
|
33
34
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
34
|
-
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
35
35
|
|
|
36
36
|
// Partial stand-ins: the builder only reaches the members defined here.
|
|
37
37
|
const fakePi = { exec: async () => undefined, getThinkingLevel: () => "off" } as unknown as ExtensionAPI;
|
|
@@ -507,9 +507,3 @@ test("sanitizeFeeds drops unusable rows and normalizes the rest", () => {
|
|
|
507
507
|
test("an explicitly empty feed list is honoured rather than reseeded", () => {
|
|
508
508
|
assert.deepEqual(resolveEffectiveSettings({ feeds: [] }).segmentOptions.feeds, []);
|
|
509
509
|
});
|
|
510
|
-
|
|
511
|
-
test("the default subscription matches what pi-prompt-cache publishes", () => {
|
|
512
|
-
assert.equal(DEFAULT_FEEDS[0]?.customType, SAVINGS);
|
|
513
|
-
assert.equal(DEFAULT_FEEDS[0]?.field, "savedUsd");
|
|
514
|
-
assert.equal(DEFAULT_FEEDS[0]?.format, "currency");
|
|
515
|
-
});
|
package/src/tests/menu.test.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { stripAnsi } from "../footer.ts";
|
|
4
5
|
import { MenuComponent } from "../menu.ts";
|
|
5
6
|
import type { MenuSection } from "../menu.ts";
|
|
6
7
|
|
|
7
|
-
const theme = {
|
|
8
|
-
|
|
8
|
+
const theme = {
|
|
9
|
+
fg: (_c: string, s: string) => s,
|
|
10
|
+
bg: (color: string, s: string) => color === "selectedBg" ? `\x1b[48;5;1m${s}\x1b[49m` : s,
|
|
11
|
+
bold: (s: string) => s,
|
|
12
|
+
getBgAnsi: (color: string) => color === "selectedBg" ? "\x1b[48;5;1m" : "",
|
|
13
|
+
} as unknown as Theme;
|
|
14
|
+
const plain = (lines: string[]) => lines.map(stripAnsi);
|
|
9
15
|
|
|
10
16
|
function makeSections(feeds: { customType: string; prefix: string }[]): MenuSection[] {
|
|
11
17
|
const items: any[] = [];
|
|
@@ -47,6 +53,29 @@ function drive() {
|
|
|
47
53
|
|
|
48
54
|
const type = (menu: any, s: string) => { for (const ch of s) menu.handleInput(ch); };
|
|
49
55
|
|
|
56
|
+
test("selected rows use the pi-topping marker and background", () => {
|
|
57
|
+
const { menu } = drive();
|
|
58
|
+
const lines = menu.render(60);
|
|
59
|
+
const plainText = plain(lines).join("\n");
|
|
60
|
+
assert.match(plainText, /> 1\. type/);
|
|
61
|
+
assert.ok(!plainText.includes("▸"));
|
|
62
|
+
assert.ok(lines.some(line => line.includes("\x1b[48;5;1m")), "selected row background is missing");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("selected row background survives an embedded reset from truncated content", () => {
|
|
66
|
+
const { menu } = drive();
|
|
67
|
+
// Width narrow enough that the "1. type" label truncates, which embeds a raw \x1b[0m reset.
|
|
68
|
+
const lines = menu.render(15);
|
|
69
|
+
const selectedLine = lines.find(line => line.includes("\x1b[48;5;1m"))!;
|
|
70
|
+
assert.ok(selectedLine, "selected row not found");
|
|
71
|
+
assert.ok(selectedLine.includes("\x1b[0m"), "expected a reset from truncateToWidth in the selected row");
|
|
72
|
+
assert.doesNotMatch(
|
|
73
|
+
selectedLine,
|
|
74
|
+
/\x1b\[0m(?!\x1b\[48;5;1m)/,
|
|
75
|
+
"selected background must be reopened after every reset",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
50
79
|
test("typing into a text row edits only on commit", () => {
|
|
51
80
|
const { menu } = drive();
|
|
52
81
|
menu.handleInput("\r"); // Enter on row 0 -> begin editing
|
|
@@ -8,6 +8,7 @@ import assert from "node:assert/strict";
|
|
|
8
8
|
import test from "node:test";
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { SegmentContextBuilder } from "../context.ts";
|
|
11
|
+
import { stripAnsi } from "../footer.ts";
|
|
11
12
|
import { SEGMENTS } from "../segments.ts";
|
|
12
13
|
import { resolveEffectiveSettings } from "../settings.ts";
|
|
13
14
|
import { FADE_SHADE_COUNT, theme } from "../theme.ts";
|
|
@@ -31,7 +32,7 @@ const BASE = builder.build(
|
|
|
31
32
|
);
|
|
32
33
|
|
|
33
34
|
const render = (tokenRate: TokenRateDisplay) => SEGMENTS.token_rate.render({ ...BASE, tokenRate });
|
|
34
|
-
const plain = (tokenRate: TokenRateDisplay) => render(tokenRate).content
|
|
35
|
+
const plain = (tokenRate: TokenRateDisplay) => stripAnsi(render(tokenRate).content);
|
|
35
36
|
const active = (rate: number | null): TokenRateDisplay => ({ rate, phase: "active", fadeShade: 0 });
|
|
36
37
|
|
|
37
38
|
test("the rate is right-aligned to the placeholder's width", () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { hostsWorkingStatus } from "../working-status-embed.ts";
|
|
4
|
+
|
|
5
|
+
test("only an opted-in editor that receives the host's status hosts the working status", () => {
|
|
6
|
+
const setWorkingStatusIndicator = (): void => {};
|
|
7
|
+
assert.equal(hostsWorkingStatus({ embedWorkingStatus: true, setWorkingStatusIndicator }), true);
|
|
8
|
+
assert.equal(hostsWorkingStatus({ embedWorkingStatus: false, setWorkingStatusIndicator }), false);
|
|
9
|
+
assert.equal(hostsWorkingStatus({ embedWorkingStatus: true }), false, "pre-0.85 hosts never send the status");
|
|
10
|
+
assert.equal(hostsWorkingStatus({}), false);
|
|
11
|
+
assert.equal(hostsWorkingStatus(undefined), false, "a wrapped third-party editor keeps pi's standalone status row");
|
|
12
|
+
});
|