@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.
package/src/box.ts CHANGED
@@ -2,16 +2,16 @@
2
2
  * Shared box-chrome painting for the live editor box (index.ts) and the
3
3
  * settings-menu preview, so the 3/2/+2 row geometry has one definition.
4
4
  *
5
- * Rainbow mode colors each glyph by its perimeter-walk position; otherwise
5
+ * Gradient mode colors each glyph by its perimeter-walk position; otherwise
6
6
  * the caller's flat painter wraps whole runs in one escape sequence, which
7
7
  * renders identically to per-glyph wraps with fewer bytes. The flat painter
8
- * and the RainbowBorder instance are injected because they differ per caller
8
+ * and the border colorizer are injected because they differ per caller
9
9
  * (host borderColor vs menu theme; shared stepped animator vs phase-from-
10
10
  * elapsed instance).
11
11
  */
12
12
  import { stripVTControlCharacters } from "node:util";
13
13
  import { visibleWidth } from "@earendil-works/pi-tui";
14
- import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
14
+ import { perimeterLength, perimeterPosition, type BorderColorizer } from "./rainbow.js";
15
15
  import type { BoxGlyphs } from "./theme.js";
16
16
 
17
17
  export interface BoxPainters {
@@ -22,28 +22,50 @@ export interface BoxPainters {
22
22
  }
23
23
 
24
24
  export function makeBoxPainters(opts: {
25
- rainbowOn: boolean;
26
- rainbow: RainbowBorder;
25
+ colorizerOn: boolean;
26
+ colorizer: BorderColorizer;
27
27
  box: BoxGlyphs;
28
28
  width: number;
29
29
  bottomIdx: number;
30
30
  flat: (str: string) => string;
31
31
  }): BoxPainters {
32
- const { rainbowOn, rainbow, box, width, bottomIdx, flat } = opts;
32
+ const { colorizerOn, colorizer, box, width, bottomIdx, flat } = opts;
33
33
  const perimeter = perimeterLength(width, bottomIdx);
34
34
  const paint = (row: number, col: number, ch: string): string =>
35
- rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
35
+ colorizerOn ? colorizer.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
36
36
  const horizRun = (row: number, startCol: number, count: number): string => {
37
- if (!rainbowOn) return flat(box.horizontal.repeat(count));
37
+ if (!colorizerOn) return flat(box.horizontal.repeat(count));
38
38
  let out = "";
39
- for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
39
+ let runPrefix: string | undefined;
40
+ let runChars = "";
41
+ for (let k = 0; k < count; k++) {
42
+ const prefix = colorizer.prefix(perimeterPosition(row, startCol + k, width, bottomIdx), perimeter);
43
+ if (runPrefix !== undefined && prefix !== runPrefix) {
44
+ out += `${runPrefix}${runChars}\x1b[39m`;
45
+ runChars = "";
46
+ }
47
+ runPrefix = prefix;
48
+ runChars += box.horizontal;
49
+ }
50
+ if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
40
51
  return out;
41
52
  };
42
53
  const gapColor = (str: string, startCol: number, row: number): string => {
43
- if (!rainbowOn) return flat(str);
54
+ if (!colorizerOn) return flat(str);
44
55
  let out = "";
56
+ let runPrefix: string | undefined;
57
+ let runChars = "";
45
58
  let k = 0;
46
- for (const ch of str) out += paint(row, startCol + k++, ch);
59
+ for (const ch of str) {
60
+ const prefix = colorizer.prefix(perimeterPosition(row, startCol + k++, width, bottomIdx), perimeter);
61
+ if (runPrefix !== undefined && prefix !== runPrefix) {
62
+ out += `${runPrefix}${runChars}\x1b[39m`;
63
+ runChars = "";
64
+ }
65
+ runPrefix = prefix;
66
+ runChars += ch;
67
+ }
68
+ if (runPrefix !== undefined) out += `${runPrefix}${runChars}\x1b[39m`;
47
69
  return out;
48
70
  };
49
71
  return { paint, horizRun, gapColor };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * pi-topping-compact's compaction progress, hosted in the bottom bar.
3
+ *
4
+ * The two extensions talk over pi's extension event bus. This extension
5
+ * announces on COMPACTION_EMBED_CHANNEL whether its bottom bar has room for the
6
+ * progress; pi-topping-compact broadcasts snapshots on COMPACTION_PROGRESS_CHANNEL
7
+ * only while it does, and ends with `{ active: false }` so pi's stats and the
8
+ * context graph return the moment the compaction finishes. Its completion
9
+ * result still appears above the editor, in pi-topping-compact's own widget.
10
+ */
11
+ import { COMPACTION_STAND_INS } from "./settings.js";
12
+ import { hexToFgAnsi } from "./theme.js";
13
+ import type { CompactionProgressView, EffectiveStatusLineSettings } from "./types.js";
14
+
15
+ /** Announced as `{ embedded: boolean }`. Shared with pi-topping-compact. */
16
+ export const COMPACTION_EMBED_CHANNEL = "pi-topping-statusline:compaction-progress-embedded";
17
+
18
+ /** Carries a CompactionProgressView plus `active: true`, or `{ active: false }`. Shared with pi-topping-compact. */
19
+ export const COMPACTION_PROGRESS_CHANNEL = "pi-topping-compact:compaction-progress";
20
+
21
+ const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
22
+
23
+ /** The view in a broadcast, or undefined when it reports no active compaction or is malformed. */
24
+ export function parseCompactionProgress(data: unknown): CompactionProgressView | undefined {
25
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
26
+ const { active, bar, percent, tokensBefore, contextWindow, elapsedMs } = data as Record<string, unknown>;
27
+ if (active !== true || typeof bar !== "string") return undefined;
28
+ if (
29
+ !isFiniteNumber(percent) ||
30
+ !isFiniteNumber(tokensBefore) ||
31
+ !isFiniteNumber(contextWindow) ||
32
+ !isFiniteNumber(elapsedMs)
33
+ ) {
34
+ return undefined;
35
+ }
36
+ return { bar, percent, tokensBefore, contextWindow, elapsedMs };
37
+ }
38
+
39
+ /** Whether the bottom bar has somewhere to put the progress: the setting is on and a segment it stands in for is shown. */
40
+ export function hostsCompactionProgress(effective: EffectiveStatusLineSettings): boolean {
41
+ return (
42
+ effective.embedCompactionProgress &&
43
+ effective.bottomRightSegments.some((id) => COMPACTION_STAND_INS[id] !== undefined)
44
+ );
45
+ }
46
+
47
+ /** pi-topping-compact's phosphor bar as it looks with a third of the context left to compact. */
48
+ const FIT_PROBE_BAR = `${hexToFgAnsi("#72f1b8")}${"\u258b".repeat(7)}\x1b[39m\x1b[2m${hexToFgAnsi("#266446")}${"\u2591".repeat(13)}\x1b[22m\x1b[39m`;
49
+
50
+ /**
51
+ * A representative hosted compaction (66% usage, 35% still to go, so 47% summarized), used to
52
+ * measure whether the stand-in segments have room before any real progress has broadcast, and
53
+ * shown in the settings preview. Real figures vary in digit count, but this stands in for a
54
+ * typical session without committing to a worst case that would starve the rest of the bar.
55
+ */
56
+ export const COMPACTION_FIT_PROBE: CompactionProgressView = {
57
+ bar: FIT_PROBE_BAR,
58
+ percent: 47,
59
+ tokensBefore: 86_000,
60
+ contextWindow: 131_072,
61
+ elapsedMs: 16_300,
62
+ };
@@ -17,10 +17,10 @@ const cellColor = (percent: number, contextWindow: number): string =>
17
17
  export function renderContextGraph(
18
18
  percent: number,
19
19
  contextWindow: number,
20
- opts: { usedTokens?: number; labelColor?: string; showBar?: boolean; showStats?: boolean } = {},
20
+ opts: { usedTokens: number; labelColor: string; showBar: boolean; showStats: boolean; estimated: boolean },
21
21
  ): string {
22
22
  const parts: string[] = [];
23
- if (opts.showBar !== false) {
23
+ if (opts.showBar) {
24
24
  const filled = Math.min(CELLS, Math.max(0, Math.round((percent * CELLS) / 100)));
25
25
  let bar = "";
26
26
  let prevColor = "";
@@ -38,11 +38,11 @@ export function renderContextGraph(
38
38
  bar += "\x1b[39m";
39
39
  parts.push(bar);
40
40
  }
41
- if (opts.showStats !== false) {
41
+ if (opts.showStats) {
42
42
  // Label wears pi's own footer-stats color so it reads as part of that run;
43
43
  // the threshold gradient still lives in the cells.
44
- const text = formatContextUsage(percent, contextWindow, opts.usedTokens);
45
- parts.push(opts.labelColor ? `${opts.labelColor}${text}\x1b[39m` : theme.fg("muted", text));
44
+ const text = formatContextUsage(percent, contextWindow, opts.usedTokens, opts.estimated);
45
+ parts.push(`${opts.labelColor}${text}\x1b[39m`);
46
46
  }
47
47
  return parts.join(" ");
48
48
  }
@@ -1,4 +1,4 @@
1
- /** Ported verbatim from oh-my-pi status-line/context-thresholds.ts. */
1
+ /** Ported from oh-my-pi status-line/context-thresholds.ts; formatContextUsage adds the `~` estimate marker. */
2
2
  import type { StatusColor } from "./theme.js";
3
3
  import { formatNumber } from "./utils.js";
4
4
 
@@ -62,11 +62,15 @@ export function formatContextUsage(
62
62
  contextPercent: number | null | undefined,
63
63
  contextWindow: number,
64
64
  usedTokens?: number,
65
+ estimated = false,
65
66
  ): string {
67
+ // An estimate wears "~" so it reads as one until pi's own figure arrives.
68
+ const about = estimated ? "~" : "";
66
69
  if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
67
- return `${formatNumber(usedTokens ?? 0)}/?`;
70
+ return `${about}${formatNumber(usedTokens ?? 0)}/?`;
68
71
  }
69
- const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
72
+ const pct =
73
+ contextPercent === null || contextPercent === undefined ? "?" : `${about}${contextPercent.toFixed(1)}%`;
70
74
  return `${pct}/${formatNumber(contextWindow)}`;
71
75
  }
72
76
 
package/src/context.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { isDeepStrictEqual } from "node:util";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ buildSessionContext,
13
+ estimateTokens,
14
+ type ExtensionAPI,
15
+ type ExtensionContext,
16
+ } from "@earendil-works/pi-coding-agent";
12
17
  import { FADE_SHADE_COUNT } from "./theme.js";
13
18
  import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
14
19
  import { feedKey } from "./utils.js";
@@ -55,16 +60,32 @@ export function getFeedDisplayState(changedAt: number, now: number): FeedDisplay
55
60
  };
56
61
  }
57
62
 
58
- function deriveContextUsage(ctx: ExtensionContext | undefined): {
63
+ interface ContextUsageFigures {
59
64
  contextWindow: number;
60
- tokens: number | undefined;
65
+ tokens: number;
61
66
  percent: number | null;
62
- } {
63
- const usage = ctx?.getContextUsage();
64
- const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
65
- const tokens = usage?.tokens ?? undefined;
66
- const percent = usage ? usage.percent : null;
67
- return { contextWindow, tokens, percent };
67
+ /** The figures are pi's estimate of a freshly compacted context, not a measurement. */
68
+ estimated: boolean;
69
+ }
70
+
71
+ /** One post-compaction estimate, keyed by session and leaf so it is rebuilt only when the branch grows. */
72
+ interface ContextEstimate {
73
+ key: string;
74
+ tokens: number | undefined;
75
+ }
76
+
77
+ /**
78
+ * pi's own estimate of the context after a compaction: the summary plus the
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.
82
+ */
83
+ export function estimateCompactedContextTokens(sessionManager: ExtensionContext["sessionManager"]): number {
84
+ let tokens = 0;
85
+ for (const message of buildSessionContext(sessionManager.getBranch(), sessionManager.getLeafId()).messages) {
86
+ tokens += estimateTokens(message);
87
+ }
88
+ return tokens;
68
89
  }
69
90
 
70
91
  function parseGitStatus(porcelain: string): GitStatusCounts {
@@ -119,6 +140,8 @@ export class SegmentContextBuilder {
119
140
  #feedTimer: ReturnType<typeof setTimeout> | undefined;
120
141
  #feedTimerAt: number | undefined;
121
142
 
143
+ #contextEstimate: ContextEstimate | undefined;
144
+
122
145
  constructor(pi: ExtensionAPI) {
123
146
  this.#pi = pi;
124
147
  }
@@ -146,6 +169,7 @@ export class SegmentContextBuilder {
146
169
  this.#feedsScannedAt = 0;
147
170
  this.#feedsScannedRef = undefined;
148
171
  this.#clearFeedLifecycle();
172
+ this.#contextEstimate = undefined;
149
173
  if (this.#repoCwd !== ctx.cwd) {
150
174
  this.#resetGitState();
151
175
  this.#repoCwd = ctx.cwd;
@@ -307,7 +331,7 @@ export class SegmentContextBuilder {
307
331
  #refreshFeeds(customTypes: readonly string[], now: number): void {
308
332
  this.#feedsScannedAt = now;
309
333
  this.#feedData = emptyFeedData();
310
- const entries = this.#ctx?.sessionManager?.getEntries();
334
+ const entries = this.#ctx?.sessionManager.getEntries();
311
335
  if (!entries) return;
312
336
  const pending = new Set(customTypes);
313
337
  for (let i = entries.length - 1; i >= 0 && pending.size > 0; i--) {
@@ -414,6 +438,40 @@ export class SegmentContextBuilder {
414
438
  }
415
439
  }
416
440
 
441
+ // ── context usage ────────────────────────────────────────────────────────
442
+
443
+ /**
444
+ * pi reports usage as unknown between a compaction and the next assistant
445
+ * response. Rather than let the graph vanish for that stretch, this fills it
446
+ * with pi's own estimate of the compacted context, flagged as such, until
447
+ * pi's measured figure arrives.
448
+ */
449
+ #deriveContextUsage(ctx: ExtensionContext | undefined): ContextUsageFigures {
450
+ const usage = ctx?.getContextUsage();
451
+ const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
452
+ if (!ctx || !usage || usage.tokens !== null || contextWindow <= 0) {
453
+ return { contextWindow, tokens: usage?.tokens ?? 0, percent: usage ? usage.percent : null, estimated: false };
454
+ }
455
+ const tokens = this.#estimateContextTokens(ctx);
456
+ if (tokens === undefined) return { contextWindow, tokens: 0, percent: null, estimated: false };
457
+ return { contextWindow, tokens, percent: (tokens / contextWindow) * 100, estimated: true };
458
+ }
459
+
460
+ #estimateContextTokens(ctx: ExtensionContext): number | undefined {
461
+ const sessionManager = ctx.sessionManager;
462
+ try {
463
+ const key = `${sessionManager.getSessionId()}\0${sessionManager.getLeafId() ?? ""}`;
464
+ if (this.#contextEstimate?.key !== key) {
465
+ this.#contextEstimate = { key, tokens: undefined };
466
+ this.#contextEstimate.tokens = estimateCompactedContextTokens(sessionManager);
467
+ }
468
+ return this.#contextEstimate.tokens;
469
+ } catch {
470
+ // The session plumbing is pi's; a surprise there costs the estimate, not the frame.
471
+ return undefined;
472
+ }
473
+ }
474
+
417
475
  // ── snapshot ─────────────────────────────────────────────────────────────
418
476
 
419
477
  build(
@@ -455,17 +513,18 @@ export class SegmentContextBuilder {
455
513
  }
456
514
  : undefined;
457
515
 
458
- const { contextWindow, tokens, percent: contextPercent } = deriveContextUsage(ctx);
516
+ const { contextWindow, tokens, percent: contextPercent, estimated } = this.#deriveContextUsage(ctx);
459
517
 
460
518
  return {
461
519
  options,
462
520
  model,
463
521
  thinkingLevel: this.#pi.getThinkingLevel(),
464
522
  cwd: ctx?.cwd ?? process.cwd(),
465
- sessionName: ctx?.sessionManager?.getSessionName(),
523
+ sessionName: ctx?.sessionManager.getSessionName(),
466
524
  contextPercent,
467
- contextTokens: tokens ?? 0,
525
+ contextTokens: tokens,
468
526
  contextWindow,
527
+ contextEstimated: estimated,
469
528
  git: {
470
529
  branch: include.git || include.pr ? this.#branch : null,
471
530
  status: include.git ? this.#gitStatus : null,
package/src/footer.ts CHANGED
@@ -27,8 +27,11 @@ function containsComponent(node: unknown, target: unknown, seen: Set<unknown>):
27
27
  }
28
28
 
29
29
  /**
30
- * Let Pi's fullscreen footer slot collapse when its component renders no rows.
31
- * Pi 0.84 gives that VStack entry minSize=1, independently of render output.
30
+ * Let pi's fullscreen footer slot collapse when its component renders no rows.
31
+ * Hosts before 0.86 give that VStack entry minSize=1 regardless of render
32
+ * output; 0.86 fixed it (#8919), where this writes 0 over 0. Delete this
33
+ * helper, its index.ts call/restore path, and its test once the minimum
34
+ * supported host is 0.86.
32
35
  */
33
36
  export function collapseFooterLayoutSlot(layoutRoot: unknown, footer: unknown): (() => void) | undefined {
34
37
  const seen = new Set<unknown>();
package/src/layout.ts CHANGED
@@ -2,21 +2,28 @@
2
2
  * The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
3
3
  *
4
4
  * Overflow strategy (in order): drop right segments right-to-left, truncate
5
- * the embedded `working` status (no ellipsis, as pi's own border does), shrink
6
- * the elastic `path` segment down to ~8 cells, drop left segments end-first
7
- * while protecting `path`. The gap between the two groups is filled with the
5
+ * the embedded `working` status with the caller's chosen ellipsis policy,
6
+ * shrink the elastic `path` segment down to ~8 cells, drop left segments
7
+ * end-first while protecting `path`. Working indicators retain pi's bare cut;
8
+ * message-style indicators use an ellipsis so truncated prose is apparent.
9
+ * The gap between the two groups is filled with the
8
10
  * box-horizontal glyph colored like the editor border, so it tracks the
9
11
  * thinking-level border color; the callback receives the gap's absolute
10
12
  * column and row in the box, so a caller can paint it as part of a
11
13
  * continuous border gradient (the rainbow effect).
12
14
  *
13
- * `options.leftFade` (0..1) blends every left segment after a leading `pi`
14
- * symbol toward the bar background, which the working-indicator transition
15
- * uses to cross-fade the group without moving the symbol or its chevron.
15
+ * `options.leftFade` (0..1) blends transient left segments toward the bar
16
+ * background while keeping the Pi symbol and model details solid, which the
17
+ * working-indicator transition uses without hiding stable model information.
18
+ * `options.workingReveal` (0..1) slides the `working` status out from behind
19
+ * the segment before it, trailing separator first, pushing the segments after
20
+ * it right. Overflow is resolved for the fully revealed status, so no segment
21
+ * appears or drops mid-slide and the last frame matches the settled bar.
16
22
  */
17
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
23
+ import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
24
  import { renderSegment } from "./segments.js";
19
25
  import { getSeparator } from "./separators.js";
26
+ import { STATUS_STABLE_SEGMENTS } from "./settings.js";
20
27
  import { theme } from "./theme.js";
21
28
  import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId } from "./types.js";
22
29
 
@@ -63,7 +70,13 @@ export function buildStatusLine(
63
70
  gapBorderColor: (str: string, startCol: number, row: number) => string,
64
71
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
65
72
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
66
- options: { leftFade?: number } = {},
73
+ options: {
74
+ leftFade?: number;
75
+ workingReveal?: number;
76
+ workingEllipsis?: string;
77
+ /** Called once overflow trimming settles, with the segment ids that actually made it into each rendered group. */
78
+ onFit?: (fit: { left: readonly StatusLineSegmentId[]; right: readonly StatusLineSegmentId[] }) => void;
79
+ } = {},
67
80
  ): string {
68
81
  const separatorDef = getSeparator(settings.separator);
69
82
 
@@ -71,6 +84,8 @@ export function buildStatusLine(
71
84
  const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
72
85
  const fgAnsi = theme.getFgAnsi("text");
73
86
  const sepAnsi = theme.getFgAnsi("statusLineSep");
87
+ const leftSepText = ` ${sepAnsi}${separatorDef.left}${fgAnsi} `;
88
+ const rightSepText = ` ${sepAnsi}${separatorDef.right}${fgAnsi} `;
74
89
 
75
90
  const left: string[] = [];
76
91
  const leftSegIds: StatusLineSegmentId[] = [];
@@ -83,10 +98,12 @@ export function buildStatusLine(
83
98
  }
84
99
 
85
100
  const right: string[] = [];
101
+ const rightSegIds: StatusLineSegmentId[] = [];
86
102
  for (const segId of segmentGroups.right) {
87
103
  const rendered = renderSegment(segId, ctx);
88
104
  if (rendered.visible && rendered.content) {
89
105
  right.push(rendered.content);
106
+ rightSegIds.push(segId);
90
107
  }
91
108
  }
92
109
 
@@ -103,7 +120,7 @@ export function buildStatusLine(
103
120
  const groupWidth = (parts: string[], sepWidth: number): number => {
104
121
  if (parts.length === 0) return 0;
105
122
  const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
106
- const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
123
+ const sepTotal = (parts.length - 1) * (sepWidth + 2);
107
124
  return partsWidth + sepTotal + 2 + capWidth;
108
125
  };
109
126
 
@@ -116,15 +133,27 @@ export function buildStatusLine(
116
133
  if (width > 0) {
117
134
  while (totalWidth() > width && right.length > 0) {
118
135
  right.pop();
136
+ rightSegIds.pop();
119
137
  rightWidth = rightGroupWidth(right);
120
138
  }
121
139
  // The working status absorbs overflow first so the Pi symbol beside it
122
- // survives on narrow terminals.
140
+ // survives on narrow terminals. Segments trailing it are dropped first so
141
+ // a static segment (e.g. model) never outlives the live status indicator.
123
142
  const workingIdx = leftSegIds.indexOf("working");
143
+ while (
144
+ workingIdx >= 0 &&
145
+ workingIdx < left.length - 1 &&
146
+ visibleWidth(left[workingIdx]) - (totalWidth() - width) < 1
147
+ ) {
148
+ left.pop();
149
+ leftSegIds.pop();
150
+ leftWidth = leftGroupWidth(left);
151
+ }
124
152
  if (workingIdx >= 0 && totalWidth() > width) {
125
153
  const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
126
154
  if (available >= 1) {
127
- left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
155
+ left[workingIdx] =
156
+ truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "") + bgAnsi + fgAnsi;
128
157
  } else {
129
158
  left.splice(workingIdx, 1);
130
159
  leftSegIds.splice(workingIdx, 1);
@@ -156,25 +185,49 @@ export function buildStatusLine(
156
185
  }
157
186
  }
158
187
 
159
- const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
160
- if (parts.length === 0) return "";
161
- const sep = direction === "left" ? separatorDef.left : separatorDef.right;
162
- const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
163
- const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
164
-
165
- let body: string;
166
- const fade = options.leftFade;
167
- if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
168
- // The faded tail opens with an explicit default-fg reset so plain text
169
- // inside it is recolored too, not just segments that set their own color.
170
- const head = parts.slice(0, fadeFrom).join(sepText);
171
- const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
172
- body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
188
+ // Trimming above only drops or truncates segments, never adds them back, so
189
+ // the surviving ids are settled here regardless of width.
190
+ options.onFit?.({ left: leftSegIds, right: rightSegIds });
191
+
192
+ const fade = options.leftFade;
193
+ if (fade !== undefined && fade < 1) {
194
+ for (const [i, id] of leftSegIds.entries()) {
195
+ if (STATUS_STABLE_SEGMENTS.has(id)) continue;
196
+ // Reset first so plain text is recolored too, not just segments that
197
+ // set their own foreground color.
198
+ left[i] = theme.fadeAnsi(`\x1b[39m${left[i]}`, fade);
199
+ }
200
+ }
201
+
202
+ const reveal = options.workingReveal;
203
+ const slideIdx = leftSegIds.indexOf("working");
204
+ if (reveal !== undefined && reveal < 1 && slideIdx >= 0) {
205
+ // Only the status's tail has emerged. Ahead of another segment, the tail
206
+ // includes the separator between them and is joined onto that segment,
207
+ // which then moves right one cell per revealed cell.
208
+ const pushes = slideIdx < left.length - 1;
209
+ const unit = pushes ? left[slideIdx] + leftSepText : left[slideIdx];
210
+ const unitWidth = visibleWidth(unit);
211
+ const shown = Math.round(unitWidth * reveal);
212
+ if (shown <= 0) {
213
+ left.splice(slideIdx, 1);
214
+ leftSegIds.splice(slideIdx, 1);
215
+ } else if (pushes) {
216
+ left.splice(slideIdx, 2, sliceByColumn(unit, unitWidth - shown, shown) + left[slideIdx + 1]);
217
+ leftSegIds.splice(slideIdx, 1);
173
218
  } else {
174
- body = parts.join(sepText);
219
+ left[slideIdx] = sliceByColumn(unit, unitWidth - shown, shown);
175
220
  }
221
+ leftWidth = leftGroupWidth(left);
222
+ }
223
+
224
+ const renderGroup = (parts: string[], direction: "left" | "right"): string => {
225
+ if (parts.length === 0) return "";
226
+ const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
227
+ const sepText = direction === "left" ? leftSepText : rightSepText;
228
+
176
229
  let content = bgAnsi + fgAnsi;
177
- content += ` ${body} `;
230
+ content += ` ${parts.join(sepText)} `;
178
231
  content += "\x1b[0m";
179
232
 
180
233
  if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
@@ -182,7 +235,7 @@ export function buildStatusLine(
182
235
  return content;
183
236
  };
184
237
 
185
- const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
238
+ const leftGroup = renderGroup(left, "left");
186
239
  const rightGroup = renderGroup(right, "right");
187
240
  if (!leftGroup && !rightGroup) return "";
188
241
 
@@ -196,7 +249,5 @@ export function buildStatusLine(
196
249
  barOrigin.col + leftWidth,
197
250
  barOrigin.row,
198
251
  );
199
- if (!leftGroup) return gapFill + rightGroup;
200
- if (!rightGroup) return leftGroup + gapFill;
201
252
  return leftGroup + gapFill + rightGroup;
202
253
  }