@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.
@@ -1,19 +1,22 @@
1
1
  /**
2
- * Contracts for the opt-in embedded working 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 (no ellipsis) before any other left segment is dropped.
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 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.
6
8
  */
7
9
  import assert from "node:assert/strict";
8
10
  import test from "node:test";
9
11
  import { visibleWidth } from "@earendil-works/pi-tui";
12
+ import { stripAnsi } from "../footer.ts";
10
13
  import { buildStatusLine } from "../layout.ts";
11
14
  import { SEGMENTS } from "../segments.ts";
15
+ import { getSeparator } from "../separators.ts";
12
16
  import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
13
17
  import { easeFade, theme } from "../theme.ts";
14
18
  import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
15
19
 
16
- const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
17
20
  const noGap = (s: string) => s;
18
21
 
19
22
  const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
@@ -48,15 +51,20 @@ test("working segment is invisible when idle", () => {
48
51
  assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
49
52
  });
50
53
 
51
- test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
52
- 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"]);
53
56
  assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
54
- 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");
55
58
  });
56
59
 
57
- test("topLeftSegments yields only working when the pi symbol is off", () => {
60
+ test("topLeftSegments respects disabled pi and model details", () => {
58
61
  const noPi = resolveEffectiveSettings({ segments: { pi: false } });
59
- 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"]);
60
68
  });
61
69
 
62
70
  test("embedWorkingStatus resolves off by default and on when set", () => {
@@ -64,35 +72,114 @@ test("embedWorkingStatus resolves off by default and on when set", () => {
64
72
  assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
65
73
  });
66
74
 
67
- test("top bar renders pi symbol, separator, then the status", () => {
68
- const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
69
- 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),
70
86
  right: [],
71
87
  });
72
88
  const plain = stripAnsi(bar);
73
89
  const piAt = plain.indexOf(theme.icon.pi);
74
90
  const statusAt = plain.indexOf("⠙ Mulling 28 tps");
91
+ const modelAt = plain.indexOf("GPT-5.6 Luna");
75
92
  assert.ok(piAt >= 0, "pi symbol present");
76
93
  assert.ok(statusAt > piAt, "status follows the pi symbol");
77
- 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);
78
98
  });
79
99
 
80
- 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", () => {
81
101
  const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
82
- 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
+ });
83
108
  const plain = stripAnsi(bar);
84
109
  assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
85
110
  assert.ok(plain.includes("⠙ Mull"), "status head is kept");
111
+ assert.ok(plain.includes("Luna"), "model details survive");
86
112
  assert.ok(!plain.includes("…"), "no ellipsis");
87
113
  assert.ok(!plain.includes("tokens"), "status tail is cut");
88
- 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);
156
+ });
157
+
158
+ test("workingEllipsis marks truncated prose without changing the bar width", () => {
159
+ const status = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
160
+ for (const width of [24, 40, 60]) {
161
+ const bar = buildStatusLine(
162
+ width,
163
+ ctxWith(status),
164
+ EFFECTIVE,
165
+ noGap,
166
+ { left: topLeftSegments(EFFECTIVE, true), right: [] },
167
+ { col: 0, row: 0 },
168
+ { workingEllipsis: "…" },
169
+ );
170
+ const plain = stripAnsi(bar);
171
+ assert.ok(plain.includes(theme.icon.pi), `pi symbol survives at width ${width}`);
172
+ assert.ok(plain.includes("…"), `truncated status ends with an ellipsis at width ${width}`);
173
+ assert.ok(!plain.includes("cancel"), `status tail is cut at width ${width}`);
174
+ assert.equal(visibleWidth(bar), width, `width ${width}`);
175
+ }
89
176
  });
90
177
 
91
178
  test("a long ANSI-styled status never exceeds the bar width", () => {
92
179
  const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
93
180
  for (const width of [12, 16, 24, 40, 60]) {
94
181
  const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
95
- left: ["pi", "working"],
182
+ left: topLeftSegments(EFFECTIVE, true),
96
183
  right: [],
97
184
  });
98
185
  assert.equal(visibleWidth(bar), width, `width ${width}`);
@@ -101,7 +188,10 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
101
188
  });
102
189
 
103
190
  test("the status drops entirely rather than the pi symbol when there is no room", () => {
104
- 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
+ });
105
195
  const plain = stripAnsi(bar);
106
196
  assert.ok(plain.includes(theme.icon.pi));
107
197
  assert.ok(!plain.includes("⠙"));
@@ -123,18 +213,144 @@ test("easeFade is monotonic and pinned at both ends", () => {
123
213
  assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
124
214
  });
125
215
 
126
- 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", () => {
127
217
  const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
