pi-stereo-view 0.1.1 → 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 +11 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/content.ts +220 -0
- package/src/index.ts +63 -23
- package/src/layout.ts +47 -7
- package/src/native.ts +50 -173
- package/src/startup.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.1.2
|
|
10
|
+
|
|
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.
|
|
12
|
+
- `Ctrl+Alt+Up` or `Ctrl+Alt+Down` switches the active panel; press again to switch back.
|
|
13
|
+
|
|
3
14
|
## 0.1.1
|
|
4
15
|
|
|
5
16
|
- Mouse wheel over Conversation or its header no longer scrolls Activity when Conversation is too short to scroll.
|
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
|
|
|
@@ -128,6 +130,8 @@ The same controls are available without the mouse:
|
|
|
128
130
|
|
|
129
131
|
`Ctrl+Alt+T` toggles split, `Ctrl+Alt+S` swaps panels, and `Ctrl+Alt+Left/Right` resizes Conversation along the current split direction.
|
|
130
132
|
|
|
133
|
+
Keyboard scrolling (`PgUp`/`PgDn` and Pi's other scroll keys) moves the active panel, marked with `●`. Clicking or scrolling a panel makes it active. `Ctrl+Alt+Up` or `Ctrl+Alt+Down` switches to the other panel; press either again to switch back.
|
|
134
|
+
|
|
131
135
|
## Settings and limits
|
|
132
136
|
|
|
133
137
|
Your layout, panel order, sizes, zoom, split on/off and timestamp settings are saved automatically and restored next time.
|
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,9 +187,9 @@ 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
|
-
ctx.ui.notify("pi-stereo-view icons: ▣ combined, ◫ split, ▤ rows, ▥ columns, ↕/↔ swap, ↗ expand, ↙ restore, ⌕ search, ≡ settings. Ctrl+F searches the active panel. Drag the ═ or ║ divider to resize.", "info");
|
|
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();
|
|
159
194
|
else if (action === "width" && /^\d+$/.test(target ?? "")) layout.setWidth(Number(target));
|
|
160
195
|
else if (action === "height" && target === "auto") layout.setHeight(null);
|
|
@@ -173,6 +208,11 @@ export default function transcript(pi: ExtensionAPI): void {
|
|
|
173
208
|
});
|
|
174
209
|
pi.registerShortcut("ctrl+alt+t", { description: "Toggle split history", handler: async () => toggle() });
|
|
175
210
|
pi.registerShortcut("ctrl+alt+s", { description: "Swap stereo-view panels", handler: async () => layout?.swap() });
|
|
211
|
+
for (const key of ["ctrl+alt+up", "ctrl+alt+down"] as const) {
|
|
212
|
+
pi.registerShortcut(key, { description: "Move keyboard scrolling to the other stereo-view panel", handler: async () => {
|
|
213
|
+
layout?.cyclePanel();
|
|
214
|
+
} });
|
|
215
|
+
}
|
|
176
216
|
pi.registerShortcut("ctrl+alt+left", { description: "Shrink conversation along the split axis", handler: async () => {
|
|
177
217
|
layout?.resize(-1);
|
|
178
218
|
} });
|
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";
|
|
@@ -20,8 +21,10 @@ const MIN_ROWS = 4;
|
|
|
20
21
|
const BUTTON_WIDTH = 5;
|
|
21
22
|
|
|
22
23
|
class PanelScrollView extends ScrollView {
|
|
23
|
-
constructor(component: Component, options: ScrollViewOptions, private readonly interact: () => void) {
|
|
24
|
+
constructor(component: Component, options: ScrollViewOptions, private readonly interact: () => void, active: () => boolean) {
|
|
24
25
|
super(component, options);
|
|
26
|
+
// Pi sends keyboard scrolling and native search to the primary ScrollView; follow the active panel.
|
|
27
|
+
Object.defineProperty(this, "primary", { get: active });
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
override handleMouse(event: TuiMouseEvent): ReturnType<Container["handleMouse"]> {
|
|
@@ -51,6 +54,25 @@ class TranscriptRows extends VStack {
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
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
|
+
|
|
54
76
|
class TranscriptPane extends VStack {
|
|
55
77
|
constructor(private readonly header: Component, private readonly view: ScrollView, private readonly height: () => number) {
|
|
56
78
|
super([
|
|
@@ -142,7 +164,16 @@ export class TranscriptLayout {
|
|
|
142
164
|
this.warn("pi-stereo-view split is unavailable: Pi's focus API is not supported.");
|
|
143
165
|
return;
|
|
144
166
|
}
|
|
145
|
-
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
|
+
}
|
|
146
177
|
const root = host.layoutRoot;
|
|
147
178
|
if (this.host) this.dispose();
|
|
148
179
|
if (root && this.rejectedRoots.has(root)) return;
|
|
@@ -168,10 +199,11 @@ export class TranscriptLayout {
|
|
|
168
199
|
// Auto scrollbars overlay one native content column; keep clocks outside it.
|
|
169
200
|
const timestampGutter = (): number => scroll.scrollbar === "auto" ? 1 : 0;
|
|
170
201
|
const showTimestamps = (): boolean => this.preferences.showTimestamps;
|
|
202
|
+
const index = new NativeContentIndex(document);
|
|
171
203
|
const activityDocument = new NativeDocument(document, "activity", this.timestamps, this.theme, timestampGutter, showTimestamps,
|
|
172
|
-
() => this.activate("activity"), false);
|
|
204
|
+
() => this.activate("activity"), false, index);
|
|
173
205
|
const chatDocument = new NativeDocument(document, "chat", this.timestamps, this.theme, timestampGutter, showTimestamps,
|
|
174
|
-
() => this.activate("chat"), false);
|
|
206
|
+
() => this.activate("chat"), false, index);
|
|
175
207
|
this.documents = { activity: activityDocument, chat: chatDocument };
|
|
176
208
|
this.disposeDocuments = () => {
|
|
177
209
|
activityDocument.dispose();
|
|
@@ -185,8 +217,9 @@ export class TranscriptLayout {
|
|
|
185
217
|
scrollbarTrackStyle: scroll.scrollbarTrackStyle,
|
|
186
218
|
scrollbarThumbStyle: scroll.scrollbarThumbStyle,
|
|
187
219
|
};
|
|
188
|
-
this.activity = new PanelScrollView(activityDocument,
|
|
189
|
-
|
|
220
|
+
this.activity = new PanelScrollView(activityDocument, scrollOptions, () => this.activate("activity"),
|
|
221
|
+
() => this.searchPane() === "activity");
|
|
222
|
+
this.chat = new PanelScrollView(chatDocument, scrollOptions, () => this.activate("chat"), () => this.searchPane() === "chat");
|
|
190
223
|
let historyHeight = 0;
|
|
191
224
|
const shown = ({ width, height }: { width: number; height: number }): boolean => {
|
|
192
225
|
const previousHeight = historyHeight;
|
|
@@ -347,7 +380,7 @@ export class TranscriptLayout {
|
|
|
347
380
|
}
|
|
348
381
|
};
|
|
349
382
|
this.rebuild();
|
|
350
|
-
const mounted = new
|
|
383
|
+
const mounted = new TranscriptRoot([
|
|
351
384
|
{ component: toolbar, basis: 1, shrink: 0, visible: () => {
|
|
352
385
|
// Root visibility is evaluated once at the start of each native layout pass.
|
|
353
386
|
this.timestamps.beginFrame();
|
|
@@ -568,6 +601,13 @@ export class TranscriptLayout {
|
|
|
568
601
|
this.changed();
|
|
569
602
|
}
|
|
570
603
|
|
|
604
|
+
/** Cycle keyboard scrolling and search to the other panel. */
|
|
605
|
+
cyclePanel(): void {
|
|
606
|
+
const pane = this.searchPane();
|
|
607
|
+
if (!pane || this.preferences.zoom) return;
|
|
608
|
+
this.activate(pane === "chat" ? "activity" : "chat");
|
|
609
|
+
}
|
|
610
|
+
|
|
571
611
|
/** Scroll one native pane without moving the other. */
|
|
572
612
|
scroll(pane: Pane, lines: number): void {
|
|
573
613
|
(pane === "chat" ? this.chat : this.activity)?.scrollBy(lines);
|
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
|
+
}
|