@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.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. 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
+ }