@xzzpig/pi-starline 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/extensions/starline/config.ts +0 -17
- package/extensions/starline/index.ts +34 -33
- package/extensions/starline/mouse/api-consumer.ts +31 -0
- package/extensions/starline/mouse/capabilities.ts +83 -129
- package/extensions/starline/mouse/editor-caret.ts +3 -3
- package/extensions/starline/mouse/index.ts +504 -690
- package/extensions/starline/mouse/selection-state.ts +17 -39
- package/extensions/starline/mouse/tool-box.ts +112 -83
- package/extensions/starline/mouse/transcript-copy.ts +1 -1
- package/extensions/starline/prototype-patch-registry.ts +8 -6
- package/extensions/starline/ui.ts +1 -1
- package/package.json +15 -8
- package/extensions/starline/mouse/word-select.ts +0 -145
|
@@ -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
|
|
|
@@ -1,40 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Toggling an expandable transcript component by clicking it.
|
|
3
3
|
*
|
|
4
4
|
* `ctrl+o` toggles every box in the transcript at once, which is a screenful
|
|
5
5
|
* for the one line of output you wanted. This resolves the component under a
|
|
6
|
-
* mouse
|
|
7
|
-
*
|
|
6
|
+
* mouse click and toggles just that one — from any row it renders, not only
|
|
7
|
+
* its hint row.
|
|
8
8
|
*
|
|
9
|
-
* ##
|
|
9
|
+
* ## The click, not the press
|
|
10
10
|
*
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* The caller (`mouse/index.ts`) never consumes the press: Pi's selection
|
|
12
|
+
* machinery anchors on it, so a drag that starts anywhere on a box — hint row
|
|
13
|
+
* included — still selects and copies text. The toggle happens when the
|
|
14
|
+
* button is released on the very cell it was pressed on with no motion in
|
|
15
|
+
* between. Pi's own release path treats such a plain click as a no-op (an
|
|
16
|
+
* empty selection copies nothing), so consuming the release costs nothing;
|
|
17
|
+
* every other release — a drag, a different cell, a link — goes to Pi
|
|
18
|
+
* untouched.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
* description)` (`keybinding-hints.js`), which is `theme.fg("dim", keyText) +
|
|
22
|
-
* theme.fg("muted", " " + description)`, and every call site wraps it in
|
|
23
|
-
* parentheses:
|
|
20
|
+
* ## Which way to toggle
|
|
24
21
|
*
|
|
25
|
-
*
|
|
26
|
-
* `... 3 more lines (ctrl+o to expand)` when collapsed.
|
|
27
|
-
* - `core/tools/{bash,find,grep,ls,read,write}.js` — `... (N more lines,
|
|
28
|
-
* ctrl+o to expand)`.
|
|
29
|
-
* - `read.js:91`, `skill-invocation-message.js:42`,
|
|
30
|
-
* `branch-summary-message.js:39`, `compaction-summary-message.js:40` —
|
|
31
|
-
* `(ctrl+o to expand)` on the title line.
|
|
22
|
+
* Two sources, in order:
|
|
32
23
|
*
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
24
|
+
* 1. **The hint row.** Pi renders it through `keyHint("app.tools.expand",
|
|
25
|
+
* description)` (`keybinding-hints.js`), which is `theme.fg("dim",
|
|
26
|
+
* keyText) + theme.fg("muted", " " + description)`, and every call site
|
|
27
|
+
* wraps it in parentheses: `bash-execution.js:140,143`,
|
|
28
|
+
* `core/tools/{bash,find,grep,ls,read,write}.js`,
|
|
29
|
+
* `read.js:91`, `skill-invocation-message.js:42`,
|
|
30
|
+
* `branch-summary-message.js:39`, `compaction-summary-message.js:40`.
|
|
31
|
+
* The description carries the *direction*: `to expand` when collapsed, `to
|
|
32
|
+
* collapse` when expanded.
|
|
33
|
+
* 2. **The component's own state field.** Every expandable component keeps
|
|
34
|
+
* its state in a runtime-public boolean — `expanded` (bash-execution,
|
|
35
|
+
* tool-execution, skill/branch/compaction summaries) or `_expanded`
|
|
36
|
+
* (custom entries and messages) — and every mutation path writes it,
|
|
37
|
+
* ctrl+o's global fan-out included. Reading it cannot desync the way a
|
|
38
|
+
* `WeakMap` of "what I last set" would (the row on screen and the field
|
|
39
|
+
* are both the component's own truth); `expandedStateOf` duck-types both
|
|
40
|
+
* spellings and declines to guess when neither is a boolean.
|
|
41
|
+
*
|
|
42
|
+
* The field is what lets a click *collapse* from any row. Most of Pi's tool
|
|
43
|
+
* renderers emit the hint only while collapsed (`remaining > 0` in
|
|
44
|
+
* `core/tools/{read,grep,ls,write,find}.js`; the `else` branch of the
|
|
45
|
+
* skill/branch/compaction summaries), so an expanded box used to have no
|
|
46
|
+
* clickable way back at all without `pi-toolbox`'s collapse anchor.
|
|
47
|
+
*
|
|
48
|
+
* ## A component that handles the mouse keeps its clicks
|
|
49
|
+
*
|
|
50
|
+
* If any component on the clicked row's path implements `onMouse`, resolution
|
|
51
|
+
* declines. The layout folds the whole transcript into one box, so
|
|
52
|
+
* `pi-mouse-events` dispatch can never deliver an event to such a component —
|
|
53
|
+
* bowing out here is the only way its `onMouse` can mean what it says.
|
|
38
54
|
*
|
|
39
55
|
* ## Why resolution goes through the component tree
|
|
40
56
|
*
|
|
@@ -50,56 +66,24 @@
|
|
|
50
66
|
* ## What this deliberately does not do
|
|
51
67
|
*
|
|
52
68
|
* Nothing here looks at how a component *renders* to decide what it is:
|
|
53
|
-
* `isExpandableComponent` asks whether `setExpanded` is callable, and the
|
|
54
|
-
* rule is scoped to that component's own rows. An ordinary message that
|
|
55
|
-
* the hint — an assistant explaining `ctrl+o`, a paste of these very
|
|
56
|
-
* a `Text` or a `Markdown` with no `setExpanded` anywhere in its
|
|
57
|
-
* resolves to nothing and the
|
|
58
|
-
*
|
|
59
|
-
* ## Two accepted limitations
|
|
60
|
-
*
|
|
61
|
-
* Both are known, both were weighed, and neither has a fix worth its cost.
|
|
62
|
-
* They are recorded here and in `docs/configuration.md` because a user meets
|
|
63
|
-
* them before they meet any of the reasoning above.
|
|
69
|
+
* `isExpandableComponent` asks whether `setExpanded` is callable, and the
|
|
70
|
+
* hint rule is scoped to that component's own rows. An ordinary message that
|
|
71
|
+
* quotes the hint — an assistant explaining `ctrl+o`, a paste of these very
|
|
72
|
+
* docs — is a `Text` or a `Markdown` with no `setExpanded` anywhere in its
|
|
73
|
+
* path, so it resolves to nothing and the click stays a selection.
|
|
64
74
|
*
|
|
65
|
-
*
|
|
66
|
-
* "the hint row exists in both states, so one rule covers both directions" is
|
|
67
|
-
* only true of `bash-execution` and of `tool-execution` results that still
|
|
68
|
-
* have a preview to hide. For everything else Pi renders the hint *only while
|
|
69
|
-
* collapsed*, so expanding it removes the very row that would close it again:
|
|
75
|
+
* ## Accepted limitation: a box's own output can read like its hint
|
|
70
76
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* expanded `ToolExecutionComponent` — the same text `bash-execution` renders
|
|
82
|
-
* natively — and the hint rule above matches it exactly as it matches Pi's
|
|
83
|
-
* own rows, because it is the component's own rendered row. That closes
|
|
84
|
-
* `read`/`grep`/`ls`/`write`/`find` boxes with one click. The second group
|
|
85
|
-
* (skill, branch and compaction summaries) keeps the limitation: no anchor
|
|
86
|
-
* exists for them, `ctrl+o` (which closes everything) is the way back, and
|
|
87
|
-
* inventing a second target (the title line, the first output row) would
|
|
88
|
-
* mean guessing at a component's layout, which is the inference this module
|
|
89
|
-
* exists to avoid. Without `pi-toolbox` installed the first group keeps the
|
|
90
|
-
* limitation too — no anchor is rendered, and nothing here invents one.
|
|
91
|
-
*
|
|
92
|
-
* **2. A box's own output can read like its hint.** The component scoping
|
|
93
|
-
* above stops a *different* message from being clickable, but not the box's
|
|
94
|
-
* own body: a line inside an expandable component that literally contains
|
|
95
|
-
* `(ctrl+o to expand)` — `cat` of a file documenting the keybinding, a
|
|
96
|
-
* transcript of this very docstring — is indistinguishable from the real hint,
|
|
97
|
-
* because Pi renders the box's output and the box's hint through the same
|
|
98
|
-
* `Text` with the same structure and only different theme colours. Matching
|
|
99
|
-
* the colours instead would make the rule depend on the user's theme.
|
|
100
|
-
* Accepted: the worst case is that a click on a box opens the box it is
|
|
101
|
-
* already inside — no crash, no lost input, and the same click a row above or
|
|
102
|
-
* below behaves normally.
|
|
77
|
+
* The component scoping above stops a *different* message from being
|
|
78
|
+
* clickable, but not the box's own body: a line inside an expandable
|
|
79
|
+
* component that literally contains `(ctrl+o to expand)` — `cat` of a file
|
|
80
|
+
* documenting the keybinding, a transcript of this very docstring — matches
|
|
81
|
+
* the hint rule and its direction, because Pi renders the box's output and
|
|
82
|
+
* the box's hint through the same `Text` with the same structure and only
|
|
83
|
+
* different theme colours. Matching the colours instead would make the rule
|
|
84
|
+
* depend on the user's theme. Accepted: the worst case is that a click on a
|
|
85
|
+
* box toggles the box it is already inside — no crash, no lost input, and the
|
|
86
|
+
* same click a row above or below behaves normally.
|
|
103
87
|
*/
|
|
104
88
|
|
|
105
89
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
@@ -164,6 +148,32 @@ export function expandHintAction(line: string, keyText: string): "expand" | "col
|
|
|
164
148
|
return match[1] === "collapse" ? "collapse" : "expand";
|
|
165
149
|
}
|
|
166
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The component's current expansion, read off its own state field.
|
|
153
|
+
*
|
|
154
|
+
* Pi's expandable message components keep a runtime-public boolean —
|
|
155
|
+
* `expanded` on bash-execution, tool-execution and the skill/branch/compaction
|
|
156
|
+
* summaries, `_expanded` on custom entries and messages — and every mutation
|
|
157
|
+
* path writes it, ctrl+o's global fan-out included, so it is the component's
|
|
158
|
+
* own current truth rather than a cached copy. A component that exposes
|
|
159
|
+
* neither spelling gets `undefined`: the caller declines to guess.
|
|
160
|
+
*/
|
|
161
|
+
export function expandedStateOf(component: object): boolean | undefined {
|
|
162
|
+
const fields = component as { expanded?: unknown; _expanded?: unknown };
|
|
163
|
+
if (typeof fields.expanded === "boolean") return fields.expanded;
|
|
164
|
+
if (typeof fields._expanded === "boolean") return fields._expanded;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether the component declares its own mouse handling (`onMouse`). */
|
|
169
|
+
function handlesMouse(component: unknown): boolean {
|
|
170
|
+
return (
|
|
171
|
+
typeof component === "object" &&
|
|
172
|
+
component !== null &&
|
|
173
|
+
typeof (component as { onMouse?: unknown }).onMouse === "function"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
167
177
|
/**
|
|
168
178
|
* The keys spelled the way the hint on screen spells them.
|
|
169
179
|
*
|
|
@@ -242,14 +252,15 @@ function scrollViewAt(root: BoxLike, x: number, y: number): unknown {
|
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
/**
|
|
245
|
-
* The expandable component
|
|
246
|
-
* undefined when that cell is anything else.
|
|
255
|
+
* The expandable component under screen cell (`x`, `y`) and the expansion to
|
|
256
|
+
* set on it, or undefined when that cell is anything else.
|
|
247
257
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
258
|
+
* The whole component tree is built and thrown away on every call: it holds
|
|
259
|
+
* no invalidation and a running tool re-renders under it (a
|
|
250
260
|
* `BashExecutionComponent` carries a ticking `Loader`), so the answer is only
|
|
251
261
|
* true of the frame that is on screen at this instant. Callers act on it
|
|
252
|
-
* inside the same
|
|
262
|
+
* inside the same event or not at all — the click flow in `mouse/index.ts`
|
|
263
|
+
* resolves once at press (identity only) and once more at release (action).
|
|
253
264
|
*/
|
|
254
265
|
export function expandTargetAt(
|
|
255
266
|
lookup: ExpandLookup,
|
|
@@ -259,6 +270,9 @@ export function expandTargetAt(
|
|
|
259
270
|
// Renamed on the way out of `lookup` so it cannot be read as this module's
|
|
260
271
|
// imported `keyText` function.
|
|
261
272
|
const { root, keyText: keys } = lookup;
|
|
273
|
+
// The keybinding gates the whole feature, hint rule included: an unbound
|
|
274
|
+
// `app.tools.expand` is a user with the expand affordances off, and "" is
|
|
275
|
+
// read as "no hint rule at all" rather than as a wildcard.
|
|
262
276
|
if (!root || !keys) return undefined;
|
|
263
277
|
|
|
264
278
|
const scrollView = scrollViewAt(root, x, y);
|
|
@@ -276,15 +290,30 @@ export function expandTargetAt(
|
|
|
276
290
|
|
|
277
291
|
const tree = createComponentTree(origin.component, origin.rect.width, lines);
|
|
278
292
|
const path = tree.pathAt(row);
|
|
293
|
+
|
|
294
|
+
// A component anywhere on the path that handles the mouse itself owns this
|
|
295
|
+
// click. The layout folds the transcript into one box, so `pi-mouse-events`
|
|
296
|
+
// dispatch can never deliver an event to it — the only way its `onMouse`
|
|
297
|
+
// can mean what it says is for this module to bow out.
|
|
298
|
+
for (const span of path) {
|
|
299
|
+
if (handlesMouse(span.component)) return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
279
302
|
for (let index = path.length - 1; index >= 0; index--) {
|
|
280
303
|
const span = path[index];
|
|
281
304
|
if (!isExpandableComponent(span.component)) continue;
|
|
305
|
+
const component = span.component as ExpandableComponent;
|
|
282
306
|
// The hint is matched against this component's own rendered row, not
|
|
283
307
|
// against the transcript, so nothing outside the box it would toggle can
|
|
284
308
|
// stand in for its hint.
|
|
285
309
|
const action = expandHintAction(span.lines[row - span.start] ?? "", keys);
|
|
286
|
-
if (
|
|
287
|
-
|
|
310
|
+
if (action) return { component, expanded: action === "expand" };
|
|
311
|
+
// No hint on this row — or none rendered in this state, which is most
|
|
312
|
+
// components once expanded. The state field is the direction now, and a
|
|
313
|
+
// component that exposes neither source is not guessed at.
|
|
314
|
+
const state = expandedStateOf(component);
|
|
315
|
+
if (state === undefined) return undefined;
|
|
316
|
+
return { component, expanded: !state };
|
|
288
317
|
}
|
|
289
318
|
return undefined;
|
|
290
319
|
}
|
|
@@ -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
|
|
@@ -11,15 +11,17 @@ export const STARLINE_PROTOTYPE_PATCH_REGISTRY = Symbol.for("pi-starline.prototy
|
|
|
11
11
|
*/
|
|
12
12
|
const LEGACY_PROTOTYPE_PATCH_REGISTRY = Symbol.for("pi-zentui.prototype-patch-registry");
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The adapters Starline still patches itself. The four mouse adapters
|
|
16
|
+
* (`mouse-viewport-input`, `mouse-wheel`, `mouse-selection-event`,
|
|
17
|
+
* `mouse-copy`) retired with the mouse patches: Starline's mouse features
|
|
18
|
+
* register with the `pi-mouse-events` extension instead of wrapping
|
|
19
|
+
* `TuiAltScreen.prototype` — see `mouse/index.ts`.
|
|
20
|
+
*/
|
|
14
21
|
type PrototypePatchAdapter =
|
|
15
22
|
| "user-message-render"
|
|
16
23
|
| "user-message-invalidate"
|
|
17
|
-
| "selector-border-render"
|
|
18
|
-
| "mouse-viewport-input"
|
|
19
|
-
| "mouse-wheel"
|
|
20
|
-
| "mouse-selection-event"
|
|
21
|
-
| "mouse-copy"
|
|
22
|
-
| "mouse-word-selection";
|
|
24
|
+
| "selector-border-render";
|
|
23
25
|
|
|
24
26
|
type PrototypeMethodName = string;
|
|
25
27
|
|
|
@@ -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.3.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,15 @@
|
|
|
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
|
+
"@xzzpig/pi-mouse-events": ">=0.1.0 <2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@xzzpig/pi-mouse-events": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
29
35
|
},
|
|
30
36
|
"pi": {
|
|
31
37
|
"extensions": [
|
|
@@ -38,12 +44,13 @@
|
|
|
38
44
|
},
|
|
39
45
|
"devDependencies": {
|
|
40
46
|
"@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.
|
|
47
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
49
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
44
50
|
"@types/node": "^26.0.0",
|
|
45
51
|
"typescript": "^7.0.2",
|
|
46
|
-
"vitest": "^4.1.8"
|
|
52
|
+
"vitest": "^4.1.8",
|
|
53
|
+
"@xzzpig/pi-mouse-events": "0.1.0"
|
|
47
54
|
},
|
|
48
55
|
"scripts": {
|
|
49
56
|
"lint": "biome check .",
|
|
@@ -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
|
-
}
|