128
- 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
+ };
129
224
  const origin = { col: 0, row: 0 };
130
- const build = (fade?: number) =>
131
- buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
225
+ const build = (fade?: number) => buildStatusLine(60, ctx, EFFECTIVE, noGap, groups, origin, { leftFade: fade });
132
226
  const solid = build();
133
227
  const faded = build(0.2);
228
+ const renderedModel = SEGMENTS.model.render(ctx);
134
229
  assert.equal(visibleWidth(faded), 60);
135
230
  assert.equal(stripAnsi(faded), stripAnsi(solid));
136
231
  assert.ok(!faded.includes("10;200;30"), "status color blended");
232
+ assert.ok(renderedModel.visible && faded.includes(renderedModel.content), "model styling untouched");
137
233
  const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
138
234
  assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
139
235
  assert.equal(build(1), solid);
140
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
@@ -74,8 +76,14 @@ export interface StatusLineSettings {
74
76
  rainbowBorder?: boolean;
75
77
  /** Animate the rainbow border when it is visible. */
76
78
  rainbowAnimation?: boolean;
77
- /** Render pi's streaming working status in the top bar instead of its own row. */
79
+ /** NVIDIA-green gradient border while the model provider is Switchyard. */
80
+ nvidiaGreenBorder?: boolean;
81
+ /** Animate the Switchyard green border when it is visible. */
82
+ nvidiaGreenAnimation?: boolean;
83
+ /** Render pi's status spinners in the top bar instead of their own row. */
78
84
  embedWorkingStatus?: boolean;
85
+ /** Host pi-topping-compact's compaction progress in the bottom bar while a compaction runs. */
86
+ embedCompactionProgress?: boolean;
79
87
  }
80
88
 
81
89
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -108,7 +116,10 @@ export interface EffectiveStatusLineSettings {
108
116
  segmentOptions: StatusLineSegmentOptions;
109
117
  rainbowBorder: boolean;
110
118
  rainbowAnimation: boolean;
119
+ nvidiaGreenBorder: boolean;
120
+ nvidiaGreenAnimation: boolean;
111
121
  embedWorkingStatus: boolean;
122
+ embedCompactionProgress: boolean;
112
123
  includes: SegmentIncludes;
113
124
  }
114
125
 
