@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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The status indicator Pi hands to the mounted editor.
3
+ *
4
+ * Pi draws a working / retry / compaction status in its own row above the editor
5
+ * unless the editor opts in with `embedWorkingStatus`; opting in makes the
6
+ * placement the editor's problem. Starline takes that deal so the plain
7
+ * `working` spinner can ride in the model pill: same glyph slot, same display
8
+ * width, no extra row and no pill bar reflow. The plain spinner also carries the
9
+ * run's elapsed time in the pill, which is the one thing the spinner alone does
10
+ * not say — and keeps the total there once the run is over.
11
+ *
12
+ * Everything that is not a plain spinner (retry, compaction, branch summary)
13
+ * spells a sentence and carries an interrupt hint, which a pill cannot hold, so
14
+ * those stay on the editor's top border in Pi's own layout.
15
+ *
16
+ * The reference is published by the editor and read by the footer, so it lives
17
+ * here rather than in either of them.
18
+ */
19
+
20
+ import { stripVTControlCharacters } from "node:util";
21
+ import { visibleWidth } from "@earendil-works/pi-tui";
22
+ import type { PolishedTuiConfig } from "./config";
23
+ import { buildElapsedLabel } from "./format";
24
+ import type { TurnTiming } from "./turn-clock";
25
+
26
+ /**
27
+ * Structural twin of Pi's `StatusIndicator`, which the package does not export.
28
+ *
29
+ * Both render helpers are optional because they only exist on the Pi versions
30
+ * that also support embedding. Without them the indicator renders nowhere and
31
+ * Pi's own row stays the only one, which is the pre-embedding behaviour.
32
+ */
33
+ export type WorkingIndicator = {
34
+ /** "working" | "retry" | "compaction" | "branchSummary". */
35
+ readonly kind?: string;
36
+ renderInBorder?: (width: number) => string;
37
+ renderSpinnerInBorder?: (width: number) => string;
38
+ };
39
+
40
+ let currentIndicator: WorkingIndicator | undefined;
41
+
42
+ export function setWorkingIndicator(indicator: WorkingIndicator | undefined): void {
43
+ currentIndicator = indicator;
44
+ }
45
+
46
+ /**
47
+ * The model pill exists when the status line is on and `pill.segments` asks for
48
+ * it. When it does not, the spinner belongs on the editor's top border instead of
49
+ * vanishing, so the editor asks exactly the question the footer answers.
50
+ */
51
+ export function footerRendersModel(config: PolishedTuiConfig): boolean {
52
+ return config.features.statusLine && config.pill.segments.includes("model");
53
+ }
54
+
55
+ /** A plain spinner, as opposed to a status that has to spell something out. */
56
+ function isSpinnerOnly(indicator: WorkingIndicator): boolean {
57
+ return indicator.kind === undefined || indicator.kind === "working";
58
+ }
59
+
60
+ /**
61
+ * Whether the pill should read as working, which is a wider question than
62
+ * whether it has a frame: `setWorkingIndicator({ frames: [] })` keeps the run
63
+ * going with the animation hidden, and the elapsed time is still true then.
64
+ */
65
+ function isPlainSpinnerWorking(): boolean {
66
+ const indicator = currentIndicator;
67
+ return indicator !== undefined && isSpinnerOnly(indicator);
68
+ }
69
+
70
+ /**
71
+ * Pi's default frames. Sparse on purpose: each frame inks a few dots, so the
72
+ * spinner reads as a moving speck.
73
+ */
74
+ const PI_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
75
+
76
+ /**
77
+ * The pill's own frames: the same braille grid, but filled, so the ink covers the
78
+ * whole cell instead of hugging one corner.
79
+ *
80
+ * Pi's sparse set is what makes the spinner look like it sits high above the
81
+ * model name — the top-left dot lands near the pill's top edge while the pill's
82
+ * text is optically centred. A filled frame has no such corner bias.
83
+ */
84
+ const PILL_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
85
+
86
+ /** `icons.mode: "ascii"` exists for terminals without a Nerd Font; braille is no safer. */
87
+ const ASCII_PILL_FRAMES = ["|", "/", "-", "\\"];
88
+
89
+ /**
90
+ * Swap Pi's frame for the pill's, keeping Pi's cadence: the frame arrives from the
91
+ * loader's own tick, so its index in Pi's set is the phase to reuse. Anything the
92
+ * session customized is passed through untouched.
93
+ */
94
+ function pillFrame(frame: string, frames: string[]): string {
95
+ const index = PI_FRAMES.indexOf(frame);
96
+ if (index < 0) return frame;
97
+ const scaled = Math.floor((index * frames.length) / PI_FRAMES.length) % frames.length;
98
+ return frames[scaled] ?? frame;
99
+ }
100
+
101
+ /** Long statuses still clip, but to what the border can hold rather than to nothing. */
102
+ function borderStatus(indicator: WorkingIndicator, width: number): string | undefined {
103
+ try {
104
+ const status = stripVTControlCharacters(
105
+ indicator.renderInBorder?.(Math.max(1, width)) ?? "",
106
+ ).trim();
107
+ return visibleWidth(status) > 0 ? status : undefined;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The current spinner frame for the footer, or undefined when the pill should
115
+ * keep its own icon: no indicator, not a plain spinner, a Pi build without the
116
+ * render helper, or `setWorkingIndicator({ frames: [] })` hiding the animation.
117
+ *
118
+ * The frame arrives already coloured (Pi paints it with the editor's border
119
+ * colour), so the colour is stripped here and the footer paints it like any
120
+ * other segment text.
121
+ */
122
+ export function readWorkingFrame(ascii = false): string | undefined {
123
+ const indicator = currentIndicator;
124
+ if (!indicator || !isSpinnerOnly(indicator)) return undefined;
125
+ try {
126
+ const frame = stripVTControlCharacters(indicator.renderSpinnerInBorder?.(1) ?? "");
127
+ if (visibleWidth(frame) === 0) return undefined;
128
+ return pillFrame(frame, ascii ? ASCII_PILL_FRAMES : PILL_FRAMES);
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * What the editor's top border has to show, if anything: every status that is
136
+ * not the pill's spinner, plus the spinner itself when there is no model pill to
137
+ * put it in.
138
+ */
139
+ export function workingStatusForBorder(
140
+ config: PolishedTuiConfig,
141
+ width: number,
142
+ ): string | undefined {
143
+ const indicator = currentIndicator;
144
+ if (!indicator) return undefined;
145
+ if (isSpinnerOnly(indicator) && footerRendersModel(config)) return undefined;
146
+ return borderStatus(indicator, width);
147
+ }
148
+
149
+ /**
150
+ * The model pill's icon slot while the agent works. The spinner takes the icon's
151
+ * place at the icon's own display width, so the pill never resizes and the pill
152
+ * bar never reflows. An icon-less pill gains the two cells the spinner needs; a
153
+ * one-cell icon is swapped in place.
154
+ */
155
+ export function modelPillIcon(icon: string, frame: string | undefined): string {
156
+ if (!frame) return icon;
157
+ const frameWidth = visibleWidth(frame);
158
+ if (frameWidth === 0) return icon;
159
+ return `${frame}${" ".repeat(Math.max(0, visibleWidth(icon) - frameWidth))}`;
160
+ }
161
+
162
+ /**
163
+ * The model pill's whole label: icon slot, model name, and the run's elapsed
164
+ * time — ticking while the agent works, frozen at the total once the run ends.
165
+ *
166
+ * The time sits after the name rather than before it because the spinner has
167
+ * already taken the icon's slot: `⣾ claude 12s` reads as one sentence, while a
168
+ * number between the spinner and the name reads as a second icon.
169
+ *
170
+ * Two reasons to keep it in the one pill rather than a segment of its own: a
171
+ * segment that appears and disappears would slide the rest of the bar sideways
172
+ * at the start and end of every turn, and "working" and "for how long" are the
173
+ * same question. Whether the number is still counting is what the icon slot
174
+ * says — a spinner there means live, the model's own icon means this is the last
175
+ * run's total.
176
+ */
177
+ export function modelPillLabel(
178
+ icon: string,
179
+ label: string,
180
+ options: { ascii?: boolean; timing?: TurnTiming } = {},
181
+ ): string {
182
+ if (!label) return "";
183
+ const iconSlot = modelPillIcon(icon, readWorkingFrame(options.ascii === true));
184
+ const working = isPlainSpinnerWorking();
185
+ const timing = options.timing ?? {};
186
+ const elapsedMs = working ? timing.running : timing.last;
187
+ const text = elapsedMs === undefined ? label : `${label} ${buildElapsedLabel(elapsedMs)}`;
188
+ return iconSlot ? `${iconSlot} ${text}` : text;
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xzzpig/pi-starline",
3
- "version": "0.5.0",
3
+ "version": "0.7.1",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,32 +20,37 @@
20
20
  "tui"
21
21
  ],
22
22
  "files": [
23
- "extensions"
23
+ "extensions",
24
+ "dist",
25
+ "types",
26
+ "FORK-CHANGELOG.md"
24
27
  ],
25
28
  "peerDependencies": {
26
- "@earendil-works/pi-ai": ">=0.86.0 <0.88",
27
- "@earendil-works/pi-coding-agent": ">=0.86.0 <0.88",
28
- "@earendil-works/pi-tui": ">=0.86.0 <0.88",
29
+ "@earendil-works/pi-ai": ">=1.0.0 <2",
30
+ "@earendil-works/pi-coding-agent": ">=1.0.0 <2",
31
+ "@earendil-works/pi-tui": ">=1.0.0 <2",
29
32
  "@xzzpig/pi-mouse-events": ">=0.2.0 <3"
30
33
  },
31
34
  "pi": {
32
35
  "extensions": [
33
36
  "./extensions"
34
37
  ],
35
- "image": "https://raw.githubusercontent.com/Andy8647/pi-starline/main/assets/starline.png"
38
+ "image": "https://raw.githubusercontent.com/Andy8647/pi-starline/main/assets/cover.png"
36
39
  },
37
40
  "publishConfig": {
38
41
  "access": "public"
39
42
  },
40
43
  "devDependencies": {
41
- "@biomejs/biome": "^2.5.0",
42
- "@earendil-works/pi-ai": "^0.87.1",
43
- "@earendil-works/pi-coding-agent": "^0.87.1",
44
- "@earendil-works/pi-tui": "^0.87.1",
44
+ "@biomejs/biome": "^2.6.3",
45
+ "@earendil-works/pi-ai": "1.1.0",
46
+ "@earendil-works/pi-coding-agent": "1.1.0",
47
+ "@earendil-works/pi-tui": "1.1.0",
45
48
  "@types/node": "^26.0.0",
49
+ "rollup": "^4.62.2",
50
+ "rollup-plugin-dts": "^6.4.1",
46
51
  "typescript": "^7.0.2",
47
52
  "vitest": "^4.1.8",
48
- "@xzzpig/pi-mouse-events": "0.2.0"
53
+ "@xzzpig/pi-mouse-events": "0.3.0"
49
54
  },
50
55
  "peerDependenciesMeta": {
51
56
  "@xzzpig/pi-mouse-events": {
@@ -55,11 +60,12 @@
55
60
  "scripts": {
56
61
  "lint": "biome check .",
57
62
  "typecheck": "tsc --noEmit",
63
+ "build:types": "rollup -c rollup.dts.config.mjs && biome format --write dist/public.d.ts",
58
64
  "test": "vitest run",
59
65
  "pi:dev": "${PI_BIN:-/opt/homebrew/bin/pi} --no-extensions -e ./extensions/starline/index.ts",
60
66
  "pi:install-local": "${PI_BIN:-/opt/homebrew/bin/pi} install ./ -l",
61
67
  "fmt": "biome format --write .",
62
- "fmt:check": "biome format --check .",
68
+ "fmt:check": "biome format .",
63
69
  "verify": "npm run lint && npm run typecheck && npm run test",
64
70
  "pack:check": "npm pack --dry-run"
65
71
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Types for Starline's pill API: make your extension's footer pill clickable and
3
+ * open Starline's popover with your own content.
4
+ *
5
+ * The API is found on `globalThis`, not imported, so an extension can use it
6
+ * without depending on Starline at runtime. Only these types are imported:
7
+ *
8
+ * import type { PillApi } from "@xzzpig/pi-starline/types/pill-api";
9
+ * const api = (globalThis as Record<symbol, unknown>)[
10
+ * Symbol.for("pi-starline.pill-api.v1")
11
+ * ] as PillApi | undefined;
12
+ *
13
+ * Guide and templates: docs/pill-api.md in the pi-starline repository.
14
+ */
15
+
16
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+
18
+ /**
19
+ * Where a pill sits and how it is painted, as the footer last drew it. Pass it
20
+ * through to `showBubble` untouched; its fields are Starline's to interpret.
21
+ */
22
+ export type PillAnchor = {
23
+ /** The pill's segment key; a click on the pill whose bubble is open closes it. */
24
+ key?: string;
25
+ /** First column painted in the pill's colour. */
26
+ startColumn?: number;
27
+ /** First column after the pill's colour, where its arrow or cap begins. */
28
+ endColumn?: number;
29
+ /** Column of the pill's centre on the footer line. */
30
+ centerColumn: number;
31
+ /** Width the footer was rendered at, which is the terminal width. */
32
+ terminalWidth: number;
33
+ /** Background SGR the pill was painted with. */
34
+ background: string;
35
+ /** Foreground SGR of the pill; "" derives a legible one from the background. */
36
+ foreground: string;
37
+ };
38
+
39
+ /**
40
+ * A body line. A plain string is information; an object with `onSelect` is a
41
+ * target: it renders underlined, the pointer turns into a hand over it, and a
42
+ * click (or Enter while it is selected) closes the bubble and then runs the
43
+ * callback.
44
+ *
45
+ * `current: true` marks the target that stands for the present state, such as
46
+ * the active level in a picker: the bubble opens with it selected, so the arrow
47
+ * keys move from there. At most one should be current; the first one wins.
48
+ */
49
+ export type PillBubbleLine = string | { text: string; onSelect: () => void; current?: boolean };
50
+
51
+ export type PillBubbleContent = {
52
+ /** Bold first line, optional. */
53
+ title?: string;
54
+ /** Body lines, wrapped to the bubble. */
55
+ body: PillBubbleLine[];
56
+ /** Muted last line, e.g. the key that dismisses it. */
57
+ hint?: string;
58
+ };
59
+
60
+ /**
61
+ * Content, or a function that returns it. A function is called again on every
62
+ * `refresh()`, which is how a bubble shows numbers that change while it is open.
63
+ */
64
+ export type PillBubbleSource = PillBubbleContent | (() => PillBubbleContent);
65
+
66
+ /** The open bubble. Returned by `showBubble` from Starline 0.4; older versions return nothing. */
67
+ export type PillBubbleHandle = {
68
+ /**
69
+ * Read the content again (when it was given as a function) and redraw. The
70
+ * bubble widens if a line outgrew it; it never narrows while open. A no-op once
71
+ * the bubble is closed, so a timer that fires late is harmless.
72
+ */
73
+ refresh(): void;
74
+ /** Close the bubble. Safe to call more than once. */
75
+ close(): void;
76
+ /** Whether the bubble is still on screen. */
77
+ isOpen(): boolean;
78
+ /**
79
+ * Run `callback` once the bubble closes, however that happens: Escape, a
80
+ * second click on its pill, another bubble replacing it, or `close()`. Stop
81
+ * timers here. Runs at once if the bubble is already closed.
82
+ */
83
+ onClose(callback: () => void): void;
84
+ };
85
+
86
+ export type PillApiContext = {
87
+ ctx: ExtensionContext;
88
+ /** The pill that was clicked, or the one the footer last drew for this key. */
89
+ pill?: PillAnchor;
90
+ /**
91
+ * "click" from the pill, "command" from `/starline <key>`. A bubble's targets
92
+ * need a pointer, so an action that offers choices can act directly from the
93
+ * command instead. Absent on Starline versions before it was added.
94
+ */
95
+ via?: "click" | "command";
96
+ };
97
+
98
+ export type PillApiAction = (context: PillApiContext) => void | Promise<void>;
99
+
100
+ export type PillApi = {
101
+ version: 1;
102
+ /**
103
+ * Make the pill published under `key` (your `ctx.ui.setStatus` key) clickable.
104
+ * Register from `session_start`; a second registration for the same key
105
+ * replaces the first. Returns an unregister handle that only removes its own
106
+ * registration.
107
+ */
108
+ register(key: string, action: PillApiAction): () => void;
109
+ /** Open the standard bubble, attached to `pill` when one is known. */
110
+ showBubble(
111
+ ctx: ExtensionContext,
112
+ content: PillBubbleSource,
113
+ pill?: PillAnchor,
114
+ ): PillBubbleHandle | undefined;
115
+ };
@@ -1,259 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { PolishedTuiConfig } from "./config";
3
- import { type FormatToken, parseFooterFormat } from "./footer-format";
4
- import { EDITOR_ACCENT_FALLBACK, renderStyleForSourceOrFallback, safeThemeFg } from "./style";
5
-
6
- export type EditorMetadataValues = {
7
- model: string;
8
- modelId: string;
9
- modelName: string;
10
- provider: string;
11
- thinking: string;
12
- sessionName: string;
13
- };
14
-
15
- type RenderedTokens = {
16
- styled: string;
17
- hasDynamic: boolean;
18
- hasNonEmptyDynamic: boolean;
19
- };
20
-
21
- const ESC = 0x1b;
22
- const BEL = 0x07;
23
- const CAN = 0x18;
24
- const SUB = 0x1a;
25
- const C1_DCS = 0x90;
26
- const C1_CSI = 0x9b;
27
- const C1_ST = 0x9c;
28
- const C1_OSC = 0x9d;
29
- const C1_SOS = 0x98;
30
- const C1_PM = 0x9e;
31
- const C1_APC = 0x9f;
32
-
33
- function consumeCsi(value: string, start: number): number {
34
- for (let index = start; index < value.length; index++) {
35
- const code = value.charCodeAt(index);
36
- if (code === CAN || code === SUB) return index + 1;
37
- if (code >= 0x40 && code <= 0x7e) return index + 1;
38
- }
39
- return value.length;
40
- }
41
-
42
- function consumeControlString(value: string, start: number, allowBel: boolean): number {
43
- for (let index = start; index < value.length; index++) {
44
- const code = value.charCodeAt(index);
45
- if (code === CAN || code === SUB) return index + 1;
46
- if (allowBel && code === BEL) return index + 1;
47
- if (code === C1_ST) return index + 1;
48
- if (code === ESC && value.charCodeAt(index + 1) === 0x5c) return index + 2;
49
- }
50
- return value.length;
51
- }
52
-
53
- function consumeEscape(value: string, start: number): number {
54
- if (start + 1 >= value.length) return value.length;
55
- const next = value.charCodeAt(start + 1);
56
- if (next === 0x5b) return consumeCsi(value, start + 2);
57
- if (next === 0x5d) return consumeControlString(value, start + 2, true);
58
- if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
59
- return consumeControlString(value, start + 2, false);
60
- }
61
-
62
- let index = start + 1;
63
- while (index < value.length) {
64
- const code = value.charCodeAt(index);
65
- if (code >= 0x20 && code <= 0x2f) {
66
- index += 1;
67
- continue;
68
- }
69
- return code >= 0x30 && code <= 0x7e ? index + 1 : index;
70
- }
71
- return value.length;
72
- }
73
-
74
- function isNormalizedWhitespace(code: number): boolean {
75
- return (
76
- code === 0x09 ||
77
- code === 0x0a ||
78
- code === 0x0b ||
79
- code === 0x0c ||
80
- code === 0x0d ||
81
- code === 0x85 ||
82
- code === 0x2028 ||
83
- code === 0x2029
84
- );
85
- }
86
-
87
- export function sanitizeEditorMetadataText(value: string): string {
88
- let sanitized = "";
89
- for (let index = 0; index < value.length; ) {
90
- const code = value.charCodeAt(index);
91
- if (code === ESC) {
92
- index = consumeEscape(value, index);
93
- continue;
94
- }
95
- if (code === C1_CSI) {
96
- index = consumeCsi(value, index + 1);
97
- continue;
98
- }
99
- if (code === C1_OSC) {
100
- index = consumeControlString(value, index + 1, true);
101
- continue;
102
- }
103
- if (code === C1_DCS || code === C1_SOS || code === C1_PM || code === C1_APC) {
104
- index = consumeControlString(value, index + 1, false);
105
- continue;
106
- }
107
- if (isNormalizedWhitespace(code)) {
108
- sanitized += " ";
109
- do index += 1;
110
- while (index < value.length && isNormalizedWhitespace(value.charCodeAt(index)));
111
- continue;
112
- }
113
- if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
114
- index += 1;
115
- continue;
116
- }
117
- sanitized += value[index];
118
- index += 1;
119
- }
120
- return sanitized;
121
- }
122
-
123
- function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
124
- switch (level.toLowerCase()) {
125
- case "minimal":
126
- return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
127
- case "low":
128
- return config.colors.editorThinkingLow ?? config.colors.editorThinking;
129
- case "medium":
130
- return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
131
- case "high":
132
- return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
133
- case "xhigh":
134
- return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
135
- default:
136
- return config.colors.editorThinking;
137
- }
138
- }
139
-
140
- function renderVariable(
141
- name: string,
142
- values: EditorMetadataValues,
143
- uiTheme: Theme,
144
- config: PolishedTuiConfig,
145
- ): { plain: string; styled: string } {
146
- const colorSource = config.colorSources.editor;
147
- const thinking = values.thinking.toLowerCase() === "off" ? "" : values.thinking;
148
- const raw =
149
- name === "model"
150
- ? values.model
151
- : name === "model_id"
152
- ? values.modelId
153
- : name === "model_name"
154
- ? values.modelName
155
- : name === "provider"
156
- ? values.provider
157
- : name === "thinking"
158
- ? thinking
159
- : name === "session_name"
160
- ? values.sessionName
161
- : "";
162
- const plain = sanitizeEditorMetadataText(raw);
163
- if (!plain) return { plain: "", styled: "" };
164
-
165
- if (name === "model" || name === "model_id" || name === "model_name") {
166
- return {
167
- plain,
168
- styled: renderStyleForSourceOrFallback(
169
- uiTheme,
170
- colorSource,
171
- config.colors.editorModel,
172
- EDITOR_ACCENT_FALLBACK,
173
- plain,
174
- ),
175
- };
176
- }
177
- if (name === "provider") {
178
- return {
179
- plain,
180
- styled: renderStyleForSourceOrFallback(
181
- uiTheme,
182
- colorSource,
183
- config.colors.editorProvider,
184
- "text",
185
- plain,
186
- ),
187
- };
188
- }
189
- if (name === "thinking") {
190
- return {
191
- plain,
192
- styled: renderStyleForSourceOrFallback(
193
- uiTheme,
194
- colorSource,
195
- editorThinkingStyle(config, plain),
196
- "muted",
197
- plain,
198
- ),
199
- };
200
- }
201
- if (name === "session_name") {
202
- return { plain, styled: safeThemeFg(uiTheme, "border", plain) };
203
- }
204
- return { plain: "", styled: "" };
205
- }
206
-
207
- function renderTokens(
208
- tokens: FormatToken[],
209
- values: EditorMetadataValues,
210
- uiTheme: Theme,
211
- config: PolishedTuiConfig,
212
- ): RenderedTokens {
213
- let styled = "";
214
- let hasDynamic = false;
215
- let hasNonEmptyDynamic = false;
216
-
217
- for (const token of tokens) {
218
- if (token.kind === "text") {
219
- const plain = sanitizeEditorMetadataText(token.value);
220
- if (plain) styled += safeThemeFg(uiTheme, "border", plain);
221
- continue;
222
- }
223
- if (token.kind === "fill") {
224
- hasDynamic = true;
225
- continue;
226
- }
227
- if (token.kind === "var") {
228
- hasDynamic = true;
229
- const rendered = renderVariable(token.name, values, uiTheme, config);
230
- styled += rendered.styled;
231
- if (rendered.plain) hasNonEmptyDynamic = true;
232
- continue;
233
- }
234
-
235
- const rendered = renderTokens(token.tokens, values, uiTheme, config);
236
- const visible = !rendered.hasDynamic || rendered.hasNonEmptyDynamic;
237
- hasDynamic = true;
238
- if (visible) {
239
- styled += rendered.styled;
240
- hasNonEmptyDynamic = true;
241
- }
242
- }
243
-
244
- return { styled, hasDynamic, hasNonEmptyDynamic };
245
- }
246
-
247
- export function renderEditorMetadataFormat(
248
- format: string,
249
- values: EditorMetadataValues,
250
- uiTheme: Theme,
251
- config: PolishedTuiConfig,
252
- ): string {
253
- return renderTokens(
254
- parseFooterFormat(sanitizeEditorMetadataText(format)),
255
- values,
256
- uiTheme,
257
- config,
258
- ).styled;
259
- }