@underactive/pi-topping-statusline 0.2.5 → 0.2.7
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 +28 -23
- package/index.ts +18 -79
- package/package.json +4 -4
- package/src/box.ts +4 -18
- package/src/compaction-bar.ts +58 -0
- package/src/compaction-embed.ts +16 -47
- package/src/compaction-progress.ts +129 -0
- package/src/context-graph.ts +3 -3
- package/src/context.ts +9 -8
- package/src/nvidia-green.ts +5 -9
- package/src/rainbow.ts +9 -10
- package/src/segments.ts +8 -5
- package/src/settings-menu.ts +3 -4
- package/src/settings.ts +2 -2
- package/src/tests/compaction-bar.test.ts +67 -0
- package/src/tests/compaction-embed.test.ts +11 -21
- package/src/tests/compaction-progress.test.ts +183 -0
- package/src/tests/context-estimate.test.ts +1 -2
- package/src/tests/feeds.test.ts +1 -1
- package/src/tests/git-status.test.ts +62 -0
- package/src/theme.ts +14 -1
- package/src/types.ts +9 -6
package/src/context.ts
CHANGED
|
@@ -77,8 +77,7 @@ interface ContextEstimate {
|
|
|
77
77
|
/**
|
|
78
78
|
* pi's own estimate of the context after a compaction: the summary plus the
|
|
79
79
|
* kept and later messages, sized by its per-message estimator. This is the
|
|
80
|
-
* figure pi hands compaction callers as estimatedTokensAfter
|
|
81
|
-
* what pi-topping-compact reports in its result.
|
|
80
|
+
* figure pi hands compaction callers as estimatedTokensAfter.
|
|
82
81
|
*/
|
|
83
82
|
export function estimateCompactedContextTokens(sessionManager: ExtensionContext["sessionManager"]): number {
|
|
84
83
|
let tokens = 0;
|
|
@@ -88,7 +87,8 @@ export function estimateCompactedContextTokens(sessionManager: ExtensionContext[
|
|
|
88
87
|
return tokens;
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
|
|
90
|
+
/** Count staged, unstaged, and untracked paths in `git status --porcelain` output. */
|
|
91
|
+
export function parseGitStatusCounts(porcelain: string): GitStatusCounts {
|
|
92
92
|
let staged = 0;
|
|
93
93
|
let unstaged = 0;
|
|
94
94
|
let untracked = 0;
|
|
@@ -134,7 +134,7 @@ export class SegmentContextBuilder {
|
|
|
134
134
|
|
|
135
135
|
#feedData: Record<string, unknown> = emptyFeedData();
|
|
136
136
|
#feedsScannedAt = 0;
|
|
137
|
-
#
|
|
137
|
+
#feedsScannedKey: string | undefined;
|
|
138
138
|
#runStartedAt = 0;
|
|
139
139
|
#feedTracking = new Map<string, FeedTracking>();
|
|
140
140
|
#feedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -167,7 +167,7 @@ export class SegmentContextBuilder {
|
|
|
167
167
|
this.#runStartedAt = Date.now();
|
|
168
168
|
this.#feedData = emptyFeedData();
|
|
169
169
|
this.#feedsScannedAt = 0;
|
|
170
|
-
this.#
|
|
170
|
+
this.#feedsScannedKey = undefined;
|
|
171
171
|
this.#clearFeedLifecycle();
|
|
172
172
|
this.#contextEstimate = undefined;
|
|
173
173
|
if (this.#repoCwd !== ctx.cwd) {
|
|
@@ -310,7 +310,7 @@ export class SegmentContextBuilder {
|
|
|
310
310
|
const out = await this.#exec("git", ["status", "--porcelain"], cwd);
|
|
311
311
|
if (this.#repoCwd !== cwd) return;
|
|
312
312
|
this.#gitStatusFetchedAt = Date.now();
|
|
313
|
-
const next = out === undefined ? null :
|
|
313
|
+
const next = out === undefined ? null : parseGitStatusCounts(out);
|
|
314
314
|
const changed =
|
|
315
315
|
next?.staged !== this.#gitStatus?.staged ||
|
|
316
316
|
next?.unstaged !== this.#gitStatus?.unstaged ||
|
|
@@ -495,8 +495,9 @@ export class SegmentContextBuilder {
|
|
|
495
495
|
let feedDisplayState: Record<string, FeedDisplayState> | undefined;
|
|
496
496
|
if (include.feeds.length > 0) {
|
|
497
497
|
// Editing the subscription list mid-session must not wait out the TTL.
|
|
498
|
-
|
|
499
|
-
|
|
498
|
+
const feedsKey = include.feeds.join("\0");
|
|
499
|
+
if (feedsKey !== this.#feedsScannedKey || now - this.#feedsScannedAt > FEED_TTL_MS) {
|
|
500
|
+
this.#feedsScannedKey = feedsKey;
|
|
500
501
|
this.#refreshFeeds(include.feeds, now);
|
|
501
502
|
}
|
|
502
503
|
feedDisplayState = this.#syncFeedTracking(options.feeds, now);
|
package/src/nvidia-green.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Provider-specific NVIDIA green border colorizer for Switchyard. */
|
|
2
|
-
import {
|
|
3
|
-
import { detectColorMode,
|
|
2
|
+
import { wrapDeg, type BorderColorizer } from "./rainbow.js";
|
|
3
|
+
import { detectColorMode, mixRgb, parseHex, rgbToFgAnsi, type ColorMode } from "./theme.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const SWITCHYARD_PROVIDER = "switchyard";
|
|
6
6
|
export const NVIDIA_GREEN_HEX = "#84c51a";
|
|
7
7
|
export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
|
|
8
8
|
const GREEN_SHADE_COUNT = 256;
|
|
@@ -27,12 +27,8 @@ function buildAnsiByShade(mode: ColorMode): string[] {
|
|
|
27
27
|
const ansiByShade: string[] = [];
|
|
28
28
|
for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
|
|
29
29
|
const progress = i / (GREEN_SHADE_COUNT - 1);
|
|
30
|
-
const rgb
|
|
31
|
-
|
|
32
|
-
Math.round(dark[1] + (bright[1] - dark[1]) * progress),
|
|
33
|
-
Math.round(dark[2] + (bright[2] - dark[2]) * progress),
|
|
34
|
-
];
|
|
35
|
-
ansiByShade.push(hexToFgAnsi(rgbToHex(rgb), mode));
|
|
30
|
+
const rgb = mixRgb(dark, bright, progress);
|
|
31
|
+
ansiByShade.push(rgbToFgAnsi(...rgb, mode));
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
ansiByShadeByMode.set(mode, ansiByShade);
|
package/src/rainbow.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (renderBoxed in index.ts) or construct a fresh instance with a phase
|
|
13
13
|
* derived from elapsed time (the settings preview).
|
|
14
14
|
*/
|
|
15
|
-
import { detectColorMode,
|
|
15
|
+
import { detectColorMode, rgbToFgAnsi, type ColorMode } from "./theme.js";
|
|
16
16
|
|
|
17
17
|
/** Frame cadence of the sweep (also the settings-preview refresh delay). */
|
|
18
18
|
export const RAINBOW_FRAME_MS = 50;
|
|
@@ -23,6 +23,7 @@ export const RAINBOW_DEG_PER_FRAME = (360 * RAINBOW_FRAME_MS) / RAINBOW_CYCLE_MS
|
|
|
23
23
|
|
|
24
24
|
const SATURATION = 1;
|
|
25
25
|
const LIGHTNESS = 0.62;
|
|
26
|
+
const ansiByHueByMode = new Map<ColorMode, string[]>();
|
|
26
27
|
|
|
27
28
|
/** Normalize a degree value into the [0, 360) range. */
|
|
28
29
|
export const wrapDeg = (deg: number): number => ((deg % 360) + 360) % 360;
|
|
@@ -80,8 +81,6 @@ export interface BorderColorizer {
|
|
|
80
81
|
*/
|
|
81
82
|
export class RainbowBorder implements BorderColorizer {
|
|
82
83
|
#phaseDeg: number;
|
|
83
|
-
#mode: ColorMode | undefined;
|
|
84
|
-
readonly #ansiByHue: string[] = [];
|
|
85
84
|
|
|
86
85
|
constructor(phaseDeg = 0) {
|
|
87
86
|
this.#phaseDeg = wrapDeg(phaseDeg);
|
|
@@ -99,17 +98,17 @@ export class RainbowBorder implements BorderColorizer {
|
|
|
99
98
|
|
|
100
99
|
prefix(perimeterPos: number, perimeter: number): string {
|
|
101
100
|
const mode = detectColorMode();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
let ansiByHue = ansiByHueByMode.get(mode);
|
|
102
|
+
if (ansiByHue === undefined) {
|
|
103
|
+
ansiByHue = [];
|
|
104
|
+
ansiByHueByMode.set(mode, ansiByHue);
|
|
105
105
|
}
|
|
106
106
|
const hue = (this.#phaseDeg + (360 * perimeterPos) / perimeter) % 360;
|
|
107
107
|
const bucket = Math.round(hue) % 360;
|
|
108
|
-
let ansi =
|
|
108
|
+
let ansi = ansiByHue[bucket];
|
|
109
109
|
if (ansi === undefined) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
this.#ansiByHue[bucket] = ansi;
|
|
110
|
+
ansi = rgbToFgAnsi(...hslToRgb(bucket, SATURATION, LIGHTNESS), mode);
|
|
111
|
+
ansiByHue[bucket] = ansi;
|
|
113
112
|
}
|
|
114
113
|
return ansi;
|
|
115
114
|
}
|
package/src/segments.ts
CHANGED
|
@@ -355,7 +355,10 @@ const contextGraphSegment: StatusLineSegment = {
|
|
|
355
355
|
|
|
356
356
|
const clampPercent = (percent: number): number => Math.round(Math.min(100, Math.max(0, percent)));
|
|
357
357
|
|
|
358
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* "47% summarized · 16.3s": the estimated share summarized so far (time-based until pi completes),
|
|
360
|
+
* and the time taken.
|
|
361
|
+
*/
|
|
359
362
|
export function formatCompactionInfo(view: CompactionProgressView): string {
|
|
360
363
|
const elapsed = `${(Math.max(0, view.elapsedMs) / 1000).toFixed(1)}s`;
|
|
361
364
|
return `${clampPercent(view.percent)}% summarized${theme.sep.dot}${elapsed}`;
|
|
@@ -373,7 +376,7 @@ export function formatCompactionTokens(view: CompactionProgressView): string {
|
|
|
373
376
|
return `${before}/${formatNumber(view.contextWindow)} (${usage}%)`;
|
|
374
377
|
}
|
|
375
378
|
|
|
376
|
-
/**
|
|
379
|
+
/** The compaction's figures, standing in for pi's stats while its progress is hosted. */
|
|
377
380
|
const compactionInfoSegment: StatusLineSegment = {
|
|
378
381
|
id: "compaction_info",
|
|
379
382
|
render(ctx) {
|
|
@@ -384,9 +387,9 @@ const compactionInfoSegment: StatusLineSegment = {
|
|
|
384
387
|
};
|
|
385
388
|
|
|
386
389
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
* pre-rendered with fg-only resets, so it is passed through verbatim.
|
|
390
|
+
* The compaction bar, standing in for the context graph while its progress is
|
|
391
|
+
* hosted, under the graph's own bar and stats toggles. The bar arrives
|
|
392
|
+
* pre-rendered with fg/attribute-only resets, so it is passed through verbatim.
|
|
390
393
|
*/
|
|
391
394
|
const compactionGraphSegment: StatusLineSegment = {
|
|
392
395
|
id: "compaction_graph",
|
package/src/settings-menu.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { makeBoxPainters, renderBoxRow } from "./box.js";
|
|
8
|
-
import {
|
|
8
|
+
import { compactionPreviewView } from "./compaction-embed.js";
|
|
9
9
|
import type { SegmentContextBuilder } from "./context.js";
|
|
10
10
|
import { buildStatusLine } from "./layout.js";
|
|
11
11
|
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
@@ -336,12 +336,11 @@ class StatusLinePreview {
|
|
|
336
336
|
{ col: 3, row: 0 },
|
|
337
337
|
{ workingEllipsis: compactionDemo ? "…" : "" },
|
|
338
338
|
);
|
|
339
|
-
// Focusing the compaction row demos the bottom bar hosting
|
|
340
|
-
// pi-topping-compact's progress, while the setting is on.
|
|
339
|
+
// Focusing the compaction row demos the bottom bar hosting a compaction's progress, while the setting is on.
|
|
341
340
|
const progressDemo = activeItemId === "embedCompactionProgress" && effective.embedCompactionProgress;
|
|
342
341
|
const bottom = buildStatusLine(
|
|
343
342
|
barWidth,
|
|
344
|
-
progressDemo ? { ...ctx, compaction:
|
|
343
|
+
progressDemo ? { ...ctx, compaction: compactionPreviewView() } : ctx,
|
|
345
344
|
effective,
|
|
346
345
|
painters.gapColor,
|
|
347
346
|
{
|
package/src/settings.ts
CHANGED
|
@@ -195,8 +195,8 @@ export const COMPACTION_STAND_INS: Partial<Record<StatusLineSegmentId, StatusLin
|
|
|
195
195
|
};
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* The bottom-right group for one frame. While
|
|
199
|
-
*
|
|
198
|
+
* The bottom-right group for one frame. While a compaction is hosted, its
|
|
199
|
+
* figures stand in for pi's stats and its bar for the context graph.
|
|
200
200
|
*/
|
|
201
201
|
export function bottomRightSegments(effective: EffectiveStatusLineSettings, compacting: boolean): StatusLineSegmentId[] {
|
|
202
202
|
if (!compacting) return effective.bottomRightSegments;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resetCapabilitiesCache, setCapabilities, setCapabilityOverrides } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderCompactionBar } from "../compaction-bar.ts";
|
|
5
|
+
|
|
6
|
+
const MODE = { mode: "truecolor" as const };
|
|
7
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
8
|
+
const stripAnsi = (text: string) => text.replace(ANSI_RE, "");
|
|
9
|
+
|
|
10
|
+
function filledColors(bar: string): Array<[number, number, number]> {
|
|
11
|
+
const colors: Array<[number, number, number]> = [];
|
|
12
|
+
for (const match of bar.matchAll(/\x1b\[38;2;(\d+);(\d+);(\d+)m▋/g)) {
|
|
13
|
+
colors.push([Number(match[1]), Number(match[2]), Number(match[3])]);
|
|
14
|
+
}
|
|
15
|
+
return colors;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function brightestCell(colors: Array<[number, number, number]>): number {
|
|
19
|
+
let brightest = 0;
|
|
20
|
+
for (let i = 1; i < colors.length; i++) {
|
|
21
|
+
if ((colors[i]?.[1] ?? 0) > (colors[brightest]?.[1] ?? 0)) brightest = i;
|
|
22
|
+
}
|
|
23
|
+
return brightest;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("bar has the expected glyphs, green trough, and foreground-only resets", () => {
|
|
27
|
+
const bar = renderCompactionBar(50, MODE);
|
|
28
|
+
assert.equal(stripAnsi(bar), `${"▋".repeat(10)}${"░".repeat(10)}`);
|
|
29
|
+
assert.match(bar, /\x1b\[38;2;38;100;70m/);
|
|
30
|
+
assert.match(bar, /\x1b\[2m/);
|
|
31
|
+
assert.doesNotMatch(bar, /\x1b\[0m/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the phosphor comet moves from right to left", () => {
|
|
35
|
+
const right = filledColors(renderCompactionBar(100, { ...MODE, shimmerElapsedMs: 500 }));
|
|
36
|
+
const left = filledColors(renderCompactionBar(100, { ...MODE, shimmerElapsedMs: 1_500 }));
|
|
37
|
+
assert.equal(right.length, 20);
|
|
38
|
+
assert.equal(left.length, 20);
|
|
39
|
+
assert.notDeepEqual(right, left);
|
|
40
|
+
assert.ok(brightestCell(right) > brightestCell(left));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("bar supports 256-color escapes", () => {
|
|
44
|
+
const bar = renderCompactionBar(100, { mode: "256color", shimmerElapsedMs: 500 });
|
|
45
|
+
assert.match(bar, /\x1b\[38;5;\d+m/);
|
|
46
|
+
assert.doesNotMatch(bar, /\x1b\[38;2;/);
|
|
47
|
+
assert.doesNotMatch(bar, /\x1b\[0m/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("bar clamps NaN and out-of-range percentages", () => {
|
|
51
|
+
const empty = renderCompactionBar(0, MODE);
|
|
52
|
+
const full = renderCompactionBar(100, MODE);
|
|
53
|
+
assert.equal(renderCompactionBar(Number.NaN, MODE), empty);
|
|
54
|
+
assert.equal(renderCompactionBar(-10, MODE), empty);
|
|
55
|
+
assert.equal(renderCompactionBar(150, MODE), full);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("default bar color follows terminal capability detection", () => {
|
|
59
|
+
try {
|
|
60
|
+
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
|
|
61
|
+
resetCapabilitiesCache();
|
|
62
|
+
assert.match(renderCompactionBar(50), /\x1b\[38;2;/);
|
|
63
|
+
} finally {
|
|
64
|
+
setCapabilityOverrides({});
|
|
65
|
+
resetCapabilitiesCache();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contracts for hosting
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Contracts for hosting compaction progress: the preconditions for hosting it,
|
|
3
|
+
* the bottom-right stand-ins, the two segments that draw the bar and figures,
|
|
4
|
+
* and the preview view.
|
|
5
5
|
*/
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
-
import {
|
|
9
|
+
import { compactionPreviewView, hostsCompactionProgress } from "../compaction-embed.ts";
|
|
10
10
|
import { stripAnsi } from "../footer.ts";
|
|
11
11
|
import { buildStatusLine } from "../layout.ts";
|
|
12
12
|
import { formatCompactionInfo, formatCompactionTokens, SEGMENTS } from "../segments.ts";
|
|
@@ -47,23 +47,13 @@ function ctxWith(compaction: CompactionProgressView | undefined): SegmentContext
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
test("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
);
|
|
56
|
-
assert.equal(
|
|
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);
|
|
50
|
+
test("the settings preview uses representative compaction progress", () => {
|
|
51
|
+
const preview = compactionPreviewView();
|
|
52
|
+
assert.equal(stripAnsi(preview.bar), "▋".repeat(7) + "░".repeat(13));
|
|
53
|
+
assert.equal(preview.percent, 47);
|
|
54
|
+
assert.equal(preview.tokensBefore, 86_000);
|
|
55
|
+
assert.equal(preview.contextWindow, 131_072);
|
|
56
|
+
assert.equal(preview.elapsedMs, 16_300);
|
|
67
57
|
});
|
|
68
58
|
|
|
69
59
|
test("embedCompactionProgress resolves on by default and off when set", () => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { CompactionMonitor, estimateCompactionPercent } from "../compaction-progress.ts";
|
|
5
|
+
|
|
6
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
7
|
+
type Handler = (payload?: unknown, ctx?: unknown) => void;
|
|
8
|
+
|
|
9
|
+
function fakePi(): { pi: ExtensionAPI; emit: (event: string, payload?: unknown, ctx?: unknown) => void } {
|
|
10
|
+
const handlers = new Map<string, Handler>();
|
|
11
|
+
const pi = { on: (event: string, handler: Handler) => handlers.set(event, handler) } as unknown as ExtensionAPI;
|
|
12
|
+
return { pi, emit: (event, payload, ctx) => handlers.get(event)?.(payload, ctx) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ctx = {
|
|
16
|
+
getContextUsage: () => ({ contextWindow: 131_072, tokens: null, percent: null }),
|
|
17
|
+
model: { contextWindow: 200_000 },
|
|
18
|
+
};
|
|
19
|
+
const beforeCompact = (tokensBefore = 86_000) => ({ preparation: { tokensBefore } });
|
|
20
|
+
const filledCells = (bar: string) => (bar.match(/▋/g) ?? []).length;
|
|
21
|
+
const stripAnsi = (bar: string) => bar.replace(/\x1b\[[0-9;]*m/g, "");
|
|
22
|
+
|
|
23
|
+
test("estimateCompactionPercent follows the bounded time curve", () => {
|
|
24
|
+
assert.equal(estimateCompactionPercent(0), 10);
|
|
25
|
+
const one = estimateCompactionPercent(1_000);
|
|
26
|
+
const ten = estimateCompactionPercent(10_000);
|
|
27
|
+
const sixty = estimateCompactionPercent(60_000);
|
|
28
|
+
assert.ok(one > 10 && ten > one && sixty > ten);
|
|
29
|
+
assert.ok(sixty <= 86);
|
|
30
|
+
assert.equal(estimateCompactionPercent(Number.NaN), 10);
|
|
31
|
+
assert.equal(estimateCompactionPercent(-5), 10);
|
|
32
|
+
assert.equal(estimateCompactionPercent(1_000_000), 86);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("monitor tracks starts, elapsed time and context usage", () => {
|
|
36
|
+
const { pi, emit } = fakePi();
|
|
37
|
+
let now = 1_000;
|
|
38
|
+
const monitor = new CompactionMonitor(() => {}, { now: () => now });
|
|
39
|
+
monitor.attach(pi);
|
|
40
|
+
assert.equal(monitor.view(), undefined);
|
|
41
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
42
|
+
assert.equal(monitor.view(), undefined, "disabled monitor ignores events");
|
|
43
|
+
monitor.enable();
|
|
44
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
45
|
+
now += 1_000;
|
|
46
|
+
const view = monitor.view();
|
|
47
|
+
assert.ok(view);
|
|
48
|
+
assert.equal(view.tokensBefore, 86_000);
|
|
49
|
+
assert.equal(view.contextWindow, 131_072);
|
|
50
|
+
assert.equal(view.elapsedMs, 1_000);
|
|
51
|
+
monitor.disable();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("context window falls back to the model and progress advances with elapsed time", () => {
|
|
55
|
+
const { pi, emit } = fakePi();
|
|
56
|
+
let now = 10_000;
|
|
57
|
+
const monitor = new CompactionMonitor(() => {}, { now: () => now });
|
|
58
|
+
monitor.attach(pi);
|
|
59
|
+
monitor.enable();
|
|
60
|
+
emit("session_before_compact", beforeCompact(), { getContextUsage: () => undefined, model: { contextWindow: 200_000 } });
|
|
61
|
+
const first = monitor.view();
|
|
62
|
+
assert.ok(first);
|
|
63
|
+
assert.equal(first.contextWindow, 200_000);
|
|
64
|
+
now += 15_000;
|
|
65
|
+
const later = monitor.view();
|
|
66
|
+
assert.ok(later);
|
|
67
|
+
assert.ok(later.percent > first.percent);
|
|
68
|
+
monitor.disable();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("remaining filled cells decrease with time and unknown usage is trough only", () => {
|
|
72
|
+
const { pi, emit } = fakePi();
|
|
73
|
+
let now = 0;
|
|
74
|
+
const monitor = new CompactionMonitor(() => {}, { now: () => now });
|
|
75
|
+
monitor.attach(pi);
|
|
76
|
+
monitor.enable();
|
|
77
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
78
|
+
const first = monitor.view();
|
|
79
|
+
assert.ok(first);
|
|
80
|
+
now = 60_000;
|
|
81
|
+
const later = monitor.view();
|
|
82
|
+
assert.ok(later);
|
|
83
|
+
assert.ok(filledCells(later.bar) < filledCells(first.bar));
|
|
84
|
+
monitor.disable();
|
|
85
|
+
|
|
86
|
+
const unknown = fakePi();
|
|
87
|
+
const emptyMonitor = new CompactionMonitor(() => {}, { now: () => 0 });
|
|
88
|
+
emptyMonitor.attach(unknown.pi);
|
|
89
|
+
emptyMonitor.enable();
|
|
90
|
+
unknown.emit("session_before_compact", beforeCompact(), { getContextUsage: () => ({ contextWindow: 0 }), model: { contextWindow: 0 } });
|
|
91
|
+
assert.equal(stripAnsi(emptyMonitor.view()!.bar), "░".repeat(20));
|
|
92
|
+
emptyMonitor.disable();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("completion freezes a drained bar during the hold and then releases the run", async () => {
|
|
96
|
+
const { pi, emit } = fakePi();
|
|
97
|
+
let now = 100;
|
|
98
|
+
let renders = 0;
|
|
99
|
+
const monitor = new CompactionMonitor(() => renders++, { now: () => now, holdMs: 20 });
|
|
100
|
+
monitor.attach(pi);
|
|
101
|
+
monitor.enable();
|
|
102
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
103
|
+
now = 500;
|
|
104
|
+
emit("session_compact");
|
|
105
|
+
const completed = monitor.view();
|
|
106
|
+
assert.ok(completed);
|
|
107
|
+
assert.equal(completed.percent, 100);
|
|
108
|
+
assert.equal(stripAnsi(completed.bar), "░".repeat(20));
|
|
109
|
+
now = 5_000;
|
|
110
|
+
assert.equal(monitor.view()?.elapsedMs, completed.elapsedMs);
|
|
111
|
+
await sleep(40);
|
|
112
|
+
assert.equal(monitor.view(), undefined);
|
|
113
|
+
assert.ok(renders >= 3, "completion and hold end both request renders");
|
|
114
|
+
monitor.disable();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("failure ends at once, including a pending hold, and is a no-op without a run", () => {
|
|
118
|
+
const { pi, emit } = fakePi();
|
|
119
|
+
let renders = 0;
|
|
120
|
+
const monitor = new CompactionMonitor(() => renders++, { now: () => 0, holdMs: 10_000 });
|
|
121
|
+
monitor.attach(pi);
|
|
122
|
+
monitor.enable();
|
|
123
|
+
emit("session_compact_failed");
|
|
124
|
+
assert.equal(renders, 0);
|
|
125
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
126
|
+
emit("session_compact");
|
|
127
|
+
emit("session_compact_failed");
|
|
128
|
+
assert.equal(monitor.view(), undefined);
|
|
129
|
+
const afterFailure = renders;
|
|
130
|
+
emit("session_compact_failed");
|
|
131
|
+
assert.equal(renders, afterFailure);
|
|
132
|
+
monitor.disable();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("agent_start guards an active run but leaves a completion hold intact", () => {
|
|
136
|
+
const { pi, emit } = fakePi();
|
|
137
|
+
const monitor = new CompactionMonitor(() => {}, { now: () => 0, holdMs: 10_000 });
|
|
138
|
+
monitor.attach(pi);
|
|
139
|
+
monitor.enable();
|
|
140
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
141
|
+
emit("agent_start");
|
|
142
|
+
assert.equal(monitor.view(), undefined);
|
|
143
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
144
|
+
emit("session_compact");
|
|
145
|
+
emit("agent_start");
|
|
146
|
+
assert.equal(monitor.view()?.percent, 100);
|
|
147
|
+
monitor.disable();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a new compaction replaces the previous run", () => {
|
|
151
|
+
const { pi, emit } = fakePi();
|
|
152
|
+
let now = 1_000;
|
|
153
|
+
const monitor = new CompactionMonitor(() => {}, { now: () => now });
|
|
154
|
+
monitor.attach(pi);
|
|
155
|
+
monitor.enable();
|
|
156
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
157
|
+
now += 1_000;
|
|
158
|
+
emit("session_before_compact", beforeCompact(70_000), ctx);
|
|
159
|
+
assert.equal(monitor.view()?.elapsedMs, 0);
|
|
160
|
+
assert.equal(monitor.view()?.tokensBefore, 70_000);
|
|
161
|
+
monitor.disable();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("disable ends the run and stops repeated ticks", async () => {
|
|
165
|
+
const { pi, emit } = fakePi();
|
|
166
|
+
let renders = 0;
|
|
167
|
+
const monitor = new CompactionMonitor(() => renders++);
|
|
168
|
+
monitor.attach(pi);
|
|
169
|
+
monitor.enable();
|
|
170
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
171
|
+
await sleep(250);
|
|
172
|
+
assert.ok(renders >= 3);
|
|
173
|
+
emit("session_compact_failed");
|
|
174
|
+
const endedRenders = renders;
|
|
175
|
+
await sleep(150);
|
|
176
|
+
assert.equal(renders, endedRenders);
|
|
177
|
+
emit("session_before_compact", beforeCompact(), ctx);
|
|
178
|
+
monitor.disable();
|
|
179
|
+
const disabledRenders = renders;
|
|
180
|
+
await sleep(150);
|
|
181
|
+
assert.equal(renders, disabledRenders);
|
|
182
|
+
assert.equal(monitor.view(), undefined);
|
|
183
|
+
});
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* The context graph between a compaction and the next response. pi reports
|
|
3
3
|
* usage as unknown for that stretch; the builder fills it with pi's own
|
|
4
4
|
* estimate of the compacted context, the figure pi hands compaction callers as
|
|
5
|
-
* estimatedTokensAfter
|
|
6
|
-
* pi-topping-compact reports.
|
|
5
|
+
* estimatedTokensAfter.
|
|
7
6
|
*/
|
|
8
7
|
import assert from "node:assert/strict";
|
|
9
8
|
import test from "node:test";
|
package/src/tests/feeds.test.ts
CHANGED
|
@@ -354,7 +354,7 @@ test("renders share one scan per TTL, and attach forces a fresh one", async () =
|
|
|
354
354
|
await sleep(3);
|
|
355
355
|
entries.push(feedEntry({ savedUsd: 1.5 }));
|
|
356
356
|
|
|
357
|
-
for (let i = 0; i < 50; i++) builder.build(80, OPTIONS, INCLUDE, undefined);
|
|
357
|
+
for (let i = 0; i < 50; i++) builder.build(80, OPTIONS, { ...INCLUDE, feeds: [...INCLUDE.feeds] }, undefined);
|
|
358
358
|
assert.equal(scans, 1, `expected 1 scan across 50 renders, got ${scans}`);
|
|
359
359
|
|
|
360
360
|
builder.attach(fakeCtx(entries, () => scans++));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { SegmentContextBuilder, parseGitStatusCounts } from "../context.ts";
|
|
5
|
+
import { stripAnsi } from "../footer.ts";
|
|
6
|
+
import { SEGMENTS } from "../segments.ts";
|
|
7
|
+
import { resolveEffectiveSettings } from "../settings.ts";
|
|
8
|
+
|
|
9
|
+
const OPTIONS = resolveEffectiveSettings({}).segmentOptions;
|
|
10
|
+
const GIT_INCLUDE = { git: true, pr: false, piStats: false, tokenRate: false, feeds: [] };
|
|
11
|
+
const NO_GIT_INCLUDE = { ...GIT_INCLUDE, git: false };
|
|
12
|
+
const CWD = "/repo";
|
|
13
|
+
|
|
14
|
+
const flush = () => new Promise<void>(resolve => setImmediate(resolve));
|
|
15
|
+
|
|
16
|
+
function fakeCtx(): ExtensionContext {
|
|
17
|
+
return {
|
|
18
|
+
cwd: CWD,
|
|
19
|
+
getContextUsage: () => undefined,
|
|
20
|
+
sessionManager: {
|
|
21
|
+
getSessionName: () => undefined,
|
|
22
|
+
},
|
|
23
|
+
} as unknown as ExtensionContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("parseGitStatusCounts separates staged, unstaged, and untracked paths", () => {
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
parseGitStatusCounts(" M unstaged.ts\nM staged.ts\nMM both.ts\nR renamed.ts\n?? new.ts\n"),
|
|
29
|
+
{ staged: 3, unstaged: 2, untracked: 1 },
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the context builder supplies changed-file counts to the git segment", async () => {
|
|
34
|
+
const commands: string[][] = [];
|
|
35
|
+
const pi = {
|
|
36
|
+
exec: async (_cmd: string, args: string[]) => {
|
|
37
|
+
commands.push(args);
|
|
38
|
+
if (args[0] === "rev-parse") return { code: 0, stdout: "/repo/.git\n/repo/.git\n/repo\n" };
|
|
39
|
+
if (args[0] === "branch") return { code: 0, stdout: "main\n" };
|
|
40
|
+
if (args[0] === "status") return { code: 0, stdout: " M unstaged.ts\nM staged.ts\n?? new.ts\n" };
|
|
41
|
+
return { code: 1, stdout: "" };
|
|
42
|
+
},
|
|
43
|
+
getThinkingLevel: () => "off",
|
|
44
|
+
} as unknown as ExtensionAPI;
|
|
45
|
+
const builder = new SegmentContextBuilder(pi);
|
|
46
|
+
builder.attach(fakeCtx());
|
|
47
|
+
await flush();
|
|
48
|
+
|
|
49
|
+
assert.equal(builder.build(80, OPTIONS, NO_GIT_INCLUDE, undefined).git.status, null);
|
|
50
|
+
builder.build(80, OPTIONS, GIT_INCLUDE, undefined);
|
|
51
|
+
await flush();
|
|
52
|
+
|
|
53
|
+
const snapshot = builder.build(80, OPTIONS, GIT_INCLUDE, undefined);
|
|
54
|
+
assert.deepEqual(snapshot.git.status, {
|
|
55
|
+
staged: 1,
|
|
56
|
+
unstaged: 1,
|
|
57
|
+
untracked: 1,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(stripAnsi(SEGMENTS.git.render(snapshot).content).includes("main *1 +1 ?1"), true);
|
|
60
|
+
assert.ok(commands.some(args => args.join(" ") === "status --porcelain"));
|
|
61
|
+
builder.dispose();
|
|
62
|
+
});
|
package/src/theme.ts
CHANGED
|
@@ -284,7 +284,20 @@ function rgbTo256(r: number, g: number, b: number): number {
|
|
|
284
284
|
return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
function
|
|
287
|
+
export function mixRgb(
|
|
288
|
+
from: readonly [number, number, number],
|
|
289
|
+
to: readonly [number, number, number],
|
|
290
|
+
amount: number,
|
|
291
|
+
): [number, number, number] {
|
|
292
|
+
const clamped = Math.min(1, Math.max(0, amount));
|
|
293
|
+
return [
|
|
294
|
+
Math.round(from[0] + (to[0] - from[0]) * clamped),
|
|
295
|
+
Math.round(from[1] + (to[1] - from[1]) * clamped),
|
|
296
|
+
Math.round(from[2] + (to[2] - from[2]) * clamped),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
|
|
288
301
|
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
289
302
|
}
|
|
290
303
|
|
package/src/types.ts
CHANGED
|
@@ -82,7 +82,7 @@ export interface StatusLineSettings {
|
|
|
82
82
|
nvidiaGreenAnimation?: boolean;
|
|
83
83
|
/** Render pi's status spinners in the top bar instead of their own row. */
|
|
84
84
|
embedWorkingStatus?: boolean;
|
|
85
|
-
/** Host
|
|
85
|
+
/** Host compaction progress in the bottom bar while pi compacts. */
|
|
86
86
|
embedCompactionProgress?: boolean;
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -159,8 +159,8 @@ export interface SegmentContext {
|
|
|
159
159
|
*/
|
|
160
160
|
workingStatus?: string;
|
|
161
161
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
162
|
+
* The live compaction progress, while its bar and figures stand in for the
|
|
163
|
+
* context graph and pi's stats in the bottom-right group.
|
|
164
164
|
*/
|
|
165
165
|
compaction?: CompactionProgressView;
|
|
166
166
|
/** Live tok/s display state, when this frame needs it. */
|
|
@@ -179,14 +179,17 @@ export interface SegmentContext {
|
|
|
179
179
|
feedDisplayState?: Record<string, FeedDisplayState>;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/** One
|
|
182
|
+
/** One frame of a compaction from CompactionMonitor, from its start through the completion hold. */
|
|
183
183
|
export interface CompactionProgressView {
|
|
184
|
-
/** The 20-cell bar, pre-rendered
|
|
184
|
+
/** The 20-cell bar, pre-rendered by compaction-bar.ts with fg/attribute-only resets (39/22, never 0). Its fill is the context share not yet summarized, so it drains as `percent` rises. */
|
|
185
185
|
bar: string;
|
|
186
|
-
/** How
|
|
186
|
+
/** How far the compaction has progressed, 0..100 (a time-based estimate until pi completes). */
|
|
187
187
|
percent: number;
|
|
188
|
+
/** Context tokens being compacted, from pi's compaction preparation. */
|
|
188
189
|
tokensBefore: number;
|
|
190
|
+
/** The model's context window; 0 when unknown. */
|
|
189
191
|
contextWindow: number;
|
|
192
|
+
/** Time since the compaction started, frozen at completion. */
|
|
190
193
|
elapsedMs: number;
|
|
191
194
|
}
|
|
192
195
|
|