@xzzpig/pi-starline 0.5.0 → 0.7.1

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 (53) hide show
  1. package/FORK-CHANGELOG.md +28 -0
  2. package/README.md +91 -181
  3. package/dist/public.d.ts +5 -0
  4. package/extensions/starline/config.ts +156 -326
  5. package/extensions/starline/context-live.ts +33 -0
  6. package/extensions/starline/editor-cursor.ts +3 -0
  7. package/extensions/starline/extension-status.ts +9 -5
  8. package/extensions/starline/footer.ts +331 -528
  9. package/extensions/starline/fork-editor-cursor.ts +14 -0
  10. package/extensions/starline/format.ts +151 -20
  11. package/extensions/starline/git-live.ts +20 -0
  12. package/extensions/starline/git-probe.ts +40 -0
  13. package/extensions/starline/git-segments.ts +175 -0
  14. package/extensions/starline/git.ts +71 -0
  15. package/extensions/starline/icons.ts +17 -0
  16. package/extensions/starline/index.ts +188 -91
  17. package/extensions/starline/live-session.ts +164 -0
  18. package/extensions/starline/live-tps.ts +165 -0
  19. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  20. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  21. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  22. package/extensions/starline/mouse/explicit-expand-target.ts +89 -0
  23. package/extensions/starline/mouse/external-layer.ts +42 -0
  24. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  25. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  26. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  27. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  28. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  29. package/extensions/starline/mouse/hint.ts +4 -4
  30. package/extensions/starline/mouse/index.ts +54 -24
  31. package/extensions/starline/mouse/pointer-events.ts +73 -0
  32. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  33. package/extensions/starline/mouse/tool-box.ts +4 -0
  34. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  35. package/extensions/starline/paste-collapse.ts +52 -13
  36. package/extensions/starline/pill-actions.ts +590 -0
  37. package/extensions/starline/pill-anchors.ts +26 -0
  38. package/extensions/starline/pill-api.ts +80 -0
  39. package/extensions/starline/pill-bubble.ts +607 -0
  40. package/extensions/starline/pill-config.ts +67 -9
  41. package/extensions/starline/pill.ts +102 -20
  42. package/extensions/starline/project-probe.ts +79 -0
  43. package/extensions/starline/prototype-patch-registry.ts +6 -1
  44. package/extensions/starline/queue-rows.ts +43 -0
  45. package/extensions/starline/settings-command.ts +124 -177
  46. package/extensions/starline/subagent-fleet.ts +246 -0
  47. package/extensions/starline/turn-clock.ts +81 -0
  48. package/extensions/starline/ui.ts +297 -156
  49. package/extensions/starline/working-indicator.ts +189 -0
  50. package/package.json +18 -12
  51. package/types/pill-api.d.ts +115 -0
  52. package/extensions/starline/editor-metadata-format.ts +0 -259
  53. package/extensions/starline/footer-format.ts +0 -279
@@ -1,178 +1,68 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ type TuiMouseEvent,
4
+ type TuiMouseEventResult,
5
+ truncateToWidth,
6
+ visibleWidth,
7
+ } from "@earendil-works/pi-tui";
3
8
  import {
4
9
  type ColorSpec,
5
- FOOTER_FORMAT_ALIASES,
6
10
  getExtensionStatusColor,
7
11
  getExtensionStatusIcon,
8
12
  type PolishedTuiConfig,
9
- type SeparatorStyle,
10
13
  } from "./config";
11
- import {
12
- collectExtensionStatusSegments,
13
- type ExtensionStatusSegment,
14
- sanitizeExtensionStatusText,
15
- } from "./extension-status";
16
- import { parseFooterFormat, renderFormatSplit, stripOrphanSeparators } from "./footer-format";
14
+ import { setActiveContextSnapshot } from "./context-live";
15
+ import { collectExtensionStatusSegments, sanitizeExtensionStatusText } from "./extension-status";
17
16
  import {
18
17
  buildContextDisplayLabel,
19
18
  buildSessionDurationLabel,
19
+ buildTpsLabel,
20
20
  contextColorTier,
21
21
  formatCwdLabel,
22
- formatGitBranchText,
23
- formatGitCommitSegment,
24
- formatGitMetricsSegment,
25
22
  formatOsLabel,
26
- formatPackageVersionSegment,
27
- formatRuntimeSegment,
28
23
  formatTimeLabel,
29
24
  formatUsernameHostLabel,
30
25
  } from "./format";
