@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.
- package/FORK-CHANGELOG.md +28 -0
- package/README.md +91 -181
- package/dist/public.d.ts +5 -0
- package/extensions/starline/config.ts +156 -326
- package/extensions/starline/context-live.ts +33 -0
- package/extensions/starline/editor-cursor.ts +3 -0
- package/extensions/starline/extension-status.ts +9 -5
- package/extensions/starline/footer.ts +331 -528
- package/extensions/starline/fork-editor-cursor.ts +14 -0
- package/extensions/starline/format.ts +151 -20
- package/extensions/starline/git-live.ts +20 -0
- package/extensions/starline/git-probe.ts +40 -0
- package/extensions/starline/git-segments.ts +175 -0
- package/extensions/starline/git.ts +71 -0
- package/extensions/starline/icons.ts +17 -0
- package/extensions/starline/index.ts +188 -91
- package/extensions/starline/live-session.ts +164 -0
- package/extensions/starline/live-tps.ts +165 -0
- package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
- package/extensions/starline/mouse/cursor-marker.ts +69 -0
- package/extensions/starline/mouse/editor-hit-test.ts +6 -3
- package/extensions/starline/mouse/explicit-expand-target.ts +89 -0
- package/extensions/starline/mouse/external-layer.ts +42 -0
- package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
- package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
- package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
- package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
- package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
- package/extensions/starline/mouse/hint.ts +4 -4
- package/extensions/starline/mouse/index.ts +54 -24
- package/extensions/starline/mouse/pointer-events.ts +73 -0
- package/extensions/starline/mouse/pointer-shape.ts +138 -0
- package/extensions/starline/mouse/tool-box.ts +4 -0
- package/extensions/starline/mouse/transcript-copy.ts +76 -33
- package/extensions/starline/paste-collapse.ts +52 -13
- package/extensions/starline/pill-actions.ts +590 -0
- package/extensions/starline/pill-anchors.ts +26 -0
- package/extensions/starline/pill-api.ts +80 -0
- package/extensions/starline/pill-bubble.ts +607 -0
- package/extensions/starline/pill-config.ts +67 -9
- package/extensions/starline/pill.ts +102 -20
- package/extensions/starline/project-probe.ts +79 -0
- package/extensions/starline/prototype-patch-registry.ts +6 -1
- package/extensions/starline/queue-rows.ts +43 -0
- package/extensions/starline/settings-command.ts +124 -177
- package/extensions/starline/subagent-fleet.ts +246 -0
- package/extensions/starline/turn-clock.ts +81 -0
- package/extensions/starline/ui.ts +297 -156
- package/extensions/starline/working-indicator.ts +189 -0
- package/package.json +18 -12
- package/types/pill-api.d.ts +115 -0
- package/extensions/starline/editor-metadata-format.ts +0 -259
- 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.
|
|
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.
|
|
27
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
28
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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/
|
|
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.
|
|
42
|
-
"@earendil-works/pi-ai": "
|
|
43
|
-
"@earendil-works/pi-coding-agent": "
|
|
44
|
-
"@earendil-works/pi-tui": "
|
|
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.
|
|
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
|
|
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
|
-
}
|