@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 +6 -6
- package/extensions/starline/config.ts +0 -17
- package/extensions/starline/mouse/capabilities.ts +36 -36
- package/extensions/starline/mouse/editor-caret.ts +3 -3
- package/extensions/starline/mouse/index.ts +163 -280
- package/extensions/starline/mouse/selection-state.ts +17 -39
- package/extensions/starline/mouse/transcript-copy.ts +1 -1
- package/extensions/starline/prototype-patch-registry.ts +1 -2
- package/extensions/starline/ui.ts +1 -1
- package/package.json +7 -7
- package/extensions/starline/mouse/word-select.ts +0 -145
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
|
|
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
|
|
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
|
|
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.
|
|
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": { "
|
|
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.
|
|
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
|
-
| "
|
|
24
|
-
| "
|
|
25
|
-
| "
|
|
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
|
-
| "
|
|
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
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
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 `
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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: ["
|
|
139
|
-
// Shares the `
|
|
140
|
-
// it. It reads the selection through `getSelectionBounds` to find the
|
|
141
|
-
// through `getSelectionColumns` to slice the cleaned text along the
|
|
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
|
|
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
|
-
"
|
|
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 `
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
|
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
|
-
* `
|
|
14
|
-
* - `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 `
|
|
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
|
-
* `
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
234
|
+
let activeHint:
|
|
235
|
+
| { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
|
|
236
|
+
| undefined;
|
|
243
237
|
|
|
244
238
|
export function activeSelectionHintText(): string | null {
|
|
245
|
-
|
|
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
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
|
585
|
-
*
|
|
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
|
|
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
|
|
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
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
* Either feature can be off
|
|
631
|
-
*
|
|
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
|
|
643
|
-
const
|
|
644
|
-
|
|
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 `
|
|
655
|
-
// on. With just `editorClickToCaret` the method is
|
|
656
|
-
// Pi that has moved it must not make this install
|
|
657
|
-
|
|
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
|
-
"
|
|
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 (
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
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("
|
|
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
|
|
4
|
+
* The selection hint is derived state, not owned state.
|
|
5
5
|
*
|
|
6
|
-
* Pi
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
13
|
+
characters: number,
|
|
14
|
+
copyKey: string,
|
|
15
|
+
externalEditorKey: string | undefined,
|
|
40
16
|
editorName: string | null = null,
|
|
41
17
|
): string | null {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 `
|
|
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
|
|
@@ -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+
|
|
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.
|
|
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.
|
|
27
|
-
"@earendil-works/pi-coding-agent": ">=0.84.
|
|
28
|
-
"@earendil-works/pi-tui": ">=0.84.
|
|
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.
|
|
42
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
43
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
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
|
-
}
|