@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
@@ -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
- modelMeta: EditorMeta;
87
- thinkingLevel: string | undefined;
88
- rightStatus?: string;
89
- /** Third-party statuses placed on the editor row (`placement: "editor"`). */
90
- editorStatus?: string;
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 side of the metadata row: vim mode, third-party statuses placed
139
- * there, and the paste/selection hints.
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
- * Hints come last: `composeMetadataLine` truncates this string from the right
149
- * when it cannot fit, and the hints are the transient, actionable part.
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 composeRightStatus(
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, editorStatus, styled].filter((part): part is string => Boolean(part));
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 > 0 && /^─+$/.test(plain);
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 < 4 + padY ||
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
- if (interior.length < 2 + padY) return undefined;
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, -(1 + 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
- padY > 0 &&
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 >= 4; 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
- modelMeta,
314
- thinkingLevel,
315
- rightStatus,
316
- editorStatus,
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 meta = renderEditorMetadataFormat(
355
- config.editorMetadataFormat,
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
- model: modelMeta.modelLabel,
358
- modelId: modelMeta.modelId ?? "",
359
- modelName: modelMeta.modelName ?? "",
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
- uiTheme,
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 = renderStyleForSourceOrFallback(
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 lines = [...padRows, ...editorLines, ...padRows, railedMeta];
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
- ...lines.map((line) => `${rail}${fillLine(line, innerWidth)}`),
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
- private readonly getExtensionStatuses: (() => ReadonlyMap<string, string>) | undefined;
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
- const modelMeta = this.getModelMeta();
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
- modelMeta,
458
- thinkingLevel: this.getThinkingLevel(),
459
- editorStatus: editorStatusText(config, this.uiTheme, this.getExtensionStatuses?.()),
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 splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
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
- const vimStatus = readVimStatus(this.base, this.uiTheme);
570
- const modelMeta = this.getModelMeta();
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
- modelMeta,
578
- thinkingLevel: this.getThinkingLevel(),
579
- rightStatus: vimStatus,
580
- editorStatus: editorStatusText(config, this.uiTheme, this.getExtensionStatuses?.()),
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 splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
729
+ return splitDrawnFrame(this.lastFrame, lines, this.getConfig(), this.uiTheme);
589
730
  }
590
731
 
591
732
  invalidate(): void {