@underactive/pi-topping-statusline 0.2.6 → 0.2.8

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/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, so it agrees with
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
- function parseGitStatus(porcelain: string): GitStatusCounts {
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
- #feedsScannedRef: readonly string[] | undefined;
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.#feedsScannedRef = undefined;
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 : parseGitStatus(out);
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
- if (include.feeds !== this.#feedsScannedRef || now - this.#feedsScannedAt > FEED_TTL_MS) {
499
- this.#feedsScannedRef = include.feeds;
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);
@@ -1,8 +1,8 @@
1
1
  /** Provider-specific NVIDIA green border colorizer for Switchyard. */
2
- import { rgbToHex, wrapDeg, type BorderColorizer } from "./rainbow.js";
3
- import { detectColorMode, hexToFgAnsi, parseHex, type ColorMode } from "./theme.js";
2
+ import { wrapDeg, type BorderColorizer } from "./rainbow.js";
3
+ import { detectColorMode, mixRgb, parseHex, rgbToFgAnsi, type ColorMode } from "./theme.js";
4
4
 
5
- export const SWITCHYARD_PROVIDER = "switchyard";
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: [number, number, number] = [
31
- Math.round(dark[0] + (bright[0] - dark[0]) * progress),
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, hexToFgAnsi, type ColorMode } from "./theme.js";
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
- if (mode !== this.#mode) {
103
- this.#mode = mode;
104
- this.#ansiByHue.length = 0;
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 = this.#ansiByHue[bucket];
108
+ let ansi = ansiByHue[bucket];
109
109
  if (ansi === undefined) {
110
- const hex = rgbToHex(hslToRgb(bucket, SATURATION, LIGHTNESS));
111
- ansi = hexToFgAnsi(hex, mode);
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
- /** "57% summarized · 16.3s": how much of the context has been summarized so far, and the time taken. */
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
- /** pi-topping-compact's figures, standing in for pi's stats while its progress is hosted. */
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
- * pi-topping-compact's bar, standing in for the context graph while its progress
388
- * is hosted, under the graph's own bar and stats toggles. The bar arrives
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",
@@ -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 { COMPACTION_FIT_PROBE } from "./compaction-embed.js";
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: COMPACTION_FIT_PROBE } : ctx,
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 pi-topping-compact's progress is
199
- * hosted, its figures stand in for pi's stats and its bar for the context graph.
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 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.
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 { hostsCompactionProgress, parseCompactionProgress } from "../compaction-embed.ts";
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("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);
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, so the graph never vanishes and agrees with the result
6
- * pi-topping-compact reports.
5
+ * estimatedTokensAfter.
7
6
  */
8
7
  import assert from "node:assert/strict";
9
8
  import test from "node:test";
@@ -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 rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
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 pi-topping-compact's compaction progress in the bottom bar while a compaction runs. */
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
- * pi-topping-compact's live compaction progress, while its bar and figures
163
- * stand in for the context graph and pi's stats in the bottom-right group.
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 broadcast from pi-topping-compact: its bar, pre-rendered, plus the snapshot it was drawn from. */
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 and pre-colored by pi-topping-compact with fg-only resets. */
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 much of the context has been summarized so far, 0..100. */
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