@xzzpig/pi-starline 0.2.0 → 0.4.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
@@ -30,7 +30,8 @@ pieces of one product, not a theme:
30
30
  - Built-in footer segments can be shown or hidden individually from `/starline`
31
31
  - Fully custom Starship-style layout via a `footerFormat` template string — see [Footer Format Template](https://github.com/Andy8647/pi-starline/blob/main/docs/configuration.md#footer-format-template)
32
32
  - Third-party Pi extension statuses from `ctx.ui.setStatus()` can be shown on the left,
33
- middle, or right side, or hidden per status key from `/starline`
33
+ middle, or right side of the footer, on the editor's metadata row, or hidden per
34
+ status key from `/starline`
34
35
 
35
36
  ### Editor (Opencode-inspired)
36
37
 
@@ -44,16 +45,17 @@ pieces of one product, not a theme:
44
45
 
45
46
  The second half of the product: the statusline tells you where you are, the
46
47
  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 the selection hint, 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.
48
+ (`tuiMode: "fullscreen"`) through the [`@xzzpig/pi-mouse-events`](https://www.npmjs.com/package/@xzzpig/pi-mouse-events)
49
+ extension, which owns the renderer prototype and dispatches mouse events to
50
+ components — the extension API stops at high-level hooks, and the selection
51
+ hint, click-to-expand and clean-copy all need to reach into the renderer
52
+ itself. Install it alongside Starline; without it Starline's mouse features
53
+ are simply off (everything else keeps working).
52
54
 
53
55
  - **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
56
  - **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
57
  - **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`.
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.
58
+ - **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
59
  - **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
60
 
59
61
  0.2.x's `fixedEditor` config block was renamed to `mouse` (migrated
@@ -150,7 +150,15 @@ export function migrateFixedEditorKeys(raw: Record<string, unknown>): {
150
150
  return { config: { ...rest, mouse }, migrated: true };
151
151
  }
152
152
 
153
- export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
153
+ /**
154
+ * Where a third-party status is drawn.
155
+ *
156
+ * `editor` is the right-hand side of the editor's metadata row, alongside the
157
+ * vim-mode label and the copy/paste hints — not the footer. It needs Starline's
158
+ * own footer installed to have data at all, because the status map only reaches
159
+ * the extension through the footer factory.
160
+ */
161
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right" | "editor";
154
162
  export type ExtensionStatusColorMode = "themed" | "original";
155
163
 
156
164
  /**
@@ -756,7 +764,13 @@ function normalizeGitMetricsConfig(record: Record<string, unknown>): GitMetricsC
756
764
  }
757
765
 
758
766
  export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
759
- return value === "off" || value === "left" || value === "middle" || value === "right";
767
+ return (
768
+ value === "off" ||
769
+ value === "left" ||
770
+ value === "middle" ||
771
+ value === "right" ||
772
+ value === "editor"
773
+ );
760
774
  }
761
775
 
762
776
  export function isExtensionStatusColorMode(value: unknown): value is ExtensionStatusColorMode {
@@ -940,7 +954,10 @@ function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number
940
954
  if (file !== undefined) {
941
955
  try {
942
956
  closeSync(file);
943
- } catch {}
957
+ } catch {
958
+ // Best-effort close during error cleanup; the outer catch
959
+ // already reports the primary failure.
960
+ }
944
961
  }
945
962
  try {
946
963
  unlinkSync(tempPath);
@@ -17,6 +17,7 @@ export type ExtensionStatusSegmentsByPlacement = {
17
17
  left: ExtensionStatusSegment[];
18
18
  middle: ExtensionStatusSegment[];
19
19
  right: ExtensionStatusSegment[];
20
+ editor: ExtensionStatusSegment[];
20
21
  };
21
22
 
22
23
  const safeSgrPattern = /\x1b\[[0-9;:]*m/g;
@@ -65,6 +66,7 @@ export function collectExtensionStatusSegments(
65
66
  left: [],
66
67
  middle: [],
67
68
  right: [],
69
+ editor: [],
68
70
  };
69
71
 
70
72
  for (const [key, value] of statuses.entries()) {
@@ -84,5 +86,6 @@ export function collectExtensionStatusSegments(
84
86
  segments.left.sort(compareKeys);
85
87
  segments.middle.sort(compareKeys);
86
88
  segments.right.sort(compareKeys);
89
+ segments.editor.sort(compareKeys);
87
90
  return segments;
88
91
  }
@@ -4,7 +4,7 @@ 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
8
  import {
9
9
  type ColorSourcesConfig,
10
10
  type ContextStyle,
@@ -42,7 +42,8 @@ import { installFooter } from "./footer";
42
42
  import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
43
  import { emptyGitStatus, readGitHost, readGitStatus } from "./git";
44
44
  import { LiveContextController } from "./live-context";
45
- import { installMouse } from "./mouse";
45
+ import { installMouseFeaturesOn } from "./mouse";
46
+ import { getMouseEventsApi } from "./mouse/api-consumer";
46
47
  import { setActiveEditor } from "./mouse/editor-mouse";
47
48
  import { readPackageVersionResult } from "./package-version";
48
49
  import { installPasteCollapse } from "./paste-collapse";
@@ -99,6 +100,7 @@ export default function (pi: ExtensionAPI) {
99
100
  let lastProjectCwd: string | undefined;
100
101
  let disposePasteCollapse: (() => void) | undefined;
101
102
  let disposeMouse: (() => void) | undefined;
103
+ let lastTuiCtx: ExtensionContext | undefined;
102
104
 
103
105
  const refresh = () => {
104
106
  if (sessionLifecycle.isCurrent()) requestFooterRender?.();
@@ -236,44 +238,41 @@ export default function (pi: ExtensionAPI) {
236
238
  };
237
239
 
238
240
  /**
239
- * Install the mouse features on `TuiAltScreen.prototype`.
241
+ * Register Starline's mouse features with the `pi-mouse-events` extension.
240
242
  *
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.
243
+ * That extension owns `TuiAltScreen.prototype` now — Starline wraps none of
244
+ * it — so this only hands it behaviour: handlers for the wheel, press, and
245
+ * copy slots, and the keyboard half of click-to-caret on
246
+ * `ctx.ui.onTerminalInput`. Without the extension installed there is no
247
+ * fallback and nothing is registered; the mouse features are simply off,
248
+ * with one console note per process so the silence is diagnosable.
250
249
  *
251
250
  * `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.
251
+ * read inside the handlers themselves, so `/starline` toggles apply without
252
+ * a restart.
254
253
  */
255
- const installMousePatches = () => {
254
+ const installMouseFeatures = (ctx: ExtensionContext) => {
256
255
  uninstallMouse();
256
+ lastTuiCtx = ctx;
257
257
  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
- );
258
+ const api = getMouseEventsApi();
259
+ if (!api) {
260
+ warnMouseApiMissing();
268
261
  return;
269
262
  }
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 });
263
+ disposeMouse = installMouseFeaturesOn(api, ctx, { getConfig: getCurrentConfig });
275
264
  };
