@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/README.md +62 -16
- package/index.ts +214 -117
- package/package.json +4 -5
- package/src/box.ts +33 -11
- package/src/compaction-embed.ts +62 -0
- package/src/context-graph.ts +5 -5
- package/src/context-thresholds.ts +7 -3
- package/src/context.ts +72 -13
- package/src/footer.ts +5 -2
- package/src/layout.ts +81 -30
- package/src/menu.ts +25 -36
- package/src/nvidia-green.ts +66 -0
- package/src/rainbow.ts +18 -4
- package/src/segments.ts +77 -5
- package/src/settings-menu.ts +102 -47
- package/src/settings.ts +30 -4
- package/src/tests/box.test.ts +6 -6
- package/src/tests/compaction-embed.test.ts +184 -0
- package/src/tests/context-estimate.test.ts +207 -0
- package/src/tests/context-thresholds.test.ts +6 -0
- package/src/tests/feeds.test.ts +2 -8
- package/src/tests/footer.test.ts +1 -0
- package/src/tests/menu.test.ts +31 -2
- package/src/tests/nvidia-green.test.ts +51 -0
- package/src/tests/token-rate.test.ts +2 -1
- package/src/tests/working-status-embed.test.ts +12 -0
- package/src/tests/working-status.test.ts +209 -0
- package/src/tests/working.test.ts +239 -23
- package/src/token-rate.ts +1 -1
- package/src/types.ts +37 -5
- package/src/working-status-embed.ts +15 -0
- package/src/working-status.ts +125 -0
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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 {
|
|
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
|
-
|
|
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 (!
|
|
37
|
+
if (!colorizerOn) return flat(box.horizontal.repeat(count));
|
|
38
38
|
let out = "";
|
|
39
|
-
|
|
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 (!
|
|
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)
|
|
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
|
+
};
|
package/src/context-graph.ts
CHANGED
|
@@ -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
|
|
20
|
+
opts: { usedTokens: number; labelColor: string; showBar: boolean; showStats: boolean; estimated: boolean },
|
|
21
21
|
): string {
|
|
22
22
|
const parts: string[] = [];
|
|
23
|
-
if (opts.showBar
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
63
|
+
interface ContextUsageFigures {
|
|
59
64
|
contextWindow: number;
|
|
60
|
-
tokens: number
|
|
65
|
+
tokens: number;
|
|
61
66
|
percent: number | null;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
523
|
+
sessionName: ctx?.sessionManager.getSessionName(),
|
|
466
524
|
contextPercent,
|
|
467
|
-
contextTokens: tokens
|
|
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
|
|
31
|
-
*
|
|
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
|
|
6
|
-
* the elastic `path` segment down to ~8 cells, drop left segments
|
|
7
|
-
* while protecting `path`.
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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: {
|
|
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 =
|
|
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] =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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 += ` ${
|
|
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"
|
|
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
|
}
|