@@ -122,10 +133,15 @@ export interface SegmentContext {
122
133
  thinkingLevel: string;
123
134
  cwd: string;
124
135
  sessionName: string | undefined;
125
- /** 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). */
126
137
  contextPercent: number | null;
127
138
  contextTokens: number;
128
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;
129
145
  git: {
130
146
  branch: string | null;
131
147
  status: { staged: number; unstaged: number; untracked: number } | null;
@@ -138,10 +154,15 @@ export interface SegmentContext {
138
154
  /** Pi's own footer stats, when this frame needs them. */
139
155
  piStats: string | undefined;
140
156
  /**
141
- * Pi's embedded working indicator, pre-rendered and pre-colored by the host
142
- * for this frame. Absent while no response is streaming.
157
+ * Pi's embedded status spinner, pre-rendered and pre-colored by the host for
158
+ * this frame. Absent while no status indicator is active.
143
159
  */
144
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;
145
166
  /** Live tok/s display state, when this frame needs it. */
146
167
  tokenRate: TokenRateDisplay | undefined;
147
168
  /**
@@ -158,6 +179,17 @@ export interface SegmentContext {
158
179
  feedDisplayState?: Record<string, FeedDisplayState>;
159
180
  }
160
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
+
161
193
  export type FeedPhase = "active" | "fading" | "hidden";
162
194
 
163
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
+ }
@@ -0,0 +1,125 @@
1
+ import type { CustomEditor } from "@earendil-works/pi-coding-agent";
2
+ import { easeFade } from "./theme.js";
3
+
4
+ export type StatusIndicatorKind = NonNullable<Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]>["kind"];
5
+
6
+ /** Slide-out time for a status that appears while the top-left group shows none. */
7
+ export const STATUS_SLIDE_MS = 300;
8
+ /** Briefly retain a cleared status so handoffs between host indicators do not flash. */
9
+ export const STATUS_HOLD_MS = 150;
10
+ /** Cross-fade budget when the embedded working status disappears: half out, half in. */
11
+ export const WORKING_FADE_MS = 750;
12
+
13
+ /** True when a status kind is a message-style indicator rather than the working spinner. */
14
+ export function isMessageKind(kind: StatusIndicatorKind | undefined): boolean {
15
+ return kind !== undefined && kind !== "working";
16
+ }
17
+
18
+ /** Which status the top-left group shows this frame and how far it has slid out or faded. */
19
+ export interface StatusFrame {
20
+ status: string | undefined;
21
+ leftFade?: number;
22
+ /** How far the status has slid out (0..1); absent once it is fully out. */
23
+ reveal?: number;
24
+ /** Kind of the status this frame shows, for truncation styling. */
25
+ kind: StatusIndicatorKind | undefined;
26
+ /** True while a slide, hold, or fade still needs repaints. */
27
+ pending: boolean;
28
+ }
29
+
30
+ interface StatusExit {
31
+ holdUntil: number;
32
+ fadeMs: number;
33
+ }
34
+
35
+ /**
36
+ * Resolve host status-indicator handoffs into stable top-bar frames.
37
+ *
38
+ * A status that appears while the top-left group shows none slides out over
39
+ * STATUS_SLIDE_MS. pi 0.86 routes working, retry, compaction, and
40
+ * branch-summary indicators through the editor border and may clear the slot
41
+ * between them. A cleared status is held briefly to bridge that gap, and a
42
+ * status arriving while the previous one is still drawn takes its place
43
+ * without sliding again. Only the working indicator — the one that genuinely
44
+ * ends a stream — then cross-fades back to user segments.
45
+ */
46
+ export class StatusTransition {
47
+ #liveShown = false;
48
+ #lastStatus: string | undefined;
49
+ #lastKind: StatusIndicatorKind | undefined;
50
+ #exit: StatusExit | undefined;
51
+ #slideFrom: number | undefined;
52
+
53
+ resolve(live: string | undefined, kind: StatusIndicatorKind | undefined, now: number): StatusFrame {
54
+ if (live !== undefined) {
55
+ if (!this.#liveShown && !this.#exitDrawsStatus(now)) this.#slideFrom = now;
56
+ this.#liveShown = true;
57
+ this.#lastStatus = live;
58
+ this.#lastKind = kind;
59
+ this.#exit = undefined;
60
+ return this.#withSlide({ status: live, kind, pending: false }, now);
61
+ }
62
+
63
+ if (this.#liveShown) {
64
+ this.#liveShown = false;
65
+ this.#exit = {
66
+ holdUntil: now + STATUS_HOLD_MS,
67
+ fadeMs: isMessageKind(this.#lastKind) ? 0 : WORKING_FADE_MS,
68
+ };
69
+ }
70
+
71
+ const exit = this.#exit;
72
+ if (!exit) return { status: undefined, kind: undefined, pending: false };
73
+ if (now < exit.holdUntil) {
74
+ return this.#withSlide({ status: this.#lastStatus, kind: this.#lastKind, pending: true }, now);
75
+ }
76
+ const elapsed = now - exit.holdUntil;
77
+ if (elapsed >= exit.fadeMs) {
78
+ this.#clearExit();
79
+ return { status: undefined, kind: undefined, pending: false };
80
+ }
81
+ const half = exit.fadeMs / 2;
82
+ const outgoing = elapsed < half;
83
+ const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
84
+ if (!outgoing) return { status: undefined, kind: undefined, leftFade, pending: true };
85
+ return this.#withSlide({ status: this.#lastStatus, kind: this.#lastKind, leftFade, pending: true }, now);
86
+ }
87
+
88
+ pending(now: number): boolean {
89
+ const exit = this.#exit;
90
+ if (exit) return now < exit.holdUntil + exit.fadeMs;
91
+ return this.#slideFrom !== undefined && now < this.#slideFrom + STATUS_SLIDE_MS;
92
+ }
93
+
94
+ reset(): void {
95
+ this.#liveShown = false;
96
+ this.#clearExit();
97
+ }
98
+
99
+ /** A cleared status is still drawn through its hold and the fade's outgoing half. */
100
+ #exitDrawsStatus(now: number): boolean {
101
+ const exit = this.#exit;
102
+ return exit !== undefined && now < exit.holdUntil + exit.fadeMs / 2;
103
+ }
104
+
105
+ /**
106
+ * Add slide-out progress to a frame that draws a status. A status cleared
107
+ * mid-slide keeps sliding through its exit rather than snapping to full width.
108
+ */
109
+ #withSlide(frame: StatusFrame, now: number): StatusFrame {
110
+ if (this.#slideFrom === undefined) return frame;
111
+ const progress = (now - this.#slideFrom) / STATUS_SLIDE_MS;
112
+ if (progress >= 1) {
113
+ this.#slideFrom = undefined;
114
+ return frame;
115
+ }
116
+ return { ...frame, reveal: easeFade(progress), pending: true };
117
+ }
118
+
119
+ #clearExit(): void {
120
+ this.#lastStatus = undefined;
121
+ this.#lastKind = undefined;
122
+ this.#exit = undefined;
123
+ this.#slideFrom = undefined;
124
+ }
125
+ }