276
265
 
266
+ let mouseApiWarned = false;
267
+ function warnMouseApiMissing() {
268
+ if (mouseApiWarned) return;
269
+ mouseApiWarned = true;
270
+ console.info(
271
+ "[starline] mouse features need the pi-mouse-events extension " +
272
+ "(`pi install npm:@xzzpig/pi-mouse-events`); they are off until it is installed.",
273
+ );
274
+ }
275
+
277
276
  /**
278
277
  * `editorCursor: "terminal"` hides the software cursor so the real one shows
279
278
  * through, which needs the hardware cursor to actually be on. Pi re-applies
@@ -324,6 +323,7 @@ export default function (pi: ExtensionAPI) {
324
323
  sessionName: ctx.sessionManager.getSessionName() ?? "",
325
324
  }),
326
325
  getThinkingLevel,
326
+ () => getActiveExtensionStatuses(),
327
327
  );
328
328
  applyPasteCollapse(editor);
329
329
  // Nothing on Pi's renderer points at the live editor, and the
@@ -357,6 +357,7 @@ export default function (pi: ExtensionAPI) {
357
357
  sessionName: ctx.sessionManager.getSessionName() ?? "",
358
358
  }),
359
359
  getThinkingLevel,
360
+ () => getActiveExtensionStatuses(),
360
361
  );
361
362
  // Two references, because these are two different objects here: the
362
363
  // wrapper is what gets mounted and hit-tested, while the draft's
@@ -484,7 +485,7 @@ export default function (pi: ExtensionAPI) {
484
485
  syncFooterState(ctx);
485
486
  stopProjectRefresh();
486
487
  applyConfiguredUi(ctx);
487
- installMousePatches();
488
+ installMouseFeatures(ctx);
488
489
  refresh();
489
490
  };
490
491
 
@@ -509,6 +510,7 @@ export default function (pi: ExtensionAPI) {
509
510
  stopProjectRefresh();
510
511
  requestFooterRender = undefined;
511
512
  getActiveExtensionStatuses = () => new Map();
513
+ lastTuiCtx = undefined;
512
514
  if (isTuiContext(ctx)) {
513
515
  ctx.ui.setFooter(undefined);
514
516
  const currentFactory = ctx.ui.getEditorComponent();
@@ -595,10 +597,10 @@ export default function (pi: ExtensionAPI) {
595
597
  setExtensionStatusColorMode(key: string, colorMode: ExtensionStatusColorMode) {
596
598
  currentConfig = saveExtensionStatusColorMode(key, colorMode);
597
599
  },
598
- setMouseConfig(patch: Partial<MouseConfig>, _ctx: ExtensionContext) {
600
+ setMouseConfig(patch: Partial<MouseConfig>, ctx: ExtensionContext) {
599
601
  currentConfig = saveMousePatch(patch);
600
602
  if (patch.enabled === true) {
601
- installMousePatches();
603
+ installMouseFeatures(lastTuiCtx ?? ctx);
602
604
  } else if (patch.enabled === false) {
603
605
  uninstallMouse();
604
606
  }
@@ -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,31 +1,22 @@
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.
17
- *
18
- * Since 0.84.4 the peer floor guarantees `copyActiveSelectionToClipboard`,
19
- * `getCopyOnSelect` and `hasActiveSelection` exist; the probe still runs so a
20
- * future Pi that moves them degrades feature-by-feature instead of breaking
21
- * the whole install.
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.
22
16
  */
