@xzzpig/pi-starline 0.2.0 → 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 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.
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).
52
53
 
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
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`.
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.
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
@@ -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,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
  }