31
- import type { GitHost } from "./git";
32
- import { type ResolvedIcons, resolveRuntimeSymbol } from "./icons";
26
+ import { renderGitSegments } from "./git-segments";
27
+ import { resolveRuntimeSymbol } from "./icons";
33
28
  import type { LiveContextOverride } from "./live-context";
34
- import { collectPillInputs, renderPillBar } from "./pill";
29
+ import { requestPointerShape } from "./mouse/pointer-shape";
30
+ import { collectPillInputs, type PillSpan, renderPillBar } from "./pill";
31
+ import { pillActionFor, queuedSegmentText, runPillAction } from "./pill-actions";
32
+ import { clearPillAnchors, setPillAnchors } from "./pill-anchors";
33
+ import { closePillBubble, isPillBubbleOpenFor, type PillAnchor } from "./pill-bubble";
35
34
  import type { FooterState } from "./state";
36
35
  import { renderStyleForSource } from "./style";
36
+ import { subagentsSegmentText } from "./subagent-fleet";
37
+ import type { TurnTiming } from "./turn-clock";
38
+ import { modelPillLabel } from "./working-indicator";
37
39
 
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);
40
+ /**
41
+ * Pi's component mouse event and result. Pi dispatches to `handleMouse` from
42
+ * pi-tui 0.85.0; on 0.84.x the method is simply never called.
43
+ */
44
+ export type FooterMouseEvent = TuiMouseEvent;
153
45
 
154
- if (!middle || middleWidth <= 0) {
155
- return `${left}${" ".repeat(gapWidth)}${right}`;
156
- }
46
+ export type FooterMouseResult = TuiMouseEventResult;
157
47
 
158
- const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
159
- const rightPadding = gapWidth - middleWidth - leftPadding;
160
- return `${left}${" ".repeat(leftPadding)}${middle}${" ".repeat(rightPadding)}${right}`;
161
- }
48
+ /**
49
+ * The footer Pi mounts. `handleMouse` is optional and undeclared by the old
50
+ * component type, which is why the object is built under this local shape.
51
+ */
52
+ type FooterComponent = {
53
+ render(width: number): string[];
54
+ invalidate(): void;
55
+ dispose(): void;
56
+ handleMouse?(event: FooterMouseEvent): FooterMouseResult | undefined;
57
+ };
162
58
 
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
- }
59
+ /**
60
+ * How many renders' worth of pill hit maps to keep. A render is also how the
61
+ * layout measures, so one frame can render the footer at more than one width;
62
+ * keying by width keeps a measurement from replacing the map the click on screen
63
+ * will be matched against.
64
+ */
65
+ const PILL_HIT_MAP_LIMIT = 4;
176
66
 
177
67
  /** Prefix a label with an icon, collapsing to the label when there is no icon. */
178
68
  function withIcon(icon: string, label: string): string {
@@ -210,6 +100,10 @@ export function installFooter(
210
100
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
211
101
  setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
212
102
  getLiveContext?: () => LiveContextOverride | undefined;
103
+ /** Decode throughput of the latest assistant message, or undefined before one landed. */
104
+ getLiveTps?: () => number | undefined;
105
+ /** The current run's elapsed time, and the last run's total. */
106
+ getTurnTiming?: () => TurnTiming;
213
107
  getThinkingLevel?: () => string | undefined;
214
108
  },
215
109
  ): void {
@@ -221,25 +115,179 @@ export function installFooter(
221
115
  tui.requestRender();
222
116
  });
223
117
 