23
17
 
24
- export type MouseCapability =
25
- | "handleViewportInput"
26
- | "routeWheel"
27
- | "handleSelectionMouseEvent"
28
- | "copyActiveSelectionToClipboard"
18
+ /** The receiver methods the features call, probed on the live renderer. */
19
+ export type MouseReceiverCapabilities =
29
20
  | "getCopyOnSelect"
30
21
  | "hasActiveSelection"
31
22
  | "getSelectionBounds"
@@ -34,6 +25,8 @@ export type MouseCapability =
34
25
  | "hasOverlay"
35
26
  | "requestRender";
36
27
 
28
+ export type MouseCapability = MouseReceiverCapabilities | "copySlot";
29
+
37
30
  export type MouseFeature =
38
31
  | "selectionHint"
39
32
  | "clickToExpandTools"
@@ -43,10 +36,6 @@ export type MouseFeature =
43
36
  | "transcriptCleanCopy";
44
37
 
45
38
  const CAPABILITIES: readonly MouseCapability[] = [
46
- "handleViewportInput",
47
- "routeWheel",
48
- "handleSelectionMouseEvent",
49
- "copyActiveSelectionToClipboard",
50
39
  "getCopyOnSelect",
51
40
  "hasActiveSelection",
52
41
  "getSelectionBounds",
@@ -54,19 +43,21 @@ const CAPABILITIES: readonly MouseCapability[] = [
54
43
  "flash",
55
44
  "hasOverlay",
56
45
  "requestRender",
46
+ "copySlot",
57
47
  ];
58
48
 
59
49
  /**
60
- * Every capability a feature needs before it may install.
50
+ * Every capability a feature needs before it may register.
61
51
  *
62
52
  * There is no `frameFreeSelection` entry, deliberately: the feature is cut
63
- * (see `installMouse` in `index.ts`). A feature listed here is one
64
- * `installMouse` installs, and a table that claims a feature nothing installs
65
- * 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.
66
57
  */
67
58
  const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
68
- // Derived from Pi 0.84.4's own select-without-copy state: the hint shows
69
- // only while the renderer is NOT auto-copying (`getCopyOnSelect` false) and
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
70
61
  // something is actually selected (`hasActiveSelection`). The character
71
62
  // count is computed from the live selection through `getSelectionBounds`
72
63
  // and `getSelectionColumns`, matching the columns the copy itself would
@@ -79,74 +70,42 @@ const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
79
70
  "getSelectionBounds",
80
71
  "getSelectionColumns",
81
72
  ],
