@xzzpig/pi-starline 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.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,744 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ type ColorSpec,
5
+ FOOTER_FORMAT_ALIASES,
6
+ getExtensionStatusColor,
7
+ getExtensionStatusIcon,
8
+ type PolishedTuiConfig,
9
+ type SeparatorStyle,
10
+ } from "./config";
11
+ import {
12
+ collectExtensionStatusSegments,
13
+ type ExtensionStatusSegment,
14
+ sanitizeExtensionStatusText,
15
+ } from "./extension-status";
16
+ import { parseFooterFormat, renderFormatSplit, stripOrphanSeparators } from "./footer-format";
17
+ import {
18
+ buildContextDisplayLabel,
19
+ buildSessionDurationLabel,
20
+ contextColorTier,
21
+ formatCwdLabel,
22
+ formatGitBranchText,
23
+ formatGitCommitSegment,
24
+ formatGitMetricsSegment,
25
+ formatOsLabel,
26
+ formatPackageVersionSegment,
27
+ formatRuntimeSegment,
28
+ formatTimeLabel,
29
+ formatUsernameHostLabel,
30
+ } from "./format";
31
+ import type { GitHost } from "./git";
32
+ import { type ResolvedIcons, resolveRuntimeSymbol } from "./icons";
33
+ import type { LiveContextOverride } from "./live-context";
34
+ import { collectPillInputs, renderPillBar } from "./pill";
35
+ import type { FooterState } from "./state";
36
+ import { renderStyleForSource } from "./style";
37
+
38
+ const separatorText: Record<SeparatorStyle, string> = {
39
+ pipe: " | ",
40
+ dot: " · ",
41
+ chevron: " › ",
42
+ none: " ",
43
+ };
44
+
45
+ function joinStatusTexts(statusTexts: string[], separator: string): string {
46
+ return statusTexts.filter(Boolean).join(separator);
47
+ }
48
+
49
+ function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string {
50
+ if (maxWidth <= 0) return "";
51
+
52
+ const fitted: string[] = [];
53
+ for (const text of statusTexts) {
54
+ const candidate = joinStatusTexts([...fitted, text], separator);
55
+ if (visibleWidth(candidate) <= maxWidth) {
56
+ fitted.push(text);
57
+ continue;
58
+ }
59
+
60
+ if (fitted.length === 0) {
61
+ return maxWidth > 1 ? truncateToWidth(text, maxWidth, "…") : "";
62
+ }
63
+ break;
64
+ }
65
+
66
+ return joinStatusTexts(fitted, separator);
67
+ }
68
+
69
+ function appendStatusArea(base: string, statusText: string, separator: string): string {
70
+ if (!base) return statusText;
71
+ if (!statusText) return base;
72
+ return `${base}${separator}${statusText}`;
73
+ }
74
+
75
+ function prependStatusArea(base: string, statusText: string, separator: string): string {
76
+ if (!base) return statusText;
77
+ if (!statusText) return base;
78
+ return `${statusText}${separator}${base}`;
79
+ }
80
+
81
+ function composeBuiltInFooterContent(left: string, right: string, innerWidth: number): string {
82
+ const leftWidth = visibleWidth(left);
83
+ const rightWidth = visibleWidth(right);
84
+ return leftWidth >= innerWidth
85
+ ? truncateToWidth(left, innerWidth, "")
86
+ : leftWidth + 1 + rightWidth <= innerWidth
87
+ ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
88
+ : truncateToWidth(left, innerWidth, "");
89
+ }
90
+
91
+ function composeFooterContent(
92
+ builtInLeft: string,
93
+ builtInRight: string,
94
+ extensionLeft: string[],
95
+ extensionMiddle: string[],
96
+ extensionRight: string[],
97
+ separator: string,
98
+ innerWidth: number,
99
+ ): string {
100
+ const builtInLeftWidth = visibleWidth(builtInLeft);
101
+ const builtInRightWidth = visibleWidth(builtInRight);
102
+ const minimumGap = builtInLeft && builtInRight ? 1 : 0;
103
+
104
+ if (builtInLeftWidth + minimumGap + builtInRightWidth > innerWidth) {
105
+ return composeBuiltInFooterContent(builtInLeft, builtInRight, innerWidth);
106
+ }
107
+
108
+ const available = Math.max(0, innerWidth - builtInLeftWidth - builtInRightWidth - minimumGap);
109
+ let remaining = available;
110
+ const leftConnectorWidth = builtInLeft && extensionLeft.length > 0 ? visibleWidth(separator) : 0;
111
+ const rightConnectorWidth =
112
+ builtInRight && extensionRight.length > 0 ? visibleWidth(separator) : 0;
113
+ let leftStatus = "";
114
+ let rightStatus = "";
115
+
116
+ if (extensionLeft.length > 0 && extensionRight.length > 0) {
117
+ const leftBudget = Math.max(0, Math.floor(available / 2) - leftConnectorWidth);
118
+ leftStatus = fitStatusTexts(extensionLeft, leftBudget, separator);
119
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
120
+
121
+ const rightBudget = Math.max(0, remaining - rightConnectorWidth);
122
+ rightStatus = fitStatusTexts(extensionRight, rightBudget, separator);
123
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
124
+
125
+ const expandedLeftBudget = Math.max(0, remaining + visibleWidth(leftStatus));
126
+ const expandedLeftStatus = fitStatusTexts(extensionLeft, expandedLeftBudget, separator);
127
+ if (visibleWidth(expandedLeftStatus) > visibleWidth(leftStatus)) {
128
+ remaining += leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
129
+ leftStatus = expandedLeftStatus;
130
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
131
+ }
132
+ } else if (extensionLeft.length > 0) {
133
+ leftStatus = fitStatusTexts(
134
+ extensionLeft,
135
+ Math.max(0, available - leftConnectorWidth),
136
+ separator,
137
+ );
138
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
139
+ } else if (extensionRight.length > 0) {
140
+ rightStatus = fitStatusTexts(
141
+ extensionRight,
142
+ Math.max(0, available - rightConnectorWidth),
143
+ separator,
144
+ );
145
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
146
+ }
147
+
148
+ const left = appendStatusArea(builtInLeft, leftStatus, separator);
149
+ const right = prependStatusArea(builtInRight, rightStatus, separator);
150
+ const gapWidth = Math.max(0, innerWidth - visibleWidth(left) - visibleWidth(right));
151
+ const middle = fitStatusTexts(extensionMiddle, gapWidth, separator);
152
+ const middleWidth = visibleWidth(middle);
153
+
154
+ if (!middle || middleWidth <= 0) {
155
+ return `${left}${" ".repeat(gapWidth)}${right}`;
156
+ }
157
+
158
+ const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
159
+ const rightPadding = gapWidth - middleWidth - leftPadding;
160
+ return `${left}${" ".repeat(leftPadding)}${middle}${" ".repeat(rightPadding)}${right}`;
161
+ }
162
+
163
+ /** Forge logo for the branch segment. Empty in ascii mode, which has no glyphs. */
164
+ export function gitHostIconGlyph(icons: ResolvedIcons, host: GitHost): string {
165
+ switch (host) {
166
+ case "github":
167
+ return icons.gitHostGithub;
168
+ case "gitlab":
169
+ return icons.gitHostGitlab;
170
+ case "bitbucket":
171
+ return icons.gitHostBitbucket;
172
+ default:
173
+ return icons.gitHostGeneric;
174
+ }
175
+ }
176
+
177
+ /** Prefix a label with an icon, collapsing to the label when there is no icon. */
178
+ function withIcon(icon: string, label: string): string {
179
+ if (!label) return "";
180
+ return icon ? `${icon} ${label}` : label;
181
+ }
182
+
183
+ /**
184
+ * Theme colour key for a thinking level. Mirrors the editor metadata line, so
185
+ * the level reads the same whether it sits above the editor or in the footer.
186
+ */
187
+ export function thinkingThemeKey(level: string): string {
188
+ switch (level.toLowerCase()) {
189
+ case "minimal":
190
+ return "thinkingMinimal";
191
+ case "low":
192
+ return "thinkingLow";
193
+ case "medium":
194
+ return "thinkingMedium";
195
+ case "high":
196
+ return "thinkingHigh";
197
+ case "xhigh":
198
+ return "thinkingXhigh";
199
+ default:
200
+ return "thinkingOff";
201
+ }
202
+ }
203
+
204
+ export function installFooter(
205
+ ctx: ExtensionContext,
206
+ state: FooterState,
207
+ getConfig: () => PolishedTuiConfig,
208
+ hooks: {
209
+ setRequestRender: (fn: (() => void) | undefined) => void;
210
+ scheduleProjectRefresh: (ctx: ExtensionContext) => void;
211
+ setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
212
+ getLiveContext?: () => LiveContextOverride | undefined;
213
+ getThinkingLevel?: () => string | undefined;
214
+ },
215
+ ): void {
216
+ ctx.ui.setFooter((tui, theme, footerData) => {
217
+ hooks.setRequestRender(() => tui.requestRender());
218
+ hooks.setExtensionStatusesGetter?.(() => footerData.getExtensionStatuses());
219
+ const unsubscribeBranch = footerData.onBranchChange(() => {
220
+ hooks.scheduleProjectRefresh(ctx);
221
+ tui.requestRender();
222
+ });
223
+
224
+ return {
225
+ dispose: () => {
226
+ unsubscribeBranch();
227
+ hooks.setRequestRender(undefined);
228
+ hooks.setExtensionStatusesGetter?.(undefined);
229
+ },
230
+ invalidate() {},
231
+ render(width: number): string[] {
232
+ if (width <= 0) return [""];
233
+ const config = getConfig();
234
+ const colorSource = config.colorSources.starship;
235
+ const iconMode = config.icons.mode;
236
+ const separator = renderStyleForSource(
237
+ theme,
238
+ colorSource,
239
+ config.colors.separator,
240
+ separatorText[config.separator],
241
+ );
242
+ const innerWidth = Math.max(1, width - 2);
243
+ const cwdLabel = renderStyleForSource(
244
+ theme,
245
+ colorSource,
246
+ config.colors.cwd,
247
+ formatCwdLabel(ctx.cwd, config.icons.cwd, {
248
+ mode: config.pathDisplay.mode,
249
+ depth: config.pathDisplay.depth,
250
+ }),
251
+ );
252
+ const modelLabel = state.modelLabel
253
+ ? renderStyleForSource(
254
+ theme,
255
+ colorSource,
256
+ config.colors.model,
257
+ withIcon(config.icons.model, state.modelLabel),
258
+ )
259
+ : "";
260
+ // "off" reads as no thinking at all, matching the editor metadata line.
261
+ const thinkingLevel = hooks.getThinkingLevel?.() ?? "";
262
+ const thinkingText = thinkingLevel.toLowerCase() === "off" ? "" : thinkingLevel;
263
+ const thinkingLabel = thinkingText
264
+ ? renderStyleForSource(
265
+ theme,
266
+ colorSource,
267
+ config.colors.thinking ?? thinkingThemeKey(thinkingText),
268
+ withIcon(config.icons.thinking, thinkingText),
269
+ )
270
+ : "";
271
+ const needsSessionName = config.footerFormat
272
+ ? /(?:\$session_name\b|\$\{session_name\})/.test(config.footerFormat)
273
+ : config.footerSegments.sessionName;
274
+ const sessionName = needsSessionName
275
+ ? sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "")
276
+ : "";
277
+ const sessionNameLabel = sessionName
278
+ ? renderStyleForSource(theme, colorSource, config.colors.sessionName, sessionName)
279
+ : "";
280
+ const builtInSessionNameLabel = sessionNameLabel ? `in ${sessionNameLabel}` : "";
281
+ const branch = state.branch;
282
+ const branchText = branch
283
+ ? formatGitBranchText(branch, config.gitBranch.maxLength)
284
+ : undefined;
285
+ const contextUsage = ctx.getContextUsage();
286
+ const liveContext = hooks.getLiveContext?.();
287
+ const contextWindow = ctx.model?.contextWindow ?? contextUsage?.contextWindow;
288
+ const useLiveContext =
289
+ liveContext !== undefined && contextWindow !== undefined && contextWindow > 0;
290
+ const contextPercent = useLiveContext
291
+ ? (liveContext.tokens / contextWindow) * 100
292
+ : contextUsage?.percent;
293
+ const contextLabel = buildContextDisplayLabel({
294
+ percent: contextPercent,
295
+ contextWindow,
296
+ style: config.contextStyle,
297
+ asciiGauge: iconMode === "ascii",
298
+ format: config.segmentOptions.context.format,
299
+ });
300
+ const tier = contextColorTier(contextPercent, config.contextThresholds);
301
+ const contextColor =
302
+ tier === "error"
303
+ ? config.colors.contextError
304
+ : tier === "warning"
305
+ ? config.colors.contextWarning
306
+ : config.colors.contextNormal;
307
+ const contextDisplay = withIcon(config.icons.context, contextLabel);
308
+ const tokensDisplay = withIcon(config.icons.tokens, state.tokenLabel);
309
+ const costDisplay = withIcon(config.icons.cost, state.costLabel);
310
+ // The cacheHit icon doubles as the tokens segment's inline cache glyph;
311
+ // state.cacheHitLabel already carries it, so no withIcon here.
312
+ const cacheHitDisplay = state.cacheHitLabel;
313
+ const gitColor = (text: string) =>
314
+ renderStyleForSource(theme, colorSource, config.colors.gitBranch, text);
315
+ const gitStatusColor = (text: string) =>
316
+ renderStyleForSource(theme, colorSource, config.colors.gitStatus, text);
317
+ // With gitHostIcon on, the branch icon becomes the origin remote's forge
318
+ // logo. Repos with no origin, and ascii mode (whose host glyphs are all
319
+ // empty), fall back to the plain branch icon.
320
+ const gitHostGlyph =
321
+ config.gitHostIcon && state.gitHost ? gitHostIconGlyph(config.icons, state.gitHost) : "";
322
+ const gitGlyph = gitHostGlyph || config.icons.git;
323
+ const gitIcon = gitGlyph ? gitColor(gitGlyph) : "";
324
+ const gitCounts = config.footerSegments.gitCounts;
325
+ const stashLabel =
326
+ state.stashed > 0
327
+ ? gitCounts
328
+ ? `${config.icons.stashed}${state.stashed}`
329
+ : config.icons.stashed
330
+ : "";
331
+ const allStatus = [
332
+ state.conflicted > 0 ? config.icons.conflicted : "",
333
+ stashLabel,
334
+ state.deleted > 0 ? config.icons.deleted : "",
335
+ state.renamed > 0 ? config.icons.renamed : "",
336
+ state.modified > 0 ? config.icons.modified : "",
337
+ state.typechanged > 0 ? config.icons.typechanged : "",
338
+ state.staged > 0 ? config.icons.staged : "",
339
+ state.untracked > 0 ? config.icons.untracked : "",
340
+ ].join("");
341
+ const aheadBehind = (() => {
342
+ if (state.ahead > 0 && state.behind > 0) {
343
+ return gitCounts
344
+ ? `${config.icons.ahead}${state.ahead}${config.icons.behind}${state.behind}`
345
+ : config.icons.diverged;
346
+ }
347
+ if (state.ahead > 0)
348
+ return gitCounts ? `${config.icons.ahead}${state.ahead}` : config.icons.ahead;
349
+ if (state.behind > 0)
350
+ return gitCounts ? `${config.icons.behind}${state.behind}` : config.icons.behind;
351
+ return "";
352
+ })();
353
+ const statusBlock =
354
+ allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
355
+ const gitStateLabel = state.gitStateLabel ?? "";
356
+ const gitStateBlock = gitStateLabel ? gitStatusColor(gitStateLabel) : "";
357
+ const renderVariable = (name: string): string => {
358
+ const canonical = FOOTER_FORMAT_ALIASES[name] ?? name;
359
+ switch (canonical) {
360
+ case "model":
361
+ return modelLabel;
362
+ case "thinking":
363
+ return thinkingLabel;
364
+ case "cwd":
365
+ return cwdLabel;
366
+ case "session_name":
367
+ return sessionNameLabel;
368
+ case "git_branch":
369
+ return branchText
370
+ ? gitIcon
371
+ ? `${gitIcon} ${gitColor(branchText)}`
372
+ : gitColor(branchText)
373
+ : "";
374
+ case "git_status":
375
+ return statusBlock;
376
+ case "git_state":
377
+ return gitStateBlock;
378
+ case "runtime": {
379
+ if (!state.runtime) return "";
380
+ const symbol = resolveRuntimeSymbol(
381
+ state.runtime.name,
382
+ state.runtime.symbol,
383
+ iconMode,
384
+ );
385
+ const label = state.runtime.version ? `${symbol} ${state.runtime.version}` : symbol;
386
+ return renderStyleForSource(theme, colorSource, state.runtime.style, label);
387
+ }
388
+ case "session_duration":
389
+ return state.sessionStartEpoch
390
+ ? renderStyleForSource(
391
+ theme,
392
+ colorSource,
393
+ config.colors.sessionDuration,
394
+ buildSessionDurationLabel(state.sessionStartEpoch),
395
+ )
396
+ : "";
397
+ case "username":
398
+ return renderStyleForSource(
399
+ theme,
400
+ colorSource,
401
+ config.colors.username,
402
+ formatUsernameHostLabel(config.icons.username),
403
+ );
404
+ case "os":
405
+ return renderStyleForSource(
406
+ theme,
407
+ colorSource,
408
+ config.colors.os,
409
+ formatOsLabel(config.icons.os, iconMode),
410
+ );
411
+ case "time":
412
+ return renderStyleForSource(
413
+ theme,
414
+ colorSource,
415
+ config.colors.time,
416
+ formatTimeLabel(config.icons.time),
417
+ );
418
+ case "context":
419
+ return renderStyleForSource(theme, colorSource, contextColor, contextDisplay);
420
+ case "tokens":
421
+ return renderStyleForSource(theme, colorSource, config.colors.tokens, tokensDisplay);
422
+ case "cache_hit":
423
+ return cacheHitDisplay
424
+ ? renderStyleForSource(theme, colorSource, config.colors.cacheHit, cacheHitDisplay)
425
+ : "";
426
+ case "cost":
427
+ return renderStyleForSource(theme, colorSource, config.colors.cost, costDisplay);
428
+ case "package":
429
+ return formatPackageVersionSegment(
430
+ theme,
431
+ state.packageVersion,
432
+ colorSource,
433
+ iconMode,
434
+ config.icons.package,
435
+ config.colors.packageVersion,
436
+ );
437
+ case "package_version":
438
+ return state.packageVersion?.version
439
+ ? renderStyleForSource(
440
+ theme,
441
+ colorSource,
442
+ config.colors.packageVersion,
443
+ state.packageVersion.version,
444
+ )
445
+ : "";
446
+ case "sep":
447
+ return renderStyleForSource(theme, colorSource, config.colors.separator, " | ");
448
+ case "git_commit":
449
+ return formatGitCommitSegment(
450
+ theme,
451
+ state.commit,
452
+ config.gitCommit,
453
+ colorSource,
454
+ config.colors.gitCommit,
455
+ );
456
+ case "git_tag":
457
+ return config.gitCommit.showTag && state.commit?.tag
458
+ ? renderStyleForSource(
459
+ theme,
460
+ colorSource,
461
+ config.colors.gitCommit,
462
+ state.commit.tag,
463
+ )
464
+ : "";
465
+ case "git_metrics":
466
+ return formatGitMetricsSegment(
467
+ theme,
468
+ state.metrics,
469
+ config.gitMetrics,
470
+ colorSource,
471
+ config.colors.gitMetricsAdded,
472
+ config.colors.gitMetricsDeleted,
473
+ );
474
+ case "git_added":
475
+ return state.metrics
476
+ ? renderStyleForSource(
477
+ theme,
478
+ colorSource,
479
+ config.colors.gitMetricsAdded,
480
+ `+${state.metrics.added}`,
481
+ )
482
+ : "";
483
+ case "git_deleted":
484
+ return state.metrics
485
+ ? renderStyleForSource(
486
+ theme,
487
+ colorSource,
488
+ config.colors.gitMetricsDeleted,
489
+ `−${state.metrics.deleted}`,
490
+ )
491
+ : "";
492
+ default:
493
+ return "";
494
+ }
495
+ };
496
+ const branchParts: string[] = [];
497
+ if (config.footerSegments.gitBranch) {
498
+ if (branchText) {
499
+ branchParts.push("on", gitIcon, gitColor(branchText));
500
+ } else if (state.commit?.detached) {
501
+ // `HEAD` uses git-branch style; `(hash)` uses git-commit style
502
+ // (bold green) per Starship `git_commit` format.
503
+ branchParts.push("on", gitIcon, gitColor("HEAD"));
504
+ if (config.footerSegments.gitCommit && state.commit.oid) {
505
+ const shortHash = state.commit.oid.slice(0, config.gitCommit.hashLength);
506
+ const tag = config.gitCommit.showTag && state.commit.tag ? state.commit.tag : "";
507
+ const inner = [shortHash, tag].filter(Boolean).join(" ");
508
+ branchParts.push(
509
+ renderStyleForSource(theme, colorSource, config.colors.gitCommit, `(${inner})`),
510
+ );
511
+ }
512
+ }
513
+ }
514
+ const gitStatusParts = config.footerSegments.gitStatus && statusBlock ? [statusBlock] : [];
515
+ const showGitState = config.footerSegments.gitBranch || config.footerSegments.gitStatus;
516
+ const gitStateParts = showGitState && gitStateBlock ? [gitStateBlock] : [];
517
+ const branchLabel = [...branchParts, ...gitStatusParts, ...gitStateParts]
518
+ .filter(Boolean)
519
+ .join(" ");
520
+ const runtimeLabel = config.footerSegments.runtime
521
+ ? formatRuntimeSegment(
522
+ theme,
523
+ state.runtime,
524
+ config.colors.runtimePrefix,
525
+ colorSource,
526
+ iconMode,
527
+ )
528
+ : "";
529
+ const packageVersionLabel = config.footerSegments.packageVersion
530
+ ? formatPackageVersionSegment(
531
+ theme,
532
+ state.packageVersion,
533
+ colorSource,
534
+ iconMode,
535
+ config.icons.package,
536
+ config.colors.packageVersion,
537
+ )
538
+ : "";
539
+ // Skip standalone gitCommit when hash is already folded into the
540
+ // branch display on detached HEAD.
541
+ const hashFoldedIntoBranch = state.commit?.detached && config.footerSegments.gitBranch;
542
+ const gitCommitLabel =
543
+ config.footerSegments.gitCommit && !hashFoldedIntoBranch
544
+ ? formatGitCommitSegment(
545
+ theme,
546
+ state.commit,
547
+ config.gitCommit,
548
+ colorSource,
549
+ config.colors.gitCommit,
550
+ )
551
+ : "";
552
+ const gitMetricsLabel = config.footerSegments.gitMetrics
553
+ ? formatGitMetricsSegment(
554
+ theme,
555
+ state.metrics,
556
+ config.gitMetrics,
557
+ colorSource,
558
+ config.colors.gitMetricsAdded,
559
+ config.colors.gitMetricsDeleted,
560
+ )
561
+ : "";
562
+
563
+ const sessionDurationSegment = (() => {
564
+ if (!config.footerSegments.sessionDuration || !state.sessionStartEpoch) return "";
565
+ const timeLabel = buildSessionDurationLabel(state.sessionStartEpoch);
566
+ const prefix = renderStyleForSource(theme, colorSource, "", "up for");
567
+ const time = renderStyleForSource(
568
+ theme,
569
+ colorSource,
570
+ config.colors.sessionDuration,
571
+ timeLabel,
572
+ );
573
+ return `${prefix} ${time}`;
574
+ })();
575
+ const usernameSegment = config.footerSegments.username
576
+ ? renderStyleForSource(
577
+ theme,
578
+ colorSource,
579
+ config.colors.username,
580
+ formatUsernameHostLabel(config.icons.username),
581
+ )
582
+ : "";
583
+ const osSegment = config.footerSegments.os
584
+ ? renderStyleForSource(
585
+ theme,
586
+ colorSource,
587
+ config.colors.os,
588
+ formatOsLabel(config.icons.os, iconMode),
589
+ )
590
+ : "";
591
+ const left = [
592
+ config.footerSegments.model ? modelLabel : "",
593
+ config.footerSegments.thinking ? thinkingLabel : "",
594
+ osSegment,
595
+ usernameSegment,
596
+ config.footerSegments.cwd ? cwdLabel : "",
597
+ config.footerSegments.sessionName ? builtInSessionNameLabel : "",
598
+ branchLabel,
599
+ gitCommitLabel,
600
+ gitMetricsLabel,
601
+ packageVersionLabel,
602
+ runtimeLabel,
603
+ sessionDurationSegment,
604
+ ]
605
+ .filter(Boolean)
606
+ .join(" ");
607
+
608
+ const timeSegment = config.footerSegments.time
609
+ ? renderStyleForSource(
610
+ theme,
611
+ colorSource,
612
+ config.colors.time,
613
+ formatTimeLabel(config.icons.time),
614
+ )
615
+ : "";
616
+ const right = [
617
+ config.footerSegments.context
618
+ ? renderStyleForSource(theme, colorSource, contextColor, contextDisplay)
619
+ : "",
620
+ config.footerSegments.tokens
621
+ ? renderStyleForSource(theme, colorSource, config.colors.tokens, tokensDisplay)
622
+ : "",
623
+ config.footerSegments.cacheHit && cacheHitDisplay
624
+ ? renderStyleForSource(theme, colorSource, config.colors.cacheHit, cacheHitDisplay)
625
+ : "",
626
+ config.footerSegments.cost
627
+ ? renderStyleForSource(theme, colorSource, config.colors.cost, costDisplay)
628
+ : "",
629
+ timeSegment,
630
+ ]
631
+ .filter(Boolean)
632
+ .join(separator);
633
+
634
+ let contentLeft = left;
635
+ let contentMiddle = "";
636
+ let contentRight = right;
637
+ if (config.footerFormat) {
638
+ const {
639
+ left: fmtLeft,
640
+ middle: fmtMiddle,
641
+ right: fmtRight,
642
+ } = renderFormatSplit(parseFooterFormat(config.footerFormat), renderVariable);
643
+ contentLeft = stripOrphanSeparators(fmtLeft);
644
+ contentMiddle = stripOrphanSeparators(fmtMiddle);
645
+ contentRight = stripOrphanSeparators(fmtRight);
646
+ }
647
+
648
+ const extensionStatuses = collectExtensionStatusSegments(
649
+ footerData.getExtensionStatuses(),
650
+ config,
651
+ );
652
+
653
+ if (config.footerStyle === "pill") {
654
+ // Several segment colours are dynamic — the context tier, the detected
655
+ // runtime, the thinking level — so the spec lookup lives here where
656
+ // that state is already resolved, and pill.ts stays presentation-only.
657
+ const pillSpecFor = (segment: string): ColorSpec => {
658
+ switch (segment) {
659
+ case "model":
660
+ return config.colors.model;
661
+ case "thinking":
662
+ return config.colors.thinking ?? thinkingThemeKey(thinkingText);
663
+ case "cwd":
664
+ return config.colors.cwd;
665
+ case "sessionName":
666
+ return config.colors.sessionName;
667
+ case "gitBranch":
668
+ return config.colors.gitBranch;
669
+ case "gitStatus":
670
+ case "gitState":
671
+ return config.colors.gitStatus;
672
+ case "gitCommit":
673
+ case "gitTag":
674
+ return config.colors.gitCommit;
675
+ case "gitMetrics":
676
+ return config.colors.gitMetricsAdded;
677
+ case "runtime":
678
+ return state.runtime?.style ?? config.colors.runtimePrefix;
679
+ case "context":
680
+ return contextColor;
681
+ case "tokens":
682
+ return config.colors.tokens;
683
+ case "cacheHit":
684
+ return config.colors.cacheHit;
685
+ case "cost":
686
+ return config.colors.cost;
687
+ case "sessionDuration":
688
+ return config.colors.sessionDuration;
689
+ case "username":
690
+ return config.colors.username;
691
+ case "time":
692
+ return config.colors.time;
693
+ case "os":
694
+ return config.colors.os;
695
+ case "packageVersion":
696
+ return config.colors.packageVersion;
697
+ default:
698
+ return config.colors.extensionStatus;
699
+ }
700
+ };
701
+
702
+ const bar = renderPillBar(
703
+ collectPillInputs(
704
+ config.pill.segments,
705
+ [...extensionStatuses.left, ...extensionStatuses.middle, ...extensionStatuses.right],
706
+ renderVariable,
707
+ pillSpecFor,
708
+ (key) => getExtensionStatusColor(config, key) ?? config.colors.extensionStatus,
709
+ (key) => getExtensionStatusIcon(config, key),
710
+ ),
711
+ theme,
712
+ colorSource,
713
+ config.pill,
714
+ Math.max(0, width - 2),
715
+ { ascii: iconMode === "ascii" },
716
+ );
717
+ // An empty bar means nothing had content; fall through to the text
718
+ // footer rather than emit a bare pair of caps.
719
+ if (bar) return [truncateToWidth(width > 2 ? ` ${bar} ` : bar, width, "")];
720
+ }
721
+
722
+ const renderExtensionStatus = (segment: ExtensionStatusSegment) =>
723
+ segment.colorMode === "original"
724
+ ? segment.text
725
+ : renderStyleForSource(theme, colorSource, config.colors.extensionStatus, segment.text);
726
+ const extensionMiddleSegments = extensionStatuses.middle.map(renderExtensionStatus);
727
+ const middleSegments = contentMiddle
728
+ ? [contentMiddle, ...extensionMiddleSegments]
729
+ : extensionMiddleSegments;
730
+ const content = composeFooterContent(
731
+ contentLeft,
732
+ contentRight,
733
+ extensionStatuses.left.map(renderExtensionStatus),
734
+ middleSegments,
735
+ extensionStatuses.right.map(renderExtensionStatus),
736
+ separator,
737
+ innerWidth,
738
+ );
739
+ const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
740
+ return [truncateToWidth(framed, width, "")];
741
+ },
742
+ };
743
+ });
744
+ }