@xzzpig/pi-starline 0.1.1 → 0.2.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
@@ -45,14 +45,14 @@ pieces of one product, not a theme:
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
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
48
+ stops at high-level hooks, and the selection hint, click-to-expand and clean-copy all
49
49
  need to reach into the renderer itself. That is also why they live in Starline:
50
50
  they sit on the same prototype patches the footer and editor already use, and
51
51
  Pi keeps its core deliberately minimal — behaviours belong in extensions.
52
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.
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 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
54
  - **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`.
55
+ - **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
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
57
  - **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
58
 
@@ -151,7 +151,7 @@ Detects Starship Nerd Font runtime/language modules, uses the Starship Nerd Font
151
151
 
152
152
  ## Install
153
153
 
154
- Starline drives Pi 0.84's alternate-screen renderer, so it needs **Pi ≥ 0.84.0**.
154
+ Starline drives Pi 0.84's alternate-screen renderer, so it needs **Pi ≥ 0.84.4**.
155
155
  If you are on a distribution that bundles an older Pi, upgrade it or wait for it to catch up.
156
156
 
157
157
  ```bash
@@ -174,7 +174,7 @@ Settings live in `~/.pi/agent/starline.json`. The file is optional — anything
174
174
  {
175
175
  "footerStyle": "pill",
176
176
  "colors": { "gitBranch": "bold purple" },
177
- "mouse": { "copyOnSelect": false }
177
+ "mouse": { "clickToExpandTools": false }
178
178
  }
179
179
  ```
180
180
 
@@ -184,7 +184,7 @@ Settings live in `~/.pi/agent/starline.json`. The file is optional — anything
184
184
 
185
185
  ## Requirements
186
186
 
187
- - [Pi](https://pi.dev) coding agent 0.80.3 or newer
187
+ - [Pi](https://pi.dev) coding agent 0.84.4 or newer
188
188
  - A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
189
189
 
190
190
  ## 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
  : {}),
@@ -14,15 +14,20 @@
14
14
  * install this table exists to prevent. The check being stricter than a plain
15
15
  * call needs (it also demands writable and configurable) costs nothing, because
16
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.
17
22
  */
18
23
 
19
24
  export type MouseCapability =
20
25
  | "handleViewportInput"
21
26
  | "routeWheel"
22
27
  | "handleSelectionMouseEvent"
23
- | "copySelectionToClipboard"
24
- | "getWordSelection"
25
- | "getSelectionSourceLine"
28
+ | "copyActiveSelectionToClipboard"
29
+ | "getCopyOnSelect"
30
+ | "hasActiveSelection"
26
31
  | "getSelectionBounds"
27
32
  | "getSelectionColumns"
28
33
  | "flash"
@@ -30,8 +35,7 @@ export type MouseCapability =
30
35
  | "requestRender";
31
36
 
32
37
  export type MouseFeature =
33
- | "selectionPendingMode"
34
- | "pathAwareWords"
38
+ | "selectionHint"
35
39
  | "clickToExpandTools"
36
40
  | "editorWheelScroll"
37
41
  | "editorClickToCaret"
@@ -42,9 +46,9 @@ const CAPABILITIES: readonly MouseCapability[] = [
42
46
  "handleViewportInput",
43
47
  "routeWheel",
44
48
  "handleSelectionMouseEvent",
45
- "copySelectionToClipboard",
46
- "getWordSelection",
47
- "getSelectionSourceLine",
49
+ "copyActiveSelectionToClipboard",
50
+ "getCopyOnSelect",
51
+ "hasActiveSelection",
48
52
  "getSelectionBounds",
49
53
  "getSelectionColumns",
50
54
  "flash",
@@ -61,25 +65,20 @@ const CAPABILITIES: readonly MouseCapability[] = [
61
65
  * is worse than no table.
62
66
  */
63
67
  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",
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
70
+ // something is actually selected (`hasActiveSelection`). The character
71
+ // count is computed from the live selection through `getSelectionBounds`
72
+ // and `getSelectionColumns`, matching the columns the copy itself would
73
+ // slice — the hint promises what ctrl+x delivers. No release interception,
74
+ // no pending state: Pi's `copyOnSelect: false` already keeps the selection
75
+ // highlighted, and the hint rides Pi's own repaints.
76
+ selectionHint: [
77
+ "getCopyOnSelect",
78
+ "hasActiveSelection",
74
79
  "getSelectionBounds",
75
80
  "getSelectionColumns",
76
- "flash",
77
- "requestRender",
78
81
  ],
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
82
  // The press it acts on arrives through `handleSelectionMouseEvent`, and it
84
83
  // asks `hasOverlay` the same question Pi's own press path asks before
85
84
  // resolving a scroll view (`tui-alt-screen.js:684`) — without it, a click on
@@ -123,11 +122,12 @@ const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
123
122
  "hasOverlay",
124
123
  "requestRender",
125
124
  ],
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.
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
131
  //
132
132
  // The clipboard write goes through `terminal.write`, which is not listed
133
133
  // because it is not on this prototype — `terminal` is a plain instance field
@@ -135,18 +135,18 @@ const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
135
135
  // It is checked at the call site instead, and a terminal that cannot be
136
136
  // written to makes the copy fall through to Pi rather than disabling the
137
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
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
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`
144
+ // contains the transcript), and raises its own "Copied!" through `flash`
145
145
  // when it answers the copy itself. The clipboard write goes through
146
146
  // `terminal.write`, unlisted for the same reason as editorBufferCopy's — it
147
147
  // is an instance field, checked at the call site instead.
