@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,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
|
+
}
|