@underactive/pi-topping-statusline 0.2.0 → 0.2.2

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.
@@ -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
+ });
@@ -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 { DEFAULT_FEEDS, DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
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
- });
@@ -48,6 +48,7 @@ test("filterVisibleRows drops empty notifications but keeps visible rows", () =>
48
48
  test("fullscreen footer slot can collapse and restores its minimum", () => {
49
49
  const footer = { render: () => [] };
50
50
  const footerContainer = { children: [footer] };
51
+ // minSize: 1 mirrors the footer dock shape used by hosts before pi 0.86.
51
52
  const footerEntry = { component: footerContainer, minSize: 1 };
52
53
  const dock = { entries: [{ component: {} }, footerEntry] };
53
54
  const root = { entries: [{ component: {} }, { component: dock, minSize: 1 }] };
@@ -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 = { fg: (_c: string, s: string) => s, bold: (s: string) => s } as unknown as Theme;
8
- const plain = (lines: string[]) => lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, ""));
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
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ NVIDIA_GREEN_DARK_HEX,
5
+ NVIDIA_GREEN_HEX,
6
+ NvidiaGreenBorder,
7
+ isSwitchyardProvider,
8
+ } from "../nvidia-green.ts";
9
+ import { resolveEffectiveSettings } from "../settings.ts";
10
+ import { detectColorMode, hexToFgAnsi } from "../theme.ts";
11
+
12
+ test("recognizes the Switchyard provider case-insensitively after trimming", () => {
13
+ assert.equal(isSwitchyardProvider("switchyard"), true);
14
+ assert.equal(isSwitchyardProvider(" Switchyard "), true);
15
+ assert.equal(isSwitchyardProvider(undefined), false);
16
+ assert.equal(isSwitchyardProvider(""), false);
17
+ assert.equal(isSwitchyardProvider("other"), false);
18
+ });
19
+
20
+ test("switchyard green settings default to true and resolve explicit false", () => {
21
+ const defaults = resolveEffectiveSettings({});
22
+ assert.equal(defaults.nvidiaGreenBorder, true);
23
+ assert.equal(defaults.nvidiaGreenAnimation, true);
24
+ const off = resolveEffectiveSettings({ nvidiaGreenBorder: false, nvidiaGreenAnimation: false });
25
+ assert.equal(off.nvidiaGreenBorder, false);
26
+ assert.equal(off.nvidiaGreenAnimation, false);
27
+ });
28
+
29
+ test("colorChar wraps a glyph in a color escape and foreground reset", () => {
30
+ const border = new NvidiaGreenBorder();
31
+ assert.match(border.colorChar("─", 0, 28), /^\x1b\[38;(2;\d+;\d+;\d+|5;\d+)m─\x1b\[39m$/);
32
+ });
33
+
34
+ test("phase zero has bright and dark opposite endpoints", () => {
35
+ const mode = detectColorMode();
36
+ const border = new NvidiaGreenBorder(0);
37
+ assert.equal(border.colorChar("─", 0, 28), `${hexToFgAnsi(NVIDIA_GREEN_HEX, mode)}─\x1b[39m`);
38
+ assert.equal(border.colorChar("─", 14, 28), `${hexToFgAnsi(NVIDIA_GREEN_DARK_HEX, mode)}─\x1b[39m`);
39
+ });
40
+
41
+ test("step by one full turn preserves the color", () => {
42
+ const stepped = new NvidiaGreenBorder(37);
43
+ const unchanged = new NvidiaGreenBorder(37);
44
+ stepped.step(360);
45
+ assert.equal(stepped.colorChar("─", 7, 28), unchanged.colorChar("─", 7, 28));
46
+ });
47
+
48
+ test("opposite positions receive different shades", () => {
49
+ const border = new NvidiaGreenBorder();
50
+ assert.notEqual(border.colorChar("─", 0, 28), border.colorChar("─", 14, 28));
51
+ });
@@ -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.replace(/\x1b\[[0-9;]*m/g, "");
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
+ });
@@ -0,0 +1,209 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ STATUS_HOLD_MS,
5
+ STATUS_SLIDE_MS,
6
+ type StatusIndicatorKind,
7
+ StatusTransition,
8
+ WORKING_FADE_MS,
9
+ } from "../working-status.ts";
10
+
11
+ /** Show a status early enough that its slide-out has finished by `at`. */
12
+ function settle(transition: StatusTransition, status: string, kind: StatusIndicatorKind | undefined, at: number): void {
13
+ transition.resolve(status, kind, at - STATUS_SLIDE_MS);
14
+ transition.resolve(status, kind, at);
15
+ }
16
+
17
+ test("a status appearing over the user's segments slides out, then settles verbatim", () => {
18
+ const transition = new StatusTransition();
19
+
20
+ assert.deepEqual(transition.resolve("⠙ Working", "working", 1_000), {
21
+ status: "⠙ Working",
22
+ kind: "working",
23
+ reveal: 0,
24
+ pending: true,
25
+ });
26
+ // Spinner frames change the text mid-slide without restarting it.
27
+ const early = transition.resolve("⠹ Working", "working", 1_000 + STATUS_SLIDE_MS / 4).reveal ?? -1;
28
+ const late = transition.resolve("⠸ Working", "working", 1_000 + (STATUS_SLIDE_MS * 3) / 4).reveal ?? -1;
29
+ assert.ok(0 < early && early < late && late < 1, "reveal grows monotonically");
30
+ assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS - 1), true);
31
+
32
+ assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 + STATUS_SLIDE_MS), {
33
+ status: "⠼ Working",
34
+ kind: "working",
35
+ pending: false,
36
+ });
37
+ assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS), false);
38
+ });
39
+
40
+ test("a status cleared mid-slide keeps sliding through its hold", () => {
41
+ const transition = new StatusTransition();
42
+ transition.resolve("⠙ Working", "working", 0);
43
+
44
+ const clearedAt = STATUS_SLIDE_MS / 4;
45
+ const cleared = transition.resolve(undefined, undefined, clearedAt);
46
+ const held = transition.resolve(undefined, undefined, clearedAt + STATUS_HOLD_MS / 2);
47
+ assert.equal(held.status, "⠙ Working");
48
+ assert.ok(0 < (cleared.reveal ?? 1) && (cleared.reveal ?? 1) < (held.reveal ?? 1), "the slide continues");
49
+ });
50
+
51
+ test("clearing a working status holds it, cross-fades both halves, then settles", () => {
52
+ const transition = new StatusTransition();
53
+ settle(transition, "⠙ Working", "working", 1_000);
54
+
55
+ const clearedAt = 2_000;
56
+ const holdEndsAt = clearedAt + STATUS_HOLD_MS;
57
+ assert.deepEqual(transition.resolve(undefined, undefined, clearedAt), {
58
+ status: "⠙ Working",
59
+ kind: "working",
60
+ pending: true,
61
+ });
62
+ assert.deepEqual(transition.resolve(undefined, undefined, holdEndsAt - 1), {
63
+ status: "⠙ Working",
64
+ kind: "working",
65
+ pending: true,
66
+ });
67
+
68
+ const outgoingStart = transition.resolve(undefined, undefined, holdEndsAt);
69
+ const outgoingMiddle = transition.resolve(undefined, undefined, holdEndsAt + 150);
70
+ const outgoingEnd = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS / 2 - 1);
71
+ assert.equal(outgoingStart.status, "⠙ Working");
72
+ assert.equal(outgoingMiddle.status, "⠙ Working");
73
+ assert.equal(outgoingEnd.status, "⠙ Working");
74
+ assert.ok(
75
+ (outgoingStart.leftFade ?? -1) > (outgoingMiddle.leftFade ?? -1) &&
76
+ (outgoingMiddle.leftFade ?? -1) > (outgoingEnd.leftFade ?? -1),
77
+ "outgoing opacity decreases monotonically",
78
+ );
79
+
80
+ const incomingStart = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS / 2);
81
+ const incomingMiddle = transition.resolve(undefined, undefined, holdEndsAt + 550);
82
+ const incomingEnd = transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS - 1);
83
+ assert.equal(incomingStart.status, undefined);
84
+ assert.equal(incomingMiddle.status, undefined);
85
+ assert.equal(incomingEnd.status, undefined);
86
+ assert.ok(
87
+ (incomingStart.leftFade ?? 2) < (incomingMiddle.leftFade ?? 2) &&
88
+ (incomingMiddle.leftFade ?? 2) < (incomingEnd.leftFade ?? 2),
89
+ "incoming opacity increases monotonically",
90
+ );
91
+ assert.equal(transition.pending(holdEndsAt + WORKING_FADE_MS - 1), true);
92
+
93
+ assert.deepEqual(transition.resolve(undefined, undefined, holdEndsAt + WORKING_FADE_MS), {
94
+ status: undefined,
95
+ kind: undefined,
96
+ pending: false,
97
+ });
98
+ assert.equal(transition.pending(holdEndsAt + WORKING_FADE_MS), false);
99
+ });
100
+
101
+ test("clearing message-style statuses holds them and then cuts without a fade", () => {
102
+ for (const kind of ["compaction", "retry"] as const) {
103
+ const transition = new StatusTransition();
104
+ settle(transition, `⠙ ${kind}`, kind, 100);
105
+ assert.deepEqual(transition.resolve(undefined, undefined, 200), {
106
+ status: `⠙ ${kind}`,
107
+ kind,
108
+ pending: true,
109
+ });
110
+ assert.deepEqual(transition.resolve(undefined, undefined, 200 + STATUS_HOLD_MS - 1), {
111
+ status: `⠙ ${kind}`,
112
+ kind,
113
+ pending: true,
114
+ });
115
+ assert.deepEqual(transition.resolve(undefined, undefined, 200 + STATUS_HOLD_MS), {
116
+ status: undefined,
117
+ kind: undefined,
118
+ pending: false,
119
+ });
120
+ }
121
+ });
122
+
123
+ test("a new status during the hold cuts in immediately and cancels the exit", () => {
124
+ const transition = new StatusTransition();
125
+ settle(transition, "⠙ Working", "working", 1_000);
126
+ transition.resolve(undefined, undefined, 1_100);
127
+
128
+ assert.deepEqual(transition.resolve("⠹ Retrying", "retry", 1_200), {
129
+ status: "⠹ Retrying",
130
+ kind: "retry",
131
+ pending: false,
132
+ });
133
+ assert.equal(transition.pending(2_000), false);
134
+ });
135
+
136
+ test("a new status during the fade cuts in immediately and cancels the exit", () => {
137
+ const transition = new StatusTransition();
138
+ settle(transition, "⠙ Working", "working", 0);
139
+ transition.resolve(undefined, undefined, 100);
140
+ const fading = transition.resolve(undefined, undefined, 300);
141
+ assert.equal(fading.status, "⠙ Working");
142
+ assert.equal(fading.pending, true);
143
+
144
+ assert.deepEqual(transition.resolve("⠸ Compacting", "compaction", 301), {
145
+ status: "⠸ Compacting",
146
+ kind: "compaction",
147
+ pending: false,
148
+ });
149
+ assert.equal(transition.pending(1_000), false);
150
+ });
151
+
152
+ test("a status arriving after the fade's outgoing half slides out again", () => {
153
+ const transition = new StatusTransition();
154
+ settle(transition, "⠙ Working", "working", 0);
155
+ transition.resolve(undefined, undefined, 100);
156
+
157
+ const incomingAt = 100 + STATUS_HOLD_MS + WORKING_FADE_MS / 2;
158
+ assert.equal(transition.resolve(undefined, undefined, incomingAt).status, undefined);
159
+ assert.deepEqual(transition.resolve("⠹ Retrying", "retry", incomingAt + 1), {
160
+ status: "⠹ Retrying",
161
+ kind: "retry",
162
+ reveal: 0,
163
+ pending: true,
164
+ });
165
+ });
166
+
167
+ test("a working-to-compaction handoff across the hold never yields an empty status", () => {
168
+ const transition = new StatusTransition();
169
+ const frames = [
170
+ transition.resolve("⠙ Working", "working", 0),
171
+ transition.resolve(undefined, undefined, 100),
172
+ transition.resolve(undefined, undefined, 100 + STATUS_HOLD_MS - 1),
173
+ transition.resolve("⠸ Compacting", "compaction", 100 + STATUS_HOLD_MS),
174
+ ];
175
+
176
+ assert.ok(frames.every(frame => frame.status !== undefined));
177
+ assert.equal(frames.at(-1)?.kind, "compaction");
178
+ });
179
+
180
+ test("an undefined kind preserves the pre-0.86 working fade", () => {
181
+ const transition = new StatusTransition();
182
+ transition.resolve("⠙ Working", undefined, 0);
183
+ transition.resolve(undefined, undefined, 10);
184
+
185
+ const fadeStarts = transition.resolve(undefined, undefined, 10 + STATUS_HOLD_MS);
186
+ assert.equal(fadeStarts.status, "⠙ Working");
187
+ assert.equal(fadeStarts.leftFade, 1);
188
+ assert.equal(fadeStarts.pending, true);
189
+ assert.deepEqual(transition.resolve(undefined, undefined, 10 + STATUS_HOLD_MS + WORKING_FADE_MS), {
190
+ status: undefined,
191
+ kind: undefined,
192
+ pending: false,
193
+ });
194
+ });
195
+
196
+ test("reset clears live, exiting, and sliding state", () => {
197
+ const transition = new StatusTransition();
198
+ transition.resolve("⠙ Working", "working", 10);
199
+ transition.resolve(undefined, undefined, 20);
200
+ transition.reset();
201
+
202
+ assert.deepEqual(transition.resolve(undefined, undefined, 21), {
203
+ status: undefined,
204
+ kind: undefined,
205
+ pending: false,
206
+ });
207
+ assert.equal(transition.pending(21), false);
208
+ assert.equal(transition.resolve("⠹ Working", "working", 22).reveal, 0, "the next status slides from the start");
209
+ });