@xzzpig/pi-starline 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,16 +44,17 @@ pieces of one product, not a theme:
44
44
 
45
45
  The second half of the product: the statusline tells you where you are, the
46
46
  mouse lets you act on what you see. Starline drives Pi 0.84's own renderer
47
- (`tuiMode: "fullscreen"`) by patching its methods directly — the extension API
48
- stops at high-level hooks, and pending-copy, click-to-expand and clean-copy all
49
- need to reach into the renderer itself. That is also why they live in Starline:
50
- they sit on the same prototype patches the footer and editor already use, and
51
- Pi keeps its core deliberately minimal — behaviours belong in extensions.
52
-
53
- - **Select and copy anywhere.** Drag to select in the transcript or the input box; double-click a word (paths and kebab-case stay whole), triple-click a line. Releasing copies — or hold the selection for `ctrl+c` with `mouse.copyOnSelect: false`, the behaviour [Pi #7720](https://github.com/earendil-works/pi/issues/7720) asks for upstream.
47
+ (`tuiMode: "fullscreen"`) through the [`@xzzpig/pi-mouse-events`](https://www.npmjs.com/package/@xzzpig/pi-mouse-events)
48
+ extension, which owns the renderer prototype and dispatches mouse events to
49
+ components — the extension API stops at high-level hooks, and the selection
50
+ hint, click-to-expand and clean-copy all need to reach into the renderer
51
+ itself. Install it alongside Starline; without it Starline's mouse features
52
+ are simply off (everything else keeps working).
53
+
54
+ - **Select and copy anywhere.** Drag to select in the transcript or the input box; double-click a word (paths and kebab-case stay whole), triple-click a line. Releasing copies — or set Pi's `fullscreenCopyOnSelect: false` and the selection stays highlighted, waiting for the copy key ([Pi #7720](https://github.com/earendil-works/pi/issues/7720), shipped in Pi 0.84.4).
54
55
  - **Clean copies.** Transcript selections drop Starline's rails, rule rows and pi-toolbox frame borders before they reach the clipboard; editor selections copy the draft's text, not the painted rows.
55
- - **Selection hint.** A pending selection shows exactly how many characters `ctrl+c` will copy, and a selection inside the editor names your editor — `ctrl+g to edit in nvim`.
56
- - **Click to expand one tool box.** A click on a box's hint row expands just that box (MCP boxes included), and the `(ctrl+o to collapse)` anchor row closes it again.
56
+ - **Selection hint.** A selection that is not auto-copying shows exactly how many characters the copy key (`ctrl+x` by default, whatever `app.message.copy` is bound to) will copy, and a selection inside the editor names your editor — `ctrl+g to edit in nvim`.
57
+ - **Click to expand one component.** A plain click anywhere on a tool box — title, output, hint row, borders — toggles just that box (MCP boxes included); bash boxes, skill blocks, branch summaries, compaction summaries and custom entries count too. The direction comes from the hint row when one is on screen and from the component's own state otherwise, so a box with no hint left once expanded still closes on a click. Only a plain click toggles: dragging across a box still selects and copies, a double-click still word-selects, and a click on a link still opens it.
57
58
  - **Click to move the caret** in the input box; **backspace/delete remove a selected range**; the **wheel scrolls the draft** over the input box, and a draft taller than the box keeps the `ctrl+g` external-editor hint on screen.
58
59
 
59
60
  0.2.x's `fixedEditor` config block was renamed to `mouse` (migrated
@@ -151,7 +152,7 @@ Detects Starship Nerd Font runtime/language modules, uses the Starship Nerd Font
151
152
 
152
153
  ## Install
153
154
 
154
- Starline drives Pi 0.84's alternate-screen renderer, so it needs **Pi ≥ 0.84.0**.
155
+ Starline drives Pi 0.84's alternate-screen renderer, so it needs **Pi ≥ 0.84.4**.
155
156
  If you are on a distribution that bundles an older Pi, upgrade it or wait for it to catch up.
156
157
 
157
158
  ```bash
@@ -174,7 +175,7 @@ Settings live in `~/.pi/agent/starline.json`. The file is optional — anything
174
175
  {
175
176
  "footerStyle": "pill",
176
177
  "colors": { "gitBranch": "bold purple" },
177
- "mouse": { "copyOnSelect": false }
178
+ "mouse": { "clickToExpandTools": false }
178
179
  }
179
180
  ```
180
181
 
@@ -184,7 +185,7 @@ Settings live in `~/.pi/agent/starline.json`. The file is optional — anything
184
185
 
185
186
  ## Requirements
186
187
 
187
- - [Pi](https://pi.dev) coding agent 0.80.3 or newer
188
+ - [Pi](https://pi.dev) coding agent 0.84.4 or newer
188
189
  - A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
189
190
 
190
191
  ## Development
@@ -109,12 +109,8 @@ export type MouseConfig = {
109
109
  wheelRouting: boolean;
110
110
  /** Show a "copied to clipboard" notice for a copy Starline itself performs. */
111
111
  copyNotice: boolean;
112
- /** Copy on mouse release. When false the highlight waits for ctrl+c. */
113
- copyOnSelect: boolean;
114
112
  /** Clicking a tool box's `ctrl+o to expand` hint expands that one box. */
115
113
  clickToExpandTools: boolean;
116
- /** Double/triple-click word selection stops at path separators. */
117
- pathAwareWords: boolean;
118
114
  /** Copy transcript selections without the painted rails, rules and frames. */
119
115
  transcriptCleanCopy: boolean;
120
116
  };
@@ -123,7 +119,6 @@ const FIXED_EDITOR_KEY_MAP: Record<string, keyof MouseConfig> = {
123
119
  enabled: "enabled",
124
120
  mouseScroll: "wheelRouting",
125
121
  copyNotice: "copyNotice",
126
- copyOnSelect: "copyOnSelect",
127
122
  clickToExpandTools: "clickToExpandTools",
128
123
  };
129
124
 
@@ -415,9 +410,7 @@ export const defaultConfig: PolishedTuiConfig = {
415
410
  enabled: true,
416
411
  wheelRouting: true,
417
412
  copyNotice: true,
418
- copyOnSelect: true,
419
413
  clickToExpandTools: true,
420
- pathAwareWords: true,
421
414
  transcriptCleanCopy: true,
422
415
  },
423
416
  };
@@ -831,18 +824,10 @@ function normalizeMouseConfig(record: Record<string, unknown>): MouseConfig {
831
824
  : defaultConfig.mouse.wheelRouting,
832
825
  copyNotice:
833
826
  typeof record.copyNotice === "boolean" ? record.copyNotice : defaultConfig.mouse.copyNotice,
834
- copyOnSelect:
835
- typeof record.copyOnSelect === "boolean"
836
- ? record.copyOnSelect
837
- : defaultConfig.mouse.copyOnSelect,
838
827
  clickToExpandTools:
839
828
  typeof record.clickToExpandTools === "boolean"
840
829
  ? record.clickToExpandTools
841
830
  : defaultConfig.mouse.clickToExpandTools,
842
- pathAwareWords:
843
- typeof record.pathAwareWords === "boolean"
844
- ? record.pathAwareWords
845
- : defaultConfig.mouse.pathAwareWords,
846
831
  transcriptCleanCopy:
847
832
  typeof record.transcriptCleanCopy === "boolean"
848
833
  ? record.transcriptCleanCopy
@@ -1304,11 +1289,9 @@ export function saveMousePatch(patch: Partial<MouseConfig>, path = configPath):
1304
1289
  ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
1305
1290
  ...(patch.wheelRouting !== undefined ? { wheelRouting: patch.wheelRouting } : {}),
1306
1291
  ...(patch.copyNotice !== undefined ? { copyNotice: patch.copyNotice } : {}),
1307
- ...(patch.copyOnSelect !== undefined ? { copyOnSelect: patch.copyOnSelect } : {}),
1308
1292
  ...(patch.clickToExpandTools !== undefined
1309
1293
  ? { clickToExpandTools: patch.clickToExpandTools }
1310
1294
  : {}),
1311
- ...(patch.pathAwareWords !== undefined ? { pathAwareWords: patch.pathAwareWords } : {}),
1312
1295
  ...(patch.transcriptCleanCopy !== undefined
1313
1296
  ? { transcriptCleanCopy: patch.transcriptCleanCopy }
1314
1297
  : {}),
@@ -4,7 +4,8 @@ import type {
4
4
  KeybindingsManager,
5
5
  Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { type EditorTheme, type TUI, TuiAltScreen } from "@earendil-works/pi-tui";
7
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
+ import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
8
9
  import {
9
10
  type ColorSourcesConfig,
10
11
  type ContextStyle,
@@ -42,7 +43,8 @@ import { installFooter } from "./footer";
42
43
  import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
44
  import { emptyGitStatus, readGitHost, readGitStatus } from "./git";
44
45
  import { LiveContextController } from "./live-context";
45
- import { installMouse } from "./mouse";
46
+ import { installMouseFeaturesOn } from "./mouse";
47
+ import { getMouseEventsApi } from "./mouse/api-consumer";
46
48
  import { setActiveEditor } from "./mouse/editor-mouse";
47
49
  import { readPackageVersionResult } from "./package-version";
48
50
  import { installPasteCollapse } from "./paste-collapse";
@@ -99,6 +101,7 @@ export default function (pi: ExtensionAPI) {
99
101
  let lastProjectCwd: string | undefined;
100
102
  let disposePasteCollapse: (() => void) | undefined;
101
103
  let disposeMouse: (() => void) | undefined;
104
+ let lastTuiCtx: ExtensionContext | undefined;
102
105
 
103
106
  const refresh = () => {
104
107
  if (sessionLifecycle.isCurrent()) requestFooterRender?.();
@@ -236,44 +239,41 @@ export default function (pi: ExtensionAPI) {
236
239
  };
237
240
 
238
241
  /**
239
- * Install the mouse features on `TuiAltScreen.prototype`.
242
+ * Register Starline's mouse features with the `pi-mouse-events` extension.
240
243
  *
241
- * The prototype, not an instance: from 0.84 Pi hands extensions a Proxy over
242
- * the live renderer and swaps the renderer itself when the TUI mode changes,
243
- * so an instance captured at session start is not necessarily the one drawing
244
- * later. The shared prototype survives both.
245
- *
246
- * Reinstalling is already safe — `installPrototypePatch` keeps one wrapper per
247
- * adapter and only swaps the behaviour behind it — but disposing first keeps
248
- * exactly one live registration, so the disposer held here always removes
249
- * everything this extension put on the prototype.
244
+ * That extension owns `TuiAltScreen.prototype` now — Starline wraps none of
245
+ * it — so this only hands it behaviour: handlers for the wheel, press, and
246
+ * copy slots, and the keyboard half of click-to-caret on
247
+ * `ctx.ui.onTerminalInput`. Without the extension installed there is no
248
+ * fallback and nothing is registered; the mouse features are simply off,
249
+ * with one console note per process so the silence is diagnosable.
250
250
  *
251
251
  * `mouse.enabled` is read here rather than captured, and every sub-option is
252
- * read inside the patches themselves, so `/starline` toggles apply without a
253
- * restart.
252
+ * read inside the handlers themselves, so `/starline` toggles apply without
253
+ * a restart.
254
254
  */
255
- const installMousePatches = () => {
255
+ const installMouseFeatures = (ctx: ExtensionContext) => {
256
256
  uninstallMouse();
257
+ lastTuiCtx = ctx;
257
258
  if (!getCurrentConfig().mouse.enabled) return;
258
- // `TuiAltScreen` only ships with pi-tui >= 0.84.0 (the peer range below
259
- // already says so). This guard is for environments that bypass peer
260
- // resolution: fail with a readable hint instead of a TypeError on
261
- // `TuiAltScreen.prototype`.
262
- if (!TuiAltScreen) {
263
- console.warn(
264
- "starline: mouse support needs @earendil-works/pi-tui >= 0.84.0 (" +
265
- "TuiAltScreen is not exported by the installed pi-tui). Upgrade Pi, " +
266
- "or wait for your distribution to bundle a newer one.",
267
- );
259
+ const api = getMouseEventsApi();
260
+ if (!api) {
261
+ warnMouseApiMissing();
268
262
  return;
269
263
  }
270
- // Repaints are not wired from here: each patch asks its own receiver —
271
- // the live renderer it is running inside — to render. Handing it the
272
- // extension's `refresh` instead tied the pending hint to the footer's
273
- // existence, and the hint is in the editor.
274
- disposeMouse = installMouse(TuiAltScreen.prototype, { getConfig: getCurrentConfig });
264
+ disposeMouse = installMouseFeaturesOn(api, ctx, { getConfig: getCurrentConfig });
275
265
  };
276
266
 
267
+ let mouseApiWarned = false;
268
+ function warnMouseApiMissing() {
269
+ if (mouseApiWarned) return;
270
+ mouseApiWarned = true;
271
+ console.info(
272
+ "[starline] mouse features need the pi-mouse-events extension " +
273
+ "(`pi install npm:@xzzpig/pi-mouse-events`); they are off until it is installed.",
274
+ );
275
+ }
276
+
277
277
  /**
278
278
  * `editorCursor: "terminal"` hides the software cursor so the real one shows
279
279
  * through, which needs the hardware cursor to actually be on. Pi re-applies
@@ -484,7 +484,7 @@ export default function (pi: ExtensionAPI) {
484
484
  syncFooterState(ctx);
485
485
  stopProjectRefresh();
486
486
  applyConfiguredUi(ctx);
487
- installMousePatches();
487
+ installMouseFeatures(ctx);
488
488
  refresh();
489
489
  };
490
490
 
@@ -509,6 +509,7 @@ export default function (pi: ExtensionAPI) {
509
509
  stopProjectRefresh();
510
510
  requestFooterRender = undefined;
511
511
  getActiveExtensionStatuses = () => new Map();
512
+ lastTuiCtx = undefined;
512
513
  if (isTuiContext(ctx)) {
513
514
  ctx.ui.setFooter(undefined);
514
515
  const currentFactory = ctx.ui.getEditorComponent();
@@ -595,10 +596,10 @@ export default function (pi: ExtensionAPI) {
595
596
  setExtensionStatusColorMode(key: string, colorMode: ExtensionStatusColorMode) {
596
597
  currentConfig = saveExtensionStatusColorMode(key, colorMode);
597
598
  },
598
- setMouseConfig(patch: Partial<MouseConfig>, _ctx: ExtensionContext) {
599
+ setMouseConfig(patch: Partial<MouseConfig>, ctx: ExtensionContext) {
599
600
  currentConfig = saveMousePatch(patch);
600
601
  if (patch.enabled === true) {
601
- installMousePatches();
602
+ installMouseFeatures(lastTuiCtx ?? ctx);
602
603
  } else if (patch.enabled === false) {
603
604
  uninstallMouse();
604
605
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The consumer-side accessor for the `pi-mouse-events` extension's API.
3
+ *
4
+ * All Starline needs from that package is its published runtime object, and
5
+ * the package publishes it under a process-global `Symbol.for` key precisely
6
+ * so consumers do not have to import it: Pi loads every extension through a
7
+ * jiti instance with `moduleCache: false`, so a static value import of
8
+ * `@xzzpig/pi-mouse-events/api` would both give Starline its own fresh copy
9
+ * of the module *and* make the whole extension fail to load when the package
10
+ * is not installed — the one situation this integration must survive
11
+ * gracefully. A type-only import carries the contract's types at development
12
+ * time; this duplicated key string is the only runtime surface, and
13
+ * `test/contract/mouse-api.test.ts` asserts it matches the real package's.
14
+ *
15
+ * `Symbol.for()` is process-global by spec, so the accessor observes exactly
16
+ * what the extension wrote, whatever module copy this file was loaded in.
17
+ */
18
+
19
+ import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
20
+
21
+ /**
22
+ * MUST match `MOUSE_EVENTS_API_KEY` in `@xzzpig/pi-mouse-events/api` — the
23
+ * contract test above is what keeps that true.
24
+ */
25
+ const MOUSE_EVENTS_API_KEY = Symbol.for("pi-mouse-events.api.v1");
26
+
27
+ export function getMouseEventsApi(): MouseEventsApi | undefined {
28
+ return (globalThis as Record<symbol, unknown>)[MOUSE_EVENTS_API_KEY] as
29
+ | MouseEventsApi
30
+ | undefined;
31
+ }
@@ -1,37 +1,34 @@
1
1
  /**
2
- * What of Pi's renderer this build can actually reach.
2
+ * What of the live renderer Starline's mouse features can actually reach.
3
3
  *
4
- * Pi's TUI internals carry no stability contract, so every method Starline
5
- * patches is probed before it is trusted. The probe is deliberately structural —
6
- * the method exists, is a function, and can be replaced and put back. Signature
7
- * drift is caught by `test/contract/pi-tui-contract.test.ts` in CI rather than
8
- * guessed at here: default parameters make `fn.length` a liar.
4
+ * Starline no longer patches anything — the `pi-mouse-events` extension owns
5
+ * the prototype — so the probe is about *calling*, not replacing: every
6
+ * method a feature needs on the receiver must exist and be callable, probed
7
+ * structurally on the instance the extension hands over (`ctx.ui`, the Proxy
8
+ * that forwards to the live renderer). Signature drift is caught by the
9
+ * contract tests rather than guessed at here.
9
10
  *
10
- * `requestRender` is the one entry a feature only ever *calls* on the receiver,
11
- * never patches. It is probed all the same, and by the same structural test: a
12
- * feature that installs and then cannot ask for a repaint leaves its own state
13
- * on screen a frame late or not at all, which is precisely the half-working
14
- * install this table exists to prevent. The check being stricter than a plain
15
- * call needs (it also demands writable and configurable) costs nothing, because
16
- * a class method declared the ordinary way is both.
11
+ * The `pi-mouse-events` API gates everything before this table runs: without
12
+ * it no feature registers at all (see `installMouseFeaturesOn`). The one API
13
+ * capability that varies on its own is the copy slot — the extension installs
14
+ * it only when `copyActiveSelectionToClipboard` exists (pi-tui ≥ 0.84.3) —
15
+ * so it arrives as `copySlotAvailable` rather than being probed here.
17
16
  */
18
17
 
19
- export type MouseCapability =
20
- | "handleViewportInput"
21
- | "routeWheel"
22
- | "handleSelectionMouseEvent"
23
- | "copySelectionToClipboard"
24
- | "getWordSelection"
25
- | "getSelectionSourceLine"
18
+ /** The receiver methods the features call, probed on the live renderer. */
19
+ export type MouseReceiverCapabilities =
20
+ | "getCopyOnSelect"
21
+ | "hasActiveSelection"
26
22
  | "getSelectionBounds"
27
23
  | "getSelectionColumns"
28
24
  | "flash"
29
25
  | "hasOverlay"
30
26
  | "requestRender";
31
27
 
28
+ export type MouseCapability = MouseReceiverCapabilities | "copySlot";
29
+
32
30
  export type MouseFeature =
33
- | "selectionPendingMode"
34
- | "pathAwareWords"
31
+ | "selectionHint"
35
32
  | "clickToExpandTools"
36
33
  | "editorWheelScroll"
37
34
  | "editorClickToCaret"
@@ -39,114 +36,76 @@ export type MouseFeature =
39
36
  | "transcriptCleanCopy";
40
37
 
41
38
  const CAPABILITIES: readonly MouseCapability[] = [
42
- "handleViewportInput",
43
- "routeWheel",
44
- "handleSelectionMouseEvent",
45
- "copySelectionToClipboard",
46
- "getWordSelection",
47
- "getSelectionSourceLine",
39
+ "getCopyOnSelect",
40
+ "hasActiveSelection",
48
41
  "getSelectionBounds",
49
42
  "getSelectionColumns",
50
43
  "flash",
51
44
  "hasOverlay",
52
45
  "requestRender",
46
+ "copySlot",
53
47
  ];
54
48
 
55
49
  /**
56
- * Every capability a feature needs before it may install.
50
+ * Every capability a feature needs before it may register.
57
51
  *
58
52
  * There is no `frameFreeSelection` entry, deliberately: the feature is cut
59
- * (see `installMouse` in `index.ts`). A feature listed here is one
60
- * `installMouse` installs, and a table that claims a feature nothing installs
61
- * is worse than no table.
53
+ * (see the header of `mouse/index.ts` before the clean-copy successor
54
+ * replaced it). A feature listed here is one `installMouseFeaturesOn`
55
+ * registers, and a table that claims a feature nothing registers is worse
56
+ * than no table.
62
57
  */
63
58
  const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
64
- // Without ctrl+c interception the pending mode strands a highlight the user
65
- // cannot copy, which is worse than copy-on-release. It also reads the
66
- // selection directly (`getSelectionBounds`, `getSelectionColumns`) to build
67
- // an exact character count, and raises its own notice (`flash`), so all
68
- // three must be reachable too. `requestRender` is what puts the pending hint
69
- // on screen the moment the button comes up: arming without it would leave
70
- // the user staring at a highlight with nothing telling them ctrl+c copies it.
71
- selectionPendingMode: [
72
- "copySelectionToClipboard",
73
- "handleViewportInput",
59
+ // Derived from Pi's own select-without-copy state: the hint shows only
60
+ // while the renderer is NOT auto-copying (`getCopyOnSelect` false) and
61
+ // something is actually selected (`hasActiveSelection`). The character
62
+ // count is computed from the live selection through `getSelectionBounds`
63
+ // and `getSelectionColumns`, matching the columns the copy itself would
64
+ // slice — the hint promises what ctrl+x delivers. No release interception,
65
+ // no pending state: Pi's `copyOnSelect: false` already keeps the selection
66
+ // highlighted, and the hint rides Pi's own repaints.
67
+ selectionHint: [
68
+ "getCopyOnSelect",
69
+ "hasActiveSelection",
74
70
  "getSelectionBounds",
75
71
  "getSelectionColumns",
76
- "flash",
77
- "requestRender",
78
- ],
79
- // Also reads the line under the pointer through the receiver's own
80
- // `getSelectionSourceLine`, so the patch has a real line to hand
81
- // `wordRangeAt` — without it there's nothing to compute a range over.
82
- pathAwareWords: ["getWordSelection", "getSelectionSourceLine"],
83
- // The press it acts on arrives through `handleSelectionMouseEvent`, and it
84
- // asks `hasOverlay` the same question Pi's own press path asks before
85
- // resolving a scroll view (`tui-alt-screen.js:684`) — without it, a click on
86
- // a dialog would toggle whatever tool box happens to sit behind it. It
87
- // consumes the press rather than calling through, so Pi never reaches its own
88
- // repaint for that event: `requestRender` is the only thing that draws the
89
- // box it just toggled.
90
- clickToExpandTools: ["handleSelectionMouseEvent", "hasOverlay", "requestRender"],
91
- // The notch arrives through `routeWheel`, which it consumes on an editor hit
92
- // rather than calling through — so Pi never reaches its own repaint for that
93
- // event and `requestRender` is the only thing that draws the scrolled box. It
94
- // asks `hasOverlay` for the same reason click-to-expand does: an overlay is
95
- // composited on top of a layout that still contains the editor
96
- // (`tui.js:123`), so without it a notch aimed at a dialog would scroll the
97
- // draft hidden behind it.
98
- editorWheelScroll: ["routeWheel", "hasOverlay", "requestRender"],
99
- // Two behaviours, one feature. The press arrives through
100
- // `handleSelectionMouseEvent`; backspace and delete over a live selection
101
- // arrive through `handleViewportInput`, which is why that is listed even
102
- // though the caret alone would not need it. Range delete then reads the
103
- // selection through `getSelectionBounds` to find out whether it is the
104
- // editor's, and — because it *consumes* the key, so Pi's `handleInput` never
105
- // reaches its own `requestImmediateRender` (`tui.js:620`) — `requestRender`
106
- // is the only thing that draws the shortened draft.
107
- //
108
- // `hasOverlay` for the same reason the features above ask it: an overlay is
109
- // composited over a layout that still contains the editor, so without it a
110
- // click on a dialog would move the caret in the box behind it, and a
111
- // backspace would delete from it.
112
- //
113
- // The consequence of listing `handleViewportInput` is deliberate and worth
114
- // naming: a Pi that has moved that method loses click-to-caret entirely, not
115
- // just the range delete. That is this table's rule — never install half a
116
- // feature — and the alternative, a caret that installs while its delete
117
- // silently does not, is exactly the half-working install the rule exists to
118
- // prevent.
119
- editorClickToCaret: [
120
- "handleSelectionMouseEvent",
121
- "handleViewportInput",
122
- "getSelectionBounds",
123
- "hasOverlay",
124
- "requestRender",
125
72
  ],
126
- // It wraps `copySelectionToClipboard`, reads the selection through
127
- // `getSelectionBounds` to find out whether it is the editor's, and raises
128
- // Pi's own "Copied!" through `flash` when it answers the copy itself.
129
- // `hasOverlay` again: a selection dropped on a dialog must not be read as
130
- // text from the draft behind it.
131
- //
132
- // The clipboard write goes through `terminal.write`, which is not listed
133
- // because it is not on this prototype — `terminal` is a plain instance field
134
- // holding somebody else's object, the same class of thing as `currentLayout`.
135
- // It is checked at the call site instead, and a terminal that cannot be
136
- // written to makes the copy fall through to Pi rather than disabling the
137
- // feature at install time.
138
- editorBufferCopy: ["copySelectionToClipboard", "getSelectionBounds", "hasOverlay", "flash"],
139
- // Shares the `copySelectionToClipboard` patch with the two features above
140
- // it. It reads the selection through `getSelectionBounds` to find the rows,
141
- // through `getSelectionColumns` to slice the cleaned text along the same
142
- // grapheme-aligned columns Pi would have used, asks `hasOverlay` before
143
- // trusting those rows (an overlay is composited over a layout that still
144
- // contains the transcript), and raises Pi's own "Copied!" through `flash`
145
- // when it answers the copy itself. The clipboard write goes through
146
- // `terminal.write`, unlisted for the same reason as editorBufferCopy's — it
147
- // is an instance field, checked at the call site instead.
73
+ // The press arrives through the `pi-mouse-events` handler slot, which
74
+ // always exists once the API does; what this feature needs beyond that is
75
+ // `hasOverlay` — the same question Pi's own press path asks before
76
+ // resolving a scroll view, without which a click on a dialog would toggle
77
+ // whatever tool box happens to sit behind it — and `requestRender`, because
78
+ // consuming the press means Pi never reaches its own repaint for that
79
+ // event.
80
+ clickToExpandTools: ["hasOverlay", "requestRender"],
81
+ // Same shape as click-to-expand: the notch arrives through the handler
82
+ // slot, `hasOverlay` keeps a notch aimed at a dialog from scrolling the
83
+ // draft hidden behind it, and consuming the notch means `requestRender` is
84
+ // the only thing that draws the scrolled box.
85
+ editorWheelScroll: ["hasOverlay", "requestRender"],
86
+ // The press half rides the same slot as click-to-expand; the keyboard half
87
+ // (backspace/delete over a selection) rides `ctx.ui.onTerminalInput`, which
88
+ // every interactive session exposes. Range delete reads the selection
89
+ // through `getSelectionBounds`, and because it *consumes* the key — so
90
+ // Pi's `handleInput` never reaches its own repaint — `requestRender` is
91
+ // the only thing that draws the shortened draft.
92
+ editorClickToCaret: ["getSelectionBounds", "hasOverlay", "requestRender"],
93
+ // Registers an `addCopyHandler`; needs the extension's copy slot to exist
94
+ // (`copyActiveSelectionToClipboard`, pi-tui ≥ 0.84.3), reads the selection
95
+ // through `getSelectionBounds` to find out whether it is the editor's, and
96
+ // raises its own "Copied!" through `flash`. `hasOverlay` again: a selection
97
+ // dropped on a dialog must not be read as text from the draft behind it.
98
+ // The clipboard write goes through `terminal.write`, unlisted because
99
+ // `terminal` is a plain instance field — checked at the call site, where a
100
+ // terminal that cannot be written to makes the copy fall through to Pi.
101
+ editorBufferCopy: ["copySlot", "getSelectionBounds", "hasOverlay", "flash"],
102
+ // Shares the copy slot with editorBufferCopy. It reads the selection
103
+ // through `getSelectionBounds` to find the rows, through
104
+ // `getSelectionColumns` to slice the cleaned text along the same
105
+ // grapheme-aligned columns Pi would have used, and asks `hasOverlay`
106
+ // before trusting those rows.
148
107
  transcriptCleanCopy: [
149
- "copySelectionToClipboard",
108
+ "copySlot",
150
109
  "getSelectionBounds",
151
110
  "getSelectionColumns",
152
111
  "hasOverlay",
@@ -154,41 +113,36 @@ const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
154
113
  ],
155
114
  };
156
115
 
157
- function isPatchable(prototype: object, name: string): boolean {
116
+ function isCallable(receiver: object, name: string): boolean {
158
117
  try {
159
- let current: object | null = prototype;
160
- while (current) {
161
- const descriptor = Object.getOwnPropertyDescriptor(current, name);
162
- if (descriptor) {
163
- if (typeof descriptor.value !== "function") return false;
164
- return descriptor.writable === true && descriptor.configurable === true;
165
- }
166
- current = Object.getPrototypeOf(current);
167
- }
168
- return false;
118
+ return typeof (receiver as Record<string, unknown>)[name] === "function";
169
119
  } catch {
170
- // A prototype that throws on inspection is one we do not touch.
120
+ // A receiver that throws on inspection is one we do not touch.
171
121
  return false;
172
122
  }
173
123
  }
174
124
 
175
- export function probeCapabilities(prototype: object): ReadonlySet<MouseCapability> {
125
+ export function probeCapabilities(receiver: object): ReadonlySet<MouseCapability> {
176
126
  const found = new Set<MouseCapability>();
177
127
  for (const capability of CAPABILITIES) {
178
- if (isPatchable(prototype, capability)) found.add(capability);
128
+ if (capability === "copySlot") continue;
129
+ if (isCallable(receiver, capability)) found.add(capability);
179
130
  }
180
131
  return found;
181
132
  }
182
133
 
183
134
  export function enabledFeatures(
184
135
  available: ReadonlySet<MouseCapability>,
136
+ copySlotAvailable: boolean,
185
137
  ): ReadonlySet<MouseFeature> {
138
+ const withSlot = new Set(available);
139
+ if (copySlotAvailable) withSlot.add("copySlot");
186
140
  const enabled = new Set<MouseFeature>();
187
141
  for (const [feature, needed] of Object.entries(REQUIREMENTS) as [
188
142
  MouseFeature,
189
143
  readonly MouseCapability[],
190
144
  ][]) {
191
- if (needed.every((capability) => available.has(capability))) enabled.add(feature);
145
+ if (needed.every((capability) => withSlot.has(capability))) enabled.add(feature);
192
146
  }
193
147
  return enabled;
194
148
  }
@@ -397,9 +397,9 @@ type EditableEditor = {
397
397
  * range back a letter at a time. So one snapshot is pushed up front through the
398
398
  * editor's own `pushUndoSnapshot`, and the editor's own is shadowed for the
399
399
  * duration of the loop and put back in a `finally` — the same shadow-and-restore
400
- * shape `copyWithNotice` uses on `flash` and `installPasteCollapse` uses on
401
- * `handlePaste`. This is a plain assignment on Starline's own editor instance,
402
- * never on anything reached through Pi's renderer Proxy.
400
+ * shape `installPasteCollapse` uses on `handlePaste`. This is a plain
401
+ * assignment on Starline's own editor instance, never on anything reached
402
+ * through Pi's renderer Proxy.
403
403
  *
404
404
  * The loop is bounded twice over: by the character count of the range, and by a
405
405
  * no-progress check, so an editor that stops deleting cannot spin.