pi-stereo-view 0.1.2 → 0.1.3
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/CHANGELOG.md +6 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/content.ts +220 -0
- package/src/index.ts +57 -22
- package/src/layout.ts +34 -4
- package/src/native.ts +50 -173
- package/src/startup.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
- Show the saved split layout from the first visible frame, without briefly displaying the normal layout first.
|
|
6
|
+
- Keep dialogue and system information, including startup and runtime warnings, errors and status notices, in Conversation. Activity contains thinking, tools and shell execution.
|
|
7
|
+
- Use the same content classification for display and search. Related tool output stays with its tool; unrecognized content stays in Conversation.
|
|
8
|
+
|
|
3
9
|
## 0.1.2
|
|
4
10
|
|
|
5
11
|
- PgUp/PgDn and Pi's other keyboard scroll keys now move the active panel (marked with `●`) instead of always Activity. Pi's built-in history search follows the active panel too.
|
package/README.md
CHANGED
|
@@ -20,8 +20,10 @@ The chat becomes two zones: your conversation with the agent, and the agent's wo
|
|
|
20
20
|
|
|
21
21
|
| Zone | What's in it |
|
|
22
22
|
| --- | --- |
|
|
23
|
-
| **Conversation** | Your messages, the LLM's replies,
|
|
24
|
-
| **Activity** |
|
|
23
|
+
| **Conversation** | Your messages, the LLM's replies, summaries and system information, including welcome, loaded resources, warnings, errors and status notices at any time. |
|
|
24
|
+
| **Activity** | Thinking, tool calls and results, related tool output, and shell execution. |
|
|
25
|
+
|
|
26
|
+
Unrecognized content stays in Conversation unless its metadata links it to a known tool call.
|
|
25
27
|
|
|
26
28
|
Messages and tools keep their familiar appearance. The input stays full width below both panels.
|
|
27
29
|
|
package/package.json
CHANGED
package/src/content.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Container, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { searchText, type SearchEntry } from "./search.js";
|
|
3
|
+
|
|
4
|
+
export type Pane = "activity" | "chat";
|
|
5
|
+
export type ContentKind = "dialogue" | "system" | "thinking" | "tool" | "shell";
|
|
6
|
+
|
|
7
|
+
const panes: Record<ContentKind, Pane> = {
|
|
8
|
+
dialogue: "chat", system: "chat", thinking: "activity", tool: "activity", shell: "activity",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface NativeContent extends Component {
|
|
12
|
+
text?: string;
|
|
13
|
+
message?: { content?: unknown; summary?: string; details?: { parentToolCallId?: unknown } };
|
|
14
|
+
entry?: { data?: { parentToolCallId?: unknown } };
|
|
15
|
+
toolCallId?: string;
|
|
16
|
+
toolName?: string;
|
|
17
|
+
args?: unknown;
|
|
18
|
+
result?: { content?: unknown };
|
|
19
|
+
command?: string;
|
|
20
|
+
outputLines?: string[];
|
|
21
|
+
setExpanded?: (expanded: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
interface NativeAssistant extends Container {
|
|
24
|
+
contentContainer: Container;
|
|
25
|
+
hasToolCalls: boolean;
|
|
26
|
+
lastMessage?: { content: { type: string; text?: string; thinking?: string }[] };
|
|
27
|
+
setHideThinkingBlock(hidden: boolean): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One native owner and its pane projection; spacing and search retain that same owner. */
|
|
31
|
+
export interface ContentEntry {
|
|
32
|
+
source: Component;
|
|
33
|
+
owner: Component;
|
|
34
|
+
component: Component;
|
|
35
|
+
kind: ContentKind;
|
|
36
|
+
spacing: Component[];
|
|
37
|
+
compact: boolean;
|
|
38
|
+
search(width: number): Omit<SearchEntry, "source">;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const nameOf = (component: Component): string => component.constructor.name;
|
|
42
|
+
const contentText = (content: unknown): string => typeof content === "string" ? content :
|
|
43
|
+
Array.isArray(content) ? content.filter(block => block?.type === "text" && typeof block.text === "string")
|
|
44
|
+
.map(block => block.text).join("\n") : "";
|
|
45
|
+
|
|
46
|
+
function assistant(component: Component): NativeAssistant | undefined {
|
|
47
|
+
if (nameOf(component) !== "AssistantMessageComponent") return undefined;
|
|
48
|
+
const native = component as NativeAssistant;
|
|
49
|
+
if (!Array.isArray(native.contentContainer?.children) || typeof native.hasToolCalls !== "boolean" ||
|
|
50
|
+
(native.lastMessage !== undefined && !Array.isArray(native.lastMessage.content))) {
|
|
51
|
+
throw new Error("Pi's assistant content metadata is not supported");
|
|
52
|
+
}
|
|
53
|
+
return native;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read supported native execution identities; unrecognized content is system information. */
|
|
57
|
+
const nativeKinds: Record<string, { kind: ContentKind; accepts(content: NativeContent): boolean }> = {
|
|
58
|
+
UserMessageComponent: { kind: "dialogue", accepts: content => typeof content.text === "string" },
|
|
59
|
+
ToolExecutionComponent: { kind: "tool", accepts: content => typeof content.toolName === "string" },
|
|
60
|
+
BashExecutionComponent: {
|
|
61
|
+
kind: "shell", accepts: content => typeof content.command === "string" && Array.isArray(content.outputLines),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Shared semantic index for painting and search, independent of wording, severity and lifecycle. */
|
|
66
|
+
export class NativeContentIndex {
|
|
67
|
+
private signature: unknown[] = [];
|
|
68
|
+
private content: ContentEntry[] = [];
|
|
69
|
+
private readonly views = new WeakMap<NativeAssistant, Partial<Record<Pane, Container>>>();
|
|
70
|
+
|
|
71
|
+
constructor(private readonly source: Container) {}
|
|
72
|
+
|
|
73
|
+
/** Return the same classification until native ownership or assistant content changes. */
|
|
74
|
+
entries(pane: Pane): ContentEntry[] {
|
|
75
|
+
this.refresh();
|
|
76
|
+
return this.content.filter(entry => panes[entry.kind] === pane);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A rebuilt history cannot retain search hits belonging to removed native owners. */
|
|
80
|
+
contains(source: Component, pane?: Pane): boolean {
|
|
81
|
+
this.refresh();
|
|
82
|
+
return this.content.some(entry => entry.source === source && (pane === undefined || panes[entry.kind] === pane));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private refresh(): void {
|
|
86
|
+
const history = this.source.children[2] as Container;
|
|
87
|
+
if (!Array.isArray(history?.children)) throw new Error("Pi's history document is not supported");
|
|
88
|
+
const signature: unknown[] = [...this.source.children, ...history.children];
|
|
89
|
+
for (const component of history.children) {
|
|
90
|
+
const metadata = component as NativeContent;
|
|
91
|
+
signature.push(metadata.toolCallId, metadata.entry?.data?.parentToolCallId, metadata.message?.details?.parentToolCallId);
|
|
92
|
+
const native = assistant(component);
|
|
93
|
+
if (native) signature.push(native.lastMessage, ...native.contentContainer.children);
|
|
94
|
+
}
|
|
95
|
+
if (signature.length === this.signature.length && signature.every((value, index) => value === this.signature[index])) return;
|
|
96
|
+
const owners = new Map<string, NativeContent>();
|
|
97
|
+
for (const component of history.children) {
|
|
98
|
+
const adapter = nativeKinds[nameOf(component)];
|
|
99
|
+
const native = component as NativeContent;
|
|
100
|
+
if (adapter?.kind === "tool" && adapter.accepts(native) && typeof native.toolCallId === "string") {
|
|
101
|
+
owners.set(native.toolCallId, native);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const entries = this.source.children.slice(0, 2).map(component => this.entry(component, "system", [], false));
|
|
105
|
+
let spacing: Component[] = [];
|
|
106
|
+
for (const component of history.children) {
|
|
107
|
+
if (nameOf(component) === "Spacer") {
|
|
108
|
+
spacing.push(component);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const native = assistant(component);
|
|
112
|
+
if (native) entries.push(...this.assistantEntries(native, spacing));
|
|
113
|
+
else {
|
|
114
|
+
const adapter = nativeKinds[nameOf(component)];
|
|
115
|
+
const native = component as NativeContent;
|
|
116
|
+
const kind = adapter?.accepts(native) ? adapter.kind : "system";
|
|
117
|
+
const parentId = native.entry?.data?.parentToolCallId ?? native.message?.details?.parentToolCallId;
|
|
118
|
+
const owner = kind === "system" && typeof parentId === "string" ? owners.get(parentId) : undefined;
|
|
119
|
+
entries.push(this.entry(component, kind, spacing, true, owner));
|
|
120
|
+
}
|
|
121
|
+
spacing = [];
|
|
122
|
+
}
|
|
123
|
+
this.content = entries;
|
|
124
|
+
this.signature = signature;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private entry(source: Component, kind: ContentKind, spacing: Component[], compact: boolean, owner?: NativeContent): ContentEntry {
|
|
128
|
+
return { source, owner: owner ?? source, component: source, kind: owner ? "tool" : kind, spacing, compact, search: width => {
|
|
129
|
+
const native = source as NativeContent;
|
|
130
|
+
if (kind === "tool") {
|
|
131
|
+
if (typeof native.toolName !== "string") throw new Error("Pi's tool metadata is not supported");
|
|
132
|
+
return {
|
|
133
|
+
label: native.toolName,
|
|
134
|
+
text: [native.toolName, JSON.stringify(native.args, null, 2), contentText(native.result?.content)].filter(Boolean).join("\n"),
|
|
135
|
+
reveal: () => {
|
|
136
|
+
if (typeof native.setExpanded !== "function") throw new Error("Pi's tool reveal is not supported");
|
|
137
|
+
native.setExpanded(true);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (kind === "shell") {
|
|
142
|
+
if (typeof native.command !== "string" || !Array.isArray(native.outputLines)) throw new Error("Pi's shell metadata is not supported");
|
|
143
|
+
return { label: "Shell", text: [native.command, ...native.outputLines].join("\n"), reveal: () => native.setExpanded?.(true) };
|
|
144
|
+
}
|
|
145
|
+
if (kind === "dialogue") {
|
|
146
|
+
if (typeof native.text !== "string") throw new Error("Pi's user text metadata is not supported");
|
|
147
|
+
return { label: "User", text: native.text };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
label: owner?.toolName ?? (native.message?.summary !== undefined ? "Summary" : "System"),
|
|
151
|
+
text: contentText(native.message?.content) || native.message?.summary || searchText(source.render(width).join("\n")),
|
|
152
|
+
reveal: typeof native.setExpanded === "function" ? () => native.setExpanded!(true) : undefined,
|
|
153
|
+
};
|
|
154
|
+
} };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private assistantEntries(source: NativeAssistant, spacing: Component[]): ContentEntry[] {
|
|
158
|
+
// Native rendering creates one child per visible text block or consecutive thinking run.
|
|
159
|
+
// Match those children to semantic content before projecting; a Text diagnostic is not thinking.
|
|
160
|
+
const runs: ContentKind[] = [];
|
|
161
|
+
let thinking = false;
|
|
162
|
+
for (const block of source.lastMessage?.content ?? []) {
|
|
163
|
+
if (block.type === "thinking") {
|
|
164
|
+
if (block.thinking?.trim() && !thinking) { runs.push("thinking"); thinking = true; }
|
|
165
|
+
} else {
|
|
166
|
+
thinking = false;
|
|
167
|
+
if (block.type === "text" && block.text?.trim()) runs.push("dialogue");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const groups: Record<Pane, Component[]> = { chat: [], activity: [] };
|
|
171
|
+
const diagnostics: Component[] = [];
|
|
172
|
+
let slot = 0;
|
|
173
|
+
let padding: Component[] = [];
|
|
174
|
+
for (const child of source.contentContainer.children) {
|
|
175
|
+
if (nameOf(child) === "Spacer") { padding.push(child); continue; }
|
|
176
|
+
const kind = runs[slot++] ?? "system";
|
|
177
|
+
if ((kind === "thinking" && nameOf(child) !== "MouseRegion") || (kind === "dialogue" && nameOf(child) !== "Markdown")) {
|
|
178
|
+
throw new Error("Pi's assistant rendering does not match its content metadata");
|
|
179
|
+
}
|
|
180
|
+
groups[panes[kind]].push(...padding, child);
|
|
181
|
+
if (kind === "system") diagnostics.push(child);
|
|
182
|
+
padding = [];
|
|
183
|
+
}
|
|
184
|
+
if (slot < runs.length) throw new Error("Pi's assistant content layout is not supported");
|
|
185
|
+
const views = this.views.get(source) ?? {};
|
|
186
|
+
this.views.set(source, views);
|
|
187
|
+
const result: ContentEntry[] = [];
|
|
188
|
+
for (const pane of ["chat", "activity"] as const) {
|
|
189
|
+
const parts = groups[pane];
|
|
190
|
+
if (!parts.length) continue;
|
|
191
|
+
let view = views[pane];
|
|
192
|
+
if (!view) {
|
|
193
|
+
view = Object.create(source) as Container;
|
|
194
|
+
const content = new Container();
|
|
195
|
+
Object.assign(view, {
|
|
196
|
+
children: [content], contentContainer: content,
|
|
197
|
+
// This is a partial view: rebuilding the assistant would restore both panes.
|
|
198
|
+
invalidate: () => content.invalidate(),
|
|
199
|
+
});
|
|
200
|
+
views[pane] = view;
|
|
201
|
+
}
|
|
202
|
+
const content = view.children[0] as Container;
|
|
203
|
+
content.children = parts;
|
|
204
|
+
result.push({
|
|
205
|
+
source, owner: source, component: view, kind: pane === "activity" ? "thinking" : runs.includes("dialogue") ? "dialogue" : "system", spacing, compact: true,
|
|
206
|
+
search: width => {
|
|
207
|
+
const kind = pane === "activity" ? "thinking" : "text";
|
|
208
|
+
const saved = (source.lastMessage?.content ?? []).filter(block => block.type === kind)
|
|
209
|
+
.map(block => kind === "text" ? block.text ?? "" : block.thinking ?? "").join("\n\n");
|
|
210
|
+
return {
|
|
211
|
+
label: pane === "activity" ? "Thinking" : "Assistant",
|
|
212
|
+
text: [saved, ...(pane === "chat" ? diagnostics.map(child => searchText(child.render(width).join("\n"))) : [])].filter(Boolean).join("\n\n"),
|
|
213
|
+
reveal: pane === "activity" ? () => source.setHideThinkingBlock(false) : undefined,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,10 @@ import { Box, Container, isKeyRelease, matchesKey, SettingsList, Text, type TUI
|
|
|
4
4
|
import { TranscriptLayout } from "./layout.js";
|
|
5
5
|
import { TOOL_START_ENTRY, TranscriptTimestamps } from "./timestamps.js";
|
|
6
6
|
import { defaults, loadPreferences, savePreferences, type Preferences } from "./preferences.js";
|
|
7
|
+
import { beforeFirstFrame } from "./startup.js";
|
|
7
8
|
|
|
8
9
|
/** Split native fullscreen history without changing message content or the input dock. */
|
|
9
|
-
export default function transcript(pi: ExtensionAPI): void {
|
|
10
|
+
export default async function transcript(pi: ExtensionAPI): Promise<void> {
|
|
10
11
|
let layout: TranscriptLayout | undefined;
|
|
11
12
|
let host: TUI | undefined;
|
|
12
13
|
let timestamps: TranscriptTimestamps | undefined;
|
|
@@ -14,16 +15,25 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
14
15
|
let writes = Promise.resolve();
|
|
15
16
|
let preferences: Preferences = { ...defaults };
|
|
16
17
|
const file = path.join(getAgentDir(), "transcript.json");
|
|
18
|
+
let preferenceWarning: string | undefined;
|
|
19
|
+
const readPreferences = async (): Promise<void> => {
|
|
20
|
+
try { preferences = await loadPreferences(file); }
|
|
21
|
+
catch (error) {
|
|
22
|
+
preferences = { ...defaults };
|
|
23
|
+
preferenceWarning = "Could not read pi-stereo-view preferences; using defaults: " + String(error);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
await readPreferences();
|
|
17
27
|
|
|
18
28
|
let settingsOpen = false;
|
|
19
29
|
const closeSearch = (): void => { layout?.closeSearch(false); };
|
|
20
30
|
|
|
21
|
-
const openSettings = async (
|
|
31
|
+
const openSettings = async (ui: ExtensionContext["ui"]): Promise<void> => {
|
|
22
32
|
if (settingsOpen) return;
|
|
23
33
|
settingsOpen = true;
|
|
24
34
|
closeSearch();
|
|
25
35
|
try {
|
|
26
|
-
await
|
|
36
|
+
await ui.custom<void>((tui, _theme, _keys, done) => {
|
|
27
37
|
const list = new SettingsList([{
|
|
28
38
|
id: "timestamps", label: "Show timestamps",
|
|
29
39
|
description: "Hide clock rows without changing messages or recorded tool starts.",
|
|
@@ -39,11 +49,11 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
39
49
|
}, () => done());
|
|
40
50
|
const content = new Container();
|
|
41
51
|
content.addChild({
|
|
42
|
-
render: () => [
|
|
52
|
+
render: () => [ui.theme.fg("accent", ui.theme.bold("pi-stereo-view settings")), ""],
|
|
43
53
|
invalidate() {},
|
|
44
54
|
});
|
|
45
55
|
content.addChild(list);
|
|
46
|
-
const box = new Box(1, 1, text =>
|
|
56
|
+
const box = new Box(1, 1, text => ui.theme.bg("customMessageBg", text));
|
|
47
57
|
box.addChild(content);
|
|
48
58
|
return {
|
|
49
59
|
render: (width: number) => box.render(width),
|
|
@@ -64,32 +74,56 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
64
74
|
const ensureLayout = (): void => {
|
|
65
75
|
if (host) layout?.attach(host);
|
|
66
76
|
};
|
|
67
|
-
const persist = (
|
|
77
|
+
const persist = (ui: ExtensionContext["ui"]): void => {
|
|
68
78
|
const snapshot = { ...preferences };
|
|
69
79
|
writes = writes.then(() => savePreferences(file, snapshot)).catch(error => {
|
|
70
|
-
|
|
80
|
+
ui.notify("Could not save pi-stereo-view preferences: " + String(error), "error");
|
|
71
81
|
});
|
|
72
82
|
};
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
const createLayout = (tui: TUI, ui: ExtensionContext["ui"], session: ExtensionContext["sessionManager"]): void => {
|
|
85
|
+
host = tui;
|
|
86
|
+
timestamps = new TranscriptTimestamps(session);
|
|
87
|
+
layout = new TranscriptLayout(preferences, () => ui.theme,
|
|
88
|
+
message => ui.notify(message, "warning"), () => persist(ui), timestamps,
|
|
89
|
+
() => { void openSettings(ui).catch(error => ui.notify(String(error), "error")); });
|
|
90
|
+
try { ensureLayout(); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
layout.dispose();
|
|
93
|
+
layout = undefined;
|
|
94
|
+
host = undefined;
|
|
95
|
+
timestamps = undefined;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
let earlySession: string | undefined;
|
|
100
|
+
const releaseStartup = beforeFirstFrame((tui, ui, session) => {
|
|
101
|
+
earlySession = session.getSessionId();
|
|
102
|
+
createLayout(tui, ui, session);
|
|
103
|
+
const mounted = layout;
|
|
104
|
+
return () => { mounted?.dispose(); };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("session_start", async (event, ctx) => {
|
|
108
|
+
releaseStartup();
|
|
75
109
|
if (ctx.mode !== "tui") return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
const early = event.reason === "startup" && earlySession === ctx.sessionManager.getSessionId() && layout !== undefined;
|
|
111
|
+
earlySession = undefined;
|
|
112
|
+
if (!early) {
|
|
113
|
+
closeSearch();
|
|
114
|
+
layout?.dispose();
|
|
115
|
+
unsubscribeInput?.();
|
|
116
|
+
await readPreferences();
|
|
117
|
+
}
|
|
118
|
+
if (preferenceWarning) {
|
|
119
|
+
ctx.ui.notify(preferenceWarning, "warning");
|
|
120
|
+
preferenceWarning = undefined;
|
|
85
121
|
}
|
|
86
122
|
let finish: () => void = () => {};
|
|
87
123
|
await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
|
|
88
|
-
host = tui;
|
|
124
|
+
if (early) host = tui;
|
|
125
|
+
else createLayout(tui, ctx.ui, ctx.sessionManager);
|
|
89
126
|
finish = done;
|
|
90
|
-
layout = new TranscriptLayout(preferences, () => ctx.ui.theme,
|
|
91
|
-
message => ctx.ui.notify(message, "warning"), () => persist(ctx), timestamps!,
|
|
92
|
-
() => { void openSettings(ctx).catch(error => ctx.ui.notify(String(error), "error")); });
|
|
93
127
|
ensureLayout();
|
|
94
128
|
return new Text("", 0, 0);
|
|
95
129
|
}, {
|
|
@@ -129,6 +163,7 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
129
163
|
pi.on("session_tree", () => { closeSearch(); timestamps?.reset(); });
|
|
130
164
|
pi.on("session_compact", () => { closeSearch(); timestamps?.reset(); });
|
|
131
165
|
pi.on("session_shutdown", async () => {
|
|
166
|
+
releaseStartup();
|
|
132
167
|
closeSearch();
|
|
133
168
|
unsubscribeInput?.();
|
|
134
169
|
unsubscribeInput = undefined;
|
|
@@ -152,7 +187,7 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
152
187
|
}
|
|
153
188
|
const [action = "toggle", target, amount] = args.trim().split(/\s+/);
|
|
154
189
|
if (action === "toggle" || action === "") toggle();
|
|
155
|
-
else if (action === "settings") await openSettings(ctx);
|
|
190
|
+
else if (action === "settings") await openSettings(ctx.ui);
|
|
156
191
|
else if (action === "help") {
|
|
157
192
|
ctx.ui.notify("pi-stereo-view icons: ▣ combined, ◫ split, ▤ rows, ▥ columns, ↕/↔ swap, ↗ expand, ↙ restore, ⌕ search, ≡ settings. Ctrl+F searches and PgUp/PgDn scroll the active panel; Ctrl+Alt+Up/Down switches it. Drag the ═ or ║ divider to resize.", "info");
|
|
158
193
|
} else if (action === "swap") layout.swap();
|
package/src/layout.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type Color, type Component, type ScrollViewOptions, type StackChild, type TUI, type TuiMouseEvent,
|
|
6
6
|
} from "@earendil-works/pi-tui";
|
|
7
7
|
import { NativeDocument, type Pane } from "./native.js";
|
|
8
|
+
import { NativeContentIndex } from "./content.js";
|
|
8
9
|
import type { Preferences } from "./preferences.js";
|
|
9
10
|
import type { TranscriptTimestamps } from "./timestamps.js";
|
|
10
11
|
import { PANEL_SEARCH_ROWS, PanelSearch, PanelSearchShortcut, type SearchHit } from "./search.js";
|
|
@@ -53,6 +54,25 @@ class TranscriptRows extends VStack {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
class TranscriptRoot extends VStack {
|
|
58
|
+
private readonly owned: Component[];
|
|
59
|
+
|
|
60
|
+
constructor(children: StackChild[]) {
|
|
61
|
+
super(children);
|
|
62
|
+
this.owned = [...this.children];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unchanged(): boolean {
|
|
66
|
+
return this.children.length === this.owned.length && this.children.every((child, index) => child === this.owned[index]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
preserveForeignChildren(native: VStack): void {
|
|
70
|
+
for (const entry of this.entries) {
|
|
71
|
+
if (!this.owned.includes(entry.component)) native.addChild(entry.component, entry);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
class TranscriptPane extends VStack {
|
|
57
77
|
constructor(private readonly header: Component, private readonly view: ScrollView, private readonly height: () => number) {
|
|
58
78
|
super([
|
|
@@ -144,7 +164,16 @@ export class TranscriptLayout {
|
|
|
144
164
|
this.warn("pi-stereo-view split is unavailable: Pi's focus API is not supported.");
|
|
145
165
|
return;
|
|
146
166
|
}
|
|
147
|
-
if (host.layoutRoot === this.mounted && this.host)
|
|
167
|
+
if (host.layoutRoot === this.mounted && this.host) {
|
|
168
|
+
const mounted = this.mounted as TranscriptRoot;
|
|
169
|
+
if (mounted.unchanged()) return;
|
|
170
|
+
const original = this.original!;
|
|
171
|
+
mounted.preserveForeignChildren(original as VStack);
|
|
172
|
+
this.dispose();
|
|
173
|
+
this.rejectedRoots.add(original);
|
|
174
|
+
this.warn("pi-stereo-view split is unavailable: Pi's fullscreen layout is not supported.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
148
177
|
const root = host.layoutRoot;
|
|
149
178
|
if (this.host) this.dispose();
|
|
150
179
|
if (root && this.rejectedRoots.has(root)) return;
|
|
@@ -170,10 +199,11 @@ export class TranscriptLayout {
|
|
|
170
199
|
// Auto scrollbars overlay one native content column; keep clocks outside it.
|
|
171
200
|
const timestampGutter = (): number => scroll.scrollbar === "auto" ? 1 : 0;
|
|
172
201
|
const showTimestamps = (): boolean => this.preferences.showTimestamps;
|
|
202
|
+
const index = new NativeContentIndex(document);
|
|
173
203
|
const activityDocument = new NativeDocument(document, "activity", this.timestamps, this.theme, timestampGutter, showTimestamps,
|
|
174
|
-
() => this.activate("activity"), false);
|
|
204
|
+
() => this.activate("activity"), false, index);
|
|
175
205
|
const chatDocument = new NativeDocument(document, "chat", this.timestamps, this.theme, timestampGutter, showTimestamps,
|
|
176
|
-
() => this.activate("chat"), false);
|
|
206
|
+
() => this.activate("chat"), false, index);
|
|
177
207
|
this.documents = { activity: activityDocument, chat: chatDocument };
|
|
178
208
|
this.disposeDocuments = () => {
|
|
179
209
|
activityDocument.dispose();
|
|
@@ -350,7 +380,7 @@ export class TranscriptLayout {
|
|
|
350
380
|
}
|
|
351
381
|
};
|
|
352
382
|
this.rebuild();
|
|
353
|
-
const mounted = new
|
|
383
|
+
const mounted = new TranscriptRoot([
|
|
354
384
|
{ component: toolbar, basis: 1, shrink: 0, visible: () => {
|
|
355
385
|
// Root visibility is evaluated once at the start of each native layout pass.
|
|
356
386
|
this.timestamps.beginFrame();
|
package/src/native.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container, Spacer, Text,
|
|
2
|
+
import { Container, Spacer, Text, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
3
|
import { searchText, type SearchEntry, type SearchHit } from "./search.js";
|
|
4
4
|
import { highlightSearchRows, searchHitRow, type SearchClock } from "./search-highlight.js";
|
|
5
5
|
import { CompactMessage, type TranscriptTimestamps } from "./timestamps.js";
|
|
6
|
-
|
|
7
|
-
export type Pane
|
|
6
|
+
import { NativeContentIndex, type Pane } from "./content.js";
|
|
7
|
+
export type { Pane } from "./content.js";
|
|
8
8
|
|
|
9
9
|
/** Decorate one selected block without changing native text or mouse coordinates. */
|
|
10
10
|
class SearchHighlight implements Component {
|
|
@@ -37,56 +37,6 @@ class SearchHighlight implements Component {
|
|
|
37
37
|
return this.component.handleMouse?.(event);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
interface NativeAssistant extends Container {
|
|
41
|
-
contentContainer: Container;
|
|
42
|
-
hasToolCalls: boolean;
|
|
43
|
-
}
|
|
44
|
-
const nameOf = (component: Component): string => component.constructor.name;
|
|
45
|
-
const noticeKinds = new WeakMap<Component, { text: string; compaction?: string }>();
|
|
46
|
-
|
|
47
|
-
/** Classify only Pi's compaction status; refresh lazy native text only after invalidation. */
|
|
48
|
-
function compactionNotice(component: Component, name: string, width: number): string | undefined {
|
|
49
|
-
if (name !== "ThemedText") return undefined;
|
|
50
|
-
const native = component as Component & { stale?: boolean; text?: string };
|
|
51
|
-
if (native.stale) component.render(width);
|
|
52
|
-
if (typeof native.text !== "string") throw new Error("Pi's status text metadata is not supported");
|
|
53
|
-
const cached = noticeKinds.get(component);
|
|
54
|
-
if (cached?.text === native.text) return cached.compaction;
|
|
55
|
-
const text = stripTerminalSequences(native.text).trim();
|
|
56
|
-
const compaction = /^Session compacted \d+ times?$/.test(text) ? text : undefined;
|
|
57
|
-
noticeKinds.set(component, { text: native.text, compaction });
|
|
58
|
-
return compaction;
|
|
59
|
-
}
|
|
60
|
-
const messagePane = (name: string, compaction?: string): Pane =>
|
|
61
|
-
name === "UserMessageComponent" || name === "Image" || name === "CompactionSummaryMessageComponent" ||
|
|
62
|
-
compaction !== undefined ? "chat" : "activity";
|
|
63
|
-
|
|
64
|
-
function assistant(component: Component): NativeAssistant | undefined {
|
|
65
|
-
if (nameOf(component) !== "AssistantMessageComponent") return undefined;
|
|
66
|
-
const source = component as NativeAssistant;
|
|
67
|
-
if (!Array.isArray(source.contentContainer?.children) || typeof source.hasToolCalls !== "boolean") {
|
|
68
|
-
throw new Error("Pi's assistant component is not supported");
|
|
69
|
-
}
|
|
70
|
-
return source;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function assistantParts(source: NativeAssistant, pane: Pane): Component[] {
|
|
74
|
-
const result: Component[] = [];
|
|
75
|
-
let spacing: Component[] = [];
|
|
76
|
-
for (const component of source.contentContainer.children) {
|
|
77
|
-
const name = nameOf(component);
|
|
78
|
-
if (name === "Spacer") {
|
|
79
|
-
spacing.push(component);
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const target = name === "Markdown" ? "chat" :
|
|
83
|
-
name === "MouseRegion" || name === "Text" ? "activity" : undefined;
|
|
84
|
-
if (!target) throw new Error("Pi's assistant content layout is not supported");
|
|
85
|
-
if (target === pane) result.push(...(pane === "chat" ? [] : spacing), component);
|
|
86
|
-
spacing = [];
|
|
87
|
-
}
|
|
88
|
-
return result;
|
|
89
|
-
}
|
|
90
40
|
|
|
91
41
|
/**
|
|
92
42
|
* Project the live native component tree, not messages or renderer strings.
|
|
@@ -95,8 +45,8 @@ function assistantParts(source: NativeAssistant, pane: Pane): Component[] {
|
|
|
95
45
|
* Set invalidatesSource to false only when a shared root owns source invalidation.
|
|
96
46
|
*/
|
|
97
47
|
export class NativeDocument extends Container {
|
|
98
|
-
private readonly views = new WeakMap<NativeAssistant, Container>();
|
|
99
48
|
private readonly stamped = new WeakMap<Component, CompactMessage>();
|
|
49
|
+
private readonly clockOwners = new WeakMap<Component, Component>();
|
|
100
50
|
private readonly compactBlocks = new Map<Component, CompactMessage>();
|
|
101
51
|
private readonly positions = new Map<Component, { row: number; lines: string[]; clock?: SearchClock; excerptRow?: number }>();
|
|
102
52
|
private excerpt?: SearchHit;
|
|
@@ -119,6 +69,7 @@ export class NativeDocument extends Container {
|
|
|
119
69
|
private readonly showTimestamps: () => boolean,
|
|
120
70
|
private readonly interact: () => void = () => {},
|
|
121
71
|
private readonly invalidatesSource = true,
|
|
72
|
+
private readonly index = new NativeContentIndex(source),
|
|
122
73
|
) {
|
|
123
74
|
super();
|
|
124
75
|
}
|
|
@@ -142,69 +93,22 @@ export class NativeDocument extends Container {
|
|
|
142
93
|
if (!Array.isArray(chat?.children)) throw new Error("Pi's history document is not supported");
|
|
143
94
|
for (const component of chat.children) {
|
|
144
95
|
this.timestamps.check(component);
|
|
145
|
-
const native = assistant(component);
|
|
146
|
-
if (native) assistantParts(native, this.pane);
|
|
147
96
|
}
|
|
97
|
+
this.index.entries(this.pane);
|
|
148
98
|
this.checked = true;
|
|
149
99
|
}
|
|
150
100
|
|
|
151
101
|
/** Read full saved text from the current native branch, including collapsed blocks. */
|
|
152
102
|
searchEntries(): SearchEntry[] {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
};
|
|
157
|
-
const contentText = (content: unknown): string => typeof content === "string" ? content :
|
|
158
|
-
Array.isArray(content) ? content.filter(block => block?.type === "text" && typeof block.text === "string")
|
|
159
|
-
.map(block => block.text).join("\n") : "";
|
|
160
|
-
for (const component of (this.source.children[2] as Container).children) {
|
|
161
|
-
const name = nameOf(component);
|
|
162
|
-
const notice = compactionNotice(component, name, this.lastWidth);
|
|
163
|
-
const native = component as Component & {
|
|
164
|
-
text?: string; lastMessage?: { content: { type: string; text?: string; thinking?: string }[] };
|
|
165
|
-
toolName?: string; args?: unknown; result?: { content?: unknown };
|
|
166
|
-
command?: string; outputLines?: string[]; message?: { content?: unknown; summary?: string };
|
|
167
|
-
setExpanded?: (expanded: boolean) => void; setHideThinkingBlock?: (hide: boolean) => void;
|
|
168
|
-
};
|
|
169
|
-
if (name === "UserMessageComponent" && this.pane === "chat") {
|
|
170
|
-
if (typeof native.text !== "string") throw new Error("Pi's user search text is not supported");
|
|
171
|
-
add(component, "User", native.text);
|
|
172
|
-
} else if (name === "AssistantMessageComponent") {
|
|
173
|
-
if (!Array.isArray(native.lastMessage?.content)) throw new Error("Pi's assistant search text is not supported");
|
|
174
|
-
const kind = this.pane === "chat" ? "text" : "thinking";
|
|
175
|
-
const text = native.lastMessage?.content.filter(block => block.type === kind)
|
|
176
|
-
.map(block => kind === "text" ? block.text ?? "" : block.thinking ?? "").join("\n\n") ?? "";
|
|
177
|
-
add(component, kind === "text" ? "Assistant" : "Thinking", text, kind === "thinking" ? () => {
|
|
178
|
-
if (typeof native.setHideThinkingBlock !== "function") throw new Error("Pi's thinking reveal is not supported");
|
|
179
|
-
native.setHideThinkingBlock(false);
|
|
180
|
-
} : undefined);
|
|
181
|
-
} else if (this.pane === "activity" && name === "ToolExecutionComponent") {
|
|
182
|
-
if (typeof native.toolName !== "string") throw new Error("Pi's tool search text is not supported");
|
|
183
|
-
add(component, native.toolName, [native.toolName, JSON.stringify(native.args, null, 2), contentText(native.result?.content)]
|
|
184
|
-
.filter(Boolean).join("\n"), () => {
|
|
185
|
-
if (typeof native.setExpanded !== "function") throw new Error("Pi's tool reveal is not supported");
|
|
186
|
-
native.setExpanded(true);
|
|
187
|
-
});
|
|
188
|
-
} else if (this.pane === "activity" && name === "BashExecutionComponent") {
|
|
189
|
-
if (typeof native.command !== "string" || !Array.isArray(native.outputLines)) {
|
|
190
|
-
throw new Error("Pi's shell search text is not supported");
|
|
191
|
-
}
|
|
192
|
-
add(component, "Shell", [native.command, ...native.outputLines].join("\n"), () => native.setExpanded?.(true));
|
|
193
|
-
} else if (name === "CompactionSummaryMessageComponent" && this.pane === "chat") {
|
|
194
|
-
add(component, "Compaction", native.message?.summary ?? "", () => native.setExpanded?.(true));
|
|
195
|
-
} else if (notice !== undefined && this.pane === "chat") {
|
|
196
|
-
add(component, "Compaction", notice);
|
|
197
|
-
} else if (this.pane === "activity" && messagePane(name, notice) === "activity" && name !== "Spacer") {
|
|
198
|
-
add(component, name.replace(/(?:Message)?Component$/, ""),
|
|
199
|
-
contentText(native.message?.content) || native.message?.summary || searchText(component.render(this.lastWidth).join("\n")));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return entries;
|
|
103
|
+
return this.index.entries(this.pane).flatMap(entry => {
|
|
104
|
+
const saved = entry.search(this.lastWidth);
|
|
105
|
+
return saved.text.trim() ? [{ source: entry.source, ...saved }] : [];
|
|
106
|
+
});
|
|
203
107
|
}
|
|
204
108
|
|
|
205
109
|
/** Reject hits whose native owner was removed by a branch change or history rebuild. */
|
|
206
110
|
containsSearchHit(hit: SearchHit): boolean {
|
|
207
|
-
return
|
|
111
|
+
return this.index.contains(hit.entry.source, this.pane);
|
|
208
112
|
}
|
|
209
113
|
/** Reveal one selected block without replacing its native renderer. */
|
|
210
114
|
reveal(hit: SearchHit, query = ""): boolean {
|
|
@@ -274,79 +178,52 @@ export class NativeDocument extends Container {
|
|
|
274
178
|
this.timestamps.prepare(chat.children);
|
|
275
179
|
this.lastWidth = width;
|
|
276
180
|
this.positions.clear();
|
|
277
|
-
if (this.excerpt && !
|
|
278
|
-
if (this.searchAnchor && !
|
|
279
|
-
const result: Component[] =
|
|
181
|
+
if (this.excerpt && !this.index.contains(this.excerpt.entry.source, this.pane)) this.excerpt = undefined;
|
|
182
|
+
if (this.searchAnchor && !this.index.contains(this.searchAnchor.entry.source, this.pane)) this.searchAnchor = undefined;
|
|
183
|
+
const result: Component[] = [];
|
|
280
184
|
const locateMatch = this.searchAnchor !== undefined || this.excerpt !== undefined;
|
|
281
|
-
let row =
|
|
282
|
-
let spacing: Component[] = [];
|
|
185
|
+
let row = 0;
|
|
283
186
|
let visibleBlock = false;
|
|
284
|
-
for (const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
view = Object.create(native) as Container;
|
|
298
|
-
const content = new Container();
|
|
299
|
-
Object.assign(view, { children: [content], contentContainer: content });
|
|
300
|
-
this.views.set(native, view);
|
|
301
|
-
}
|
|
302
|
-
const content = view.children[0] as Container;
|
|
303
|
-
if (content.children.length !== parts.length || parts.some((part, index) => part !== content.children[index])) {
|
|
304
|
-
content.children = parts;
|
|
305
|
-
}
|
|
306
|
-
routed = view;
|
|
187
|
+
for (const entry of this.index.entries(this.pane)) {
|
|
188
|
+
const component = entry.source;
|
|
189
|
+
const routed = entry.component;
|
|
190
|
+
this.clockOwners.set(component, entry.owner);
|
|
191
|
+
const start = result.length;
|
|
192
|
+
const wasVisible = visibleBlock;
|
|
193
|
+
// Orientation keeps native padding; message projections share compact presentation.
|
|
194
|
+
if (!entry.compact) result.push(...entry.spacing, routed);
|
|
195
|
+
else {
|
|
196
|
+
let stamped = this.stamped.get(routed);
|
|
197
|
+
if (!stamped) {
|
|
198
|
+
stamped = new CompactMessage(routed, () => this.timestamps.label(this.clockOwners.get(component) ?? component), this.theme, this.timestampGutter, this.showTimestamps);
|
|
199
|
+
this.stamped.set(routed, stamped);
|
|
307
200
|
}
|
|
308
|
-
|
|
309
|
-
|
|
201
|
+
this.compactBlocks.set(component, stamped);
|
|
202
|
+
visibleBlock = stamped.prepare(width, visibleBlock) || visibleBlock;
|
|
203
|
+
result.push(stamped);
|
|
310
204
|
}
|
|
311
|
-
if (
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
visibleBlock = stamped.prepare(width, visibleBlock) || visibleBlock;
|
|
324
|
-
result.push(stamped);
|
|
325
|
-
}
|
|
326
|
-
if (this.searchAnchor?.entry.source === component && !this.excerpt) {
|
|
327
|
-
const last = result.length - 1;
|
|
328
|
-
result[last] = this.highlighted(result[last]);
|
|
329
|
-
}
|
|
330
|
-
if (locateMatch) {
|
|
331
|
-
const lines = result.slice(start).flatMap(child => child.render(width));
|
|
332
|
-
let excerptRow: number | undefined;
|
|
333
|
-
if (this.excerpt?.entry.source === component) {
|
|
334
|
-
const fragment = new Text(this.theme().fg("accent", "Search match: ") + searchText(this.excerpt.text), 1, 0);
|
|
335
|
-
if (!lines.length && wasVisible) {
|
|
336
|
-
result.push(new Spacer(1));
|
|
337
|
-
lines.push("");
|
|
338
|
-
}
|
|
339
|
-
excerptRow = row + lines.length;
|
|
340
|
-
const decorated = this.highlighted(fragment, true);
|
|
341
|
-
result.push(decorated);
|
|
342
|
-
lines.push(...decorated.render(width));
|
|
343
|
-
visibleBlock = true;
|
|
205
|
+
if (this.searchAnchor?.entry.source === component && !this.excerpt) {
|
|
206
|
+
const last = result.length - 1;
|
|
207
|
+
result[last] = this.highlighted(result[last]);
|
|
208
|
+
}
|
|
209
|
+
if (locateMatch) {
|
|
210
|
+
const lines = result.slice(start).flatMap(child => child.render(width));
|
|
211
|
+
let excerptRow: number | undefined;
|
|
212
|
+
if (this.excerpt?.entry.source === component) {
|
|
213
|
+
const fragment = new Text(this.theme().fg("accent", "Search match: ") + searchText(this.excerpt.text), 1, 0);
|
|
214
|
+
if (!lines.length && wasVisible) {
|
|
215
|
+
result.push(new Spacer(1));
|
|
216
|
+
lines.push("");
|
|
344
217
|
}
|
|
345
|
-
|
|
346
|
-
|
|
218
|
+
excerptRow = row + lines.length;
|
|
219
|
+
const decorated = this.highlighted(fragment, true);
|
|
220
|
+
result.push(decorated);
|
|
221
|
+
lines.push(...decorated.render(width));
|
|
222
|
+
visibleBlock = true;
|
|
347
223
|
}
|
|
224
|
+
this.positions.set(component, { row, lines, clock: this.compactBlocks.get(component)?.searchClock(), excerptRow });
|
|
225
|
+
row += lines.length;
|
|
348
226
|
}
|
|
349
|
-
spacing = [];
|
|
350
227
|
}
|
|
351
228
|
// Native follow-end guidance overlays the bottom row. Keep the selected last line above it.
|
|
352
229
|
if (this.searchAnchor) result.push(new Spacer(1));
|
package/src/startup.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InteractiveMode, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isViewportTUI, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
type Ui = ExtensionContext["ui"];
|
|
5
|
+
type Session = ExtensionContext["sessionManager"];
|
|
6
|
+
type StartupHost = {
|
|
7
|
+
isInitialized?: boolean;
|
|
8
|
+
renderer?: TUI;
|
|
9
|
+
sessionManager?: Session;
|
|
10
|
+
createExtensionUIContext?: () => Ui;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Mount once before the first native frame; restore only hooks still owned by this extension. */
|
|
14
|
+
export function beforeFirstFrame(mount: (tui: TUI, ui: Ui, session: Session) => () => void): () => void {
|
|
15
|
+
const prototype = InteractiveMode.prototype;
|
|
16
|
+
const original = prototype.init;
|
|
17
|
+
const initDescriptor = Object.getOwnPropertyDescriptor(prototype, "init");
|
|
18
|
+
if (typeof original !== "function" || !initDescriptor?.writable) return () => {};
|
|
19
|
+
let pending: typeof mount | undefined = mount;
|
|
20
|
+
let restoreStart = (): void => {};
|
|
21
|
+
const release = (): void => {
|
|
22
|
+
pending = undefined;
|
|
23
|
+
if (prototype.init === initialize) prototype.init = original;
|
|
24
|
+
restoreStart();
|
|
25
|
+
};
|
|
26
|
+
async function initialize(this: InteractiveMode): Promise<void> {
|
|
27
|
+
const mountLayout = pending;
|
|
28
|
+
release();
|
|
29
|
+
// A later wrapper may retain this trampoline. Cancellation makes it inert without clobbering that wrapper.
|
|
30
|
+
if (!mountLayout) return original.call(this);
|
|
31
|
+
const owner = this as unknown as StartupHost;
|
|
32
|
+
const tui = owner.renderer;
|
|
33
|
+
const session = owner.sessionManager;
|
|
34
|
+
// Unsupported hosts keep native startup. Never patch a regular renderer or invent a context.
|
|
35
|
+
if (owner.isInitialized !== false || !tui || !isViewportTUI(tui) ||
|
|
36
|
+
typeof owner.createExtensionUIContext !== "function" ||
|
|
37
|
+
typeof session?.buildContextEntries !== "function" || typeof session.getBranch !== "function") {
|
|
38
|
+
return original.call(this);
|
|
39
|
+
}
|
|
40
|
+
const ui = owner.createExtensionUIContext();
|
|
41
|
+
if (typeof ui?.theme?.style !== "function" || typeof ui.notify !== "function") return original.call(this);
|
|
42
|
+
const start = tui.start;
|
|
43
|
+
const descriptor = Object.getOwnPropertyDescriptor(tui, "start");
|
|
44
|
+
if (typeof start !== "function" || (descriptor ? !("value" in descriptor) || !descriptor.writable : !Object.isExtensible(tui))) {
|
|
45
|
+
return original.call(this);
|
|
46
|
+
}
|
|
47
|
+
let dispose: (() => void) | undefined;
|
|
48
|
+
restoreStart = (): void => {
|
|
49
|
+
if (tui.start !== startWithLayout) return;
|
|
50
|
+
if (descriptor) Object.defineProperty(tui, "start", descriptor);
|
|
51
|
+
else delete (tui as Partial<TUI>).start;
|
|
52
|
+
};
|
|
53
|
+
function startWithLayout(this: TUI): void {
|
|
54
|
+
restoreStart();
|
|
55
|
+
try { dispose = mountLayout!(tui!, ui, session!); }
|
|
56
|
+
catch (error) { ui.notify("pi-stereo-view early layout is unavailable: " + String(error), "warning"); }
|
|
57
|
+
start.call(this);
|
|
58
|
+
}
|
|
59
|
+
tui.start = startWithLayout;
|
|
60
|
+
try {
|
|
61
|
+
await original.call(this);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
dispose?.();
|
|
64
|
+
throw error;
|
|
65
|
+
} finally {
|
|
66
|
+
restoreStart();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
prototype.init = initialize;
|
|
70
|
+
return release;
|
|
71
|
+
}
|