@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
|
@@ -5,13 +5,13 @@ import {
|
|
|
5
5
|
type EditorComponent,
|
|
6
6
|
type EditorTheme,
|
|
7
7
|
type TUI,
|
|
8
|
+
type TuiMouseEvent,
|
|
9
|
+
type TuiMouseEventResult,
|
|
8
10
|
truncateToWidth,
|
|
9
11
|
visibleWidth,
|
|
10
12
|
} from "@earendil-works/pi-tui";
|
|
11
13
|
import type { PolishedTuiConfig } from "./config";
|
|
12
14
|
import { applyEditorCursorStyleToLines } from "./editor-cursor";
|
|
13
|
-
import { renderEditorMetadataFormat } from "./editor-metadata-format";
|
|
14
|
-
import { collectExtensionStatusSegments } from "./extension-status";
|
|
15
15
|
import { activeSelectionHintText, externalEditorHintText } from "./mouse";
|
|
16
16
|
import { composeHints } from "./mouse/hint";
|
|
17
17
|
import { pasteExpandHintText } from "./paste-collapse";
|
|
@@ -21,17 +21,15 @@ import {
|
|
|
21
21
|
renderStyleForSourceOrFallback,
|
|
22
22
|
safeThemeFg,
|
|
23
23
|
} from "./style";
|
|
24
|
+
import {
|
|
25
|
+
setWorkingIndicator,
|
|
26
|
+
type WorkingIndicator,
|
|
27
|
+
workingStatusForBorder,
|
|
28
|
+
} from "./working-indicator";
|
|
24
29
|
|
|
25
30
|
const SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-starline.polished-frame");
|
|
26
31
|
|
|
27
|
-
/**
|
|
28
|
-
* The key this package used before it was renamed from pi-zentui. Read, never
|
|
29
|
-
* written as the primary key: if both packages are loaded in the same
|
|
30
|
-
* session, a base editor built by either one must still resolve the
|
|
31
|
-
* splitter, or the wrapper double-renders the inner frame instead of
|
|
32
|
-
* unwrapping it. See the alias assignments after each class below, which
|
|
33
|
-
* expose the splitter under this key too so the reverse direction resolves.
|
|
34
|
-
*/
|
|
32
|
+
/** The pre-rename key remains shared with pi-zentui in either load order. */
|
|
35
33
|
const LEGACY_SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-zentui.polished-frame");
|
|
36
34
|
|
|
37
35
|
type PolishedFrameSplit = {
|
|
@@ -39,6 +37,14 @@ type PolishedFrameSplit = {
|
|
|
39
37
|
trailingLines: string[];
|
|
40
38
|
};
|
|
41
39
|
|
|
40
|
+
/** What one render drew, kept so a wrapper can take the frame apart again. */
|
|
41
|
+
type DrawnFrame = PolishedFrameSplit & {
|
|
42
|
+
/** The array `render` returned; a wrapper hands back this exact one. */
|
|
43
|
+
lines: string[];
|
|
44
|
+
/** Blank rows `editorMinRows` added below the text. */
|
|
45
|
+
fillerRows: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
42
48
|
type AutocompleteEditorInternals = {
|
|
43
49
|
autocompleteList?: Pick<Component, "render">;
|
|
44
50
|
isShowingAutocomplete?: () => boolean;
|
|
@@ -69,25 +75,21 @@ type WrappedEditor = EditorComponent &
|
|
|
69
75
|
[LEGACY_SPLIT_POLISHED_FRAME]?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
70
76
|
};
|
|
71
77
|
|
|
72
|
-
type EditorMeta = {
|
|
73
|
-
modelLabel: string;
|
|
74
|
-
modelId?: string;
|
|
75
|
-
modelName?: string;
|
|
76
|
-
providerLabel: string;
|
|
77
|
-
sessionName?: string;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
78
|
type PolishedFrameOptions = {
|
|
81
79
|
width: number;
|
|
82
80
|
baseRendered: string[];
|
|
83
81
|
autocompleteSource: AutocompleteEditorInternals;
|
|
84
82
|
uiTheme: Theme;
|
|
85
83
|
config: PolishedTuiConfig;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
/** A wrapped base editor's mode (`INSERT`, `NORMAL`, …), when it has one. */
|
|
85
|
+
vimStatus?: string;
|
|
86
|
+
/** Pi's status text for the top rule (retry, compaction, …). */
|
|
87
|
+
borderStatus?: string;
|
|
88
|
+
/** Draft lines scrolled out of view above / below, for Pi's `↑ N more` cue. */
|
|
89
|
+
scrollTop?: number;
|
|
90
|
+
scrollBottom?: number;
|
|
91
|
+
/** Reports what the frame drew: `handleMouse` bounds clicks by it, a wrapper splits by it. */
|
|
92
|
+
onFrame?: (frame: Omit<DrawnFrame, "lines">) => void;
|
|
91
93
|
splitBaseFrame?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
92
94
|
};
|
|
93
95
|
|
|
@@ -135,24 +137,15 @@ function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset:
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
/**
|
|
138
|
-
* The right
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* The hint used to live on the editor's bottom border, drawn there by the fixed
|
|
142
|
-
* editor's compositor. Pi 0.84 supersedes the fixed editor, so the hint needs a
|
|
143
|
-
* home that does not depend on it — and the border is not it, because
|
|
144
|
-
* `isHorizontalBorder` requires an unbroken rule to find the frame again. The
|
|
145
|
-
* metadata row is always rendered, even when `editorMetadataFormat` is blank,
|
|
146
|
-
* so the hint shows up whatever the user has done to that template.
|
|
140
|
+
* The right end of the top rule: a wrapped editor's vim mode, then the paste
|
|
141
|
+
* and selection hints.
|
|
147
142
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
143
|
+
* Until 0.9 these sat on a metadata row under the text, which made the box a
|
|
144
|
+
* row taller than Pi's even with nothing to say. The top rule is there anyway,
|
|
145
|
+
* and nothing else claims its right end — Pi's status text starts at the left,
|
|
146
|
+
* the scroll cue is centred — so the box stays the height of its text.
|
|
150
147
|
*/
|
|
151
|
-
function
|
|
152
|
-
vimStatus: string | undefined,
|
|
153
|
-
editorStatus: string | undefined,
|
|
154
|
-
uiTheme: Theme,
|
|
155
|
-
): string | undefined {
|
|
148
|
+
function composeTopRightStatus(vimStatus: string | undefined, uiTheme: Theme): string | undefined {
|
|
156
149
|
// A live selection hint already spells the external editor out ("N
|
|
157
150
|
// characters selected, ctrl+x to copy ⋅ ctrl+g to edit in $EDITOR"), so it
|
|
158
151
|
// takes precedence; the always-on hint only speaks when nothing else does.
|
|
@@ -161,44 +154,10 @@ function composeRightStatus(
|
|
|
161
154
|
activeSelectionHintText() ?? externalEditorHintText(),
|
|
162
155
|
);
|
|
163
156
|
const styled = hint ? safeThemeFg(uiTheme, "muted", hint) : undefined;
|
|
164
|
-
const parts = [vimStatus,
|
|
157
|
+
const parts = [vimStatus, styled].filter((part): part is string => Boolean(part));
|
|
165
158
|
return parts.length > 0 ? parts.join(" ⋅ ") : undefined;
|
|
166
159
|
}
|
|
167
160
|
|
|
168
|
-
/**
|
|
169
|
-
* Statuses configured with `placement: "editor"`, joined for the metadata row.
|
|
170
|
-
*
|
|
171
|
-
* The row is one muted line like the hints beside it, so the per-status colours
|
|
172
|
-
* and icons (footer features) are not applied here; `colorMode: "original"`
|
|
173
|
-
* text keeps whatever the extension already styled it with.
|
|
174
|
-
*/
|
|
175
|
-
function editorStatusText(
|
|
176
|
-
config: PolishedTuiConfig,
|
|
177
|
-
uiTheme: Theme,
|
|
178
|
-
statuses: ReadonlyMap<string, string> | undefined,
|
|
179
|
-
): string | undefined {
|
|
180
|
-
if (!statuses || statuses.size === 0) return undefined;
|
|
181
|
-
const segments = collectExtensionStatusSegments(statuses, config).editor;
|
|
182
|
-
if (segments.length === 0) return undefined;
|
|
183
|
-
return segments
|
|
184
|
-
.map((segment) =>
|
|
185
|
-
segment.colorMode === "original" ? segment.text : safeThemeFg(uiTheme, "muted", segment.text),
|
|
186
|
-
)
|
|
187
|
-
.join(" ⋅ ");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function composeMetadataLine(left: string, right: string | undefined, width: number): string {
|
|
191
|
-
if (!right) return left;
|
|
192
|
-
const maxWidth = Math.max(0, width);
|
|
193
|
-
const rightWidth = visibleWidth(right);
|
|
194
|
-
if (rightWidth >= maxWidth) return truncateToWidth(right, maxWidth, "");
|
|
195
|
-
|
|
196
|
-
const leftWidth = Math.max(0, maxWidth - rightWidth - 1);
|
|
197
|
-
const leftText = truncateToWidth(left, leftWidth, "");
|
|
198
|
-
const gap = " ".repeat(Math.max(1, maxWidth - visibleWidth(leftText) - rightWidth));
|
|
199
|
-
return `${leftText}${gap}${right}`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
161
|
function plainRenderedText(line: string): string {
|
|
203
162
|
return line
|
|
204
163
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
@@ -206,11 +165,21 @@ function plainRenderedText(line: string): string {
|
|
|
206
165
|
.replace(/\[\/?[^\]]+\]/g, "");
|
|
207
166
|
}
|
|
208
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A frame rule, possibly with text riding on it (`── ⠋ Retrying ──── hint ──`):
|
|
170
|
+
* it starts and ends with rule glyphs. Rail rows start with the rail and
|
|
171
|
+
* autocomplete rows with text, so neither matches.
|
|
172
|
+
*/
|
|
209
173
|
function isHorizontalBorder(line: string): boolean {
|
|
210
174
|
const plain = plainRenderedText(line).trim();
|
|
211
|
-
return plain.length >
|
|
175
|
+
return plain.length > 1 && plain.startsWith("─") && plain.endsWith("─");
|
|
212
176
|
}
|
|
213
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Take a Starline frame apart from its lines alone. Only the fallback for
|
|
180
|
+
* `splitPolishedFrame` — an editor normally answers from what it drew — so it
|
|
181
|
+
* cannot tell `editorMinRows` filler from blank draft lines and keeps both.
|
|
182
|
+
*/
|
|
214
183
|
function unwrapPolishedFrameOnly(
|
|
215
184
|
lines: string[],
|
|
216
185
|
config: PolishedTuiConfig,
|
|
@@ -220,26 +189,20 @@ function unwrapPolishedFrameOnly(
|
|
|
220
189
|
// to be the padding assumed here.
|
|
221
190
|
const padY = config.editorPaddingY > 0 ? 1 : 0;
|
|
222
191
|
if (
|
|
223
|
-
lines.length <
|
|
192
|
+
lines.length < 3 + 2 * padY ||
|
|
224
193
|
!isHorizontalBorder(lines[0] ?? "") ||
|
|
225
194
|
!isHorizontalBorder(lines.at(-1) ?? "")
|
|
226
195
|
)
|
|
227
196
|
return undefined;
|
|
228
197
|
|
|
229
198
|
const interior = lines.slice(1, -1);
|
|
230
|
-
|
|
199
|
+
const isBlank = (line: string | undefined) => plainRenderedText(line ?? "").trim() === "";
|
|
231
200
|
|
|
232
201
|
if (config.features.copyFriendly) {
|
|
233
|
-
if (
|
|
234
|
-
(padY > 0 && plainRenderedText(interior[0] ?? "").trim() !== "") ||
|
|
235
|
-
(padY > 0 && plainRenderedText(interior.at(-2) ?? "").trim() !== "") ||
|
|
236
|
-
!(interior.at(-1) ?? "").startsWith(" ")
|
|
237
|
-
)
|
|
238
|
-
return undefined;
|
|
239
|
-
|
|
202
|
+
if (padY > 0 && (!isBlank(interior[0]) || !isBlank(interior.at(-1)))) return undefined;
|
|
240
203
|
const { prompt, promptWidth } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
241
204
|
const continuation = " ".repeat(promptWidth);
|
|
242
|
-
const content = interior.slice(padY, -
|
|
205
|
+
const content = interior.slice(padY, interior.length - padY);
|
|
243
206
|
const unwrapped: string[] = [];
|
|
244
207
|
for (let index = 0; index < content.length; index++) {
|
|
245
208
|
const prefix = index === 0 ? prompt : continuation;
|
|
@@ -253,13 +216,8 @@ function unwrapPolishedFrameOnly(
|
|
|
253
216
|
const { rail } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
254
217
|
if (!rail || interior.some((line) => !line.startsWith(rail))) return undefined;
|
|
255
218
|
const unrailed = interior.map((line) => line.slice(rail.length));
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
(plainRenderedText(unrailed[0] ?? "").trim() !== "" ||
|
|
259
|
-
plainRenderedText(unrailed.at(-2) ?? "").trim() !== "")
|
|
260
|
-
)
|
|
261
|
-
return undefined;
|
|
262
|
-
return unrailed.slice(padY, -(1 + padY));
|
|
219
|
+
if (padY > 0 && (!isBlank(unrailed[0]) || !isBlank(unrailed.at(-1)))) return undefined;
|
|
220
|
+
return unrailed.slice(padY, unrailed.length - padY);
|
|
263
221
|
}
|
|
264
222
|
|
|
265
223
|
function splitPolishedFrame(
|
|
@@ -268,7 +226,7 @@ function splitPolishedFrame(
|
|
|
268
226
|
uiTheme: Theme,
|
|
269
227
|
): PolishedFrameSplit | undefined {
|
|
270
228
|
if (!isHorizontalBorder(lines[0] ?? "")) return undefined;
|
|
271
|
-
for (let bottomIndex = lines.length - 1; bottomIndex >=
|
|
229
|
+
for (let bottomIndex = lines.length - 1; bottomIndex >= 2; bottomIndex--) {
|
|
272
230
|
if (!isHorizontalBorder(lines[bottomIndex] ?? "")) continue;
|
|
273
231
|
const editorLines = unwrapPolishedFrameOnly(lines.slice(0, bottomIndex + 1), config, uiTheme);
|
|
274
232
|
if (editorLines) {
|
|
@@ -278,6 +236,24 @@ function splitPolishedFrame(
|
|
|
278
236
|
return undefined;
|
|
279
237
|
}
|
|
280
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Split a frame this editor drew. A wrapper hands back the very array `render`
|
|
241
|
+
* returned, so the frame is known exactly — text rows apart from padding and
|
|
242
|
+
* `editorMinRows` filler, whatever rides on the rules. Lines from anywhere else
|
|
243
|
+
* go through the positional parse.
|
|
244
|
+
*/
|
|
245
|
+
function splitDrawnFrame(
|
|
246
|
+
drawn: DrawnFrame | undefined,
|
|
247
|
+
lines: string[],
|
|
248
|
+
config: PolishedTuiConfig,
|
|
249
|
+
uiTheme: Theme,
|
|
250
|
+
): PolishedFrameSplit | undefined {
|
|
251
|
+
if (drawn && drawn.lines === lines) {
|
|
252
|
+
return { editorLines: drawn.editorLines, trailingLines: drawn.trailingLines };
|
|
253
|
+
}
|
|
254
|
+
return splitPolishedFrame(lines, config, uiTheme);
|
|
255
|
+
}
|
|
256
|
+
|
|
281
257
|
function vimModeColor(mode: string): string {
|
|
282
258
|
switch (mode.toLowerCase()) {
|
|
283
259
|
case "insert":
|
|
@@ -295,6 +271,133 @@ function vimModeColor(mode: string): string {
|
|
|
295
271
|
}
|
|
296
272
|
}
|
|
297
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A frame rule with things riding on it.
|
|
276
|
+
*
|
|
277
|
+
* Three things can share the rule: Pi's status text on the left (`── ⠋ Retrying
|
|
278
|
+
* (1/3) ────`), hints on the right (`──── 12 characters selected ──`), and the
|
|
279
|
+
* draft's scroll position, centred in Pi's own ` ↑ 3 more ` wording. When they
|
|
280
|
+
* cannot all fit, the status wins (it carries a cancel hint), then the right
|
|
281
|
+
* side, which is cut from its end; the scroll label is dropped rather than
|
|
282
|
+
* pushed off-centre.
|
|
283
|
+
*
|
|
284
|
+
* The status and hints arrive already coloured, so only the rule and the label
|
|
285
|
+
* are painted here.
|
|
286
|
+
*/
|
|
287
|
+
function composeBorderLine(
|
|
288
|
+
width: number,
|
|
289
|
+
parts: { status?: string; scroll?: string; right?: string },
|
|
290
|
+
border: (text: string) => string,
|
|
291
|
+
): string {
|
|
292
|
+
const rule = (count: number) => border("─".repeat(Math.max(0, count)));
|
|
293
|
+
if (width <= 0) return rule(width);
|
|
294
|
+
|
|
295
|
+
const status = parts.status ? truncateToWidth(parts.status, Math.max(1, width - 5), "") : "";
|
|
296
|
+
const statusWidth = visibleWidth(status);
|
|
297
|
+
const leftWidth = statusWidth > 0 ? statusWidth + 4 : 0;
|
|
298
|
+
|
|
299
|
+
// ` hint ──`, kept clear of the status by at least one rule cell.
|
|
300
|
+
const rightRoom = width - leftWidth - 1 - 4;
|
|
301
|
+
const right = parts.right && rightRoom >= 1 ? truncateToWidth(parts.right, rightRoom, "") : "";
|
|
302
|
+
const rightTextWidth = visibleWidth(right);
|
|
303
|
+
const rightWidth = rightTextWidth > 0 ? rightTextWidth + 4 : 0;
|
|
304
|
+
|
|
305
|
+
const label = parts.scroll ? ` ${parts.scroll} ` : "";
|
|
306
|
+
const labelWidth = visibleWidth(label);
|
|
307
|
+
const labelStart = labelWidth > 0 ? Math.floor((width - labelWidth) / 2) : -1;
|
|
308
|
+
const centred =
|
|
309
|
+
labelWidth > 0 &&
|
|
310
|
+
labelStart >= leftWidth + 1 &&
|
|
311
|
+
labelStart + labelWidth <= width - rightWidth - 1;
|
|
312
|
+
// A label that cannot be centred still beats dropping it, but only when there
|
|
313
|
+
// is nothing else on the rule competing for its ends.
|
|
314
|
+
const rightAligned =
|
|
315
|
+
labelWidth > 0 && !centred && leftWidth === 0 && rightWidth === 0 && labelWidth + 2 <= width;
|
|
316
|
+
|
|
317
|
+
let out = statusWidth > 0 ? `${border("── ")}${status}${border(" ")}` : "";
|
|
318
|
+
let cursor = leftWidth;
|
|
319
|
+
const end = width - rightWidth;
|
|
320
|
+
if (centred) {
|
|
321
|
+
out += rule(labelStart - cursor) + border(label);
|
|
322
|
+
cursor = labelStart + labelWidth;
|
|
323
|
+
} else if (rightAligned) {
|
|
324
|
+
out += rule(width - labelWidth) + border(label);
|
|
325
|
+
cursor = width;
|
|
326
|
+
}
|
|
327
|
+
out += rule(end - cursor);
|
|
328
|
+
return rightWidth > 0 ? `${out}${border(" ")}${right}${border(" ──")}` : out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Pi's own wording for the border scroll cue. */
|
|
332
|
+
function scrollLabel(direction: "↑" | "↓", count: number | undefined): string | undefined {
|
|
333
|
+
return count && count > 0 ? `${direction} ${count} more` : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Read back the count Pi painted into a border line. The wrapped editor drops the
|
|
338
|
+
* base's frame lines, so this is how a base editor's scroll cue survives.
|
|
339
|
+
*/
|
|
340
|
+
function readScrollCount(line: string | undefined, direction: "↑" | "↓"): number {
|
|
341
|
+
if (!line) return 0;
|
|
342
|
+
const match = new RegExp(`${direction}\\s+(\\d+)\\s+more`).exec(plainRenderedText(line));
|
|
343
|
+
return match ? Number(match[1]) : 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The fields of a Pi mouse event the frame translation reads. Generic callers
|
|
348
|
+
* pass the whole `TuiMouseEvent` and get the same type back.
|
|
349
|
+
*/
|
|
350
|
+
export type FrameMouseEvent = {
|
|
351
|
+
x: number;
|
|
352
|
+
y: number;
|
|
353
|
+
width: number;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Map a click in Starline's frame onto the coordinates Pi's own editor expects.
|
|
358
|
+
*
|
|
359
|
+
* Pi counts from its own frame: `y = 1` is the first text row, `x = 0` is the box's
|
|
360
|
+
* left edge, and `width` is the whole box. Starline's frame puts a rail (or a
|
|
361
|
+
* copy-friendly prompt) in front of every row, pads above and below the text when
|
|
362
|
+
* `editorPaddingY` is on, and follows a short draft with `editorMinRows` filler.
|
|
363
|
+
*
|
|
364
|
+
* A click on a filler row lands at the end of the last text row, as a click
|
|
365
|
+
* past the end of a line does. Anything else outside the text block returns
|
|
366
|
+
* undefined: the borders and padding are Starline's, and Pi would otherwise read
|
|
367
|
+
* a click on the bottom rule as a click in its autocomplete list — which it
|
|
368
|
+
* draws immediately after that rule in its own frame, but rows earlier in ours.
|
|
369
|
+
*/
|
|
370
|
+
export function translateEditorMouse<Event extends FrameMouseEvent>(
|
|
371
|
+
event: Event,
|
|
372
|
+
geometry: { railWidth: number; padTop: number; editorLineCount: number; fillerRows?: number },
|
|
373
|
+
): Event | undefined {
|
|
374
|
+
const { railWidth, padTop, editorLineCount, fillerRows = 0 } = geometry;
|
|
375
|
+
if (editorLineCount <= 0) return undefined;
|
|
376
|
+
const row = event.y - padTop;
|
|
377
|
+
if (!Number.isFinite(row) || row < 1 || row > editorLineCount + fillerRows) return undefined;
|
|
378
|
+
const width = Math.max(1, event.width - railWidth);
|
|
379
|
+
const filler = row > editorLineCount;
|
|
380
|
+
return {
|
|
381
|
+
...event,
|
|
382
|
+
x: filler ? width - 1 : event.x - railWidth,
|
|
383
|
+
y: filler ? editorLineCount : row,
|
|
384
|
+
width,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pi's own click handling, resolved once at load.
|
|
390
|
+
*
|
|
391
|
+
* `Editor.handleMouse` only exists from pi-tui 1.4. The peer floor is older, so
|
|
392
|
+
* the lookup is a plain property read that stays undefined on those builds rather
|
|
393
|
+
* than a call the type system assumes is there.
|
|
394
|
+
*/
|
|
395
|
+
const baseHandleMouse = (
|
|
396
|
+
CustomEditor.prototype as {
|
|
397
|
+
handleMouse?: (this: unknown, event: TuiMouseEvent) => TuiMouseEventResult | undefined;
|
|
398
|
+
}
|
|
399
|
+
).handleMouse;
|
|
400
|
+
|
|
298
401
|
function readVimStatus(editor: WrappedEditor, uiTheme: Theme): string | undefined {
|
|
299
402
|
const mode = editor.getMode?.();
|
|
300
403
|
if (typeof mode !== "string") return undefined;
|
|
@@ -310,10 +413,11 @@ function renderPolishedFrame({
|
|
|
310
413
|
autocompleteSource,
|
|
311
414
|
uiTheme,
|
|
312
415
|
config,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
416
|
+
vimStatus,
|
|
417
|
+
borderStatus,
|
|
418
|
+
scrollTop,
|
|
419
|
+
scrollBottom,
|
|
420
|
+
onFrame,
|
|
317
421
|
splitBaseFrame,
|
|
318
422
|
}: PolishedFrameOptions): string[] {
|
|
319
423
|
if (width <= 2) return clampRenderedLines(baseRendered, width);
|
|
@@ -351,42 +455,29 @@ function renderPolishedFrame({
|
|
|
351
455
|
ownedFrame?.editorLines ?? editorFrame.slice(1, -1),
|
|
352
456
|
config.editorCursor,
|
|
353
457
|
);
|
|
354
|
-
const
|
|
355
|
-
|
|
458
|
+
const fillerRows = Math.max(0, config.editorMinRows - editorLines.length);
|
|
459
|
+
onFrame?.({ editorLines, trailingLines: autocompleteLines, fillerRows });
|
|
460
|
+
|
|
461
|
+
const borderPaint = (text: string) =>
|
|
462
|
+
renderStyleForSourceOrFallback(
|
|
463
|
+
uiTheme,
|
|
464
|
+
colorSource,
|
|
465
|
+
config.colors.editorBorder,
|
|
466
|
+
EDITOR_BORDER_FALLBACK,
|
|
467
|
+
text,
|
|
468
|
+
);
|
|
469
|
+
const top = composeBorderLine(
|
|
470
|
+
width,
|
|
356
471
|
{
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
provider: modelMeta.providerLabel,
|
|
361
|
-
thinking: thinkingLevel ?? "",
|
|
362
|
-
sessionName: modelMeta.sessionName ?? "",
|
|
472
|
+
status: borderStatus,
|
|
473
|
+
scroll: scrollLabel("↑", scrollTop),
|
|
474
|
+
right: composeTopRightStatus(vimStatus, uiTheme),
|
|
363
475
|
},
|
|
364
|
-
|
|
365
|
-
config,
|
|
366
|
-
);
|
|
367
|
-
const status = composeRightStatus(rightStatus, editorStatus, uiTheme);
|
|
368
|
-
const copyFriendlyMeta = composeMetadataLine(meta, status, Math.max(0, width - 1));
|
|
369
|
-
const railedMeta = composeMetadataLine(meta, status, innerWidth);
|
|
370
|
-
|
|
371
|
-
const top = renderStyleForSourceOrFallback(
|
|
372
|
-
uiTheme,
|
|
373
|
-
colorSource,
|
|
374
|
-
config.colors.editorBorder,
|
|
375
|
-
EDITOR_BORDER_FALLBACK,
|
|
376
|
-
"─".repeat(width),
|
|
476
|
+
borderPaint,
|
|
377
477
|
);
|
|
378
|
-
const bottom =
|
|
379
|
-
uiTheme,
|
|
380
|
-
colorSource,
|
|
381
|
-
config.colors.editorBorder,
|
|
382
|
-
EDITOR_BORDER_FALLBACK,
|
|
383
|
-
"─".repeat(width),
|
|
384
|
-
);
|
|
385
|
-
// The metadata row stays even when it renders blank: splitPolishedFrame parses
|
|
386
|
-
// the frame back by position, so changing the line count breaks the wrapped
|
|
387
|
-
// editor path. An empty editorMetadataFormat leaves a blank row, not no row.
|
|
478
|
+
const bottom = composeBorderLine(width, { scroll: scrollLabel("↓", scrollBottom) }, borderPaint);
|
|
388
479
|
const padRows = config.editorPaddingY > 0 ? [""] : [];
|
|
389
|
-
const
|
|
480
|
+
const fillRows = Array.from({ length: fillerRows }, () => "");
|
|
390
481
|
const renderedLines = config.features.copyFriendly
|
|
391
482
|
? [
|
|
392
483
|
top,
|
|
@@ -395,14 +486,16 @@ function renderPolishedFrame({
|
|
|
395
486
|
(line, index) =>
|
|
396
487
|
`${index === 0 ? prompt : copyFriendlyContinuation}${fillLine(line, innerWidth)}`,
|
|
397
488
|
),
|
|
489
|
+
...fillRows,
|
|
398
490
|
...padRows,
|
|
399
|
-
` ${truncateToWidth(copyFriendlyMeta, Math.max(0, width - 1), "")}`,
|
|
400
491
|
bottom,
|
|
401
492
|
...autocompleteLines,
|
|
402
493
|
]
|
|
403
494
|
: [
|
|
404
495
|
top,
|
|
405
|
-
...
|
|
496
|
+
...[...padRows, ...editorLines, ...fillRows, ...padRows].map(
|
|
497
|
+
(line) => `${rail}${fillLine(line, innerWidth)}`,
|
|
498
|
+
),
|
|
406
499
|
bottom,
|
|
407
500
|
...autocompleteLines,
|
|
408
501
|
];
|
|
@@ -411,11 +504,18 @@ function renderPolishedFrame({
|
|
|
411
504
|
}
|
|
412
505
|
|
|
413
506
|
export class PolishedEditor extends CustomEditor {
|
|
414
|
-
private readonly getModelMeta: () => EditorMeta;
|
|
415
|
-
private readonly getThinkingLevel: () => string | undefined;
|
|
416
507
|
private readonly getConfig: () => PolishedTuiConfig;
|
|
417
508
|
private readonly uiTheme: Theme;
|
|
418
|
-
|
|
509
|
+
/** The last frame drawn: bounds what `handleMouse` translates, and answers a wrapper's split. */
|
|
510
|
+
private lastFrame: DrawnFrame | undefined;
|
|
511
|
+
/**
|
|
512
|
+
* Pi only routes a status to the editor when it advertises this flag, and stops
|
|
513
|
+
* drawing its own row above the editor. Starline wants that deal so the plain
|
|
514
|
+
* spinner can sit in the model pill; see `working-indicator.ts`. The base class
|
|
515
|
+
* never sees the indicator (starline does not forward it), so the border it
|
|
516
|
+
* would draw stays a plain rule and this frame stays the only one.
|
|
517
|
+
*/
|
|
518
|
+
readonly embedWorkingStatus = true;
|
|
419
519
|
|
|
420
520
|
constructor(
|
|
421
521
|
tui: TUI,
|
|
@@ -423,17 +523,11 @@ export class PolishedEditor extends CustomEditor {
|
|
|
423
523
|
keybindings: KeybindingsManager,
|
|
424
524
|
uiTheme: Theme,
|
|
425
525
|
getConfig: () => PolishedTuiConfig,
|
|
426
|
-
getModelMeta: () => EditorMeta,
|
|
427
|
-
getThinkingLevel: () => string | undefined,
|
|
428
|
-
getExtensionStatuses?: () => ReadonlyMap<string, string>,
|
|
429
526
|
) {
|
|
430
527
|
super(tui, theme, keybindings, { paddingX: 0 });
|
|
431
528
|
this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
|
|
432
529
|
this.uiTheme = uiTheme;
|
|
433
530
|
this.getConfig = getConfig;
|
|
434
|
-
this.getModelMeta = getModelMeta;
|
|
435
|
-
this.getThinkingLevel = getThinkingLevel;
|
|
436
|
-
this.getExtensionStatuses = getExtensionStatuses;
|
|
437
531
|
}
|
|
438
532
|
|
|
439
533
|
render(width: number): string[] {
|
|
@@ -445,7 +539,7 @@ export class PolishedEditor extends CustomEditor {
|
|
|
445
539
|
const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
|
|
446
540
|
const innerWidth = Math.max(0, width - railWidth);
|
|
447
541
|
const rendered = super.render(innerWidth);
|
|
448
|
-
|
|
542
|
+
let drawn: Omit<DrawnFrame, "lines"> | undefined;
|
|
449
543
|
const result = renderPolishedFrame({
|
|
450
544
|
width,
|
|
451
545
|
baseRendered: rendered,
|
|
@@ -454,15 +548,47 @@ export class PolishedEditor extends CustomEditor {
|
|
|
454
548
|
autocompleteSource: this as unknown as AutocompleteEditorInternals,
|
|
455
549
|
uiTheme: this.uiTheme,
|
|
456
550
|
config,
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
551
|
+
borderStatus: workingStatusForBorder(config, Math.max(1, width - 5)),
|
|
552
|
+
// Pi paints the draft's scroll position into the borders it draws, and both
|
|
553
|
+
// of those lines are dropped when Starline redraws the frame, so the counts
|
|
554
|
+
// are read back out of them here. Works whether the base inlines the cue
|
|
555
|
+
// (Pi 0.84) or routes it through renderTopBorder (Pi 1.4+).
|
|
556
|
+
scrollTop: readScrollCount(rendered[0], "↑"),
|
|
557
|
+
scrollBottom: readScrollCount(rendered.at(-1), "↓"),
|
|
558
|
+
onFrame: (frame) => {
|
|
559
|
+
drawn = frame;
|
|
560
|
+
},
|
|
460
561
|
});
|
|
562
|
+
this.lastFrame = drawn && { ...drawn, lines: result };
|
|
461
563
|
return result;
|
|
462
564
|
}
|
|
463
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Pi 1.4 turns a click into a caret move itself (`Editor.handleMouse`), counting
|
|
568
|
+
* rows from its own frame. Starline's frame is not that frame, so the press is
|
|
569
|
+
* translated back before Pi sees it — grapheme-accurate columns, autocomplete
|
|
570
|
+
* clicks and focus then come from Pi rather than from a patched press handler.
|
|
571
|
+
*/
|
|
572
|
+
override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
573
|
+
if (typeof baseHandleMouse !== "function") return undefined;
|
|
574
|
+
const config = this.getConfig();
|
|
575
|
+
const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
|
|
576
|
+
const translated = translateEditorMouse(event, {
|
|
577
|
+
railWidth,
|
|
578
|
+
padTop: config.editorPaddingY > 0 ? 1 : 0,
|
|
579
|
+
editorLineCount: this.lastFrame?.editorLines.length ?? 0,
|
|
580
|
+
fillerRows: this.lastFrame?.fillerRows ?? 0,
|
|
581
|
+
});
|
|
582
|
+
if (!translated) return undefined;
|
|
583
|
+
return baseHandleMouse.call(this, translated);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
setWorkingStatusIndicator(indicator: WorkingIndicator | undefined): void {
|
|
587
|
+
setWorkingIndicator(indicator);
|
|
588
|
+
}
|
|
589
|
+
|
|
464
590
|
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
465
|
-
return
|
|
591
|
+
return splitDrawnFrame(this.lastFrame, lines, this.getConfig(), this.uiTheme);
|
|
466
592
|
}
|
|
467
593
|
}
|
|
468
594
|
// Also resolvable under the pre-rename key, so a pi-zentui wrapper reading a
|
|
@@ -473,15 +599,20 @@ export class PolishedEditor extends CustomEditor {
|
|
|
473
599
|
PolishedEditor.prototype[SPLIT_POLISHED_FRAME];
|
|
474
600
|
|
|
475
601
|
export class WrappedPolishedEditor implements EditorComponent {
|
|
602
|
+
/**
|
|
603
|
+
* Same contract as `PolishedEditor`: see the note there. The wrapper is what Pi
|
|
604
|
+
* mounts, so the wrapper is what has to advertise it.
|
|
605
|
+
*/
|
|
606
|
+
readonly embedWorkingStatus = true;
|
|
607
|
+
|
|
476
608
|
constructor(
|
|
477
609
|
private readonly base: WrappedEditor,
|
|
478
610
|
private readonly uiTheme: Theme,
|
|
479
611
|
private readonly getConfig: () => PolishedTuiConfig,
|
|
480
|
-
private readonly getModelMeta: () => EditorMeta,
|
|
481
|
-
private readonly getThinkingLevel: () => string | undefined,
|
|
482
|
-
private readonly getExtensionStatuses?: () => ReadonlyMap<string, string>,
|
|
483
612
|
) {}
|
|
484
613
|
|
|
614
|
+
private lastFrame: DrawnFrame | undefined;
|
|
615
|
+
|
|
485
616
|
get focused(): boolean {
|
|
486
617
|
return Boolean(this.base.focused);
|
|
487
618
|
}
|
|
@@ -566,26 +697,36 @@ export class WrappedPolishedEditor implements EditorComponent {
|
|
|
566
697
|
const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
|
|
567
698
|
const innerWidth = Math.max(0, width - railWidth);
|
|
568
699
|
const rendered = this.base.render(innerWidth);
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
return renderPolishedFrame({
|
|
700
|
+
let drawn: Omit<DrawnFrame, "lines"> | undefined;
|
|
701
|
+
const result = renderPolishedFrame({
|
|
572
702
|
width,
|
|
573
703
|
baseRendered: rendered,
|
|
574
704
|
autocompleteSource: this.base,
|
|
575
705
|
uiTheme: this.uiTheme,
|
|
576
706
|
config,
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
707
|
+
vimStatus: readVimStatus(this.base, this.uiTheme),
|
|
708
|
+
borderStatus: workingStatusForBorder(config, Math.max(1, width - 5)),
|
|
709
|
+
// The base's frame lines are dropped, so its scroll cues are read back
|
|
710
|
+
// from them and redrawn by Starline's own borders.
|
|
711
|
+
scrollTop: readScrollCount(rendered[0], "↑"),
|
|
712
|
+
scrollBottom: readScrollCount(rendered.at(-1), "↓"),
|
|
581
713
|
splitBaseFrame: (
|
|
582
714
|
this.base[SPLIT_POLISHED_FRAME] ?? this.base[LEGACY_SPLIT_POLISHED_FRAME]
|
|
583
715
|
)?.bind(this.base),
|
|
716
|
+
onFrame: (frame) => {
|
|
717
|
+
drawn = frame;
|
|
718
|
+
},
|
|
584
719
|
});
|
|
720
|
+
this.lastFrame = drawn && { ...drawn, lines: result };
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
setWorkingStatusIndicator(indicator: WorkingIndicator | undefined): void {
|
|
725
|
+
setWorkingIndicator(indicator);
|
|
585
726
|
}
|
|
586
727
|
|
|
587
728
|
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
588
|
-
return
|
|
729
|
+
return splitDrawnFrame(this.lastFrame, lines, this.getConfig(), this.uiTheme);
|
|
589
730
|
}
|
|
590
731
|
|
|
591
732
|
invalidate(): void {
|