82
- // The press it acts on arrives through `handleSelectionMouseEvent`, and it
83
- // asks `hasOverlay` the same question Pi's own press path asks before
84
- // resolving a scroll view (`tui-alt-screen.js:684`) — without it, a click on
85
- // a dialog would toggle whatever tool box happens to sit behind it. It
86
- // consumes the press rather than calling through, so Pi never reaches its own
87
- // repaint for that event: `requestRender` is the only thing that draws the
88
- // box it just toggled.
89
- clickToExpandTools: ["handleSelectionMouseEvent", "hasOverlay", "requestRender"],
90
- // The notch arrives through `routeWheel`, which it consumes on an editor hit
91
- // rather than calling through — so Pi never reaches its own repaint for that
92
- // event and `requestRender` is the only thing that draws the scrolled box. It
93
- // asks `hasOverlay` for the same reason click-to-expand does: an overlay is
94
- // composited on top of a layout that still contains the editor
95
- // (`tui.js:123`), so without it a notch aimed at a dialog would scroll the
96
- // draft hidden behind it.
97
- editorWheelScroll: ["routeWheel", "hasOverlay", "requestRender"],
98
- // Two behaviours, one feature. The press arrives through
99
- // `handleSelectionMouseEvent`; backspace and delete over a live selection
100
- // arrive through `handleViewportInput`, which is why that is listed even
101
- // though the caret alone would not need it. Range delete then reads the
102
- // selection through `getSelectionBounds` to find out whether it is the
103
- // editor's, and — because it *consumes* the key, so Pi's `handleInput` never
104
- // reaches its own `requestImmediateRender` (`tui.js:620`) — `requestRender`
105
- // is the only thing that draws the shortened draft.
106
- //
107
- // `hasOverlay` for the same reason the features above ask it: an overlay is
108
- // composited over a layout that still contains the editor, so without it a
109
- // click on a dialog would move the caret in the box behind it, and a
110
- // backspace would delete from it.
111
- //
112
- // The consequence of listing `handleViewportInput` is deliberate and worth
113
- // naming: a Pi that has moved that method loses click-to-caret entirely, not
114
- // just the range delete. That is this table's rule — never install half a
115
- // feature — and the alternative, a caret that installs while its delete
116
- // silently does not, is exactly the half-working install the rule exists to
117
- // prevent.
118
- editorClickToCaret: [
119
- "handleSelectionMouseEvent",
120
- "handleViewportInput",
121
- "getSelectionBounds",
122
- "hasOverlay",
123
- "requestRender",
124
- ],
125
- // It wraps `copyActiveSelectionToClipboard` — the method Pi 0.84.4's
126
- // Ctrl+X handler reaches (`handleCopyCommand` with `preferSelection`), so
127
- // the clean copy answers exactly the copies that key performs — and reads
128
- // the selection through `getSelectionBounds` to find out whether it is the
129
- // editor's. `hasOverlay` again: a selection dropped on a dialog must not be
130
- // read as 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: ["copyActiveSelectionToClipboard", "getSelectionBounds", "hasOverlay", "flash"],
139
- // Shares the `copyActiveSelectionToClipboard` patch with the two features
140
- // above it. It reads the selection through `getSelectionBounds` to find the
141
- // rows, through `getSelectionColumns` to slice the cleaned text along the
142
- // same 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 its 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
- "copyActiveSelectionToClipboard",
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
  }