224
- return {
118
+ /**
119
+ * Pill hit areas from the render that is on screen, per width.
120
+ *
121
+ * Replaced on every render, read only by `handleMouse`: no timers, no
122
+ * invalidation, nothing to keep in sync with what the footer drew.
123
+ */
124
+ const pillSpansByWidth = new Map<number, PillSpan[]>();
125
+
126
+ const rememberPillSpans = (
127
+ width: number,
128
+ spans: readonly PillSpan[],
129
+ columnOffset: number,
130
+ lineWidth: number,
131
+ ) => {
132
+ const visibleSpans: PillSpan[] = [];
133
+ const anchors = new Map<string, PillAnchor>();
134
+ for (const span of spans) {
135
+ // The bar starts `columnOffset` columns in, and the line may have been cut
136
+ // again after the bar was; clamp to what the terminal actually shows.
137
+ const startColumn = Math.min(lineWidth, span.startColumn + columnOffset);
138
+ const endColumn = Math.min(lineWidth, span.endColumn + columnOffset);
139
+ if (endColumn <= startColumn) continue;
140
+ const bodyStartColumn = Math.min(endColumn - 1, span.bodyStartColumn + columnOffset);
141
+ const bodyEndColumn = Math.max(
142
+ bodyStartColumn + 1,
143
+ Math.min(endColumn, span.bodyEndColumn + columnOffset),
144
+ );
145
+ visibleSpans.push({ ...span, startColumn, endColumn, bodyStartColumn, bodyEndColumn });
146
+ // Same columns, plus the colours, for anything that wants to sit next to
147
+ // this pill later — a bubble today, whatever the next action needs.
148
+ anchors.set(span.key, {
149
+ key: span.key,
150
+ startColumn: bodyStartColumn,
151
+ endColumn: bodyEndColumn,
152
+ centerColumn: Math.floor((startColumn + endColumn - 1) / 2),
153
+ terminalWidth: width,
154
+ background: span.bg,
155
+ foreground: span.fg,
156
+ });
157
+ }
158
+ pillSpansByWidth.set(width, visibleSpans);
159
+ setPillAnchors(anchors);
160
+ if (pillSpansByWidth.size > PILL_HIT_MAP_LIMIT) {
161
+ const oldest = pillSpansByWidth.keys().next();
162
+ if (!oldest.done) pillSpansByWidth.delete(oldest.value);
163
+ }
164
+ };
165
+
166
+ /**
167
+ * The pill this mouse-down already dealt with: it opened that pill's bubble, or
168
+ * closed it because it was the one open.
169
+ *
170
+ * `click` is not a terminal event: Pi-tui synthesizes one on release for every
171
+ * press that did not move, and dispatches it to the same component. Running the
172
+ * action on the press is what takes the button-hold out of the bubble's latency,
173
+ * so that synthesized click has to be swallowed rather than toggling it back.
174
+ */
175
+ let pressActed: { key: string; x: number } | null = null;
176
+
177
+ const component: FooterComponent = {
225
178
  dispose: () => {
226
179
  unsubscribeBranch();
227
180
  hooks.setRequestRender(undefined);
228
181
  hooks.setExtensionStatusesGetter?.(undefined);
229
182
  },
230
183
  invalidate() {},
184
+ /**
185
+ * A click on a pill runs that segment's action.
186
+ *
187
+ * Pi's footer slot is a `Container`, which forwards mouse events to the
188
+ * component inside it with `y` rebased to this component and `x` unchanged, so
189
+ * these are exactly the columns `render` measured. Nothing is declared or
190
+ * patched for this.
191
+ *
192
+ * Returning undefined for anything that is not a target (another row, empty
193
+ * space, an inert segment) is what lets the click fall through instead of
194
+ * being swallowed by the footer.
195
+ */
196
+ handleMouse(event: FooterMouseEvent): FooterMouseResult | undefined {
197
+ if (!Number.isFinite(event.x) || !Number.isFinite(event.y)) return undefined;
198
+ // The pill bar is the one and only row this component draws.
199
+ if (event.y !== 0) return undefined;
200
+ const width = typeof event.width === "number" ? Math.floor(event.width) : undefined;
201
+ if (width === undefined) return undefined;
202
+ const spans = pillSpansByWidth.get(width);
203
+ if (!spans) return undefined;
204
+
205
+ const hit = spans.find((span) => event.x >= span.startColumn && event.x < span.endColumn);
206
+ const action = hit === undefined ? undefined : pillActionFor(hit.key);
207
+ if (!hit || !action) {
208
+ // Nothing here to click: padding at either end, a gap, or a pill that is
209
+ // only a read-out. For an open bubble this row is then just "outside" like
210
+ // the rest of the screen, and the outside-click dismissal cannot decide
211
+ // that alone — it stays off this row so a press on a pill keeps its
212
+ // toggle. Press, not click: a press that claims nothing is never followed
213
+ // by a synthesized click, and this must not turn into a swallow.
214
+ if (event.type === "press") closePillBubble();
215
+ return undefined;
216
+ }
217
+
218
+ // Moving over a pill that does something turns the pointer into a hand.
219
+ // Pi has no leave event; mouse/pointer-shape.ts puts it back once a move
220
+ // lands anywhere that does not ask for it.
221
+ if (event.type === "move") {
222
+ requestPointerShape("pointer");
223
+ return { handled: true };
224
+ }
225
+
226
+ const anchor = () => ({
227
+ key: hit.key,
228
+ startColumn: hit.bodyStartColumn,
229
+ endColumn: hit.bodyEndColumn,
230
+ centerColumn: Math.floor((hit.startColumn + hit.endColumn - 1) / 2),
231
+ terminalWidth: width,
232
+ background: hit.bg,
233
+ foreground: hit.fg,
234
+ });
235
+
236
+ /**
237
+ * The action runs on the press, not on Pi's synthesized click.
238
+ *
239
+ * Waiting for the click means the bubble opens after the button is released,
240
+ * which is where the delay came from: a click is press + release + synth, and
241
+ * that hop is pure added latency for a target one row tall. The press is also
242
+ * what a drag starts from, so a drag cancels what the press opened.
243
+ */
244
+ if (event.type === "press") {
245
+ // Pressing the pill again puts its bubble away, like a menu-bar item.
246
+ if (isPillBubbleOpenFor(hit.key)) {
247
+ closePillBubble();
248
+ // The release still synthesizes a click for this press. Claim it, so that
249
+ // click is swallowed instead of running the action and reopening what
250
+ // this press just closed.
251
+ pressActed = { key: hit.key, x: event.x };
252
+ return { handled: true };
253
+ }
254
+ runPillAction(action, ctx, anchor());
255
+ pressActed = { key: hit.key, x: event.x };
256
+ return { handled: true };
257
+ }
258
+
259
+ // A press that moves was a selection or a stray gesture: undo it rather than
260
+ // leaving a bubble the user never asked for. Two columns is past any jitter.
261
+ if (event.type === "drag") {
262
+ if (pressActed && Math.abs(event.x - pressActed.x) > 2) {
263
+ closePillBubble();
264
+ pressActed = null;
265
+ }
266
+ return { handled: true };
267
+ }
268
+
269
+ if (event.type === "click") {
270
+ const acted = pressActed;
271
+ pressActed = null;
272
+ // The press already ran this; running it again would toggle the bubble shut.
273
+ if (acted?.key === hit.key) return { handled: true };
274
+ // No press preceded this click, which terminals do not send today, but a
275
+ // synthetic click from elsewhere would still mean what it always meant.
276
+ if (isPillBubbleOpenFor(hit.key)) {
277
+ closePillBubble();
278
+ return { handled: true };
279
+ }
280
+ runPillAction(action, ctx, anchor());
281
+ return { handled: true };
282
+ }
283
+
284
+ return { handled: true };
285
+ },
231
286
  render(width: number): string[] {
232
287
  if (width <= 0) return [""];
233
288
  const config = getConfig();
234
289
  const colorSource = config.colorSources.starship;
235
290
  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
291
  const cwdLabel = renderStyleForSource(
244
292
  theme,
245
293
  colorSource,
@@ -254,7 +302,13 @@ export function installFooter(
254
302
  theme,
255
303
  colorSource,
256
304
  config.colors.model,
257
- withIcon(config.icons.model, state.modelLabel),
305
+ // While the agent works the spinner takes the icon's slot at the same
306
+ // display width, so the pill never resizes, and the run's elapsed time
307
+ // joins the name behind it.
308
+ modelPillLabel(config.icons.model, state.modelLabel, {
309
+ ascii: iconMode === "ascii",
310
+ timing: hooks.getTurnTiming?.(),
311
+ }),
258
312
  )
259
313
  : "";
260
314
  // "off" reads as no thinking at all, matching the editor metadata line.
@@ -268,20 +322,10 @@ export function installFooter(
268
322
  withIcon(config.icons.thinking, thinkingText),
269
323
  )
270
324
  : "";
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
- : "";
325
+ const sessionName = sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "");
277
326
  const sessionNameLabel = sessionName
278
327
  ? renderStyleForSource(theme, colorSource, config.colors.sessionName, sessionName)
279
328
  : "";
280
- const builtInSessionNameLabel = sessionNameLabel ? `in ${sessionNameLabel}` : "";
281
- const branch = state.branch;
282
- const branchText = branch
283
- ? formatGitBranchText(branch, config.gitBranch.maxLength)
284
- : undefined;
285
329
  const contextUsage = ctx.getContextUsage();
286
330
  const liveContext = hooks.getLiveContext?.();
287
331
  const contextWindow = ctx.model?.contextWindow ?? contextUsage?.contextWindow;
@@ -305,76 +349,49 @@ export function installFooter(
305
349
  ? config.colors.contextWarning
306
350
  : config.colors.contextNormal;
307
351
  const contextDisplay = withIcon(config.icons.context, contextLabel);
308
- const tokensDisplay = withIcon(config.icons.tokens, state.tokenLabel);
309
352
  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) : "";
353
+ const tpsLabel = buildTpsLabel(hooks.getLiveTps?.());
354
+ const tpsDisplay = tpsLabel ? withIcon(config.icons.tps, tpsLabel) : "";
355
+ // The one pill shows a percentage; the rest of these numbers are only
356
+ // on screen while its bubble is open.
357
+ const contextTabTokens = useLiveContext ? liveContext.tokens : undefined;
358
+ setActiveContextSnapshot({
359
+ percent: contextPercent ?? undefined,
360
+ contextWindow,
361
+ tokens: contextTabTokens,
362
+ tokenLabel: state.tokenLabel,
363
+ cacheLabel: state.cacheHitLabel,
364
+ });
365
+ const git = renderGitSegments(state, config, theme, colorSource);
357
366
  const renderVariable = (name: string): string => {
358
- const canonical = FOOTER_FORMAT_ALIASES[name] ?? name;
359
- switch (canonical) {
367
+ switch (name) {
360
368
  case "model":
361
369
  return modelLabel;
362
370
  case "thinking":
363
371
  return thinkingLabel;
372
+ case "queue":
373
+ return renderStyleForSource(
374
+ theme,
375
+ colorSource,
376
+ config.colors.queue,
377
+ queuedSegmentText(ctx, config.icons.queue),
378
+ );
379
+ case "subagents":
380
+ return renderStyleForSource(
381
+ theme,
382
+ colorSource,
383
+ config.colors.subagents,
384
+ withIcon(
385
+ config.icons.subagents,
386
+ subagentsSegmentText(config.segmentOptions.subagents.format),
387
+ ),
388
+ );
364
389
  case "cwd":
365
390
  return cwdLabel;
366
391
  case "session_name":
367
392
  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;
393
+ case "git":
394
+ return git.label;
378
395
  case "runtime": {
379
396
  if (!state.runtime) return "";
380
397
  const symbol = resolveRuntimeSymbol(
@@ -417,23 +434,10 @@ export function installFooter(
417
434
  );
418
435
  case "context":
419
436
  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
437
  case "cost":
427
438
  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
- );
439
+ case "tps":
440
+ return renderStyleForSource(theme, colorSource, config.colors.tps, tpsDisplay);
437
441
  case "package_version":
438
442
  return state.packageVersion?.version
439
443
  ? renderStyleForSource(
@@ -443,302 +447,101 @@ export function installFooter(
443
447
  state.packageVersion.version,
444
448
  )
445
449
  : "";
446
- case "sep":
447
- return renderStyleForSource(theme, colorSource, config.colors.separator, " | ");
448
450
  case "git_commit":
449
- return formatGitCommitSegment(
450
- theme,
451
- state.commit,
452
- config.gitCommit,
453
- colorSource,
454
- config.colors.gitCommit,
455
- );
451
+ return git.commit;
456
452
  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
- : "";
453
+ return git.tag;
465
454
  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
- : "";
455
+ return git.metrics;
492
456
  default:
493
457
  return "";
494
458
  }
495
459
  };
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
460
  const extensionStatuses = collectExtensionStatusSegments(
649
461
  footerData.getExtensionStatuses(),
650
462
  config,
651
463
  );
652
464
 
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
- }
465
+ // Several segment colours are dynamic — the context tier, the detected
466
+ // runtime, the thinking level — so the spec lookup lives here where
467
+ // that state is already resolved, and pill.ts stays presentation-only.
468
+ const pillSpecFor = (segment: string): ColorSpec => {
469
+ switch (segment) {
470
+ case "model":
471
+ return config.colors.model;
472
+ case "thinking":
473
+ return config.colors.thinking ?? thinkingThemeKey(thinkingText);
474
+ case "queue":
475
+ return config.colors.queue;
476
+ case "subagents":
477
+ return config.colors.subagents;
478
+ case "cwd":
479
+ return config.colors.cwd;
480
+ case "sessionName":
481
+ return config.colors.sessionName;
482
+ case "git":
483
+ return config.colors.gitBranch;
484
+ case "gitCommit":
485
+ case "gitTag":
486
+ return config.colors.gitCommit;
487
+ case "gitMetrics":
488
+ return config.colors.gitMetricsAdded;
489
+ case "runtime":
490
+ return state.runtime?.style ?? config.colors.runtimePrefix;
491
+ case "context":
492
+ return contextColor;
493
+ case "cost":
494
+ return config.colors.cost;
495
+ case "tps":
496
+ return config.colors.tps;
497
+ case "sessionDuration":
498
+ return config.colors.sessionDuration;
499
+ case "username":
500
+ return config.colors.username;
501
+ case "time":
502
+ return config.colors.time;
503
+ case "os":
504
+ return config.colors.os;
505
+ case "packageVersion":
506
+ return config.colors.packageVersion;
507
+ default:
508
+ return config.colors.extensionStatus;
509
+ }
510
+ };
721
511
 
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,
512
+ const spans: PillSpan[] = [];
513
+ const bar = renderPillBar(
514
+ collectPillInputs(
515
+ config.pill.segments,
516
+ [...extensionStatuses.left, ...extensionStatuses.middle, ...extensionStatuses.right],
517
+ renderVariable,
518
+ pillSpecFor,
519
+ (key) => getExtensionStatusColor(config, key) ?? config.colors.extensionStatus,
520
+ (key) => getExtensionStatusIcon(config, key),
521
+ ),
522
+ theme,
523
+ colorSource,
524
+ config.pill,
525
+ width,
526
+ {
527
+ ascii: iconMode === "ascii",
528
+ onSpan: (span) => spans.push(span),
529
+ },
738
530
  );
739
- const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
740
- return [truncateToWidth(framed, width, "")];
531
+ // Nothing has content: draw nothing rather than a bare pair of caps.
532
+ if (!bar) {
533
+ pillSpansByWidth.delete(width);
534
+ clearPillAnchors();
535
+ return [""];
536
+ }
537
+ // Flush left, in the editor's first column: the bar's round cap sits
538
+ // under the editor's rail rather than a column to its right.
539
+ const line = truncateToWidth(bar, width, "");
540
+ rememberPillSpans(width, spans, 0, visibleWidth(line));
541
+ return [line];
741
542
  },
742
543
  };
544
+
545
+ return component;
743
546
  });
744
547
  }