@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,75 @@
1
+ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
2
+ import type { SessionLifecycle } from "./session-lifecycle";
3
+
4
+ export type LiveContextOverride = {
5
+ tokens: number;
6
+ };
7
+
8
+ function usageComponent(value: unknown): number {
9
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
10
+ }
11
+
12
+ export function calculateLiveContextTokens(usage: Usage | undefined): number | undefined {
13
+ if (!usage) return undefined;
14
+ const totalTokens = usageComponent(usage.totalTokens);
15
+ if (totalTokens > 0) return totalTokens;
16
+ const calculated =
17
+ usageComponent(usage.input) +
18
+ usageComponent(usage.output) +
19
+ usageComponent(usage.cacheRead) +
20
+ usageComponent(usage.cacheWrite);
21
+ return calculated > 0 ? calculated : undefined;
22
+ }
23
+
24
+ export function liveContextFromMessage(message: unknown): LiveContextOverride | undefined {
25
+ if (!message || typeof message !== "object") return undefined;
26
+ const assistant = message as Partial<AssistantMessage>;
27
+ if (assistant.role !== "assistant") return undefined;
28
+ if (assistant.stopReason === "error" || assistant.stopReason === "aborted") return undefined;
29
+ const tokens = calculateLiveContextTokens(assistant.usage);
30
+ return tokens === undefined ? undefined : { tokens };
31
+ }
32
+
33
+ export class LiveContextController {
34
+ private readonly lifecycle: SessionLifecycle;
35
+ private readonly requestRender: () => void;
36
+ private override: LiveContextOverride | undefined;
37
+ private cancelScheduledRender: (() => void) | undefined;
38
+ private scheduledGeneration: number | undefined;
39
+
40
+ constructor(lifecycle: SessionLifecycle, requestRender: () => void) {
41
+ this.lifecycle = lifecycle;
42
+ this.requestRender = requestRender;
43
+ }
44
+
45
+ get(): LiveContextOverride | undefined {
46
+ return this.override;
47
+ }
48
+
49
+ update(message: unknown): boolean {
50
+ const next = liveContextFromMessage(message);
51
+ if (!next || !this.lifecycle.isCurrent()) return false;
52
+ this.override = next;
53
+ const generation = this.lifecycle.currentGeneration();
54
+ if (this.cancelScheduledRender && this.scheduledGeneration !== generation) {
55
+ this.cancelScheduledRender = undefined;
56
+ this.scheduledGeneration = undefined;
57
+ }
58
+ if (!this.cancelScheduledRender) {
59
+ this.scheduledGeneration = generation;
60
+ this.cancelScheduledRender = this.lifecycle.defer(() => {
61
+ this.cancelScheduledRender = undefined;
62
+ this.scheduledGeneration = undefined;
63
+ if (this.override) this.requestRender();
64
+ }, 250);
65
+ }
66
+ return true;
67
+ }
68
+
69
+ clear(): void {
70
+ this.override = undefined;
71
+ this.cancelScheduledRender?.();
72
+ this.cancelScheduledRender = undefined;
73
+ this.scheduledGeneration = undefined;
74
+ }
75
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * What of Pi's renderer this build can actually reach.
3
+ *
4
+ * Pi's TUI internals carry no stability contract, so every method Starline
5
+ * patches is probed before it is trusted. The probe is deliberately structural —
6
+ * the method exists, is a function, and can be replaced and put back. Signature
7
+ * drift is caught by `test/contract/pi-tui-contract.test.ts` in CI rather than
8
+ * guessed at here: default parameters make `fn.length` a liar.
9
+ *
10
+ * `requestRender` is the one entry a feature only ever *calls* on the receiver,
11
+ * never patches. It is probed all the same, and by the same structural test: a
12
+ * feature that installs and then cannot ask for a repaint leaves its own state
13
+ * on screen a frame late or not at all, which is precisely the half-working
14
+ * install this table exists to prevent. The check being stricter than a plain
15
+ * call needs (it also demands writable and configurable) costs nothing, because
16
+ * a class method declared the ordinary way is both.
17
+ */
18
+
19
+ export type MouseCapability =
20
+ | "handleViewportInput"
21
+ | "routeWheel"
22
+ | "handleSelectionMouseEvent"
23
+ | "copySelectionToClipboard"
24
+ | "getWordSelection"
25
+ | "getSelectionSourceLine"
26
+ | "getSelectionBounds"
27
+ | "getSelectionColumns"
28
+ | "flash"
29
+ | "hasOverlay"
30
+ | "requestRender";
31
+
32
+ export type MouseFeature =
33
+ | "selectionPendingMode"
34
+ | "pathAwareWords"
35
+ | "clickToExpandTools"
36
+ | "editorWheelScroll"
37
+ | "editorClickToCaret"
38
+ | "editorBufferCopy"
39
+ | "transcriptCleanCopy";
40
+
41
+ const CAPABILITIES: readonly MouseCapability[] = [
42
+ "handleViewportInput",
43
+ "routeWheel",
44
+ "handleSelectionMouseEvent",
45
+ "copySelectionToClipboard",
46
+ "getWordSelection",
47
+ "getSelectionSourceLine",
48
+ "getSelectionBounds",
49
+ "getSelectionColumns",
50
+ "flash",
51
+ "hasOverlay",
52
+ "requestRender",
53
+ ];
54
+
55
+ /**
56
+ * Every capability a feature needs before it may install.
57
+ *
58
+ * There is no `frameFreeSelection` entry, deliberately: the feature is cut
59
+ * (see `installMouse` in `index.ts`). A feature listed here is one
60
+ * `installMouse` installs, and a table that claims a feature nothing installs
61
+ * is worse than no table.
62
+ */
63
+ const REQUIREMENTS: Record<MouseFeature, readonly MouseCapability[]> = {
64
+ // Without ctrl+c interception the pending mode strands a highlight the user
65
+ // cannot copy, which is worse than copy-on-release. It also reads the
66
+ // selection directly (`getSelectionBounds`, `getSelectionColumns`) to build
67
+ // an exact character count, and raises its own notice (`flash`), so all
68
+ // three must be reachable too. `requestRender` is what puts the pending hint
69
+ // on screen the moment the button comes up: arming without it would leave
70
+ // the user staring at a highlight with nothing telling them ctrl+c copies it.
71
+ selectionPendingMode: [
72
+ "copySelectionToClipboard",
73
+ "handleViewportInput",
74
+ "getSelectionBounds",
75
+ "getSelectionColumns",
76
+ "flash",
77
+ "requestRender",
78
+ ],
79
+ // Also reads the line under the pointer through the receiver's own
80
+ // `getSelectionSourceLine`, so the patch has a real line to hand
81
+ // `wordRangeAt` — without it there's nothing to compute a range over.
82
+ pathAwareWords: ["getWordSelection", "getSelectionSourceLine"],
83
+ // The press it acts on arrives through `handleSelectionMouseEvent`, and it
84
+ // asks `hasOverlay` the same question Pi's own press path asks before
85
+ // resolving a scroll view (`tui-alt-screen.js:684`) — without it, a click on
86
+ // a dialog would toggle whatever tool box happens to sit behind it. It
87
+ // consumes the press rather than calling through, so Pi never reaches its own
88
+ // repaint for that event: `requestRender` is the only thing that draws the
89
+ // box it just toggled.
90
+ clickToExpandTools: ["handleSelectionMouseEvent", "hasOverlay", "requestRender"],
91
+ // The notch arrives through `routeWheel`, which it consumes on an editor hit
92
+ // rather than calling through — so Pi never reaches its own repaint for that
93
+ // event and `requestRender` is the only thing that draws the scrolled box. It
94
+ // asks `hasOverlay` for the same reason click-to-expand does: an overlay is
95
+ // composited on top of a layout that still contains the editor
96
+ // (`tui.js:123`), so without it a notch aimed at a dialog would scroll the
97
+ // draft hidden behind it.
98
+ editorWheelScroll: ["routeWheel", "hasOverlay", "requestRender"],
99
+ // Two behaviours, one feature. The press arrives through
100
+ // `handleSelectionMouseEvent`; backspace and delete over a live selection
101
+ // arrive through `handleViewportInput`, which is why that is listed even
102
+ // though the caret alone would not need it. Range delete then reads the
103
+ // selection through `getSelectionBounds` to find out whether it is the
104
+ // editor's, and — because it *consumes* the key, so Pi's `handleInput` never
105
+ // reaches its own `requestImmediateRender` (`tui.js:620`) — `requestRender`
106
+ // is the only thing that draws the shortened draft.
107
+ //
108
+ // `hasOverlay` for the same reason the features above ask it: an overlay is
109
+ // composited over a layout that still contains the editor, so without it a
110
+ // click on a dialog would move the caret in the box behind it, and a
111
+ // backspace would delete from it.
112
+ //
113
+ // The consequence of listing `handleViewportInput` is deliberate and worth
114
+ // naming: a Pi that has moved that method loses click-to-caret entirely, not
115
+ // just the range delete. That is this table's rule — never install half a
116
+ // feature — and the alternative, a caret that installs while its delete
117
+ // silently does not, is exactly the half-working install the rule exists to
118
+ // prevent.
119
+ editorClickToCaret: [
120
+ "handleSelectionMouseEvent",
121
+ "handleViewportInput",
122
+ "getSelectionBounds",
123
+ "hasOverlay",
124
+ "requestRender",
125
+ ],
126
+ // It wraps `copySelectionToClipboard`, reads the selection through
127
+ // `getSelectionBounds` to find out whether it is the editor's, and raises
128
+ // Pi's own "Copied!" through `flash` when it answers the copy itself.
129
+ // `hasOverlay` again: a selection dropped on a dialog must not be read as
130
+ // text from the draft behind it.
131
+ //
132
+ // The clipboard write goes through `terminal.write`, which is not listed
133
+ // because it is not on this prototype — `terminal` is a plain instance field
134
+ // holding somebody else's object, the same class of thing as `currentLayout`.
135
+ // It is checked at the call site instead, and a terminal that cannot be
136
+ // written to makes the copy fall through to Pi rather than disabling the
137
+ // feature at install time.
138
+ editorBufferCopy: ["copySelectionToClipboard", "getSelectionBounds", "hasOverlay", "flash"],
139
+ // Shares the `copySelectionToClipboard` patch with the two features above
140
+ // it. It reads the selection through `getSelectionBounds` to find the rows,
141
+ // through `getSelectionColumns` to slice the cleaned text along the same
142
+ // grapheme-aligned columns Pi would have used, asks `hasOverlay` before
143
+ // trusting those rows (an overlay is composited over a layout that still
144
+ // contains the transcript), and raises Pi's own "Copied!" through `flash`
145
+ // when it answers the copy itself. The clipboard write goes through
146
+ // `terminal.write`, unlisted for the same reason as editorBufferCopy's — it
147
+ // is an instance field, checked at the call site instead.
148
+ transcriptCleanCopy: [
149
+ "copySelectionToClipboard",
150
+ "getSelectionBounds",
151
+ "getSelectionColumns",
152
+ "hasOverlay",
153
+ "flash",
154
+ ],
155
+ };
156
+
157
+ function isPatchable(prototype: object, name: string): boolean {
158
+ try {
159
+ let current: object | null = prototype;
160
+ while (current) {
161
+ const descriptor = Object.getOwnPropertyDescriptor(current, name);
162
+ if (descriptor) {
163
+ if (typeof descriptor.value !== "function") return false;
164
+ return descriptor.writable === true && descriptor.configurable === true;
165
+ }
166
+ current = Object.getPrototypeOf(current);
167
+ }
168
+ return false;
169
+ } catch {
170
+ // A prototype that throws on inspection is one we do not touch.
171
+ return false;
172
+ }
173
+ }
174
+
175
+ export function probeCapabilities(prototype: object): ReadonlySet<MouseCapability> {
176
+ const found = new Set<MouseCapability>();
177
+ for (const capability of CAPABILITIES) {
178
+ if (isPatchable(prototype, capability)) found.add(capability);
179
+ }
180
+ return found;
181
+ }
182
+
183
+ export function enabledFeatures(
184
+ available: ReadonlySet<MouseCapability>,
185
+ ): ReadonlySet<MouseFeature> {
186
+ const enabled = new Set<MouseFeature>();
187
+ for (const [feature, needed] of Object.entries(REQUIREMENTS) as [
188
+ MouseFeature,
189
+ readonly MouseCapability[],
190
+ ][]) {
191
+ if (needed.every((capability) => available.has(capability))) enabled.add(feature);
192
+ }
193
+ return enabled;
194
+ }
195
+
196
+ /**
197
+ * One line naming everything that will not run, or null when all is well. Pi
198
+ * prints this once per process — a line per feature would be noise on a build
199
+ * where Pi has moved on.
200
+ */
201
+ export function disabledFeatureWarning(enabled: ReadonlySet<MouseFeature>): string | null {
202
+ const disabled = (Object.keys(REQUIREMENTS) as MouseFeature[]).filter(
203
+ (feature) => !enabled.has(feature),
204
+ );
205
+ if (disabled.length === 0) return null;
206
+ return `[starline] This Pi build does not expose what these mouse features need, so they are off: ${disabled.join(", ")}. Everything else still works.`;
207
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Which of Pi's components rendered a given row of a container's output.
3
+ *
4
+ * `hit-test.ts` answers the same question from Pi's *layout* tree, and for
5
+ * the editor and the transcript viewport that is the right tree. It cannot
6
+ * answer it for a message inside the transcript, because there is no box to
7
+ * find: `layoutComponent` (pi-tui's `layout.js`) only builds child boxes for
8
+ * a component carrying a `LAYOUT_NODE`, which is `Stack` and `ScrollView` and
9
+ * nothing else. Every message component extends `Container`, which has none,
10
+ * so the whole transcript is one leaf box with `children: []` and a `lines`
11
+ * array. Ownership inside that array has to come from the *component* tree.
12
+ *
13
+ * It can, exactly. `Container.render(width)` (pi-tui's `tui.ts`) renders each
14
+ * child at the **same width** and concatenates the results in order, with no
15
+ * gap, padding or reordering:
16
+ *
17
+ * ```ts
18
+ * render(width: number): string[] {
19
+ * const lines: string[] = [];
20
+ * for (const child of this.children) {
21
+ * for (const line of child.render(width)) lines.push(line);
22
+ * }
23
+ * return lines;
24
+ * }
25
+ * ```
26
+ *
27
+ * So a child occupies rows `[sum of its preceding siblings' rendered heights,
28
+ * + its own rendered height)` of its parent's output, and the same rule
29
+ * applies again inside a child that is itself a container. Rendering is the
30
+ * only way to learn those heights — a component's height is whatever its
31
+ * `render` returns.
32
+ *
33
+ * ## Why the walk verifies instead of trusting `children`
34
+ *
35
+ * A `Container` subclass may override `render`, and the ones that matter do:
36
+ * `pi-toolbox` replaces `ToolExecutionComponent.prototype.render` with one
37
+ * that draws `╭─╮ │ ╰─╯` around its children's lines. Its `children` are then
38
+ * no longer a partition of its own rows at all — they are the *contents* of a
39
+ * frame it added rows around. Descending on the strength of `children` alone
40
+ * would attribute the frame's rows to whichever child happened to line up.
41
+ *
42
+ * So each step of the walk checks its own premise: a child's rendered lines
43
+ * must appear **verbatim, in place** in the parent's output at the offset the
44
+ * running total predicts. If they do, the offset is not an estimate, it is
45
+ * the parent's own output read back. If they do not, the parent is not a
46
+ * plain concatenator and the walk stops there — which is the correct answer,
47
+ * because the parent is then the component that actually drew those rows.
48
+ *
49
+ * The check is a prefix check, and stops at the child containing the target
50
+ * row: siblings after it are never rendered. That is cheaper than verifying
51
+ * the whole parent and just as exact — matching `parent.lines[0 .. cursor +
52
+ * height)` against the concatenation of children `0..k` establishes exactly
53
+ * where child `k` sits, and nothing later in the parent's output can move it.
54
+ *
55
+ * ## Cost
56
+ *
57
+ * Every child up to the target row is rendered once, at each level. This runs
58
+ * on a copy and on a click, never per frame — and components cache their own
59
+ * renders (pi-tui's `Text` and `Markdown` keep a `cachedLines`; `pi-toolbox`
60
+ * keeps a `__frameCache`), so a repeat render of an unchanged component is
61
+ * usually a cache probe rather than real work. A `ComponentTree` also caches
62
+ * every render it does, so asking about a hundred rows of one selection costs
63
+ * one pass, not a hundred.
64
+ *
65
+ * Nothing here imports pi-tui: `ComponentLike` is the structural slice a
66
+ * component has to expose for this to work, so the tests can build a real
67
+ * component graph out of pi-tui's own `Container` and the extension does not
68
+ * take a dependency on its class identities.
69
+ */
70
+
71
+ /** The slice of a Pi component this module reads. */
72
+ export type ComponentLike = {
73
+ children?: readonly unknown[];
74
+ render(width: number): string[];
75
+ };
76
+
77
+ /**
78
+ * One component's own rows, as an interval of the tree root's output.
79
+ * `start` is inclusive and `end` exclusive, both in the root's row space;
80
+ * `lines` are the component's own rendered rows, so `lines[row - start]` is
81
+ * the text at root row `row`.
82
+ */
83
+ export type ComponentSpan = {
84
+ component: unknown;
85
+ start: number;
86
+ end: number;
87
+ lines: readonly string[];
88
+ };
89
+
90
+ export type ComponentTree = {
91
+ /** Root first, innermost last. Empty when `row` is outside the root. */
92
+ pathAt(row: number): ComponentSpan[];
93
+ /** The innermost component that rendered `row`. */
94
+ ownerAt(row: number): ComponentSpan | undefined;
95
+ };
96
+
97
+ /** Whether `value` renders — the one method this module needs to call. */
98
+ export function isComponentLike(value: unknown): value is ComponentLike {
99
+ return (
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ typeof (value as { render?: unknown }).render === "function"
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Whether `component` is one of Pi's expandable message components — the duck
108
+ * type for "clicking this could expand or collapse it". Verified against the
109
+ * installed `pi-coding-agent`: `bash-execution`, `branch-summary-message`,
110
+ * `compaction-summary-message`, `custom-entry`, `custom-message`,
111
+ * `skill-invocation-message` and `tool-execution` all expose `setExpanded`;
112
+ * pi-tui's own leaf components (`Text`, `Markdown`, `Container`) expose none.
113
+ *
114
+ * Note what this does *not* say: nothing about how the component looks. It
115
+ * was briefly used to infer that a component had drawn a border, which is
116
+ * exactly the inference frame-free selection was cut for — `setExpanded` says
117
+ * a box could be expanded, never that a box was drawn. It is kept for the
118
+ * question it can answer, which is Task 8's.
119
+ */
120
+ export function isExpandableComponent(component: unknown): boolean {
121
+ return (
122
+ typeof component === "object" &&
123
+ component !== null &&
124
+ typeof (component as { setExpanded?: unknown }).setExpanded === "function"
125
+ );
126
+ }
127
+
128
+ function childrenOf(component: unknown): readonly unknown[] {
129
+ const children = (component as { children?: unknown }).children;
130
+ return Array.isArray(children) ? children : [];
131
+ }
132
+
133
+ /**
134
+ * A component's rendered rows, memoised, and never able to take a copy down
135
+ * with it: a component whose `render` throws (or returns something that is
136
+ * not an array of lines) is treated as zero rows tall. Its siblings keep
137
+ * their own offsets, because a zero-height child advances the running total
138
+ * by zero — the same thing a component that legitimately renders nothing
139
+ * does, which both `ToolExecutionComponent` (`if (this.hideComponent) return
140
+ * []`) and `pi-toolbox`'s framed render already do routinely.
141
+ */
142
+ function renderOnce(
143
+ component: unknown,
144
+ width: number,
145
+ cache: Map<unknown, readonly string[]>,
146
+ ): readonly string[] {
147
+ const cached = cache.get(component);
148
+ if (cached) return cached;
149
+ let lines: readonly string[] = [];
150
+ if (isComponentLike(component)) {
151
+ try {
152
+ const rendered = component.render(width);
153
+ if (Array.isArray(rendered)) lines = rendered;
154
+ } catch {
155
+ lines = [];
156
+ }
157
+ }
158
+ cache.set(component, lines);
159
+ return lines;
160
+ }
161
+
162
+ /**
163
+ * The child of `parent` that rendered `row`, or `undefined` when `parent`
164
+ * turns out not to be a plain concatenation of its children (see the module
165
+ * docstring) or has no child there.
166
+ */
167
+ function childSpanAt(
168
+ parent: ComponentSpan,
169
+ row: number,
170
+ width: number,
171
+ cache: Map<unknown, readonly string[]>,
172
+ ): ComponentSpan | undefined {
173
+ let cursor = parent.start;
174
+ for (const child of childrenOf(parent.component)) {
175
+ const lines = renderOnce(child, width, cache);
176
+ for (let offset = 0; offset < lines.length; offset++) {
177
+ if (parent.lines[cursor - parent.start + offset] !== lines[offset]) return undefined;
178
+ }
179
+ if (row >= cursor && row < cursor + lines.length) {
180
+ return { component: child, start: cursor, end: cursor + lines.length, lines };
181
+ }
182
+ cursor += lines.length;
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * A row→component map over the component graph rooted at `root`, rendered at
189
+ * `width`.
190
+ *
191
+ * `width` must be the width `root` was actually rendered at — for the
192
+ * transcript that is the scroll content box's `rect.width`, which is what
193
+ * pi-tui passed to `renderCached` when it produced `scrollContentLines`.
194
+ * `rootLines` is that same output when the caller already holds it (the
195
+ * selection path does), which saves re-rendering the entire transcript to
196
+ * learn something it was just handed; omit it and the root renders once.
197
+ *
198
+ * The returned tree caches every render it performs, so it is worth keeping
199
+ * for the whole of one question (a selection's rows) and throwing away after
200
+ * — it holds no invalidation logic and will happily hand back stale rows if
201
+ * kept across a component update.
202
+ */
203
+ export function createComponentTree(
204
+ root: ComponentLike,
205
+ width: number,
206
+ rootLines?: readonly string[],
207
+ ): ComponentTree {
208
+ const cache = new Map<unknown, readonly string[]>();
209
+ if (rootLines) cache.set(root, rootLines);
210
+
211
+ const pathAt = (row: number): ComponentSpan[] => {
212
+ if (!Number.isInteger(row) || row < 0) return [];
213
+ const lines = renderOnce(root, width, cache);
214
+ if (row >= lines.length) return [];
215
+ const path: ComponentSpan[] = [{ component: root, start: 0, end: lines.length, lines }];
216
+ // A component that is its own descendant would otherwise loop here.
217
+ const seen = new Set<unknown>([root]);
218
+ let current = path[0];
219
+ for (;;) {
220
+ const child = childSpanAt(current, row, width, cache);
221
+ if (!child || seen.has(child.component)) break;
222
+ seen.add(child.component);
223
+ path.push(child);
224
+ current = child;
225
+ }
226
+ return path;
227
+ };
228
+
229
+ return { pathAt, ownerAt: (row) => pathAt(row).at(-1) };
230
+ }