@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,65 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelLabelSource, TokensCacheFormat } from "./config";
3
+ import {
4
+ buildCacheHitLabel,
5
+ buildContextLabel,
6
+ buildCostLabel,
7
+ buildTokenLabel,
8
+ formatProviderLabel,
9
+ getUsageTotals,
10
+ } from "./format";
11
+ import type { GitHost, GitStatusSummary } from "./git";
12
+ import type { PackageVersionResult } from "./package-version";
13
+ import type { RuntimeInfo } from "./runtime";
14
+
15
+ export type FooterState = GitStatusSummary & {
16
+ modelLabel: string;
17
+ modelId: string;
18
+ modelName: string;
19
+ providerLabel: string;
20
+ contextLabel: string;
21
+ tokenLabel: string;
22
+ cacheHitLabel: string;
23
+ costLabel: string;
24
+ runtime?: RuntimeInfo;
25
+ gitHost?: GitHost;
26
+ packageVersion?: PackageVersionResult;
27
+ sessionStartEpoch?: number;
28
+ };
29
+
30
+ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
31
+ return {
32
+ modelLabel: "no-model",
33
+ modelId: "",
34
+ modelName: "",
35
+ providerLabel: "Unknown",
36
+ contextLabel: "--",
37
+ tokenLabel: "↑0 ↓0",
38
+ cacheHitLabel: "",
39
+ costLabel: "$0.000",
40
+ runtime: undefined,
41
+ gitHost: undefined,
42
+ packageVersion: undefined,
43
+ sessionStartEpoch: Date.now(),
44
+ ...gitDefaults,
45
+ };
46
+ }
47
+
48
+ export function syncState(
49
+ state: FooterState,
50
+ ctx: ExtensionContext,
51
+ cacheHitIcon: string,
52
+ modelLabelSource: ModelLabelSource,
53
+ tokensCache: TokensCacheFormat = "percent",
54
+ ): void {
55
+ const totals = getUsageTotals(ctx);
56
+ const m = ctx.model;
57
+ state.modelId = m?.id ?? "";
58
+ state.modelName = m?.name ?? "";
59
+ state.modelLabel = (modelLabelSource === "name" ? m?.name || m?.id : m?.id) ?? "no-model";
60
+ state.providerLabel = formatProviderLabel(ctx.model?.provider);
61
+ state.contextLabel = buildContextLabel(ctx);
62
+ state.tokenLabel = buildTokenLabel(totals, cacheHitIcon, tokensCache);
63
+ state.cacheHitLabel = buildCacheHitLabel(totals, cacheHitIcon);
64
+ state.costLabel = buildCostLabel(totals);
65
+ }
@@ -0,0 +1,528 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { ColorSource, ColorSpec } from "./config";
3
+
4
+ type ThemeLike = {
5
+ fg(color: string, text: string): string;
6
+ bold?: (text: string) => string;
7
+ italic?: (text: string) => string;
8
+ underline?: (text: string) => string;
9
+ /**
10
+ * Pi's `Theme.getFgAnsi` — returns the resolved SGR sequence for a theme color
11
+ * without wrapping any text. Optional because test fixtures and older Pi
12
+ * versions may not provide it; `themeColorToFgSgr` falls back to probing
13
+ * `fg()` with an empty string.
14
+ */
15
+ getFgAnsi?(color: string): string;
16
+ };
17
+
18
+ export type { ThemeLike };
19
+
20
+ export const EDITOR_ACCENT_STYLE = "blue";
21
+ export const EDITOR_BORDER_STYLE = "bright-black";
22
+
23
+ export type SourceStyleFallback = {
24
+ theme: ColorSpec;
25
+ terminal: ColorSpec;
26
+ };
27
+
28
+ export const EDITOR_ACCENT_FALLBACK: SourceStyleFallback = {
29
+ theme: "accent",
30
+ terminal: EDITOR_ACCENT_STYLE,
31
+ };
32
+
33
+ export const EDITOR_BORDER_FALLBACK: SourceStyleFallback = {
34
+ theme: "borderMuted",
35
+ terminal: EDITOR_BORDER_STYLE,
36
+ };
37
+
38
+ function isHexColor(value: string): boolean {
39
+ return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
40
+ }
41
+
42
+ function expandHexColor(hex: string): string {
43
+ const body = hex.slice(1);
44
+ if (body.length === 3) {
45
+ return body
46
+ .split("")
47
+ .map((ch) => ch + ch)
48
+ .join("");
49
+ }
50
+ return body;
51
+ }
52
+
53
+ function hexToAnsi(hex: string, isBackground = false): string {
54
+ const normalized = expandHexColor(hex);
55
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
56
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
57
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
58
+ return `\x1b[${isBackground ? 48 : 38};2;${r};${g};${b}m`;
59
+ }
60
+
61
+ const terminalColorCodes = new Map([
62
+ ["black", 30],
63
+ ["red", 31],
64
+ ["green", 32],
65
+ ["yellow", 33],
66
+ ["blue", 34],
67
+ ["purple", 35],
68
+ ["cyan", 36],
69
+ ["white", 37],
70
+ ["bright-black", 90],
71
+ ["bright-red", 91],
72
+ ["bright-green", 92],
73
+ ["bright-yellow", 93],
74
+ ["bright-blue", 94],
75
+ ["bright-purple", 95],
76
+ ["bright-cyan", 96],
77
+ ["bright-white", 97],
78
+ ]);
79
+
80
+ const terminalStyleModifiers = new Map([
81
+ ["bold", 1],
82
+ ["dim", 2],
83
+ ["dimmed", 2],
84
+ ["italic", 3],
85
+ ["underline", 4],
86
+ ]);
87
+
88
+ const themeColorNameMap = new Map([
89
+ ["red", "error"],
90
+ ["bright-red", "error"],
91
+ ["green", "success"],
92
+ ["bright-green", "success"],
93
+ ["yellow", "warning"],
94
+ ["bright-yellow", "warning"],
95
+ ["blue", "syntaxFunction"],
96
+ ["bright-blue", "syntaxFunction"],
97
+ ["cyan", "syntaxFunction"],
98
+ ["bright-cyan", "syntaxFunction"],
99
+ ["purple", "syntaxKeyword"],
100
+ ["bright-purple", "syntaxKeyword"],
101
+ ["black", "muted"],
102
+ ["bright-black", "muted"],
103
+ ["white", "text"],
104
+ ["bright-white", "text"],
105
+ ]);
106
+
107
+ const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
108
+
109
+ const themeColorTokens = new Set<ThemeColor>([
110
+ "accent",
111
+ "border",
112
+ "borderAccent",
113
+ "borderMuted",
114
+ "success",
115
+ "error",
116
+ "warning",
117
+ "muted",
118
+ "dim",
119
+ "text",
120
+ "thinkingText",
121
+ "userMessageText",
122
+ "customMessageText",
123
+ "customMessageLabel",
124
+ "toolTitle",
125
+ "toolOutput",
126
+ "mdHeading",
127
+ "mdLink",
128
+ "mdLinkUrl",
129
+ "mdCode",
130
+ "mdCodeBlock",
131
+ "mdCodeBlockBorder",
132
+ "mdQuote",
133
+ "mdQuoteBorder",
134
+ "mdHr",
135
+ "mdListBullet",
136
+ "toolDiffAdded",
137
+ "toolDiffRemoved",
138
+ "toolDiffContext",
139
+ "syntaxComment",
140
+ "syntaxKeyword",
141
+ "syntaxFunction",
142
+ "syntaxVariable",
143
+ "syntaxString",
144
+ "syntaxNumber",
145
+ "syntaxType",
146
+ "syntaxOperator",
147
+ "syntaxPunctuation",
148
+ "thinkingOff",
149
+ "thinkingMinimal",
150
+ "thinkingLow",
151
+ "thinkingMedium",
152
+ "thinkingHigh",
153
+ "thinkingXhigh",
154
+ "bashMode",
155
+ ]);
156
+
157
+ function terminalColorToAnsi(color: string, isBackground = false): string | undefined {
158
+ const normalized = color.toLowerCase();
159
+ const colorCode = terminalColorCodes.get(normalized);
160
+ if (colorCode !== undefined) return `${isBackground ? colorCode + 10 : colorCode}`;
161
+
162
+ if (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized)) {
163
+ return `${isBackground ? 48 : 38};5;${normalized}`;
164
+ }
165
+
166
+ if (isHexColor(normalized)) return hexToAnsi(normalized, isBackground).slice(2, -1);
167
+ return undefined;
168
+ }
169
+
170
+ function isExplicitTerminalColorToken(token: string): boolean {
171
+ const normalized = token.toLowerCase();
172
+ if (normalized.startsWith("fg:") || normalized.startsWith("bg:")) return true;
173
+ if (isHexColor(normalized)) return true;
174
+ return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized);
175
+ }
176
+
177
+ function isSupportedStyleToken(token: string): boolean {
178
+ const normalized = token.toLowerCase();
179
+ if (terminalStyleModifiers.has(normalized)) return true;
180
+ if (terminalColorToAnsi(normalized) !== undefined) return true;
181
+
182
+ const isForeground = normalized.startsWith("fg:");
183
+ const isBackground = normalized.startsWith("bg:");
184
+ if (isForeground || isBackground) {
185
+ return terminalColorToAnsi(normalized.slice(3), isBackground) !== undefined;
186
+ }
187
+
188
+ return themeColorTokens.has(token as ThemeColor);
189
+ }
190
+
191
+ export function isSupportedColorSpec(style: ColorSpec): boolean {
192
+ const trimmed = style.trim();
193
+ if (trimmed === "") return true;
194
+ return trimmed.split(/\s+/).every(isSupportedStyleToken);
195
+ }
196
+
197
+ function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
198
+ let rendered = text;
199
+ for (const token of styleTokens) {
200
+ const normalized = token.toLowerCase();
201
+ if (normalized === "bold") rendered = theme.bold?.(rendered) ?? rendered;
202
+ if (normalized === "italic") rendered = theme.italic?.(rendered) ?? rendered;
203
+ if (normalized === "underline") rendered = theme.underline?.(rendered) ?? rendered;
204
+ }
205
+ return rendered;
206
+ }
207
+
208
+ export function safeThemeFg(theme: ThemeLike, color: string, text: string): string {
209
+ try {
210
+ return theme.fg(color, text);
211
+ } catch {
212
+ return text;
213
+ }
214
+ }
215
+
216
+ function mapThemeColor(styleTokens: string[]): string | undefined {
217
+ let fallback: string | undefined;
218
+ for (const token of styleTokens) {
219
+ const normalized = token.toLowerCase();
220
+ if (themeStyleModifiers.has(normalized)) continue;
221
+ if (normalized === "dim" || normalized === "dimmed") {
222
+ fallback = "muted";
223
+ continue;
224
+ }
225
+
226
+ const mapped = themeColorNameMap.get(normalized);
227
+ if (mapped) return mapped;
228
+ return token;
229
+ }
230
+ return fallback;
231
+ }
232
+
233
+ /**
234
+ * Colorize text using a theme color token or hex color.
235
+ * Non-hex values are passed directly to `theme.fg()`; invalid tokens fall back
236
+ * to unstyled text so a config typo does not break rendering.
237
+ */
238
+ export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
239
+ if (isHexColor(color)) {
240
+ return `${hexToAnsi(color)}${text}\x1b[39m`;
241
+ }
242
+ return safeThemeFg(theme, color, text);
243
+ }
244
+
245
+ /**
246
+ * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202",
247
+ * "bg:blue", "underline bg:#bf5700").
248
+ */
249
+ export function renderTerminalStyle(style: string, text: string): string {
250
+ const codes: string[] = [];
251
+ for (const token of style.trim().split(/\s+/)) {
252
+ if (!token) continue;
253
+
254
+ const normalized = token.toLowerCase();
255
+ const modifier = terminalStyleModifiers.get(normalized);
256
+ if (modifier !== undefined) {
257
+ codes.push(`${modifier}`);
258
+ continue;
259
+ }
260
+
261
+ const isForeground = normalized.startsWith("fg:");
262
+ const isBackground = normalized.startsWith("bg:");
263
+ const colorName = isForeground || isBackground ? normalized.slice(3) : normalized;
264
+ const color = terminalColorToAnsi(colorName, isBackground);
265
+ if (color) codes.push(color);
266
+ }
267
+
268
+ return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
269
+ }
270
+
271
+ /**
272
+ * Apply Starship-style terminal styling first, falling back to Pi theme tokens for
273
+ * legacy config values such as "accent" or "syntaxKeyword".
274
+ */
275
+ export function renderStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
276
+ if (style.trim() === "") return text;
277
+ const styled = renderTerminalStyle(style, text);
278
+ return styled === text ? colorize(theme, style, text) : styled;
279
+ }
280
+
281
+ export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
282
+ const trimmed = style.trim();
283
+ if (trimmed === "") return text;
284
+
285
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
286
+ if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
287
+
288
+ const color = mapThemeColor(tokens) ?? "text";
289
+ return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
290
+ }
291
+
292
+ export function renderStyleForSource(
293
+ theme: ThemeLike,
294
+ source: ColorSource,
295
+ style: ColorSpec,
296
+ text: string,
297
+ ): string {
298
+ return source === "terminal"
299
+ ? renderStyle(theme, style, text)
300
+ : renderThemeStyle(theme, style, text);
301
+ }
302
+
303
+ export function renderStyleForSourceOrFallback(
304
+ theme: ThemeLike,
305
+ source: ColorSource,
306
+ style: ColorSpec | undefined,
307
+ fallback: ColorSpec | SourceStyleFallback,
308
+ text: string,
309
+ ): string {
310
+ const fallbackStyle = typeof fallback === "string" ? fallback : fallback[source];
311
+ return renderStyleForSource(theme, source, style ?? fallbackStyle, text);
312
+ }
313
+
314
+ export function renderEditorAccent(text: string): string {
315
+ return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
316
+ }
317
+
318
+ export function renderEditorBorder(text: string): string {
319
+ return renderTerminalStyle(EDITOR_BORDER_STYLE, text);
320
+ }
321
+
322
+ export function renderAccentLine(theme: ThemeLike, source: ColorSource, text: string): string {
323
+ return renderStyleForSourceOrFallback(theme, source, undefined, EDITOR_ACCENT_FALLBACK, text);
324
+ }
325
+
326
+ export function renderChromeBorder(
327
+ theme: ThemeLike,
328
+ source: ColorSource,
329
+ terminalFallbackStyle: ColorSpec,
330
+ text: string,
331
+ ): string {
332
+ return renderStyleForSourceOrFallback(
333
+ theme,
334
+ source,
335
+ undefined,
336
+ { theme: "borderMuted", terminal: terminalFallbackStyle },
337
+ text,
338
+ );
339
+ }
340
+
341
+ /* -------------------------------------------------------------------------
342
+ * Structured ColorSpec resolution
343
+ *
344
+ * The `render*` functions above return ready-wrapped strings, which is all the
345
+ * text footer needs. The pill footer needs the foreground and background as
346
+ * separate SGR sequences, because the arrow joining two pills draws the left
347
+ * pill's background as its foreground and the right pill's background as its
348
+ * background.
349
+ *
350
+ * These resolvers are additive: they mirror the token semantics of
351
+ * `renderStyle` / `renderThemeStyle` but never feed back into them, so text
352
+ * mode output stays byte-identical to upstream. In particular they cannot
353
+ * replace the theme path, which styles via chalk (`\x1b[1m..\x1b[22m`) and
354
+ * closes with `\x1b[39m` rather than `\x1b[0m`.
355
+ * ---------------------------------------------------------------------- */
356
+
357
+ const FG_RESET = "\x1b[39m";
358
+
359
+ export type ResolvedStyle = {
360
+ /** Full SGR sequence for the foreground, e.g. `\x1b[38;2;203;166;247m`. */
361
+ fg?: string;
362
+ /** Full SGR sequence for the background, only set by an explicit `bg:` token. */
363
+ bg?: string;
364
+ bold: boolean;
365
+ italic: boolean;
366
+ underline: boolean;
367
+ dim: boolean;
368
+ };
369
+
370
+ function emptyResolvedStyle(): ResolvedStyle {
371
+ return { bold: false, italic: false, underline: false, dim: false };
372
+ }
373
+
374
+ function hasAnyStyle(resolved: ResolvedStyle): boolean {
375
+ return Boolean(
376
+ resolved.fg ||
377
+ resolved.bg ||
378
+ resolved.bold ||
379
+ resolved.italic ||
380
+ resolved.underline ||
381
+ resolved.dim,
382
+ );
383
+ }
384
+
385
+ /**
386
+ * Resolve a Pi theme color key to its foreground SGR sequence.
387
+ *
388
+ * Prefers Pi's public `Theme.getFgAnsi`, which has already picked the right
389
+ * encoding for the terminal (truecolor `38;2;r;g;b` vs 256-colour `38;5;n`).
390
+ * Falls back to probing `fg(color, "")`, whose result is the same sequence
391
+ * followed by `\x1b[39m`. Returns undefined for themes that do neither —
392
+ * notably test fixtures — so callers can degrade instead of emitting garbage.
393
+ */
394
+ export function themeColorToFgSgr(theme: ThemeLike, color: string): string | undefined {
395
+ if (typeof theme.getFgAnsi === "function") {
396
+ try {
397
+ const ansi = theme.getFgAnsi(color);
398
+ if (typeof ansi === "string" && ansi.startsWith("\x1b[")) return ansi;
399
+ } catch {
400
+ // Unknown key for this theme — fall through to the probe.
401
+ }
402
+ }
403
+ try {
404
+ const probe = theme.fg(color, "");
405
+ if (typeof probe !== "string" || !probe.endsWith(FG_RESET)) return undefined;
406
+ const ansi = probe.slice(0, -FG_RESET.length);
407
+ return ansi.startsWith("\x1b[") ? ansi : undefined;
408
+ } catch {
409
+ return undefined;
410
+ }
411
+ }
412
+
413
+ function shiftSgr(sgr: string, from: string, to: string, namedDelta: number): string | undefined {
414
+ const match = /^\x1b\[([0-9;:]*)m$/.exec(sgr);
415
+ if (!match) return undefined;
416
+ const parts = (match[1] ?? "").split(";");
417
+ const head = parts[0] ?? "";
418
+ if (head === from) return `\x1b[${[to, ...parts.slice(1)].join(";")}m`;
419
+
420
+ const code = Number(head);
421
+ if (!Number.isInteger(code) || parts.length !== 1) return undefined;
422
+ const shifted = code + namedDelta;
423
+ const lowBase = namedDelta > 0 ? 30 : 40;
424
+ const brightBase = namedDelta > 0 ? 90 : 100;
425
+ const inLow = code >= lowBase && code <= lowBase + 9;
426
+ const inBright = code >= brightBase && code <= brightBase + 9;
427
+ return inLow || inBright ? `\x1b[${shifted}m` : undefined;
428
+ }
429
+
430
+ /**
431
+ * Convert a foreground SGR sequence to the same colour as a background.
432
+ * Handles `38;2;r;g;b`, `38;5;n`, the 30-37/90-97 named codes, and the `39`
433
+ * default. This is how a theme colour becomes a pill background without ever
434
+ * extracting RGB — whatever encoding Pi chose is preserved.
435
+ */
436
+ export function toBackgroundSgr(fgSgr: string): string | undefined {
437
+ if (fgSgr === FG_RESET) return "\x1b[49m";
438
+ return shiftSgr(fgSgr, "38", "48", 10);
439
+ }
440
+
441
+ /** Inverse of {@link toBackgroundSgr}, used to draw a pill's arrow. */
442
+ export function toForegroundSgr(bgSgr: string): string | undefined {
443
+ if (bgSgr === "\x1b[49m") return FG_RESET;
444
+ return shiftSgr(bgSgr, "48", "38", -10);
445
+ }
446
+
447
+ function resolveTerminalTokens(tokens: string[]): ResolvedStyle {
448
+ const resolved = emptyResolvedStyle();
449
+ for (const token of tokens) {
450
+ const normalized = token.toLowerCase();
451
+
452
+ const modifier = terminalStyleModifiers.get(normalized);
453
+ if (modifier !== undefined) {
454
+ if (modifier === 1) resolved.bold = true;
455
+ if (modifier === 2) resolved.dim = true;
456
+ if (modifier === 3) resolved.italic = true;
457
+ if (modifier === 4) resolved.underline = true;
458
+ continue;
459
+ }
460
+
461
+ const isForeground = normalized.startsWith("fg:");
462
+ const isBackground = normalized.startsWith("bg:");
463
+ const colorName = isForeground || isBackground ? normalized.slice(3) : normalized;
464
+ const fragment = terminalColorToAnsi(colorName, isBackground);
465
+ if (!fragment) continue;
466
+ if (isBackground) resolved.bg = `\x1b[${fragment}m`;
467
+ else resolved.fg = `\x1b[${fragment}m`;
468
+ }
469
+ return resolved;
470
+ }
471
+
472
+ function resolveThemeTokens(theme: ThemeLike, tokens: string[]): ResolvedStyle {
473
+ const resolved = emptyResolvedStyle();
474
+ for (const token of tokens) {
475
+ const normalized = token.toLowerCase();
476
+ if (normalized === "bold") resolved.bold = true;
477
+ if (normalized === "italic") resolved.italic = true;
478
+ if (normalized === "underline") resolved.underline = true;
479
+ }
480
+ resolved.fg = themeColorToFgSgr(theme, mapThemeColor(tokens) ?? "text");
481
+ return resolved;
482
+ }
483
+
484
+ /**
485
+ * Resolve a ColorSpec into separate foreground/background SGR sequences plus
486
+ * text attributes.
487
+ *
488
+ * Token precedence mirrors the `render*` functions exactly: an explicit
489
+ * terminal colour token (`fg:`/`bg:`, hex, or a 256 index) anywhere in the spec
490
+ * routes the whole spec down the terminal path regardless of `source`, which is
491
+ * what lets a literal `#cba6f7` override the theme per key.
492
+ */
493
+ export function resolveColorSpec(
494
+ theme: ThemeLike,
495
+ source: ColorSource,
496
+ spec: ColorSpec,
497
+ ): ResolvedStyle {
498
+ const trimmed = spec.trim();
499
+ if (trimmed === "") return emptyResolvedStyle();
500
+
501
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
502
+ if (source !== "terminal" && !tokens.some(isExplicitTerminalColorToken)) {
503
+ return resolveThemeTokens(theme, tokens);
504
+ }
505
+
506
+ const resolved = resolveTerminalTokens(tokens);
507
+ if (hasAnyStyle(resolved)) return resolved;
508
+
509
+ // Nothing matched the terminal vocabulary. `renderStyle` falls back to
510
+ // `colorize` here, which treats the whole spec as a theme key.
511
+ resolved.fg = themeColorToFgSgr(theme, trimmed);
512
+ return resolved;
513
+ }
514
+
515
+ /**
516
+ * The background a pill should paint for this spec: an explicit `bg:` when
517
+ * given, otherwise the spec's foreground colour promoted to a background. Undefined
518
+ * when the spec names no colour at all, leaving the caller to pick a neutral.
519
+ */
520
+ export function resolveBackgroundSgr(
521
+ theme: ThemeLike,
522
+ source: ColorSource,
523
+ spec: ColorSpec,
524
+ ): string | undefined {
525
+ const resolved = resolveColorSpec(theme, source, spec);
526
+ if (resolved.bg) return resolved.bg;
527
+ return resolved.fg ? toBackgroundSgr(resolved.fg) : undefined;
528
+ }