148
148
  transcriptCleanCopy: [
149
- "copySelectionToClipboard",
149
+ "copyActiveSelectionToClipboard",
150
150
  "getSelectionBounds",
151
151
  "getSelectionColumns",
152
152
  "hasOverlay",
@@ -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.
@@ -5,32 +5,22 @@
5
5
  * what it would do to it. `installMouse` probes what the running Pi build
6
6
  * exposes, logs once if something is missing, and installs each feature gated
7
7
  * on exactly its own declared requirement (`capabilities.ts`). Today that is
8
- * six features across five patches. Three of those methods carry two features
8
+ * six features across four patches. Two of those methods carry two features
9
9
  * each, and in every case the two share a single patch, because the patch
10
10
  * registry holds one behaviour per adapter key and a second registration would
11
11
  * silently replace the first:
12
12
  *
13
- * `selectionPendingMode`, across two patches:
14
- * - `copySelectionToClipboard` — the method Pi calls itself on mouse
15
- * release. Wrapped to arm a pending state instead of copying immediately
16
- * when `copyOnSelect` is off, calling through for every other caller
17
- * (including its own ctrl+c path below).
18
- * - `handleViewportInput` — watched for a bare `ctrl+c` (`\x03`); with a
19
- * selection pending it performs the real copy by calling back through
20
- * `copySelectionToClipboard` (guarded so that call is recognised as the
21
- * real thing and not re-armed), then clears the pending state. Anything
22
- * else — including `ctrl+c` with nothing pending — calls through to Pi's
23
- * own handler, which is what keeps `ctrl+c` interrupting.
24
- *
25
- * `pathAwareWords`, one patch:
26
- * - `getWordSelection` — Pi's own double-click word lookup, replaced with
27
- * `wordRangeAt` (`word-select.ts`, ported from pi issue #7746) so a path or
28
- * a kebab-case identifier selects whole instead of stopping at `/` or `-`.
29
- * The installed 0.84.1 build predates #7746, so this substitutes Pi's
30
- * entire word-selection rule rather than layering path handling on top of
31
- * it — that is what #7746 itself proposes, not scope creep introduced
32
- * here. Calls through when `wordRangeAt` declines (column past the end of
33
- * the line) or when `mouse.pathAwareWords` is off.
13
+ * `selectionHint`, one patch plus a derived hint:
14
+ * - `copyActiveSelectionToClipboard` — the method Pi 0.84.4's Ctrl+X handler
15
+ * reaches (`handleCopyCommand` with `preferSelection`). Wrapped so a
16
+ * selection the clean-copy features recognise copies clean, calling
17
+ * through for everything else.
18
+ * - The hint is derived, not owned: `activeSelectionHintText()` renders the
19
+ * live renderer's state — an active selection that is not being
20
+ * auto-copied (`getCopyOnSelect() === false`) — so select-without-copy is
21
+ * Pi's own `fullscreenCopyOnSelect`, and this module only tells the user
22
+ * what to press. The copy key is `app.message.copy` (default ctrl+x),
23
+ * resolved from Pi's keybinding registry so a rebind shows up in the hint.
34
24
  *
35
25
  * `clickToExpandTools`, one patch:
36
26
  * - `handleSelectionMouseEvent` — watched for a left-button press that landed
@@ -63,7 +53,7 @@
63
53
  * interrupt and exit chords cannot be swallowed by it from either direction.
64
54
  * Every backspace it does not act on falls through and deletes one character.
65
55
  *
66
- * `editorBufferCopy`, sharing the `copySelectionToClipboard` patch:
56
+ * `editorBufferCopy`, sharing the `copyActiveSelectionToClipboard` patch:
67
57
  * - A selection lying inside the input box is copied from the *draft* rather
68
58
  * than from the rendered rows. That is the one place this module builds
69
59
  * clipboard bytes itself, and the reason is that the rendered rows are not
@@ -80,15 +70,10 @@
80
70
  * was clean before changes.
81
71
  *
82
72
  * Everywhere else the clipboard gets exactly what Pi's own
83
- * `copySelectionToClipboard` puts there — see `installMouse` for why frame-free
73
+ * `copyActiveSelectionToClipboard` puts there — see `installMouse` for why frame-free
84
74
  * selection is not part of this.
85
75
  */
86
- import {
87
- getKeybindings,
88
- matchesKey,
89
- sliceByColumn,
90
- stripTerminalSequences,
91
- } from "@earendil-works/pi-tui";
76
+ import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
92
77
  import type { PolishedTuiConfig } from "../config";
93
78
  import { installPrototypePatch } from "../prototype-patch-registry";
94
79
  import {
@@ -107,37 +92,17 @@ import { activeEditor, wheelTarget } from "./editor-mouse";
107
92
  import { scrollEditorBy } from "./editor-scroll";
108
93
  import { editorVisualRowCount } from "./editor-text-cursor";
109
94
  import { type BoxLike, scrollContentLinesFor } from "./hit-test";
110
- import { externalEditorName, SelectionPendingState, selectionHintText } from "./selection-state";
95
+ import { externalEditorName, selectionHintText } from "./selection-state";
111
96
  import { type ExpandTarget, expandKeyText, expandTargetAt, keyTextFor } from "./tool-box";
112
97
  import { cleanTranscriptRows } from "./transcript-copy";
113
- import { wordRangeAt } from "./word-select";
114
98
 
99
+ /** Pi's interrupt chord — refused by the range-delete branch, never consumed. */
115
100
  const CTRL_C = "\x03";
116
101
  /** Pi's exit chord, and `tui.editor.deleteCharForward`'s second default key. */
117
102
  const CTRL_D = "\x04";
118
103
 
119
- /**
120
- * Whether `data` is the ctrl+c chord, in whatever encoding the terminal sent
121
- * it. Pi 0.84 pushes the Kitty keyboard protocol (and falls back to xterm
122
- * modifyOtherKeys) on every capable terminal, and under either protocol the
123
- * chord arrives as an escape sequence — `\x1b[99;5u` or `\x1b[27;5;99~` —
124
- * never as the bare `\x03` a plain legacy terminal sends. The pending copy
125
- * must recognise all three, or ctrl+c falls through to Pi's own binding
126
- * (`app.clear`, clear the editor) exactly when it was about to copy. Pi's
127
- * own key matcher is the authority here: it parses every protocol this
128
- * build negotiates, and its `ctrl+c` arm accepts the raw control character
129
- * too, so a legacy terminal keeps working unchanged.
130
- */
131
- function isCtrlC(data: unknown): boolean {
132
- if (typeof data !== "string" || data.length === 0) return false;
133
- try {
134
- return matchesKey(data, "ctrl+c");
135
- } catch {
136
- // A pi-tui build that has moved `matchesKey` must not break the copy:
137
- // fall back to the raw byte, which is what this branch has always read.
138
- return data === CTRL_C;
139
- }
140
- }
104
+ /** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
105
+ const COPY_KEYBINDING = "app.message.copy";
141
106
 
142
107
  /** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
143
108
  const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
@@ -171,11 +136,23 @@ type MouseCapablePrototype = {
171
136
  row: number,
172
137
  selection: SelectionBounds,
173
138
  ): SelectionColumns;
174
- copySelectionToClipboard(this: unknown): Promise<void>;
139
+ /**
140
+ * Pi 0.84.4's public selection-copy entry (`tui-alt-screen.ts`): true when
141
+ * the active selection was copied, false when there was none. This is what
142
+ * coding-agent's Ctrl+X handler reaches, so a clean copy answering this
143
+ * method answers exactly the copies that key performs.
144
+ */
145
+ copyActiveSelectionToClipboard(this: unknown): Promise<boolean>;
146
+ /**
147
+ * Pi 0.84.4's select-without-copy state. The hint reads it to decide
148
+ * whether a selection is waiting for the copy key or was already copied on
149
+ * release.
150
+ */
151
+ getCopyOnSelect(this: unknown): boolean;
152
+ /** Whether the fullscreen viewport has a non-empty active text selection. */
153
+ hasActiveSelection(this: unknown): boolean;
175
154
  handleViewportInput(this: unknown, data: string): { consume: boolean } | undefined;
176
155
  flash(this: unknown, message: string, durationMs?: number): void;
177
- getWordSelection(this: unknown, point: SelectionPoint): SelectionBounds | undefined;
178
- getSelectionSourceLine(this: unknown, point: SelectionPoint): string;
179
156
  hasOverlay(this: unknown): boolean;
180
157
  /**
181
158
  * `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
@@ -203,7 +180,7 @@ type MouseCapablePrototype = {
203
180
  selectionFocus?: unknown;
204
181
  /**
205
182
  * `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
206
- * how `copySelectionToClipboard` reaches the clipboard — it emits OSC 52
183
+ * how `copyActiveSelectionToClipboard` reaches the clipboard — it emits OSC 52
207
184
  * through the terminal rather than through any renderer method.
208
185
  */
209
186
  terminal?: { rows?: number; write?: (data: string) => void };
@@ -234,15 +211,72 @@ export type InstallMouseDeps = {
234
211
  let hasWarned = false;
235
212
 
236
213
  /**
237
- * Reader for the pending-selection state of whichever `installMouse` call is
214
+ * The live renderer the hint reads. `installMouse` receives only the
215
+ * prototype, so the instance is captured here from the first patched call
216
+ * that sees one — `handleViewportInput` sees every key and mouse event, and
217
+ * repaints ride Pi's own `requestRender`, so the hint is never more than one
218
+ * input away from the current renderer.
219
+ */
220
+ let activeReceiver: MouseCapablePrototype | undefined;
221
+
222
+ /**
223
+ * Reader for the derived selection hint of whichever `installMouse` call is
238
224
  * currently active, mirroring `pasteExpandHintText`'s pattern in
239
- * `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
240
- * on every render; there is nothing to wire when no mouse install is active.
225
+ * `../paste-collapse.ts`. `ui.ts` composes this with the paste hint on every
226
+ * render; there is nothing to wire when no mouse install is active.
227
+ *
228
+ * The hint is computed on demand from the live renderer, never cached:
229
+ * "selected, ctrl+x to copy" is true exactly while Pi is not auto-copying
230
+ * and something is actually selected. The character count goes through the
231
+ * same two copy paths the copy key itself would answer, so the hint's
232
+ * promise and the clipboard's bytes stay in agreement.
241
233
  */
242
- let activeState: SelectionPendingState | undefined;
234
+ let activeHint:
235
+ | { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
236
+ | undefined;
243
237
 
244
238
  export function activeSelectionHintText(): string | null {
245
- return activeState ? selectionHintText(activeState, externalEditorName()) : null;
239
+ if (!activeHint || !activeReceiver) return null;
240
+ const { getConfig, bufferCopy, cleanCopy } = activeHint;
241
+ try {
242
+ // Pi auto-copies on release; a selection that is already on the
243
+ // clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
244
+ // setting flipped at runtime flips the hint with it.
245
+ if (activeReceiver.getCopyOnSelect()) return null;
246
+ if (!activeReceiver.hasActiveSelection()) return null;
247
+ const bounds = activeReceiver.getSelectionBounds();
248
+ if (!bounds) return null;
249
+ const pending = pendingSelectionText(
250
+ activeReceiver,
251
+ getConfig(),
252
+ bounds,
253
+ bufferCopy,
254
+ cleanCopy,
255
+ );
256
+ return selectionHintText(
257
+ pending.text.length,
258
+ copyKeyText(),
259
+ // An editor selection cannot grow past the visible window — there is
260
+ // no drag-scroll — so the hint for one points at the external editor,
261
+ // the way to act on the whole draft. "" (unbound) shows no suffix.
262
+ pending.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
263
+ externalEditorName(),
264
+ );
265
+ } catch {
266
+ // The renderer is best effort; a receiver that has moved on offers no
267
+ // hint rather than breaking the metadata row.
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * The rendered name of the copy key, for the hint. `app.message.copy` is what
274
+ * Pi 0.84.4's Ctrl+X path is bound to (see `handleCopyCommand` in
275
+ * interactive-mode.ts), and the registry resolves a user rebind so the hint
276
+ * always quotes the key that really copies.
277
+ */
278
+ function copyKeyText(): string {
279
+ return keyTextFor(COPY_KEYBINDING);
246
280
  }
247
281
 
248
282
  /**
@@ -283,10 +317,10 @@ function refreshExternalEditorHint(
283
317
  }
284
318
 
285
319
  /**
286
- * The exact text `copySelectionToClipboard` would produce, built the same way
287
- * it builds it: per row, through the receiver's own `getSelectionColumns`,
288
- * then `sliceByColumn` and `stripTerminalSequences` (both exported by
289
- * pi-tui), joined with "\n" — see `copySelectionToClipboard` in
320
+ * The exact text Pi's selection copy would produce, built the same way it
321
+ * builds it: per row, through the receiver's own `getSelectionColumns`, then
322
+ * `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
323
+ * joined with "\n" — see `copyActiveSelectionToClipboard` in
290
324
  * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Reusing Pi's
291
325
  * own helpers instead of re-deriving the column math is what keeps this exact
292
326
  * rather than an estimate. The scroll-view case needs the box behind
@@ -321,42 +355,6 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
321
355
  return rows.join("\n");
322
356
  }
323
357
 
324
- /**
325
- * Runs `copySelectionToClipboard` (Pi's real one, reached through the
326
- * receiver so the `mouse-copy` patch's re-entrancy guard applies) with its
327
- * own "Copied!" flash suppressed when `copyNotice` is off.
328
- *
329
- * Pi's copy method flashes unconditionally — see
330
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Respecting
331
- * `copyNotice: false` therefore means shadowing `flash` on the receiver for
332
- * the duration of this one call and restoring it immediately after, the same
333
- * shadow-and-restore shape `installPasteCollapse` already uses on `handlePaste`.
334
- * `receiver` here is Pi's own instance (the wrapper runs inside Pi's own
335
- * method call, not through the extension-facing Proxy), so a plain
336
- * assignment is safe.
337
- *
338
- * 0.84.2 (#8110) boundary: Pi's copy is now async, and when the host injects
339
- * a clipboard (`copySelection` — coding-agent does since 0.84.2), the flash
340
- * lands after `await`, outside this synchronous shadow. `copyNotice: false`
341
- * therefore still suppresses the toast on the OSC 52 fallback path (no host
342
- * clipboard) but not on the host-clipboard path. The ctrl+c caller must stay
343
- * synchronous — it returns `{ consume: true }` to Pi's `handleInput` — so
344
- * awaiting Pi's copy here is not an option.
345
- */
346
- function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): void {
347
- if (showNotice) {
348
- receiver.copySelectionToClipboard();
349
- return;
350
- }
351
- const originalFlash = receiver.flash;
352
- receiver.flash = () => {};
353
- try {
354
- receiver.copySelectionToClipboard();
355
- } finally {
356
- receiver.flash = originalFlash;
357
- }
358
- }
359
-
360
358
  /**
361
359
  * Writes the draft's own text to the clipboard for a selection that lies inside
362
360
  * the input box, and reports whether it did — `false` means this selection was
@@ -368,14 +366,13 @@ function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): v
368
366
  * the frame fills each row out to, and a newline wherever the draft happened to
369
367
  * wrap. None of that is in the draft. The bytes go out the same way Pi's own
370
368
  * copy sends them — OSC 52 through `terminal.write`, see
371
- * `copySelectionToClipboard` in
369
+ * `copyActiveSelectionToClipboard` in
372
370
  * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
373
371
  * downstream can tell the two copies apart.
374
372
  *
375
- * The flash is unconditional, exactly as Pi's is. `copyNotice: false` is
376
- * honoured by `copyWithNotice`, which shadows `flash` around the whole call;
377
- * checking the setting here as well would suppress the notice twice on one path
378
- * and not at all on the other.
373
+ * The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
374
+ * own copy flashes unconditionally, so this check is the whole notice path for
375
+ * a clean copy.
379
376
  */
380
377
  function editorTextForSelection(
381
378
  receiver: MouseCapablePrototype,
@@ -407,7 +404,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
407
404
  const write = receiver.terminal?.write;
408
405
  if (typeof write !== "function") return false;
409
406
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
410
- receiver.flash("Copied!");
407
+ if (config.mouse.copyNotice) receiver.flash("Copied!");
411
408
  return true;
412
409
  } catch {
413
410
  // Reading the editor and the layout is best effort. Anything this trips
@@ -508,7 +505,7 @@ function copyTranscriptSelection(
508
505
  const write = receiver.terminal?.write;
509
506
  if (typeof write !== "function") return false;
510
507
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
511
- receiver.flash("Copied!");
508
+ if (config.mouse.copyNotice) receiver.flash("Copied!");
512
509
  return true;
513
510
  } catch {
514
511
  // Cleaning reads the layout and the selection, both best effort.
@@ -532,8 +529,6 @@ function copyTranscriptSelection(
532
529
  * all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
533
530
  * ctrl+d are Pi's interrupt and exit chords; consuming either because a
534
531
  * selection happens to be live would break a global key to save a keystroke.
535
- * The ctrl+c branch above already returns before this is reached, but stating
536
- * it here means the guarantee does not depend on branch order.
537
532
  */
538
533
  function isRangeDeleteKey(data: unknown): boolean {
539
534
  if (typeof data !== "string" || data.length === 0) return false;
@@ -581,11 +576,11 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
581
576
  }
582
577
 
583
578
  /**
584
- * The text a copy of this selection would put on the clipboard, for the pending
585
- * hint's character count.
579
+ * The text a copy of this selection would put on the clipboard, for the hint's
580
+ * character count.
586
581
  *
587
582
  * It has to ask the same two paths the copy itself asks, in the same order, or
588
- * the hint promises a number ctrl+c does not deliver.
583
+ * the hint promises a number the copy does not deliver.
589
584
  */
590
585
  function pendingSelectionText(
591
586
  receiver: MouseCapablePrototype,
@@ -596,8 +591,8 @@ function pendingSelectionText(
596
591
  ): { text: string; inEditor: boolean } {
597
592
  const buffered = bufferCopy ? editorTextForSelection(receiver, config, bounds) : undefined;
598
593
  if (buffered !== undefined) return { text: buffered, inEditor: true };
599
- // The count the hint shows must be the number ctrl+c actually delivers, so
600
- // a selection the copy would clean is counted after cleaning.
594
+ // The count the hint shows must be the number the copy key actually
595
+ // delivers, so a selection the copy would clean is counted after cleaning.
601
596
  if (cleanCopy && config.mouse.transcriptCleanCopy) {
602
597
  try {
603
598
  const cleaned = transcriptSelectionText(receiver, config, bounds);
@@ -611,25 +606,24 @@ function pendingSelectionText(
611
606
  }
612
607
 
613
608
  /**
614
- * Installs the two features that live on `copySelectionToClipboard`.
615
- *
616
- * `selectionPendingMode` — arm on release, copy on ctrl+c — spans two patches:
617
- * the copy patch decides whether a release copies now or arms and waits, and
618
- * the `handleViewportInput` patch is what a waiting selection is eventually
619
- * released by.
620
- *
621
- * `editorBufferCopy` sits in front of the real copy on that same method. They
622
- * share one patch rather than taking a key each because
623
- * `installPrototypePatch` holds exactly one behaviour per adapter key: a second
624
- * registration under `mouse-copy` would silently replace the first, and two
625
- * different keys would leave the order they run in — and which of them gets to
626
- * consume the call — implicit. Here the precedence is written down: the buffer
627
- * copy answers first, and only when it declines does the pending mode or
628
- * predecessor see the call.
629
- *
630
- * Either feature can be off. With `selectionPendingMode` unavailable there is
631
- * no state and every call is a real copy; with `editorBufferCopy` unavailable
632
- * the clipboard is Pi's own bytes, exactly as before.
609
+ * Installs the clean-copy features on Pi 0.84.4's public copy entry, and the
610
+ * derived selection hint.
611
+ *
612
+ * `editorBufferCopy` and `transcriptCleanCopy` sit in front of
613
+ * `copyActiveSelectionToClipboard` — the method coding-agent's Ctrl+X handler
614
+ * reaches (`handleCopyCommand` with `preferSelection`) — so the clean copy
615
+ * answers exactly the copies that key performs. They share one patch rather
616
+ * than taking a key each because `installPrototypePatch` holds exactly one
617
+ * behaviour per adapter key: a second registration under `mouse-copy` would
618
+ * silently replace the first, and two different keys would leave the order
619
+ * they run in — and which of them gets to consume the call — implicit. Here
620
+ * the precedence is written down: the buffer copy answers first, and only
621
+ * when it declines does the clean copy or predecessor see the call.
622
+ *
623
+ * `selectionHint` owns no state at all: it records the config reader and the
624
+ * clean-copy flags so `activeSelectionHintText()` can derive the hint on
625
+ * demand from the live renderer. Either clean-copy feature can be off; with
626
+ * neither available the clipboard is Pi's own bytes, exactly as before.
633
627
  */
634
628
  function installCopying(
635
629
  prototype: MouseCapablePrototype,
@@ -639,81 +633,40 @@ function installCopying(
639
633
  const bufferCopy = features.has("editorBufferCopy");
640
634
  const cleanCopy = features.has("transcriptCleanCopy");
641
635
  const rangeDelete = features.has("editorClickToCaret");
642
- const pendingMode = features.has("selectionPendingMode");
643
- const state = pendingMode ? new SelectionPendingState() : undefined;
644
- const previousState = activeState;
645
- if (state) activeState = state;
646
-
647
- // Set while this module is driving `copySelectionToClipboard` itself (the
648
- // ctrl+c path below), so the `mouse-copy` patch performs the copy instead of
649
- // re-arming the state it is itself in the middle of clearing.
650
- let performingRealCopy = false;
636
+ const hint = features.has("selectionHint");
637
+ const previousHint = activeHint;
638
+ if (hint) activeHint = { getConfig: deps.getConfig, bufferCopy, cleanCopy };
651
639
 
652
640
  const cleanups: Array<() => void> = [];
653
641
 
654
- // Only installed when a feature that answers `copySelectionToClipboard` is
655
- // on. With just `editorClickToCaret` the method is not ours to touch, and a
656
- // Pi that has moved it must not make this install throw.
657
- if (pendingMode || bufferCopy || cleanCopy) {
642
+ // Only installed when a feature that answers `copyActiveSelectionToClipboard`
643
+ // is on. With just `editorClickToCaret` or `selectionHint` the method is
644
+ // not ours to touch, and a Pi that has moved it must not make this install
645
+ // throw.
646
+ if (bufferCopy || cleanCopy) {
658
647
  cleanups.push(
659
648
  installPrototypePatch(
660
649
  prototype,
661
- "copySelectionToClipboard",
650
+ "copyActiveSelectionToClipboard",
662
651
  "mouse-copy",
663
652
  ({ predecessor, receiver, args }) => {
664
653
  const typedReceiver = receiver as MouseCapablePrototype;
665
654
  const config = deps.getConfig();
666
- if (!state || config.mouse.copyOnSelect || performingRealCopy) {
667
- // The real copy, whether Pi triggered it directly on release
668
- // (`copyOnSelect: true`), this module is driving it itself for
669
- // ctrl+c below, or pending mode is not installed at all.
670
- if (bufferCopy && copyEditorSelection(typedReceiver, config)) return undefined;
671
- if (
672
- cleanCopy &&
673
- config.mouse.transcriptCleanCopy &&
674
- copyTranscriptSelection(typedReceiver, config)
675
- ) {
676
- return undefined;
677
- }
678
- return Reflect.apply(predecessor, receiver, args);
655
+ if (bufferCopy && copyEditorSelection(typedReceiver, config)) return true;
656
+ if (
657
+ cleanCopy &&
658
+ config.mouse.transcriptCleanCopy &&
659
+ copyTranscriptSelection(typedReceiver, config)
660
+ ) {
661
+ return true;
679
662
  }
680
- const bounds = typedReceiver.getSelectionBounds();
681
- if (!bounds) {
682
- // A collapsed or empty selection (e.g. a plain click after a prior
683
- // drag) still reaches this call — Pi runs it unconditionally on
684
- // every release and relies on its own `if (!selection) return;`
685
- // guard. Any stale arm from an earlier selection must not survive
686
- // this: left in place, it would make a later ctrl+c consume the
687
- // key for a no-op copy instead of falling through to interrupt.
688
- if (state.pending) {
689
- state.clear();
690
- typedReceiver.requestRender();
691
- }
692
- return undefined;
693
- }
694
- const pendingText = pendingSelectionText(
695
- typedReceiver,
696
- config,
697
- bounds,
698
- bufferCopy,
699
- cleanCopy,
700
- );
701
- state.arm(
702
- pendingText.text.length,
703
- // An editor selection cannot grow past the visible window —
704
- // there is no drag-scroll — so the hint for one points at the
705
- // external editor, the way to act on the whole draft. ""
706
- // (unbound) simply shows no suffix.
707
- pendingText.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
708
- );
709
- typedReceiver.requestRender();
710
- return undefined;
663
+ return Reflect.apply(predecessor, receiver, args);
711
664
  },
712
665
  ),
713
666
  );
714
667
  }
715
668
 
716
- if (!state && !rangeDelete) {
669
+ if (!hint && !rangeDelete) {
717
670
  return () => {
718
671
  for (const cleanup of cleanups) cleanup();
719
672
  };
@@ -726,58 +679,27 @@ function installCopying(
726
679
  "mouse-viewport-input",
727
680
  ({ predecessor, receiver, args }) => {
728
681
  const data = args[0];
682
+ const typedReceiver = receiver as MouseCapablePrototype;
729
683
  // Keystrokes and mouse events both land here, so this is the one
730
684
  // place that sees every draft change; keep the outgrew-the-box
731
- // hint current with it.
732
- refreshExternalEditorHint(receiver as MouseCapablePrototype, deps.getConfig());
733
- // ---- Branch 1: ctrl+c, `selectionPendingMode`'s. ----------------
685
+ // hint current with it, and keep the selection hint's renderer
686
+ // reference pointed at the live instance.
687
+ activeReceiver = typedReceiver;
688
+ refreshExternalEditorHint(typedReceiver, deps.getConfig());
689
+ // ---- Backspace and delete, `editorClickToCaret`'s. ------------
734
690
  //
735
- // This branch is FIRST, and deliberately so. The one outcome that
736
- // must never regress is a bare ctrl+c with nothing pending reaching
737
- // Pi and interrupting; evaluating its branch before any other means
738
- // no feature added to this method later can intercept it by being
739
- // installed in a different order. `isRangeDeleteKey` refuses ctrl+c
740
- // and ctrl+d outright as well, so the guarantee holds structurally
741
- // from both directions rather than by ordering alone.
742
- if (state && isCtrlC(data) && state.pending) {
743
- const typedReceiver = receiver as MouseCapablePrototype;
744
- // `state.pending` can be stale: Pi clears its own selection
745
- // through paths this module never sees (e.g. starting a new
746
- // drag overwrites `selectionAnchor`/`selectionFocus` directly,
747
- // with no call to `copySelectionToClipboard`). Re-read the real
748
- // bounds before deciding: a copy that would be a no-op must not
749
- // consume ctrl+c, or an in-flight interrupt gets swallowed for
750
- // nothing.
751
- if (!typedReceiver.getSelectionBounds()) {
752
- state.clear();
753
- typedReceiver.requestRender();
754
- return Reflect.apply(predecessor, receiver, args);
755
- }
756
- performingRealCopy = true;
757
- try {
758
- copyWithNotice(typedReceiver, deps.getConfig().mouse.copyNotice);
759
- } finally {
760
- performingRealCopy = false;
761
- }
762
- state.clear();
763
- typedReceiver.requestRender();
764
- return { consume: true };
765
- }
766
- // ---- Branch 2: backspace and delete, `editorClickToCaret`'s. ----
767
- //
768
- // The two branches key off disjoint input — ctrl+c above, backspace
769
- // and delete here — so they compose rather than compete. Range
770
- // delete consumes the key only when it really removed something;
771
- // every other backspace falls through and goes on deleting one
772
- // character, which is the behaviour it must not break.
691
+ // Range delete consumes the key only when it really removed
692
+ // something; every other backspace falls through and goes on
693
+ // deleting one character, which is the behaviour it must not
694
+ // break. `isRangeDeleteKey` refuses ctrl+c and ctrl+d outright,
695
+ // so Pi's interrupt and exit chords can never be swallowed by
696
+ // this branch.
773
697
  if (rangeDelete && isRangeDeleteKey(data)) {
774
- const typedReceiver = receiver as MouseCapablePrototype;
775
698
  if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
776
699
  // Pi's `handleInput` returns as soon as a listener consumes,
777
700
  // so the focused editor never sees the key and Pi never
778
701
  // reaches its own `requestImmediateRender` (`tui.js:620`).
779
702
  // Redrawing the changed draft is this branch's job.
780
- state?.clear();
781
703
  typedReceiver.requestRender();
782
704
  return { consume: true };
783
705
  }
@@ -789,47 +711,9 @@ function installCopying(
789
711
 
790
712
  return () => {
791
713
  for (const cleanup of cleanups) cleanup();
792
- if (activeState === state) activeState = previousState;
793
714
  };
794
715
  }
795
716
 
796
- /**
797
- * Installs `pathAwareWords` on `getWordSelection`.
798
- *
799
- * The receiver's own `getSelectionSourceLine` gives the raw line under the
800
- * point; `stripTerminalSequences` is applied the same way predecessor itself
801
- * applies it (see `getWordSelection` in
802
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`), so
803
- * `wordRangeAt` sees the same plain text Pi's own segmenter would. `col` is
804
- * the only thing the wrapper changes on the way out — `row`, `scrollView`
805
- * and any other point field ride through unmodified, which is what keeps a
806
- * scroll-view selection landing in the right place.
807
- */
808
- function installPathAwareWords(
809
- prototype: MouseCapablePrototype,
810
- deps: InstallMouseDeps,
811
- ): () => void {
812
- return installPrototypePatch(
813
- prototype,
814
- "getWordSelection",
815
- "mouse-word-selection",
816
- ({ predecessor, receiver, args }) => {
817
- if (!deps.getConfig().mouse.pathAwareWords) {
818
- return Reflect.apply(predecessor, receiver, args);
819
- }
820
- const typedReceiver = receiver as MouseCapablePrototype;
821
- const point = args[0] as SelectionPoint;
822
- const line = stripTerminalSequences(typedReceiver.getSelectionSourceLine(point));
823
- const range = wordRangeAt(line, point.col);
824
- if (!range) return Reflect.apply(predecessor, receiver, args);
825
- return {
826
- start: { ...point, col: range.start },
827
- end: { ...point, col: range.end, boundary: true },
828
- };
829
- },
830
- );
831
- }
832
-
833
717
  /**
834
718
  * A left-button press: not a release, not a drag, not another button.
835
719
  *
@@ -1102,16 +986,13 @@ export function installMouse(prototype: object, deps: InstallMouseDeps): () => v
1102
986
  // below: `pi-toolbox` publishing which rows it drew a border on, at which
1103
987
  // columns, so Starline reads a fact instead of inferring one.
1104
988
  if (
1105
- enabled.has("selectionPendingMode") ||
989
+ enabled.has("selectionHint") ||
1106
990
  enabled.has("editorBufferCopy") ||
1107
991
  enabled.has("editorClickToCaret") ||
1108
992
  enabled.has("transcriptCleanCopy")
1109
993
  ) {
1110
994
  cleanups.push(installCopying(typedPrototype, deps, enabled));
1111
995
  }
1112
- if (enabled.has("pathAwareWords")) {
1113
- cleanups.push(installPathAwareWords(typedPrototype, deps));
1114
- }
1115
996
  if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
1116
997
  cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
1117
998
  }
@@ -1122,6 +1003,8 @@ export function installMouse(prototype: object, deps: InstallMouseDeps): () => v
1122
1003
  if (cleanups.length === 0) return () => {};
1123
1004
  return () => {
1124
1005
  externalEditorHint = null;
1006
+ activeReceiver = undefined;
1007
+ activeHint = undefined;
1125
1008
  for (const cleanup of cleanups) cleanup();
1126
1009
  };
1127
1010
  }
@@ -1,50 +1,28 @@
1
1
  import { basename } from "node:path";
2
2
 
3
3
  /**
4
- * The one piece of selection state Starline owns.
4
+ * The selection hint is derived state, not owned state.
5
5
  *
6
- * Pi holds the anchor, the focus and the granularity; this holds only whether a
7
- * released selection is still waiting to be copied. Keeping it this small is
8
- * what the 0.2.0 controller could not do, because back then Pi had no selection
9
- * at all.
6
+ * Pi 0.84.4 owns "select without copying" (`fullscreenCopyOnSelect`), so
7
+ * Starline no longer intercepts the release to arm a pending copy — the hint
8
+ * is computed on demand from the live renderer instead: an active selection
9
+ * that the renderer is not auto-copying. `selectionHintText` is the pure
10
+ * rendering of that state; `index.ts` gathers the pieces.
10
11
  */
11
- export class SelectionPendingState {
12
- private characters = 0;
13
- /**
14
- * `keyText("app.editor.external")`, resolved at arm time, when the pending
15
- * selection is the editor's. Editor selections cannot grow past the visible
16
- * window (there is no drag-scroll), so the hint points at the external
17
- * editor as the way to act on the whole draft.
18
- */
19
- private externalEditorKey: string | undefined;
20
-
21
- arm(characters: number, externalEditorKey?: string): void {
22
- this.characters = characters > 0 ? characters : 0;
23
- this.externalEditorKey = this.characters > 0 ? externalEditorKey : undefined;
24
- }
25
-
26
- clear(): void {
27
- this.characters = 0;
28
- this.externalEditorKey = undefined;
29
- }
30
-
31
- get pending(): { characters: number; externalEditorKey?: string } | undefined {
32
- return this.characters > 0
33
- ? { characters: this.characters, externalEditorKey: this.externalEditorKey }
34
- : undefined;
35
- }
36
- }
37
-
38
12
  export function selectionHintText(
39
- state: SelectionPendingState,
13
+ characters: number,
14
+ copyKey: string,
15
+ externalEditorKey: string | undefined,
40
16
  editorName: string | null = null,
41
17
  ): string | null {
42
- const pending = state.pending;
43
- if (!pending) return null;
44
- const noun = pending.characters === 1 ? "character" : "characters";
45
- const base = `${pending.characters} ${noun} selected, ctrl+c to copy`;
46
- return pending.externalEditorKey
47
- ? `${base} ⋅ ${pending.externalEditorKey} to edit in ${editorName ?? "$EDITOR"}`
18
+ if (characters <= 0) return null;
19
+ const noun = characters === 1 ? "character" : "characters";
20
+ const base =
21
+ copyKey.length > 0
22
+ ? `${characters} ${noun} selected, ${copyKey} to copy`
23
+ : `${characters} ${noun} selected`;
24
+ return externalEditorKey
25
+ ? `${base} ⋅ ${externalEditorKey} to edit in ${editorName ?? "$EDITOR"}`
48
26
  : base;
49
27
  }
50
28
 
@@ -2,7 +2,7 @@
2
2
  * Strips decoration out of a transcript selection before it reaches the
3
3
  * clipboard.
4
4
  *
5
- * Pi's `copySelectionToClipboard` copies *rendered* rows. In the transcript
5
+ * Pi's `copyActiveSelectionToClipboard` copies *rendered* rows. In the transcript
6
6
  * those rows carry chrome that two components paint around their content:
7
7
  *
8
8
  * - Starline's own user messages (`user-message.ts`): a full-width `─` rule
@@ -18,8 +18,7 @@ type PrototypePatchAdapter =
18
18
  | "mouse-viewport-input"
19
19
  | "mouse-wheel"
20
20
  | "mouse-selection-event"
21
- | "mouse-copy"
22
- | "mouse-word-selection";
21
+ | "mouse-copy";
23
22
 
24
23
  type PrototypeMethodName = string;
25
24
 
@@ -143,7 +143,7 @@ function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset:
143
143
  */
144
144
  function composeRightStatus(vimStatus: string | undefined, uiTheme: Theme): string | undefined {
145
145
  // A live selection hint already spells the external editor out ("N
146
- // characters selected, ctrl+c to copy ⋅ ctrl+g to edit in $EDITOR"), so it
146
+ // characters selected, ctrl+x to copy ⋅ ctrl+g to edit in $EDITOR"), so it
147
147
  // takes precedence; the always-on hint only speaks when nothing else does.
148
148
  const hint = composeHints(
149
149
  pasteExpandHintText(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xzzpig/pi-starline",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,9 +23,9 @@
23
23
  "extensions"
24
24
  ],
25
25
  "peerDependencies": {
26
- "@earendil-works/pi-ai": ">=0.84.0",
27
- "@earendil-works/pi-coding-agent": ">=0.84.0",
28
- "@earendil-works/pi-tui": ">=0.84.0"
26
+ "@earendil-works/pi-ai": ">=0.84.4",
27
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
28
+ "@earendil-works/pi-tui": ">=0.84.4"
29
29
  },
30
30
  "pi": {
31
31
  "extensions": [
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@biomejs/biome": "^2.5.0",
41
- "@earendil-works/pi-ai": "^0.84.2",
42
- "@earendil-works/pi-coding-agent": "^0.84.2",
43
- "@earendil-works/pi-tui": "^0.84.2",
41
+ "@earendil-works/pi-ai": "^0.84.4",
42
+ "@earendil-works/pi-coding-agent": "^0.84.4",
43
+ "@earendil-works/pi-tui": "^0.84.4",
44
44
  "@types/node": "^26.0.0",
45
45
  "typescript": "^7.0.2",
46
46
  "vitest": "^4.1.8"
@@ -1,145 +0,0 @@
1
- /**
2
- * Which columns a double click on one line should select.
3
- *
4
- * Ported from `getWordSelection` in pi commit `56419be51`
5
- * (`feat/word-selection-path-chars`, `packages/tui/src/tui-alt-screen.ts`),
6
- * filed upstream as issue #7746. The installed Pi build's own
7
- * `getWordSelection` predates that patch, so this substitutes Pi's whole
8
- * word-selection rule rather than layering path handling on top of it — see
9
- * `index.ts` for where that substitution happens and why it's the intended
10
- * scope, not creep.
11
- *
12
- * This is a pure function of one already-plain (no ANSI) line and a terminal
13
- * column; the caller in `index.ts` is what reads Pi's live source line and
14
- * strips escape sequences before calling in, matching the split
15
- * `getWordSelection`/`getSelectionSourceLine` already have upstream.
16
- */
17
- import { visibleWidth } from "@earendil-works/pi-tui";
18
-
19
- const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
20
-
21
- /**
22
- * Characters that join two words into one for double-click selection.
23
- *
24
- * `Intl.Segmenter` already keeps `_` and `.` inside a word, so `foo_bar` and
25
- * `file.ts` survive on their own. It breaks on `/` and `-`, which is what
26
- * takes a path or a kebab-case identifier apart. Terminals keep these inside
27
- * a word so paths and URLs select whole; this set is what makes fullscreen's
28
- * own double-click agree with them.
29
- */
30
- const WORD_JOINERS = new Set(["/", "-"]);
31
-
32
- type WordSegment = {
33
- start: number;
34
- end: number;
35
- isWordLike: boolean;
36
- isJoiner: boolean;
37
- isSpace: boolean;
38
- };
39
-
40
- /** `segments[index]`, guaranteed — every call site here only ever passes an index it just found in range. */
41
- function segmentAt(segments: readonly WordSegment[], index: number): WordSegment {
42
- const segment = segments[index];
43
- if (segment === undefined) {
44
- throw new RangeError(`word-select: no segment at index ${index}`);
45
- }
46
- return segment;
47
- }
48
-
49
- function collectWordSegments(line: string): WordSegment[] {
50
- const segments: WordSegment[] = [];
51
- let start = 0;
52
- for (const segment of wordSegmenter.segment(line)) {
53
- const text = segment.segment;
54
- const end = start + visibleWidth(text);
55
- segments.push({
56
- start,
57
- end,
58
- isWordLike: segment.isWordLike === true,
59
- // Joiners arrive one per segment, so `//` in a URL is two of these.
60
- isJoiner: text.length > 0 && [...text].every((char) => WORD_JOINERS.has(char)),
61
- isSpace: text.length > 0 && text.trim().length === 0,
62
- });
63
- start = end;
64
- }
65
- return segments;
66
- }
67
-
68
- /**
69
- * Walk back over a run of joiners that opens a word, as in `/usr` or `--flag`.
70
- *
71
- * The run only counts as part of the word when nothing but whitespace
72
- * precedes it, which is what separates a leading separator from an infix one.
73
- * In `http://a.com` the `//` is preceded by `:`, so the scheme stays out.
74
- */
75
- function openingJoinerRun(segments: WordSegment[], first: number): number {
76
- let index = first;
77
- while (segments[index - 1]?.isJoiner) index--;
78
- if (index === first) return first;
79
- const before = segments[index - 1];
80
- return before === undefined || before.isSpace ? index : first;
81
- }
82
-
83
- /**
84
- * Grow a word segment across joiners that have a word on both sides, plus any
85
- * separator run that opens it.
86
- *
87
- * Anchoring on word-like segments is what keeps this conservative: in
88
- * `http://a.com/b` the `:` is not a joiner, so the run stops there instead of
89
- * swallowing the scheme, and a dash used as prose punctuation in `a - b` has
90
- * whitespace around it rather than a word.
91
- */
92
- function expandOverWordJoiners(
93
- segments: WordSegment[],
94
- index: number,
95
- ): { start: number; end: number } {
96
- const anchor = segmentAt(segments, index);
97
- if (anchor.isJoiner) {
98
- // Clicking a separator should take the word it belongs to. Prefer the
99
- // word on the left so an infix `/` grows the whole path; failing that,
100
- // step over the run to the word it opens, so clicking the `/` of
101
- // `/usr/bin` works.
102
- if (segments[index - 1]?.isWordLike && segments[index + 1]?.isWordLike) {
103
- return expandOverWordJoiners(segments, index - 1);
104
- }
105
- if (!segments[index - 1]?.isWordLike) {
106
- let next = index;
107
- while (segments[next]?.isJoiner) next++;
108
- if (segments[next]?.isWordLike) return expandOverWordJoiners(segments, next);
109
- }
110
- }
111
- if (!anchor.isWordLike) return { start: anchor.start, end: anchor.end };
112
-
113
- let first = index;
114
- while (first >= 2 && segments[first - 1]?.isJoiner && segments[first - 2]?.isWordLike) {
115
- first -= 2;
116
- }
117
- first = openingJoinerRun(segments, first);
118
- // A trailing run gets no such treatment: nothing follows it to make it
119
- // part of the word, and `trailing- x` reading as `trailing-` would be
120
- // wrong.
121
- let last = index;
122
- while (segments[last + 1]?.isJoiner && segments[last + 2]?.isWordLike) {
123
- last += 2;
124
- }
125
- return { start: segmentAt(segments, first).start, end: segmentAt(segments, last).end };
126
- }
127
-
128
- /**
129
- * The column range a double click at `column` on `line` should select, in
130
- * the same column units as Pi's own `SelectionRange`. `undefined` when
131
- * `column` lands past the end of the line — the caller calls through to Pi's
132
- * own selection in that case rather than inventing a range.
133
- */
134
- export function wordRangeAt(
135
- line: string,
136
- column: number,
137
- ): { start: number; end: number } | undefined {
138
- const segments = collectWordSegments(line);
139
- for (let index = 0; index < segments.length; index++) {
140
- const segment = segmentAt(segments, index);
141
- if (column < segment.start || column >= segment.end) continue;
142
- return expandOverWordJoiners(segments, index);
143
- }
144
- return undefined;
145
- }