@underactive/pi-topping-statusline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Context usage bar graph, ported from pi-synthwave-statusline's generateDots
3
+ * with OMP's palette substituted: each filled cell is colored by the threshold
4
+ * band its own position falls into, so the graph's gradient matches the bar's
5
+ * context percentage color at every fill level.
6
+ */
7
+ import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds.js";
8
+ import { theme } from "./theme.js";
9
+
10
+ const CELLS = 20;
11
+ const FILLED = "\u258b";
12
+ const TROUGH = "\u2591";
13
+
14
+ const cellColor = (percent: number, contextWindow: number): string =>
15
+ theme.getFgAnsi(getContextUsageThemeColor(getContextUsageLevel(percent, contextWindow)));
16
+
17
+ export function renderContextGraph(
18
+ percent: number,
19
+ contextWindow: number,
20
+ opts: { usedTokens?: number; labelColor?: string; showBar?: boolean; showStats?: boolean } = {},
21
+ ): string {
22
+ const parts: string[] = [];
23
+ if (opts.showBar !== false) {
24
+ const filled = Math.min(CELLS, Math.max(0, Math.round((percent * CELLS) / 100)));
25
+ let bar = "";
26
+ let prevColor = "";
27
+ for (let i = 0; i < filled; i++) {
28
+ const c = cellColor(((i + 1) / CELLS) * 100, contextWindow);
29
+ if (c !== prevColor) {
30
+ bar += c;
31
+ prevColor = c;
32
+ }
33
+ bar += FILLED;
34
+ }
35
+ // Resets stay fg/attribute-only (39/22, never 0) so a caller's background
36
+ // fill survives the whole run.
37
+ if (filled < CELLS) bar += `\x1b[39m\x1b[2m${TROUGH.repeat(CELLS - filled)}\x1b[22m`;
38
+ bar += "\x1b[39m";
39
+ parts.push(bar);
40
+ }
41
+ if (opts.showStats !== false) {
42
+ // Label wears pi's own footer-stats color so it reads as part of that run;
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));
46
+ }
47
+ return parts.join(" ");
48
+ }
@@ -0,0 +1,84 @@
1
+ /** Ported verbatim from oh-my-pi status-line/context-thresholds.ts. */
2
+ import type { StatusColor } from "./theme.js";
3
+ import { formatNumber } from "./utils.js";
4
+
5
+ export type ContextUsageLevel = "normal" | "warning" | "elevated" | "error";
6
+
7
+ const CONTEXT_WARNING_PERCENT_THRESHOLD = 50;
8
+ const CONTEXT_WARNING_TOKEN_THRESHOLD = 150_000;
9
+ const CONTEXT_ELEVATED_PERCENT_THRESHOLD = 70;
10
+ const CONTEXT_ELEVATED_TOKEN_THRESHOLD = 270_000;
11
+ const CONTEXT_ERROR_PERCENT_THRESHOLD = 90;
12
+ const CONTEXT_ERROR_TOKEN_THRESHOLD = 500_000;
13
+
14
+ function reachesThreshold(
15
+ contextPercent: number,
16
+ contextWindow: number,
17
+ percentThreshold: number,
18
+ tokenThreshold: number,
19
+ ): boolean {
20
+ if (!Number.isFinite(contextPercent) || contextPercent <= 0) {
21
+ return false;
22
+ }
23
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
24
+ return contextPercent >= percentThreshold;
25
+ }
26
+ const tokenPercentThreshold = (tokenThreshold / contextWindow) * 100;
27
+ return contextPercent >= Math.min(percentThreshold, tokenPercentThreshold);
28
+ }
29
+
30
+ export function getContextUsageLevel(contextPercent: number, contextWindow: number): ContextUsageLevel {
31
+ if (reachesThreshold(contextPercent, contextWindow, CONTEXT_ERROR_PERCENT_THRESHOLD, CONTEXT_ERROR_TOKEN_THRESHOLD)) {
32
+ return "error";
33
+ }
34
+ if (
35
+ reachesThreshold(
36
+ contextPercent,
37
+ contextWindow,
38
+ CONTEXT_ELEVATED_PERCENT_THRESHOLD,
39
+ CONTEXT_ELEVATED_TOKEN_THRESHOLD,
40
+ )
41
+ ) {
42
+ return "elevated";
43
+ }
44
+ if (
45
+ reachesThreshold(
46
+ contextPercent,
47
+ contextWindow,
48
+ CONTEXT_WARNING_PERCENT_THRESHOLD,
49
+ CONTEXT_WARNING_TOKEN_THRESHOLD,
50
+ )
51
+ ) {
52
+ return "warning";
53
+ }
54
+ return "normal";
55
+ }
56
+
57
+ /**
58
+ * Format context usage as `<pct>%/<window>` when the model window is known.
59
+ * Unknown windows render as `<tokens>/?`.
60
+ */
61
+ export function formatContextUsage(
62
+ contextPercent: number | null | undefined,
63
+ contextWindow: number,
64
+ usedTokens?: number,
65
+ ): string {
66
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
67
+ return `${formatNumber(usedTokens ?? 0)}/?`;
68
+ }
69
+ const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
70
+ return `${pct}/${formatNumber(contextWindow)}`;
71
+ }
72
+
73
+ export function getContextUsageThemeColor(level: ContextUsageLevel): StatusColor {
74
+ switch (level) {
75
+ case "error":
76
+ return "error";
77
+ case "elevated":
78
+ return "statusLineContextElevated";
79
+ case "warning":
80
+ return "warning";
81
+ case "normal":
82
+ return "statusLineContext";
83
+ }
84
+ }
package/src/context.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * SegmentContext builder — the pi-side replacement for the state plumbing in
3
+ * oh-my-pi's StatusLineComponent (git HEAD watcher, status/PR caches).
4
+ *
5
+ * All git/gh access shells out via pi.exec, mirroring the bundled
6
+ * border-status-editor example and pi-synthwave-statusline.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import type { SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
12
+
13
+ const GIT_STATUS_TTL_MS = 1_000;
14
+ const BRANCH_POLL_TTL_MS = 5_000;
15
+ const EXEC_TIMEOUT_MS = 2_000;
16
+
17
+ /** getEntries() filters and copies the whole session, so rescans are rate-limited. */
18
+ const FEED_TTL_MS = 2_000;
19
+
20
+ interface GitStatusCounts {
21
+ staged: number;
22
+ unstaged: number;
23
+ untracked: number;
24
+ }
25
+
26
+ interface RepoInfo {
27
+ gitDir: string;
28
+ commonDir: string;
29
+ toplevel: string;
30
+ }
31
+
32
+ function deriveContextUsage(ctx: ExtensionContext | undefined): {
33
+ contextWindow: number;
34
+ tokens: number | undefined;
35
+ percent: number | null;
36
+ } {
37
+ const usage = ctx?.getContextUsage();
38
+ const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
39
+ const tokens = usage?.tokens ?? undefined;
40
+ const percent = usage ? usage.percent : contextWindow > 0 && tokens != null ? (tokens / contextWindow) * 100 : null;
41
+ return { contextWindow, tokens, percent };
42
+ }
43
+
44
+ function parseGitStatus(porcelain: string): GitStatusCounts {
45
+ let staged = 0;
46
+ let unstaged = 0;
47
+ let untracked = 0;
48
+ for (const line of porcelain.split("\n")) {
49
+ if (line.length < 2) continue;
50
+ if (line.startsWith("??")) {
51
+ untracked++;
52
+ continue;
53
+ }
54
+ if (line[0] !== " ") staged++;
55
+ if (line[1] !== " ") unstaged++;
56
+ }
57
+ return { staged, unstaged, untracked };
58
+ }
59
+
60
+ export class SegmentContextBuilder {
61
+ #pi: ExtensionAPI;
62
+ #ctx: ExtensionContext | undefined;
63
+ #requestRender: () => void = () => {};
64
+ #piStatsProvider: ((width: number) => string | undefined) | undefined;
65
+ #tokenRateProvider: (() => TokenRateDisplay) | undefined;
66
+
67
+ #repo: RepoInfo | null | undefined = undefined;
68
+ #repoCwd: string | undefined;
69
+ #repoResolving = false;
70
+
71
+ #branch: string | null = null;
72
+ #branchFetchedAt = 0;
73
+ #branchInFlight = false;
74
+ #headWatcher: fs.FSWatcher | undefined;
75
+ #watcherOk = false;
76
+
77
+ #gitStatus: GitStatusCounts | null = null;
78
+ #gitStatusFetchedAt = 0;
79
+ #gitStatusInFlight = false;
80
+
81
+ #pr: { number: number; url: string } | null = null;
82
+ #prBranch: string | null | undefined = undefined;
83
+ #prInFlight = false;
84
+ #ghUnavailable = false;
85
+
86
+ #worktree: { projectName: string; worktreeName: string } | null = null;
87
+
88
+ #feedData: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
89
+ #feedsScannedAt = 0;
90
+ #feedsScanned = "";
91
+ #runStartedAt = 0;
92
+
93
+ constructor(pi: ExtensionAPI) {
94
+ this.#pi = pi;
95
+ }
96
+
97
+ setRequestRender(fn: () => void): void {
98
+ this.#requestRender = fn;
99
+ }
100
+
101
+ setPiStatsProvider(provider: (width: number) => string | undefined): void {
102
+ this.#piStatsProvider = provider;
103
+ }
104
+
105
+ setTokenRateProvider(provider: () => TokenRateDisplay): void {
106
+ this.#tokenRateProvider = provider;
107
+ }
108
+
109
+ attach(ctx: ExtensionContext): void {
110
+ this.#ctx = ctx;
111
+ // A publisher's figure typically covers one process run — pi-prompt-cache
112
+ // restarts its savings at zero on every session start — while a resumed
113
+ // session still carries the previous run's entries. Anything predating this
114
+ // attach belongs to that stale run.
115
+ this.#runStartedAt = Date.now();
116
+ this.#feedData = Object.create(null) as Record<string, unknown>;
117
+ this.#feedsScannedAt = 0;
118
+ this.#feedsScanned = "";
119
+ if (this.#repoCwd !== ctx.cwd) {
120
+ this.#resetGitState();
121
+ this.#repoCwd = ctx.cwd;
122
+ void this.#resolveRepo(ctx.cwd);
123
+ }
124
+ }
125
+
126
+ dispose(): void {
127
+ this.#headWatcher?.close();
128
+ this.#headWatcher = undefined;
129
+ this.#watcherOk = false;
130
+ this.#ctx = undefined;
131
+ }
132
+
133
+ // ── git plumbing ─────────────────────────────────────────────────────────
134
+
135
+ #resetGitState(): void {
136
+ this.#headWatcher?.close();
137
+ this.#headWatcher = undefined;
138
+ this.#watcherOk = false;
139
+ this.#repo = undefined;
140
+ this.#branch = null;
141
+ this.#branchFetchedAt = 0;
142
+ this.#gitStatus = null;
143
+ this.#gitStatusFetchedAt = 0;
144
+ this.#pr = null;
145
+ this.#prBranch = undefined;
146
+ this.#worktree = null;
147
+ }
148
+
149
+ async #exec(cmd: string, args: string[], cwd: string): Promise<string | undefined> {
150
+ const result = await this.#pi.exec(cmd, args, { cwd, timeout: EXEC_TIMEOUT_MS }).catch(() => undefined);
151
+ if (!result || result.code !== 0) return undefined;
152
+ return result.stdout;
153
+ }
154
+
155
+ async #resolveRepo(cwd: string): Promise<void> {
156
+ if (this.#repoResolving) return;
157
+ this.#repoResolving = true;
158
+ try {
159
+ const out = await this.#exec(
160
+ "git",
161
+ ["rev-parse", "--absolute-git-dir", "--git-common-dir", "--show-toplevel"],
162
+ cwd,
163
+ );
164
+ if (this.#repoCwd !== cwd) return;
165
+ if (!out) {
166
+ this.#repo = null;
167
+ return;
168
+ }
169
+ const [gitDir, commonDirRaw, toplevel] = out.trim().split("\n");
170
+ if (!gitDir || !toplevel) {
171
+ this.#repo = null;
172
+ return;
173
+ }
174
+ const commonDir = path.resolve(cwd, commonDirRaw || gitDir);
175
+ this.#repo = { gitDir, commonDir, toplevel };
176
+
177
+ // Linked worktree: git-dir diverges from the shared common dir.
178
+ if (path.resolve(gitDir) !== commonDir) {
179
+ const base = path.basename(path.dirname(commonDir));
180
+ const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
181
+ if (projectName) {
182
+ this.#worktree = { projectName, worktreeName: path.basename(toplevel) };
183
+ }
184
+ }
185
+
186
+ this.#setupHeadWatcher();
187
+ void this.#refreshBranch();
188
+ this.#requestRender();
189
+ } finally {
190
+ this.#repoResolving = false;
191
+ }
192
+ }
193
+
194
+ #setupHeadWatcher(): void {
195
+ this.#headWatcher?.close();
196
+ this.#headWatcher = undefined;
197
+ this.#watcherOk = false;
198
+ if (!this.#repo) return;
199
+ const headPath = path.join(this.#repo.gitDir, "HEAD");
200
+ try {
201
+ const watcher = fs.watch(headPath, () => {
202
+ if (this.#headWatcher !== watcher) return;
203
+ this.#branchFetchedAt = 0;
204
+ this.#gitStatusFetchedAt = 0;
205
+ void this.#refreshBranch();
206
+ });
207
+ watcher.on("error", () => {
208
+ if (this.#headWatcher !== watcher) return;
209
+ watcher.close();
210
+ this.#headWatcher = undefined;
211
+ this.#watcherOk = false;
212
+ });
213
+ this.#headWatcher = watcher;
214
+ this.#watcherOk = true;
215
+ } catch {
216
+ this.#watcherOk = false;
217
+ }
218
+ }
219
+
220
+ async #refreshBranch(): Promise<void> {
221
+ const cwd = this.#repoCwd;
222
+ if (!cwd || !this.#repo || this.#branchInFlight) return;
223
+ this.#branchInFlight = true;
224
+ try {
225
+ const out = await this.#exec("git", ["branch", "--show-current"], cwd);
226
+ if (this.#repoCwd !== cwd) return;
227
+ this.#branchFetchedAt = Date.now();
228
+ const next = out?.trim() || "detached";
229
+ const changed = next !== this.#branch;
230
+ this.#branch = next;
231
+ if (changed) {
232
+ this.#prBranch = undefined;
233
+ this.#requestRender();
234
+ }
235
+ } finally {
236
+ this.#branchInFlight = false;
237
+ }
238
+ }
239
+
240
+ async #refreshGitStatus(): Promise<void> {
241
+ const cwd = this.#repoCwd;
242
+ if (!cwd || !this.#repo || this.#gitStatusInFlight) return;
243
+ this.#gitStatusInFlight = true;
244
+ try {
245
+ const out = await this.#exec("git", ["status", "--porcelain"], cwd);
246
+ if (this.#repoCwd !== cwd) return;
247
+ this.#gitStatusFetchedAt = Date.now();
248
+ const next = out === undefined ? null : parseGitStatus(out);
249
+ const changed =
250
+ next?.staged !== this.#gitStatus?.staged ||
251
+ next?.unstaged !== this.#gitStatus?.unstaged ||
252
+ next?.untracked !== this.#gitStatus?.untracked;
253
+ this.#gitStatus = next;
254
+ if (changed) this.#requestRender();
255
+ } finally {
256
+ this.#gitStatusInFlight = false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Newest in-run payload for each subscribed customType, in one backwards
262
+ * pass. Walks raw append order rather than the branch-filtered view: a
263
+ * publisher's own tally does not rewind when /tree moves the leaf, so an
264
+ * entry stranded on an abandoned branch still reflects what it published.
265
+ */
266
+ #refreshFeeds(customTypes: readonly string[], now: number): void {
267
+ this.#feedsScannedAt = now;
268
+ this.#feedData = Object.create(null) as Record<string, unknown>;
269
+ const entries = this.#ctx?.sessionManager?.getEntries();
270
+ if (!entries) return;
271
+ const pending = new Set(customTypes);
272
+ for (let i = entries.length - 1; i >= 0 && pending.size > 0; i--) {
273
+ const entry = entries[i];
274
+ // custom_message entries carry a customType too, so the kind is checked
275
+ // first: matching on customType alone lets one shadow a real entry.
276
+ if (entry?.type !== "custom" || !pending.has(entry.customType)) continue;
277
+ const ts = Date.parse(entry.timestamp);
278
+ if (!Number.isFinite(ts) || ts < this.#runStartedAt) break;
279
+ pending.delete(entry.customType);
280
+ if (entry.data !== undefined) this.#feedData[entry.customType] = entry.data;
281
+ }
282
+ }
283
+
284
+ async #refreshPr(): Promise<void> {
285
+ const cwd = this.#repoCwd;
286
+ const branch = this.#branch;
287
+ if (!cwd || !this.#repo || !branch || branch === "detached") return;
288
+ if (this.#ghUnavailable || this.#prInFlight || this.#prBranch === branch) return;
289
+ this.#prInFlight = true;
290
+ try {
291
+ const result = await this.#pi
292
+ .exec("gh", ["pr", "view", "--json", "number,url"], { cwd, timeout: 5_000 })
293
+ .catch((error: unknown) => {
294
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") this.#ghUnavailable = true;
295
+ return undefined;
296
+ });
297
+ if (this.#repoCwd !== cwd) return;
298
+ this.#prBranch = branch;
299
+ let pr: { number: number; url: string } | null = null;
300
+ if (result && result.code === 0) {
301
+ try {
302
+ const parsed = JSON.parse(result.stdout) as { number?: number; url?: string };
303
+ if (typeof parsed.number === "number" && typeof parsed.url === "string") {
304
+ const u = URL.parse(parsed.url);
305
+ if (
306
+ u &&
307
+ (u.protocol === "https:" || u.protocol === "http:") &&
308
+ u.hostname === "github.com" &&
309
+ !/[\u0000-\u001f\u007f]/.test(parsed.url)
310
+ ) {
311
+ pr = { number: parsed.number, url: u.href };
312
+ }
313
+ }
314
+ } catch {
315
+ // non-JSON output — treat as no PR
316
+ }
317
+ }
318
+ const changed = pr?.number !== this.#pr?.number;
319
+ this.#pr = pr;
320
+ if (changed) this.#requestRender();
321
+ } finally {
322
+ this.#prInFlight = false;
323
+ }
324
+ }
325
+
326
+ // ── snapshot ─────────────────────────────────────────────────────────────
327
+
328
+ build(
329
+ width: number,
330
+ options: StatusLineSegmentOptions,
331
+ include: SegmentIncludes,
332
+ scrollHint: string | undefined,
333
+ ): SegmentContext {
334
+ const ctx = this.#ctx;
335
+ const now = Date.now();
336
+
337
+ if (include.git || include.pr) {
338
+ const branchStale = now - this.#branchFetchedAt > BRANCH_POLL_TTL_MS;
339
+ if (this.#repo && (branchStale && !this.#watcherOk)) void this.#refreshBranch();
340
+ if (include.git && this.#repo && now - this.#gitStatusFetchedAt > GIT_STATUS_TTL_MS) {
341
+ void this.#refreshGitStatus();
342
+ }
343
+ if (include.pr) void this.#refreshPr();
344
+ }
345
+
346
+ if (include.feeds.length > 0) {
347
+ // Editing the subscription list mid-session must not wait out the TTL.
348
+ const key = include.feeds.join("\u0000");
349
+ if (key !== this.#feedsScanned || now - this.#feedsScannedAt > FEED_TTL_MS) {
350
+ this.#feedsScanned = key;
351
+ this.#refreshFeeds(include.feeds, now);
352
+ }
353
+ }
354
+
355
+ const model = ctx?.model
356
+ ? {
357
+ name: ctx.model.name,
358
+ id: ctx.model.id,
359
+ provider: ctx.model.provider,
360
+ reasoning: ctx.model.reasoning === true,
361
+ }
362
+ : undefined;
363
+
364
+ const { contextWindow, tokens, percent: contextPercent } = deriveContextUsage(ctx);
365
+
366
+ return {
367
+ options,
368
+ model,
369
+ thinkingLevel: this.#pi.getThinkingLevel(),
370
+ cwd: ctx?.cwd ?? process.cwd(),
371
+ sessionName: ctx?.sessionManager?.getSessionName(),
372
+ contextPercent,
373
+ contextTokens: tokens ?? 0,
374
+ contextWindow,
375
+ git: {
376
+ branch: include.git || include.pr ? this.#branch : null,
377
+ status: include.git ? this.#gitStatus : null,
378
+ pr: include.pr ? this.#pr : null,
379
+ },
380
+ worktree: this.#worktree,
381
+ scrollHint,
382
+ piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
383
+ tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
384
+ feedData: include.feeds.length > 0 ? { ...this.#feedData } : undefined,
385
+ };
386
+ }
387
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
3
+ *
4
+ * Overflow strategy (in order): drop right segments right-to-left, shrink the
5
+ * elastic `path` segment down to ~8 cells, drop left segments end-first while
6
+ * protecting `path`. The gap between the two groups is filled with the
7
+ * box-horizontal glyph colored like the editor border, so it tracks the
8
+ * thinking-level border color; the callback receives the gap's absolute
9
+ * column and row in the box, so a caller can paint it as part of a
10
+ * continuous border gradient (the rainbow effect).
11
+ */
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+ import { renderSegment } from "./segments.js";
14
+ import { getSeparator } from "./separators.js";
15
+ import { theme } from "./theme.js";
16
+ import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId } from "./types.js";
17
+
18
+ const TRANSPARENT_BG_ANSI = "\x1b[49m";
19
+
20
+ export function buildStatusLine(
21
+ width: number,
22
+ ctx: SegmentContext,
23
+ settings: EffectiveStatusLineSettings,
24
+ gapBorderColor: (str: string, startCol: number, row: number) => string,
25
+ segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
26
+ barOrigin: { col: number; row: number } = { col: 0, row: 0 },
27
+ ): string {
28
+ const separatorDef = getSeparator(settings.separator);
29
+
30
+ const bgAnsi = settings.transparent ? TRANSPARENT_BG_ANSI : theme.getBgAnsi();
31
+ const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
32
+ const fgAnsi = theme.getFgAnsi("text");
33
+ const sepAnsi = theme.getFgAnsi("statusLineSep");
34
+
35
+ const leftParts: string[] = [];
36
+ const leftSegIds: StatusLineSegmentId[] = [];
37
+ for (const segId of segmentGroups.left) {
38
+ const rendered = renderSegment(segId, ctx);
39
+ if (rendered.visible && rendered.content) {
40
+ leftParts.push(rendered.content);
41
+ leftSegIds.push(segId);
42
+ }
43
+ }
44
+
45
+ const rightParts: string[] = [];
46
+ for (const segId of segmentGroups.right) {
47
+ const rendered = renderSegment(segId, ctx);
48
+ if (rendered.visible && rendered.content) {
49
+ rightParts.push(rendered.content);
50
+ }
51
+ }
52
+
53
+ const left = [...leftParts];
54
+ const right = [...rightParts];
55
+
56
+ const leftSepWidth = visibleWidth(separatorDef.left);
57
+ const rightSepWidth = visibleWidth(separatorDef.right);
58
+ // Transparent mode drops the round caps (they need a bg fill to bridge);
59
+ // opaque groups wear a half-circle on both ends, drawn with the bar bg as
60
+ // fg so each group reads as a pill. Unicode/ascii presets define no
61
+ // half-circle glyphs, so caps vanish there.
62
+ const capLeft = transparentBg ? "" : theme.sep.halfCircleLeft;
63
+ const capRight = transparentBg ? "" : theme.sep.halfCircleRight;
64
+ const capWidth = visibleWidth(capLeft) + visibleWidth(capRight);
65
+
66
+ const groupWidth = (parts: string[], sepWidth: number): number => {
67
+ if (parts.length === 0) return 0;
68
+ const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
69
+ const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
70
+ return partsWidth + sepTotal + 2 + capWidth;
71
+ };
72
+
73
+ const leftGroupWidth = (parts: string[]): number => groupWidth(parts, leftSepWidth);
74
+ const rightGroupWidth = (parts: string[]): number => groupWidth(parts, rightSepWidth);
75
+ let leftWidth = leftGroupWidth(left);
76
+ let rightWidth = rightGroupWidth(right);
77
+ const totalWidth = () => leftWidth + rightWidth + (left.length > 0 || right.length > 0 ? 1 : 0);
78
+
79
+ if (width > 0) {
80
+ while (totalWidth() > width && right.length > 0) {
81
+ right.pop();
82
+ rightWidth = rightGroupWidth(right);
83
+ }
84
+ // Shrink path before dropping left segments — path is the only elastic segment
85
+ const pathIdx = leftSegIds.indexOf("path");
86
+ if (pathIdx >= 0 && totalWidth() > width) {
87
+ const overflow = totalWidth() - width;
88
+ const currentPathVW = visibleWidth(left[pathIdx]);
89
+ const minPathVW = 8;
90
+ const shrinkable = currentPathVW - minPathVW;
91
+ if (shrinkable > 0) {
92
+ const shrinkBy = Math.min(shrinkable, overflow);
93
+ const currentMaxLen = ctx.options.path.maxLength;
94
+ let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
95
+ const pathCtx = (maxLen: number): SegmentContext => ({
96
+ ...ctx,
97
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
98
+ });
99
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
100
+ if (reRendered.visible && reRendered.content) {
101
+ // maxLength governs path text, not icon prefix; iterate to compensate
102
+ for (let i = 0; i < 8; i++) {
103
+ const saved = currentPathVW - visibleWidth(reRendered.content);
104
+ if (saved >= shrinkBy) break;
105
+ const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
106
+ if (nextMaxLen >= newMaxLen) break;
107
+ newMaxLen = nextMaxLen;
108
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
109
+ if (!adjusted.visible || !adjusted.content) break;
110
+ reRendered = adjusted;
111
+ }
112
+ left[pathIdx] = reRendered.content;
113
+ leftWidth = leftGroupWidth(left);
114
+ }
115
+ }
116
+ }
117
+ const leftOverflowDropIndex = (): number => {
118
+ // Preserve the current working directory as long as possible.
119
+ for (let i = leftSegIds.length - 1; i >= 0; i--) {
120
+ if (leftSegIds[i] !== "path") return i;
121
+ }
122
+ return left.length - 1;
123
+ };
124
+
125
+ while (totalWidth() > width && left.length > 0) {
126
+ const dropIdx = leftOverflowDropIndex();
127
+ left.splice(dropIdx, 1);
128
+ leftSegIds.splice(dropIdx, 1);
129
+ leftWidth = leftGroupWidth(left);
130
+ }
131
+ }
132
+
133
+ const renderGroup = (parts: string[], direction: "left" | "right"): string => {
134
+ if (parts.length === 0) return "";
135
+ const sep = direction === "left" ? separatorDef.left : separatorDef.right;
136
+ const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
137
+
138
+ let content = bgAnsi + fgAnsi;
139
+ content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
140
+ content += "\x1b[0m";
141
+
142
+ if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
143
+ if (capRight) content = `${content}${capPrefix}${capRight}\x1b[0m`;
144
+ return content;
145
+ };
146
+
147
+ const leftGroup = renderGroup(left, "left");
148
+ const rightGroup = renderGroup(right, "right");
149
+ if (!leftGroup && !rightGroup) return "";
150
+
151
+ if (width === 0) {
152
+ return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
153
+ }
154
+
155
+ const gapWidth = Math.max(1, width - leftWidth - rightWidth);
156
+ const gapFill = gapBorderColor(
157
+ theme.getBox(settings.borderStyle).horizontal.repeat(gapWidth),
158
+ barOrigin.col + leftWidth,
159
+ barOrigin.row,
160
+ );
161
+ if (!leftGroup) return gapFill + rightGroup;
162
+ if (!rightGroup) return leftGroup + gapFill;
163
+ return leftGroup + gapFill + rightGroup;
164
+ }