@xzzpig/pi-starline 0.1.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/LICENSE +22 -0
- package/README.md +223 -0
- package/extensions/starline/config.ts +1317 -0
- package/extensions/starline/editor-cursor.ts +55 -0
- package/extensions/starline/editor-factory-marker.ts +49 -0
- package/extensions/starline/editor-metadata-format.ts +259 -0
- package/extensions/starline/extension-status.ts +88 -0
- package/extensions/starline/footer-format.ts +279 -0
- package/extensions/starline/footer.ts +744 -0
- package/extensions/starline/format.ts +473 -0
- package/extensions/starline/git.ts +467 -0
- package/extensions/starline/icons.ts +294 -0
- package/extensions/starline/index.ts +651 -0
- package/extensions/starline/live-context.ts +75 -0
- package/extensions/starline/mouse/capabilities.ts +207 -0
- package/extensions/starline/mouse/component-tree.ts +230 -0
- package/extensions/starline/mouse/editor-caret.ts +498 -0
- package/extensions/starline/mouse/editor-hit-test.ts +133 -0
- package/extensions/starline/mouse/editor-mouse.ts +142 -0
- package/extensions/starline/mouse/editor-scroll.ts +119 -0
- package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
- package/extensions/starline/mouse/editor-text-edit.ts +90 -0
- package/extensions/starline/mouse/hint.ts +14 -0
- package/extensions/starline/mouse/hit-test.ts +144 -0
- package/extensions/starline/mouse/index.ts +1119 -0
- package/extensions/starline/mouse/selection-state.ts +68 -0
- package/extensions/starline/mouse/tool-box.ts +290 -0
- package/extensions/starline/mouse/transcript-copy.ts +166 -0
- package/extensions/starline/mouse/word-select.ts +145 -0
- package/extensions/starline/package-version.ts +650 -0
- package/extensions/starline/paste-collapse.ts +312 -0
- package/extensions/starline/pill-config.ts +122 -0
- package/extensions/starline/pill.ts +295 -0
- package/extensions/starline/project-refresh.ts +104 -0
- package/extensions/starline/project-state.ts +66 -0
- package/extensions/starline/prototype-patch-registry.ts +136 -0
- package/extensions/starline/runtime.ts +841 -0
- package/extensions/starline/selector-border.ts +77 -0
- package/extensions/starline/session-lifecycle.ts +60 -0
- package/extensions/starline/settings-command.ts +925 -0
- package/extensions/starline/state.ts +65 -0
- package/extensions/starline/style.ts +528 -0
- package/extensions/starline/ui.ts +611 -0
- package/extensions/starline/user-message.ts +267 -0
- package/package.json +60 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one piece of selection state Starline owns.
|
|
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.
|
|
10
|
+
*/
|
|
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
|
+
export function selectionHintText(
|
|
39
|
+
state: SelectionPendingState,
|
|
40
|
+
editorName: string | null = null,
|
|
41
|
+
): 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"}`
|
|
48
|
+
: base;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The user's external editor, for the "edit in …" hints.
|
|
53
|
+
*
|
|
54
|
+
* `$EDITOR` is a shell variable; the hint must say what it expands to, or a
|
|
55
|
+
* reader stares at a literal `$EDITOR`. `$VISUAL` outranks `$EDITOR` (the
|
|
56
|
+
* usual convention: VISUAL is the full-screen one), the first word of the
|
|
57
|
+
* value is the command, and its basename is what reads well in a hint —
|
|
58
|
+
* "nvim", not "/opt/homebrew/bin/nvim". Null when neither variable is set;
|
|
59
|
+
* callers keep the literal `$EDITOR` then, which is itself the hint that
|
|
60
|
+
* nothing is configured.
|
|
61
|
+
*/
|
|
62
|
+
export function externalEditorName(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
63
|
+
const value = env.VISUAL || env.EDITOR;
|
|
64
|
+
if (!value) return null;
|
|
65
|
+
const command = value.trim().split(/\s+/)[0];
|
|
66
|
+
if (!command) return null;
|
|
67
|
+
return basename(command);
|
|
68
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expanding one tool box by clicking it.
|
|
3
|
+
*
|
|
4
|
+
* `ctrl+o` toggles every box in the transcript at once, which is a screenful
|
|
5
|
+
* for the one line of output you wanted. This resolves the component under a
|
|
6
|
+
* mouse press and, when the press landed on that component's *hint row*,
|
|
7
|
+
* toggles just it.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the hint row is the only target
|
|
10
|
+
*
|
|
11
|
+
* The plan for this feature started as "click the box's border". Pi 0.84's own
|
|
12
|
+
* tool boxes have no border: there is not a single box-drawing character
|
|
13
|
+
* anywhere in `pi-coding-agent`'s tool rendering, and a collapsed box is a
|
|
14
|
+
* plain `Container` of preview lines with an inline hint. The `╭─╮ │ ╰─╯` in a
|
|
15
|
+
* real session comes from `pi-toolbox`, a separate extension that patches
|
|
16
|
+
* `ToolExecutionComponent.prototype.render`. So a border is conditional on
|
|
17
|
+
* what else is installed and cannot be the target — and inferring one from the
|
|
18
|
+
* rendered rows is exactly what frame-free selection was cut for.
|
|
19
|
+
*
|
|
20
|
+
* The hint row can be. Pi renders it through `keyHint("app.tools.expand",
|
|
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:
|
|
24
|
+
*
|
|
25
|
+
* - `bash-execution.js:140,143` — `(ctrl+o to collapse)` when expanded,
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* The description carries the *direction*: `to expand` when collapsed, `to
|
|
34
|
+
* collapse` when expanded. That is what this module toggles on, rather than
|
|
35
|
+
* tracking expansion itself — a `WeakMap` of "what I last set" would desync
|
|
36
|
+
* the first time `ctrl+o` toggled everything behind its back, whereas the row
|
|
37
|
+
* on screen is by definition current.
|
|
38
|
+
*
|
|
39
|
+
* ## Why resolution goes through the component tree
|
|
40
|
+
*
|
|
41
|
+
* `layoutComponent` builds a child box only for a component carrying a
|
|
42
|
+
* `LAYOUT_NODE`, and only `Stack` and `ScrollView` have one. Every message
|
|
43
|
+
* component extends `Container`, which has none, so the whole transcript is a
|
|
44
|
+
* single leaf box and `boxesAt` can never return a tool box. The layout tree
|
|
45
|
+
* is used for exactly what it does know — which scroll view is under the
|
|
46
|
+
* pointer, and where content row 0 sits on screen — and ownership below that
|
|
47
|
+
* comes from `component-tree.ts`. `test/contract/transcript-layout.test.ts`
|
|
48
|
+
* pins both halves against pi-tui's real engine.
|
|
49
|
+
*
|
|
50
|
+
* ## What this deliberately does not do
|
|
51
|
+
*
|
|
52
|
+
* Nothing here looks at how a component *renders* to decide what it is:
|
|
53
|
+
* `isExpandableComponent` asks whether `setExpanded` is callable, and the hint
|
|
54
|
+
* rule is scoped to that component's own rows. An ordinary message that quotes
|
|
55
|
+
* the hint — an assistant explaining `ctrl+o`, a paste of these very docs — is
|
|
56
|
+
* a `Text` or a `Markdown` with no `setExpanded` anywhere in its path, so it
|
|
57
|
+
* resolves to nothing and the press starts a selection as usual.
|
|
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.
|
|
64
|
+
*
|
|
65
|
+
* **1. Clicking cannot always collapse what it expanded.** The premise that
|
|
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:
|
|
70
|
+
*
|
|
71
|
+
* - `core/tools/{read,grep,ls,write,find}.js` append the hint inside `if
|
|
72
|
+
* (remaining > 0)`, and expanding sets `maxLines` to `lines.length`, so
|
|
73
|
+
* `remaining` becomes 0 and the row is not emitted at all (`read.js:113-118`
|
|
74
|
+
* is the clearest instance).
|
|
75
|
+
* - `skill-invocation-message.js:42`, `branch-summary-message.js:39` and
|
|
76
|
+
* `compaction-summary-message.js:40` build the hint in their `else` branch,
|
|
77
|
+
* i.e. only when `this.expanded` is false.
|
|
78
|
+
*
|
|
79
|
+
* For the first group there is a way back: current `pi-toolbox` appends a
|
|
80
|
+
* `(ctrl+o to collapse)` anchor row as the last line inside the frame of an
|
|
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.
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
106
|
+
import { getKeybindings, stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
107
|
+
import { createComponentTree, isComponentLike, isExpandableComponent } from "./component-tree";
|
|
108
|
+
import { type BoxLike, boxesAt, scrollContentLinesFor, scrollContentOrigin } from "./hit-test";
|
|
109
|
+
|
|
110
|
+
/** The slice of a Pi message component this module calls. */
|
|
111
|
+
export type ExpandableComponent = { setExpanded(expanded: boolean): void };
|
|
112
|
+
|
|
113
|
+
export type ExpandTarget = {
|
|
114
|
+
component: ExpandableComponent;
|
|
115
|
+
/** What `setExpanded` should be called with — the opposite of today. */
|
|
116
|
+
expanded: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type ExpandLookup = {
|
|
120
|
+
/** `currentLayout.root`, or undefined before the first frame. */
|
|
121
|
+
root: BoxLike | undefined;
|
|
122
|
+
/** `keyText("app.tools.expand")` for this session; "" when unbound. */
|
|
123
|
+
keyText: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const KEYBINDING = "app.tools.expand";
|
|
127
|
+
|
|
128
|
+
function escapeRegExp(value: string): string {
|
|
129
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One press asks one question, but the pattern is the same on every press of a
|
|
133
|
+
// session, so it is built once per distinct key text rather than per call.
|
|
134
|
+
let cachedKeyText: string | undefined;
|
|
135
|
+
let cachedPattern: RegExp | undefined;
|
|
136
|
+
|
|
137
|
+
function hintPattern(keyText: string): RegExp {
|
|
138
|
+
if (cachedPattern && cachedKeyText === keyText) return cachedPattern;
|
|
139
|
+
// `\([^()]*` is the parenthesis Pi always opens before the hint, with
|
|
140
|
+
// whatever prefix the call site put inside it ("... 12 more lines, ").
|
|
141
|
+
// Case-insensitive: pi-mcp-adapter renders its collapsed-result hint as
|
|
142
|
+
// `(Ctrl+O to expand)` — capitalized, unlike the `ctrl+o` Pi's own
|
|
143
|
+
// `keyText` yields — and a case-sensitive match declined the press, so MCP
|
|
144
|
+
// tool boxes were the one box a click could not open. Widening the match
|
|
145
|
+
// only reaches rows an expandable component renders about its own key, so
|
|
146
|
+
// the cost is the same accepted false positive as limitation 2 above.
|
|
147
|
+
cachedPattern = new RegExp(`\\([^()]*${escapeRegExp(keyText)} to (expand|collapse)\\)`, "i");
|
|
148
|
+
cachedKeyText = keyText;
|
|
149
|
+
return cachedPattern;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether `line` is Pi's expand hint, and which way clicking it goes.
|
|
154
|
+
*
|
|
155
|
+
* The line arrives with theme colours in it, so it is stripped the same way
|
|
156
|
+
* Pi's own `getWordSelection` strips a selection source line. An empty
|
|
157
|
+
* `keyText` declines everything: with no key bound the pattern would collapse
|
|
158
|
+
* to `(… to expand)` and start matching ordinary prose.
|
|
159
|
+
*/
|
|
160
|
+
export function expandHintAction(line: string, keyText: string): "expand" | "collapse" | undefined {
|
|
161
|
+
if (!keyText) return undefined;
|
|
162
|
+
const match = hintPattern(keyText).exec(stripTerminalSequences(line));
|
|
163
|
+
if (!match) return undefined;
|
|
164
|
+
return match[1] === "collapse" ? "collapse" : "expand";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The keys spelled the way the hint on screen spells them.
|
|
169
|
+
*
|
|
170
|
+
* The primary source is `pi-coding-agent`'s own exported `keyText` — literally
|
|
171
|
+
* the function `keyHint` calls to build the text this module then matches, so
|
|
172
|
+
* there is no second formatting rule to drift (it is `keyText` that turns
|
|
173
|
+
* `alt` into `option` on macOS and joins alternatives with "/"), and no second
|
|
174
|
+
* keybinding registry to disagree.
|
|
175
|
+
*
|
|
176
|
+
* That last point is not theoretical. `getKeybindings()` is a *singleton* per
|
|
177
|
+
* copy of pi-tui, and a copy is per `node_modules` tree: in this repo
|
|
178
|
+
* `pi-coding-agent` nests its own pi-tui, so the registry Starline's direct
|
|
179
|
+
* import reaches is not the registry the hints are rendered from. Production
|
|
180
|
+
* resolves both to Pi's own copy, but reading the value through the same
|
|
181
|
+
* package that renders it makes the question moot instead of assumed.
|
|
182
|
+
*
|
|
183
|
+
* pi-tui's registry is kept as a fallback for the case where `keyText` yields
|
|
184
|
+
* nothing — which also means nothing is on screen to match, so the fallback
|
|
185
|
+
* can only ever turn a non-match into a non-match. Both routes return "" when
|
|
186
|
+
* `app.tools.expand` is unbound, and `expandHintAction` reads "" as "no hint
|
|
187
|
+
* rule at all" rather than as a wildcard.
|
|
188
|
+
*/
|
|
189
|
+
export function expandKeyText(): string {
|
|
190
|
+
return keyTextFor(KEYBINDING);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `keyText(keybinding)` with the registry fallback, shared by every feature
|
|
195
|
+
* that quotes a key on screen. See `expandKeyText` for why the primary route
|
|
196
|
+
* is pi-coding-agent's own function rather than this repo's registry.
|
|
197
|
+
*/
|
|
198
|
+
export function keyTextFor(keybinding: string): string {
|
|
199
|
+
try {
|
|
200
|
+
const rendered = keyText(keybinding as never);
|
|
201
|
+
if (typeof rendered === "string" && rendered.length > 0) return rendered;
|
|
202
|
+
} catch {
|
|
203
|
+
// A Pi build that has moved this function is one where quoting the key
|
|
204
|
+
// simply shows nothing; it is never a reason to break a mouse feature.
|
|
205
|
+
}
|
|
206
|
+
return fallbackKeyText(keybinding);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `keyText` re-derived from pi-tui's registry: keys joined with "/", `alt`
|
|
211
|
+
* shown as `option` on macOS, matching `formatKeyText` in
|
|
212
|
+
* `keybinding-hints.js`.
|
|
213
|
+
*/
|
|
214
|
+
function fallbackKeyText(keybinding: string): string {
|
|
215
|
+
try {
|
|
216
|
+
const bound: unknown = getKeybindings().getKeys(keybinding as never);
|
|
217
|
+
if (!Array.isArray(bound) || bound.length === 0) return "";
|
|
218
|
+
const keys = bound.filter((key): key is string => typeof key === "string");
|
|
219
|
+
return keys
|
|
220
|
+
.map((key: string) =>
|
|
221
|
+
key
|
|
222
|
+
.split("+")
|
|
223
|
+
.map((part) =>
|
|
224
|
+
process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part,
|
|
225
|
+
)
|
|
226
|
+
.join("+"),
|
|
227
|
+
)
|
|
228
|
+
.join("/");
|
|
229
|
+
} catch {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The innermost scroll view whose box contains the screen cell. */
|
|
235
|
+
function scrollViewAt(root: BoxLike, x: number, y: number): unknown {
|
|
236
|
+
let found: unknown;
|
|
237
|
+
for (const box of boxesAt(root, x, y)) {
|
|
238
|
+
const scrollView = (box as { scrollView?: unknown }).scrollView;
|
|
239
|
+
if (scrollView !== undefined) found = scrollView;
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The expandable component whose hint row is at screen cell (`x`, `y`), or
|
|
246
|
+
* undefined when that cell is anything else.
|
|
247
|
+
*
|
|
248
|
+
* Resolution is done end to end here and thrown away: the component tree it
|
|
249
|
+
* builds holds no invalidation and a running tool re-renders under it (a
|
|
250
|
+
* `BashExecutionComponent` carries a ticking `Loader`), so the answer is only
|
|
251
|
+
* true of the frame that is on screen at this instant. Callers act on it
|
|
252
|
+
* inside the same press or not at all.
|
|
253
|
+
*/
|
|
254
|
+
export function expandTargetAt(
|
|
255
|
+
lookup: ExpandLookup,
|
|
256
|
+
x: number,
|
|
257
|
+
y: number,
|
|
258
|
+
): ExpandTarget | undefined {
|
|
259
|
+
// Renamed on the way out of `lookup` so it cannot be read as this module's
|
|
260
|
+
// imported `keyText` function.
|
|
261
|
+
const { root, keyText: keys } = lookup;
|
|
262
|
+
if (!root || !keys) return undefined;
|
|
263
|
+
|
|
264
|
+
const scrollView = scrollViewAt(root, x, y);
|
|
265
|
+
if (scrollView === undefined) return undefined;
|
|
266
|
+
const lines = scrollContentLinesFor(root, scrollView);
|
|
267
|
+
const origin = scrollContentOrigin(root, scrollView);
|
|
268
|
+
if (!lines || !origin) return undefined;
|
|
269
|
+
if (!isComponentLike(origin.component)) return undefined;
|
|
270
|
+
|
|
271
|
+
// The content box is laid out at `viewportY - scrollTop`, so a screen row
|
|
272
|
+
// becomes a content row by subtracting its origin — the relation pinned in
|
|
273
|
+
// `test/contract/transcript-layout.test.ts`.
|
|
274
|
+
const row = y - origin.rect.y;
|
|
275
|
+
if (row < 0 || row >= lines.length) return undefined;
|
|
276
|
+
|
|
277
|
+
const tree = createComponentTree(origin.component, origin.rect.width, lines);
|
|
278
|
+
const path = tree.pathAt(row);
|
|
279
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
280
|
+
const span = path[index];
|
|
281
|
+
if (!isExpandableComponent(span.component)) continue;
|
|
282
|
+
// The hint is matched against this component's own rendered row, not
|
|
283
|
+
// against the transcript, so nothing outside the box it would toggle can
|
|
284
|
+
// stand in for its hint.
|
|
285
|
+
const action = expandHintAction(span.lines[row - span.start] ?? "", keys);
|
|
286
|
+
if (!action) return undefined;
|
|
287
|
+
return { component: span.component as ExpandableComponent, expanded: action === "expand" };
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips decoration out of a transcript selection before it reaches the
|
|
3
|
+
* clipboard.
|
|
4
|
+
*
|
|
5
|
+
* Pi's `copySelectionToClipboard` copies *rendered* rows. In the transcript
|
|
6
|
+
* those rows carry chrome that two components paint around their content:
|
|
7
|
+
*
|
|
8
|
+
* - Starline's own user messages (`user-message.ts`): a full-width `─` rule
|
|
9
|
+
* above and below, and every content row prefixed with the configured rail
|
|
10
|
+
* glyph plus a space (`│ hello`).
|
|
11
|
+
* - `pi-toolbox`'s tool box frames (`frame.ts`): rounded corners
|
|
12
|
+
* (`╭──╮` / `╰──╯`) and every content row wrapped in verticals
|
|
13
|
+
* (`│ hello │`).
|
|
14
|
+
* - pi-coding-agent's `DynamicBorder` (bash boxes) paints the same full-width
|
|
15
|
+
* `─` rule — but bash content rows start with a space, not the rail
|
|
16
|
+
* (`bash-execution.js` renders them with paddingX=1), so under the default
|
|
17
|
+
* rail the pair below never forms and a bash box copies verbatim. Only in
|
|
18
|
+
* `copyFriendly` mode (empty rail prefix), where the rule pair alone
|
|
19
|
+
* decides, do its borders come off. Its content is never touched either
|
|
20
|
+
* way.
|
|
21
|
+
*
|
|
22
|
+
* The clipboard should hold the content, not the chrome. The frame-free
|
|
23
|
+
* selection task that was cut from 0.3.0 tried to do this by *inferring* where
|
|
24
|
+
* a frame was from rendered text alone, and every discriminator it tried
|
|
25
|
+
* produced a new edge case — a frame is not a structural concept in Pi. This
|
|
26
|
+
* module therefore recognises only the exact markers above: a rounded-corner
|
|
27
|
+
* pair of equal width, and a full-width rule pair whose every inner row
|
|
28
|
+
* carries the configured rail prefix. Anything else — a markdown table with
|
|
29
|
+
* its square `┌┬┐` corners, a blockquote's `│ ` gutter, a horizontal rule the
|
|
30
|
+
* assistant actually wrote — is content and passes through byte-identical.
|
|
31
|
+
*
|
|
32
|
+
* Everything here is a pure function over strings; the Pi-facing wiring
|
|
33
|
+
* (selection bounds, column mapping, the OSC 52 write) lives in `index.ts`.
|
|
34
|
+
*/
|
|
35
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One entry per selected row. `null` drops the row entirely — it was pure
|
|
39
|
+
* decoration (a frame edge or border rule). Otherwise `text` is the row's
|
|
40
|
+
* ANSI-free content and `leftTrim` the number of *visible columns* removed
|
|
41
|
+
* from its left edge, which the caller subtracts from the selection columns
|
|
42
|
+
* so a drag that started mid-row still lands on the same character.
|
|
43
|
+
*/
|
|
44
|
+
export type CleanedRow = { text: string; leftTrim: number } | null;
|
|
45
|
+
|
|
46
|
+
export type CleanedRows = {
|
|
47
|
+
rows: CleanedRow[];
|
|
48
|
+
/**
|
|
49
|
+
* False when no selected row carried any recognised decoration. The caller
|
|
50
|
+
* falls back to Pi's verbatim copy in that case, so a selection with
|
|
51
|
+
* nothing to clean takes exactly the path it always did.
|
|
52
|
+
*/
|
|
53
|
+
changed: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A full-width rule: Starline's user message border, `DynamicBorder`. */
|
|
57
|
+
const RULE_ROW_RE = /^─+$/;
|
|
58
|
+
/** pi-toolbox's rounded frame edges. Square corners are content (tables). */
|
|
59
|
+
const FRAME_OPEN_RE = /^╭─*╮$/;
|
|
60
|
+
const FRAME_CLOSE_RE = /^╰─*╯$/;
|
|
61
|
+
|
|
62
|
+
function strippedRow(lines: readonly string[], cache: string[], row: number): string {
|
|
63
|
+
let text = cache[row];
|
|
64
|
+
if (text === undefined) {
|
|
65
|
+
text = stripTerminalSequences(lines[row] ?? "");
|
|
66
|
+
cache[row] = text;
|
|
67
|
+
}
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classifies every row of `lines`, then cleans the rows in
|
|
73
|
+
* `[startRow, endRow]`. The scan covers the whole buffer rather than the
|
|
74
|
+
* selection because a drag that starts inside a box never contains the box's
|
|
75
|
+
* own edges; whether a row is framed is a property of rows the selection may
|
|
76
|
+
* not touch.
|
|
77
|
+
*/
|
|
78
|
+
export function cleanTranscriptRows(
|
|
79
|
+
lines: readonly string[],
|
|
80
|
+
startRow: number,
|
|
81
|
+
endRow: number,
|
|
82
|
+
railGlyph: string,
|
|
83
|
+
): CleanedRows {
|
|
84
|
+
const cache: string[] = new Array(lines.length);
|
|
85
|
+
/** Rows to drop: paired frame edges and paired border rules. */
|
|
86
|
+
const dropped = new Set<number>();
|
|
87
|
+
/** pi-toolbox frame content rows: strip one `│` from each side. */
|
|
88
|
+
const frameContent = new Set<number>();
|
|
89
|
+
/** Starline user message rows: strip the rail prefix. */
|
|
90
|
+
const railContent = new Set<number>();
|
|
91
|
+
|
|
92
|
+
// Pass 1 — rounded frames. Edges pair strictly by equal width, inner rows
|
|
93
|
+
// are content. A rounded box drawn inside bash output can mis-pair, but the
|
|
94
|
+
// rows it would swallow are inside a real frame and get cleaned anyway.
|
|
95
|
+
const openEdges: Array<{ row: number; width: number }> = [];
|
|
96
|
+
const framePairs: Array<[number, number]> = [];
|
|
97
|
+
for (let row = 0; row < lines.length; row++) {
|
|
98
|
+
const text = strippedRow(lines, cache, row);
|
|
99
|
+
if (FRAME_OPEN_RE.test(text)) {
|
|
100
|
+
openEdges.push({ row, width: visibleWidth(text) });
|
|
101
|
+
} else if (FRAME_CLOSE_RE.test(text)) {
|
|
102
|
+
const open = openEdges[openEdges.length - 1];
|
|
103
|
+
if (open && open.width === visibleWidth(text)) {
|
|
104
|
+
openEdges.pop();
|
|
105
|
+
framePairs.push([open.row, row]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const [open, close] of framePairs) {
|
|
110
|
+
dropped.add(open);
|
|
111
|
+
dropped.add(close);
|
|
112
|
+
for (let row = open + 1; row < close; row++) frameContent.add(row);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Pass 2 — full-width rule pairs. Two rules of equal width with at least
|
|
116
|
+
// one row between them are a Starline user message's borders, and the rows
|
|
117
|
+
// between carry the rail. Any inner row *without* the rail prefix breaks
|
|
118
|
+
// the candidate: it is what keeps an assistant's own `---` rules, and the
|
|
119
|
+
// text between two of them, from being eaten — those rows are content.
|
|
120
|
+
//
|
|
121
|
+
// With an empty rail prefix (the `copyFriendly` render drops the rail)
|
|
122
|
+
// nothing can break a candidate, so any two equal-width rules pair. That
|
|
123
|
+
// also matches a markdown `---` pair, which then loses its rules from the
|
|
124
|
+
// copy — accepted: a horizontal rule is closer to chrome than to content,
|
|
125
|
+
// and the alternative is leaving real borders in every copyFriendly copy.
|
|
126
|
+
const railPrefix = railGlyph.length > 0 ? `${railGlyph} ` : "";
|
|
127
|
+
let lastRule: { row: number; width: number } | undefined;
|
|
128
|
+
for (let row = 0; row < lines.length; row++) {
|
|
129
|
+
if (dropped.has(row)) continue;
|
|
130
|
+
const text = strippedRow(lines, cache, row);
|
|
131
|
+
if (RULE_ROW_RE.test(text)) {
|
|
132
|
+
const width = visibleWidth(text);
|
|
133
|
+
if (lastRule && lastRule.width === width && row - lastRule.row >= 2) {
|
|
134
|
+
dropped.add(lastRule.row);
|
|
135
|
+
dropped.add(row);
|
|
136
|
+
for (let inner = lastRule.row + 1; inner < row; inner++) railContent.add(inner);
|
|
137
|
+
}
|
|
138
|
+
lastRule = { row, width };
|
|
139
|
+
} else if (railPrefix.length > 0 && !text.startsWith(railPrefix)) {
|
|
140
|
+
lastRule = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rows: CleanedRow[] = [];
|
|
145
|
+
let changed = false;
|
|
146
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
147
|
+
if (dropped.has(row)) {
|
|
148
|
+
rows.push(null);
|
|
149
|
+
changed = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const text = strippedRow(lines, cache, row);
|
|
153
|
+
if (frameContent.has(row) && text.startsWith("│") && text.endsWith("│") && text.length >= 2) {
|
|
154
|
+
rows.push({ text: text.slice(1, -1), leftTrim: 1 });
|
|
155
|
+
changed = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (railContent.has(row) && railPrefix.length > 0 && text.startsWith(railPrefix)) {
|
|
159
|
+
rows.push({ text: text.slice(railPrefix.length), leftTrim: visibleWidth(railPrefix) });
|
|
160
|
+
changed = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
rows.push({ text, leftTrim: 0 });
|
|
164
|
+
}
|
|
165
|
+
return { rows, changed };
|
|
166
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|