@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.
@@ -1,21 +1,56 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { STATUS_HOLD_MS, StatusTransition, WORKING_FADE_MS } from "../working-status.ts";
4
-
5
- test("a live status is returned verbatim without scheduling repaints", () => {
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", () => {
6
18
  const transition = new StatusTransition();
7
19
 
8
20
  assert.deepEqual(transition.resolve("⠙ Working", "working", 1_000), {
9
21
  status: "⠙ Working",
10
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",
11
35
  pending: false,
12
36
  });
13
- assert.equal(transition.pending(1_000), false);
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");
14
49
  });
15
50
 
16
51
  test("clearing a working status holds it, cross-fades both halves, then settles", () => {
17
52
  const transition = new StatusTransition();
18
- transition.resolve("⠙ Working", "working", 1_000);
53
+ settle(transition, "⠙ Working", "working", 1_000);
19
54
 
20
55
  const clearedAt = 2_000;
21
56
  const holdEndsAt = clearedAt + STATUS_HOLD_MS;
@@ -66,7 +101,7 @@ test("clearing a working status holds it, cross-fades both halves, then settles"
66
101
  test("clearing message-style statuses holds them and then cuts without a fade", () => {
67
102
  for (const kind of ["compaction", "retry"] as const) {
68
103
  const transition = new StatusTransition();
69
- transition.resolve(`⠙ ${kind}`, kind, 100);
104
+ settle(transition, `⠙ ${kind}`, kind, 100);
70
105
  assert.deepEqual(transition.resolve(undefined, undefined, 200), {
71
106
  status: `⠙ ${kind}`,
72
107
  kind,
@@ -87,7 +122,7 @@ test("clearing message-style statuses holds them and then cuts without a fade",
87
122
 
88
123
  test("a new status during the hold cuts in immediately and cancels the exit", () => {
89
124
  const transition = new StatusTransition();
90
- transition.resolve("⠙ Working", "working", 1_000);
125
+ settle(transition, "⠙ Working", "working", 1_000);
91
126
  transition.resolve(undefined, undefined, 1_100);
92
127
 
93
128
  assert.deepEqual(transition.resolve("⠹ Retrying", "retry", 1_200), {
@@ -100,7 +135,7 @@ test("a new status during the hold cuts in immediately and cancels the exit", ()
100
135
 
101
136
  test("a new status during the fade cuts in immediately and cancels the exit", () => {
102
137
  const transition = new StatusTransition();
103
- transition.resolve("⠙ Working", "working", 0);
138
+ settle(transition, "⠙ Working", "working", 0);
104
139
  transition.resolve(undefined, undefined, 100);
105
140
  const fading = transition.resolve(undefined, undefined, 300);
106
141
  assert.equal(fading.status, "⠙ Working");
@@ -114,6 +149,21 @@ test("a new status during the fade cuts in immediately and cancels the exit", ()
114
149
  assert.equal(transition.pending(1_000), false);
115
150
  });
116
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
+
117
167
  test("a working-to-compaction handoff across the hold never yields an empty status", () => {
118
168
  const transition = new StatusTransition();
119
169
  const frames = [
@@ -143,7 +193,7 @@ test("an undefined kind preserves the pre-0.86 working fade", () => {
143
193
  });
144
194
  });
145
195
 
146
- test("reset clears live and exiting state", () => {
196
+ test("reset clears live, exiting, and sliding state", () => {
147
197
  const transition = new StatusTransition();
148
198
  transition.resolve("⠙ Working", "working", 10);
149
199
  transition.resolve(undefined, undefined, 20);
@@ -155,4 +205,5 @@ test("reset clears live and exiting state", () => {
155
205
  pending: false,
156
206
  });
157
207
  assert.equal(transition.pending(21), false);
208
+ assert.equal(transition.resolve("⠹ Working", "working", 22).reveal, 0, "the next status slides from the start");
158
209
  });
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * Contracts for the opt-in embedded status indicator: the `working` segment
3
- * passes pi's pre-rendered status through verbatim, the top-left group swaps
4
- * everything but the Pi symbol for it while a stream is live, and the layout
5
- * truncates it before any other left segment is dropped. The caller chooses
6
- * whether truncated status text uses an ellipsis.
3
+ * passes pi's pre-rendered status through verbatim, the top-left group keeps
4
+ * the Pi symbol and configured model details while a stream is live, and the
5
+ * layout truncates it before any other left segment is dropped. The caller
6
+ * chooses whether truncated status text uses an ellipsis, and how far an
7
+ * appearing status has slid out ahead of the model details.
7
8
  */
8
9
  import assert from "node:assert/strict";
9
10
  import test from "node:test";
10
11
  import { visibleWidth } from "@earendil-works/pi-tui";
12
+ import { stripAnsi } from "../footer.ts";
11
13
  import { buildStatusLine } from "../layout.ts";
12
14
  import { SEGMENTS } from "../segments.ts";
15
+ import { getSeparator } from "../separators.ts";
13
16
  import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
14
17
  import { easeFade, theme } from "../theme.ts";
15
18
  import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
16
19
 
17
- const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
18
20
  const noGap = (s: string) => s;
19
21
 
20
22
  const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
@@ -49,15 +51,20 @@ test("working segment is invisible when idle", () => {
49
51
  assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
50
52
  });
51
53
 
52
- test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
53
- assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
54
+ test("topLeftSegments keeps pi and model details around the working status", () => {
55
+ assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working", "model"]);
54
56
  assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
55
- assert.ok(EFFECTIVE.leftSegments.length > 2, "default left group has more than pi + one segment");
57
+ assert.ok(EFFECTIVE.leftSegments.includes("path"), "default path segment is suppressed while working");
56
58
  });
57
59
 
58
- test("topLeftSegments yields only working when the pi symbol is off", () => {
60
+ test("topLeftSegments respects disabled pi and model details", () => {
59
61
  const noPi = resolveEffectiveSettings({ segments: { pi: false } });
60
- assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
62
+ assert.deepEqual(topLeftSegments(noPi, true), ["working", "model"]);
63
+
64
+ const noModelDetails = resolveEffectiveSettings({
65
+ segments: { model: false, provider: false, thinking: false },
66
+ });
67
+ assert.deepEqual(topLeftSegments(noModelDetails, true), ["pi", "working"]);
61
68
  });
62
69
 
63
70
  test("embedWorkingStatus resolves off by default and on when set", () => {
@@ -65,39 +72,98 @@ test("embedWorkingStatus resolves off by default and on when set", () => {
65
72
  assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
66
73
  });
67
74
 
68
- test("top bar renders pi symbol, separator, then the status", () => {
69
- const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
70
- left: ["pi", "working"],
75
+ test("top bar renders pi symbol, status, then model details", () => {
76
+ const ctx = ctxWith("⠙ Mulling 28 tps");
77
+ ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
78
+ ctx.thinkingLevel = "max";
79
+ const effective = resolveEffectiveSettings({
80
+ transparent: true,
81
+ segments: { model: true, provider: true, thinking: true },
82
+ });
83
+ ctx.options = effective.segmentOptions;
84
+ const bar = buildStatusLine(120, ctx, effective, noGap, {
85
+ left: topLeftSegments(effective, true),
71
86
  right: [],
72
87
  });
73
88
  const plain = stripAnsi(bar);
74
89
  const piAt = plain.indexOf(theme.icon.pi);
75
90
  const statusAt = plain.indexOf("⠙ Mulling 28 tps");
91
+ const modelAt = plain.indexOf("GPT-5.6 Luna");
76
92
  assert.ok(piAt >= 0, "pi symbol present");
77
93
  assert.ok(statusAt > piAt, "status follows the pi symbol");
78
- assert.equal(visibleWidth(bar), 80);
94
+ assert.ok(modelAt > statusAt, "model details follow the status");
95
+ assert.ok(plain.includes("openai-codex"), "provider remains visible");
96
+ assert.ok(plain.includes("max"), "thinking level remains visible");
97
+ assert.equal(visibleWidth(bar), 120);
79
98
  });
80
99
 
81
- test("narrow widths truncate the status without an ellipsis and keep the pi symbol", () => {
100
+ test("narrow widths truncate the status before dropping model details", () => {
82
101
  const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
83
- const bar = buildStatusLine(20, ctxWith(status), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
102
+ const ctx = ctxWith(status);
103
+ ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
104
+ const bar = buildStatusLine(24, ctx, EFFECTIVE, noGap, {
105
+ left: topLeftSegments(EFFECTIVE, true),
106
+ right: [],
107
+ });
84
108
  const plain = stripAnsi(bar);
85
109
  assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
86
110
  assert.ok(plain.includes("⠙ Mull"), "status head is kept");
111
+ assert.ok(plain.includes("Luna"), "model details survive");
87
112
  assert.ok(!plain.includes("…"), "no ellipsis");
88
113
  assert.ok(!plain.includes("tokens"), "status tail is cut");
89
- assert.equal(visibleWidth(bar), 20);
114
+ assert.equal(visibleWidth(bar), 24);
115
+ });
116
+
117
+ test("opaque bars restore the group background after truncating the status", () => {
118
+ const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
119
+ const ctx = ctxWith(status);
120
+ ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
121
+ const opaque = resolveEffectiveSettings({ transparent: false });
122
+ ctx.options = opaque.segmentOptions;
123
+ const bar = buildStatusLine(24, ctx, opaque, noGap, {
124
+ left: topLeftSegments(opaque, true),
125
+ right: [],
126
+ });
127
+ const plain = stripAnsi(bar);
128
+ assert.ok(plain.includes("⠙ Mull"), "status head is kept");
129
+ assert.ok(!plain.includes("tokens"), "status tail is cut");
130
+ const reset = theme.getBgAnsi() + theme.getFgAnsi("text");
131
+ const resetAt = bar.indexOf(reset);
132
+ const modelAt = bar.indexOf("Luna");
133
+ assert.ok(resetAt >= 0, "group background and text color are re-asserted after the truncated status");
134
+ assert.ok(resetAt < modelAt, "the reset lands before the model text");
135
+ });
136
+
137
+ test("overflow drops trailing model details before touching the live status", () => {
138
+ const status = "⠙ Working";
139
+ const ctx = ctxWith(status);
140
+ ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
141
+ ctx.thinkingLevel = "max";
142
+ const effective = resolveEffectiveSettings({
143
+ transparent: true,
144
+ segments: { model: true, provider: true, thinking: true },
145
+ });
146
+ ctx.options = effective.segmentOptions;
147
+ const bar = buildStatusLine(34, ctx, effective, noGap, {
148
+ left: topLeftSegments(effective, true),
149
+ right: [],
150
+ });
151
+ const plain = stripAnsi(bar);
152
+ assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
153
+ assert.ok(plain.includes("⠙ Working"), "the spinner status survives untruncated");
154
+ assert.ok(!plain.includes("GPT-5.6"), "model details are dropped to protect the live status");
155
+ assert.equal(visibleWidth(bar), 34);
90
156
  });
91
157
 
92
158
  test("workingEllipsis marks truncated prose without changing the bar width", () => {
93
159
  const status = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
94
- for (const width of [12, 16, 24, 40, 60]) {
160
+ for (const width of [24, 40, 60]) {
95
161
  const bar = buildStatusLine(
96
162
  width,
97
163
  ctxWith(status),
98
164
  EFFECTIVE,
99
165
  noGap,
100
- { left: ["pi", "working"], right: [] },
166
+ { left: topLeftSegments(EFFECTIVE, true), right: [] },
101
167
  { col: 0, row: 0 },
102
168
  { workingEllipsis: "…" },
103
169
  );
@@ -113,7 +179,7 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
113
179
  const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
114
180
  for (const width of [12, 16, 24, 40, 60]) {
115
181
  const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
116
- left: ["pi", "working"],
182
+ left: topLeftSegments(EFFECTIVE, true),
117
183
  right: [],
118
184
  });
119
185
  assert.equal(visibleWidth(bar), width, `width ${width}`);
@@ -122,7 +188,10 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
122
188
  });
123
189
 
124
190
  test("the status drops entirely rather than the pi symbol when there is no room", () => {
125
- const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
191
+ const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, {
192
+ left: topLeftSegments(EFFECTIVE, true),
193
+ right: [],
194
+ });
126
195
  const plain = stripAnsi(bar);
127
196
  assert.ok(plain.includes(theme.icon.pi));
128
197
  assert.ok(!plain.includes("⠙"));
@@ -144,18 +213,144 @@ test("easeFade is monotonic and pinned at both ends", () => {
144
213
  assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
145
214
  });
146
215
 
147
- test("leftFade recolors the tail but not the pi symbol or the bar width", () => {
216
+ test("leftFade recolors the working status but not pi or model details", () => {
148
217
  const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
149
- const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = { left: ["pi", "working"], right: [] };
218
+ const ctx = ctxWith(status);
219
+ ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
220
+ const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = {
221
+ left: topLeftSegments(EFFECTIVE, true),
222
+ right: [],
223
+ };
150
224
  const origin = { col: 0, row: 0 };
151
- const build = (fade?: number) =>
152
- buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
225
+ const build = (fade?: number) => buildStatusLine(60, ctx, EFFECTIVE, noGap, groups, origin, { leftFade: fade });
153
226
  const solid = build();
154
227
  const faded = build(0.2);
228
+ const renderedModel = SEGMENTS.model.render(ctx);
155
229
  assert.equal(visibleWidth(faded), 60);
156
230
  assert.equal(stripAnsi(faded), stripAnsi(solid));
157
231
  assert.ok(!faded.includes("10;200;30"), "status color blended");
232
+ assert.ok(renderedModel.visible && faded.includes(renderedModel.content), "model styling untouched");
158
233
  const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
159
234
  assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
160
235
  assert.equal(build(1), solid);
161
236
  });
237
+
238
+ test("leftFade does not recolor the right group", () => {
239
+ const ctx = ctxWith("⠙ Working");
240
+ ctx.sessionName = "stable-session";
241
+ const renderedSession = SEGMENTS.session_name.render(ctx);
242
+ const bar = buildStatusLine(
243
+ 80,
244
+ ctx,
245
+ EFFECTIVE,
246
+ noGap,
247
+ { left: topLeftSegments(EFFECTIVE, true), right: ["session_name"] },
248
+ { col: 0, row: 0 },
249
+ { leftFade: 0.2 },
250
+ );
251
+ assert.ok(renderedSession.visible && bar.includes(renderedSession.content), "right group styling untouched");
252
+ });
253
+
254
+ /** A top bar mid-slide, with the status revealed by `reveal` and Luna as the model. */
255
+ function slidingBar(
256
+ status: string,
257
+ reveal: number | undefined,
258
+ { width = 60, effective = EFFECTIVE, leftFade }: {
259
+ width?: number;
260
+ effective?: ReturnType<typeof resolveEffectiveSettings>;
261
+ leftFade?: number;
262
+ } = {},
263
+ ): string {
264
+ const ctx = ctxWith(status);
265
+ ctx.options = effective.segmentOptions;
266
+ ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
267
+ ctx.sessionName = "stable-session";
268
+ return buildStatusLine(
269
+ width,
270
+ ctx,
271
+ effective,
272
+ noGap,
273
+ { left: topLeftSegments(effective, true), right: ["session_name"] },
274
+ { col: 0, row: 0 },
275
+ { leftFade, workingReveal: reveal },
276
+ );
277
+ }
278
+
279
+ test("workingReveal slides the status out behind the pi symbol, pushing model details right", () => {
280
+ const settled = slidingBar("⠙ Working...", undefined);
281
+ const piAt = stripAnsi(settled).indexOf(theme.icon.pi);
282
+ assert.equal(slidingBar("⠙ Working...", 1), settled);
283
+ assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0)).includes("⠙"), "nothing has emerged at the start");
284
+
285
+ let modelAt = -1;
286
+ for (let step = 0; step <= 30; step++) {
287
+ const bar = slidingBar("⠙ Working...", step / 30);
288
+ const plain = stripAnsi(bar);
289
+ assert.equal(visibleWidth(bar), 60, `width at step ${step}`);
290
+ assert.equal(plain.indexOf(theme.icon.pi), piAt, `pi symbol stays put at step ${step}`);
291
+ const at = plain.indexOf("Luna");
292
+ assert.ok(at >= modelAt, `model details never move left at step ${step}`);
293
+ modelAt = at;
294
+ }
295
+ assert.equal(modelAt, stripAnsi(settled).indexOf("Luna"), "model details end where they settle");
296
+ });
297
+
298
+ test("the status's tail emerges first, led by its trailing separator", () => {
299
+ const status = "⠙ Working...";
300
+ const sep = getSeparator(EFFECTIVE.separator).left;
301
+ const unitWidth = visibleWidth(status) + visibleWidth(sep) + 2;
302
+ const frame = (shown: number) => stripAnsi(slidingBar(status, shown / unitWidth));
303
+ const model = `${theme.icon.model} Luna`;
304
+ assert.ok(frame(2).includes(`${theme.icon.pi} ${sep} ${sep} ${model}`), "separator first");
305
+ assert.ok(frame(7).includes(`${theme.icon.pi} ${sep} g... ${sep} ${model}`), "then the status's tail");
306
+ assert.ok(!frame(7).includes("Workin"), "the head is still hidden");
307
+ });
308
+
309
+ test("without model details the status slides out alone after the pi separator", () => {
310
+ const effective = resolveEffectiveSettings({
311
+ transparent: true,
312
+ segments: { model: false, provider: false, thinking: false },
313
+ });
314
+ const sep = getSeparator(effective.separator).left;
315
+ assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0, { effective })).includes(sep), "no separator yet");
316
+ const plain = stripAnsi(slidingBar("⠙ Working...", 4 / 12, { effective }));
317
+ assert.ok(plain.includes(`${theme.icon.pi} ${sep} g...`), "tail follows the pi separator");
318
+ assert.ok(!plain.includes("Workin"), "the head is still hidden");
319
+ });
320
+
321
+ test("the slide keeps the overflow decided for the fully revealed status", () => {
322
+ // At 40 cells the settled status leaves no room for the session name, even
323
+ // though a partly revealed one would.
324
+ assert.ok(!stripAnsi(slidingBar("⠙ Working...", undefined, { width: 40 })).includes("stable-session"));
325
+ for (let step = 0; step <= 20; step++) {
326
+ const bar = slidingBar("⠙ Working...", step / 20, { width: 40 });
327
+ assert.ok(!stripAnsi(bar).includes("stable-session"), `right group stays dropped at step ${step}`);
328
+ }
329
+ });
330
+
331
+ test("sliding frames keep the bar width with opaque caps, wide glyphs, and truncation", () => {
332
+ const opaque = resolveEffectiveSettings({ transparent: false });
333
+ const statuses = ["⠙ Working...", "🌑 Working", `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(10)}\x1b[39m`];
334
+ for (const effective of [EFFECTIVE, opaque]) {
335
+ for (const status of statuses) {
336
+ for (const width of [24, 60]) {
337
+ for (let step = 0; step <= 20; step++) {
338
+ const bar = slidingBar(status, step / 20, { width, effective });
339
+ assert.equal(visibleWidth(bar), width, `${JSON.stringify(status)} at width ${width}, step ${step}`);
340
+ }
341
+ }
342
+ }
343
+ }
344
+ });
345
+
346
+ test("a status fading mid-slide blends its emerged tail but not model details", () => {
347
+ const status = "\x1b[38;2;10;200;30m⠙ Working...\x1b[39m";
348
+ const bar = slidingBar(status, 0.5, { leftFade: 0.2 });
349
+ const ctx = ctxWith(status);
350
+ ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
351
+ const renderedModel = SEGMENTS.model.render(ctx);
352
+ assert.ok(stripAnsi(bar).includes("..."), "part of the status has emerged");
353
+ assert.ok(!bar.includes("10;200;30"), "emerged status blended");
354
+ assert.ok(renderedModel.visible && bar.includes(renderedModel.content), "model styling untouched");
355
+ assert.equal(visibleWidth(bar), 60);
356
+ });
package/src/token-rate.ts CHANGED
@@ -29,7 +29,7 @@ const EMA_ALPHA = 0.4;
29
29
  class StreamingWordCounter {
30
30
  #inWordByStream = new Map<string, boolean>();
31
31
 
32
- count(text: string, stream = "default"): number {
32
+ count(text: string, stream: string): number {
33
33
  let inWord = this.#inWordByStream.get(stream) ?? false;
34
34
  let count = 0;
35
35
  for (let i = 0; i < text.length; i++) {
package/src/types.ts CHANGED
@@ -19,7 +19,9 @@ export type StatusLineSegmentId =
19
19
  | "feeds"
20
20
  | "context_graph"
21
21
  | "scroll_hint"
22
- | "working";
22
+ | "working"
23
+ | "compaction_info"
24
+ | "compaction_graph";
23
25
 
24
26
  /**
25
27
  * A feed the statusline subscribes to: custom session entries published by
@@ -80,6 +82,8 @@ export interface StatusLineSettings {
80
82
  nvidiaGreenAnimation?: boolean;
81
83
  /** Render pi's status spinners in the top bar instead of their own row. */
82
84
  embedWorkingStatus?: boolean;
85
+ /** Host pi-topping-compact's compaction progress in the bottom bar while a compaction runs. */
86
+ embedCompactionProgress?: boolean;
83
87
  }
84
88
 
85
89
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -115,6 +119,7 @@ export interface EffectiveStatusLineSettings {
115
119
  nvidiaGreenBorder: boolean;
116
120
  nvidiaGreenAnimation: boolean;
117
121
  embedWorkingStatus: boolean;
122
+ embedCompactionProgress: boolean;
118
123
  includes: SegmentIncludes;
119
124
  }
120
125
 
@@ -128,10 +133,15 @@ export interface SegmentContext {
128
133
  thinkingLevel: string;
129
134
  cwd: string;
130
135
  sessionName: string | undefined;
131
- /** Context usage percent, or null when unknown (e.g. right after compaction). */
136
+ /** Context usage percent, or null when unknown (no model, or a session pi cannot size). */
132
137
  contextPercent: number | null;
133
138
  contextTokens: number;
134
139
  contextWindow: number;
140
+ /**
141
+ * True while contextPercent and contextTokens are pi's estimate of a freshly
142
+ * compacted context, shown until pi measures the next response.
143
+ */
144
+ contextEstimated?: boolean;
135
145
  git: {
136
146
  branch: string | null;
137
147
  status: { staged: number; unstaged: number; untracked: number } | null;
@@ -148,6 +158,11 @@ export interface SegmentContext {
148
158
  * this frame. Absent while no status indicator is active.
149
159
  */
150
160
  workingStatus?: string;
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.
164
+ */
165
+ compaction?: CompactionProgressView;
151
166
  /** Live tok/s display state, when this frame needs it. */
152
167
  tokenRate: TokenRateDisplay | undefined;
153
168
  /**
@@ -164,6 +179,17 @@ export interface SegmentContext {
164
179
  feedDisplayState?: Record<string, FeedDisplayState>;
165
180
  }
166
181
 
182
+ /** One broadcast from pi-topping-compact: its bar, pre-rendered, plus the snapshot it was drawn from. */
183
+ export interface CompactionProgressView {
184
+ /** The 20-cell bar, pre-rendered and pre-colored by pi-topping-compact with fg-only resets. */
185
+ bar: string;
186
+ /** How much of the context has been summarized so far, 0..100. */
187
+ percent: number;
188
+ tokensBefore: number;
189
+ contextWindow: number;
190
+ elapsedMs: number;
191
+ }
192
+
167
193
  export type FeedPhase = "active" | "fading" | "hidden";
168
194
 
169
195
  export interface FeedDisplayState {
@@ -0,0 +1,15 @@
1
+ import type { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Event-bus channel on which this extension announces, as `{ embedded: boolean }`,
5
+ * whether its bar hosts Pi's working status. pi-topping slides its loader's response
6
+ * model out only while it does. Shared with pi-topping.
7
+ */
8
+ export const WORKING_STATUS_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
9
+
10
+ type EmbeddingEditor = Partial<Pick<CustomEditor, "embedWorkingStatus" | "setWorkingStatusIndicator">>;
11
+
12
+ /** Whether an editor this extension built draws the host's working status in its bar. */
13
+ export function hostsWorkingStatus(editor: EmbeddingEditor | undefined): boolean {
14
+ return editor?.embedWorkingStatus === true && typeof editor.setWorkingStatusIndicator === "function";
15
+ }