pi-stereo-view 0.1.2 → 0.1.4

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-10-20
4
+
5
+ ### Fixed
6
+
7
+ - Keep the split layout active while Pi-Bolt streams thinking or text. Later replies no longer require a restart to restore the panels; saved panel sizes and layout settings stay intact.
8
+ - Hide empty self-rendered tool blocks without leaving blank spacing or timestamps, even when the tool returns images to the model. Normal tool output and visibly rendered images remain visible.
9
+
10
+ ### Changed
11
+
12
+ - Show late output linked to a tool call directly below that call in Activity, even when other work has happened since. Activity search follows the same grouping; combined history keeps its original order.
13
+
14
+ ## 0.1.3 - 2026-10-19
15
+
16
+ - Show the saved split layout from the first visible frame, without briefly displaying the normal layout first.
17
+ - Keep dialogue and system information, including startup and runtime warnings, errors and status notices, in Conversation. Activity contains thinking, tools and shell execution.
18
+ - Use the same content classification for display and search. Related tool output stays with its tool; unrecognized content stays in Conversation.
19
+
3
20
  ## 0.1.2
4
21
 
5
22
  - 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,12 @@ 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, compaction summaries and compaction notices. |
24
- | **Activity** | Tool calls and results, thinking, and other messages generated by Pi. |
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.
27
+
28
+ Late output linked to a tool call appears directly below that call in Activity, even if other work has happened since. Combined history keeps its original order.
25
29
 
26
30
  Messages and tools keep their familiar appearance. The input stays full width below both panels.
27
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-stereo-view",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Keep Pi conversation separate from tool output and agent activity.",
6
6
  "license": "MIT",
package/src/content.ts ADDED
@@ -0,0 +1,239 @@
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
+ contentIsStale?: boolean;
28
+ buildContent?(message: NonNullable<NativeAssistant["lastMessage"]>): void;
29
+ setHideThinkingBlock(hidden: boolean): void;
30
+ }
31
+
32
+ /** One native owner and its pane projection; spacing and search retain that same owner. */
33
+ export interface ContentEntry {
34
+ source: Component;
35
+ owner: Component;
36
+ component: Component;
37
+ kind: ContentKind;
38
+ spacing: Component[];
39
+ compact: boolean;
40
+ search(width: number): Omit<SearchEntry, "source">;
41
+ }
42
+
43
+ const nameOf = (component: Component): string => component.constructor.name;
44
+ const contentText = (content: unknown): string => typeof content === "string" ? content :
45
+ Array.isArray(content) ? content.filter(block => block?.type === "text" && typeof block.text === "string")
46
+ .map(block => block.text).join("\n") : "";
47
+
48
+ function assistant(component: Component): NativeAssistant | undefined {
49
+ if (nameOf(component) !== "AssistantMessageComponent") return undefined;
50
+ const native = component as NativeAssistant;
51
+ if (!Array.isArray(native.contentContainer?.children) || typeof native.hasToolCalls !== "boolean" ||
52
+ (native.lastMessage !== undefined && !Array.isArray(native.lastMessage.content))) {
53
+ throw new Error("Pi's assistant content metadata is not supported");
54
+ }
55
+ // Pi-Bolt builds streamed children lazily. Synchronize the real owner before
56
+ // indexing or cloning it, so a pane cannot rebuild both content kinds itself.
57
+ if (native.contentIsStale === true) {
58
+ if (!native.lastMessage || typeof native.buildContent !== "function") {
59
+ throw new Error("Pi's deferred assistant content is not supported");
60
+ }
61
+ native.buildContent(native.lastMessage);
62
+ }
63
+ return native;
64
+ }
65
+
66
+ /** Read supported native execution identities; unrecognized content is system information. */
67
+ const nativeKinds: Record<string, { kind: ContentKind; accepts(content: NativeContent): boolean }> = {
68
+ UserMessageComponent: { kind: "dialogue", accepts: content => typeof content.text === "string" },
69
+ ToolExecutionComponent: { kind: "tool", accepts: content => typeof content.toolName === "string" },
70
+ BashExecutionComponent: {
71
+ kind: "shell", accepts: content => typeof content.command === "string" && Array.isArray(content.outputLines),
72
+ },
73
+ };
74
+
75
+ /** Shared semantic index for painting and search, independent of wording, severity and lifecycle. */
76
+ export class NativeContentIndex {
77
+ private signature: unknown[] = [];
78
+ private content: ContentEntry[] = [];
79
+ private readonly views = new WeakMap<NativeAssistant, Partial<Record<Pane, Container>>>();
80
+
81
+ constructor(private readonly source: Container) {}
82
+
83
+ /** Return the same classification until native ownership or assistant content changes. */
84
+ entries(pane: Pane): ContentEntry[] {
85
+ this.refresh();
86
+ return this.content.filter(entry => panes[entry.kind] === pane);
87
+ }
88
+
89
+ /** A rebuilt history cannot retain search hits belonging to removed native owners. */
90
+ contains(source: Component, pane?: Pane): boolean {
91
+ this.refresh();
92
+ return this.content.some(entry => entry.source === source && (pane === undefined || panes[entry.kind] === pane));
93
+ }
94
+
95
+ private refresh(): void {
96
+ const history = this.source.children[2] as Container;
97
+ if (!Array.isArray(history?.children)) throw new Error("Pi's history document is not supported");
98
+ const signature: unknown[] = [...this.source.children, ...history.children];
99
+ for (const component of history.children) {
100
+ const metadata = component as NativeContent;
101
+ signature.push(metadata.toolCallId, metadata.entry?.data?.parentToolCallId, metadata.message?.details?.parentToolCallId);
102
+ const native = assistant(component);
103
+ if (native) signature.push(native.lastMessage, ...native.contentContainer.children);
104
+ }
105
+ if (signature.length === this.signature.length && signature.every((value, index) => value === this.signature[index])) return;
106
+ const owners = new Map<string, NativeContent>();
107
+ for (const component of history.children) {
108
+ const adapter = nativeKinds[nameOf(component)];
109
+ const native = component as NativeContent;
110
+ if (adapter?.kind === "tool" && adapter.accepts(native) && typeof native.toolCallId === "string") {
111
+ owners.set(native.toolCallId, native);
112
+ }
113
+ }
114
+ const entries = this.source.children.slice(0, 2).map(component => this.entry(component, "system", [], false));
115
+ let spacing: Component[] = [];
116
+ for (const component of history.children) {
117
+ if (nameOf(component) === "Spacer") {
118
+ spacing.push(component);
119
+ continue;
120
+ }
121
+ const native = assistant(component);
122
+ if (native) entries.push(...this.assistantEntries(native, spacing));
123
+ else {
124
+ const adapter = nativeKinds[nameOf(component)];
125
+ const native = component as NativeContent;
126
+ const kind = adapter?.accepts(native) ? adapter.kind : "system";
127
+ const parentId = native.entry?.data?.parentToolCallId ?? native.message?.details?.parentToolCallId;
128
+ const owner = kind === "system" && typeof parentId === "string" ? owners.get(parentId) : undefined;
129
+ entries.push(this.entry(component, kind, spacing, true, owner));
130
+ }
131
+ spacing = [];
132
+ }
133
+ // Project late execution output beside its owner, never reorder native history.
134
+ const linked = new Map<Component, ContentEntry[]>();
135
+ for (const entry of entries) {
136
+ if (entry.owner === entry.source) continue;
137
+ const group = linked.get(entry.owner) ?? [];
138
+ group.push(entry);
139
+ linked.set(entry.owner, group);
140
+ }
141
+ this.content = entries.flatMap(entry => entry.owner === entry.source
142
+ ? [entry, ...(linked.get(entry.source) ?? [])] : []);
143
+ this.signature = signature;
144
+ }
145
+
146
+ private entry(source: Component, kind: ContentKind, spacing: Component[], compact: boolean, owner?: NativeContent): ContentEntry {
147
+ return { source, owner: owner ?? source, component: source, kind: owner ? "tool" : kind, spacing, compact, search: width => {
148
+ const native = source as NativeContent;
149
+ if (kind === "tool") {
150
+ if (typeof native.toolName !== "string") throw new Error("Pi's tool metadata is not supported");
151
+ return {
152
+ label: native.toolName,
153
+ text: [native.toolName, JSON.stringify(native.args, null, 2), contentText(native.result?.content)].filter(Boolean).join("\n"),
154
+ reveal: () => {
155
+ if (typeof native.setExpanded !== "function") throw new Error("Pi's tool reveal is not supported");
156
+ native.setExpanded(true);
157
+ },
158
+ };
159
+ }
160
+ if (kind === "shell") {
161
+ if (typeof native.command !== "string" || !Array.isArray(native.outputLines)) throw new Error("Pi's shell metadata is not supported");
162
+ return { label: "Shell", text: [native.command, ...native.outputLines].join("\n"), reveal: () => native.setExpanded?.(true) };
163
+ }
164
+ if (kind === "dialogue") {
165
+ if (typeof native.text !== "string") throw new Error("Pi's user text metadata is not supported");
166
+ return { label: "User", text: native.text };
167
+ }
168
+ return {
169
+ label: owner?.toolName ?? (native.message?.summary !== undefined ? "Summary" : "System"),
170
+ text: contentText(native.message?.content) || native.message?.summary || searchText(source.render(width).join("\n")),
171
+ reveal: typeof native.setExpanded === "function" ? () => native.setExpanded!(true) : undefined,
172
+ };
173
+ } };
174
+ }
175
+
176
+ private assistantEntries(source: NativeAssistant, spacing: Component[]): ContentEntry[] {
177
+ // Native rendering creates one child per visible text block or consecutive thinking run.
178
+ // Match those children to semantic content before projecting; a Text diagnostic is not thinking.
179
+ const runs: ContentKind[] = [];
180
+ let thinking = false;
181
+ for (const block of source.lastMessage?.content ?? []) {
182
+ if (block.type === "thinking") {
183
+ if (block.thinking?.trim() && !thinking) { runs.push("thinking"); thinking = true; }
184
+ } else {
185
+ thinking = false;
186
+ if (block.type === "text" && block.text?.trim()) runs.push("dialogue");
187
+ }
188
+ }
189
+ const groups: Record<Pane, Component[]> = { chat: [], activity: [] };
190
+ const diagnostics: Component[] = [];
191
+ let slot = 0;
192
+ let padding: Component[] = [];
193
+ for (const child of source.contentContainer.children) {
194
+ if (nameOf(child) === "Spacer") { padding.push(child); continue; }
195
+ const kind = runs[slot++] ?? "system";
196
+ if ((kind === "thinking" && nameOf(child) !== "MouseRegion") || (kind === "dialogue" && nameOf(child) !== "Markdown")) {
197
+ throw new Error("Pi's assistant rendering does not match its content metadata");
198
+ }
199
+ groups[panes[kind]].push(...padding, child);
200
+ if (kind === "system") diagnostics.push(child);
201
+ padding = [];
202
+ }
203
+ if (slot < runs.length) throw new Error("Pi's assistant content layout is not supported");
204
+ const views = this.views.get(source) ?? {};
205
+ this.views.set(source, views);
206
+ const result: ContentEntry[] = [];
207
+ for (const pane of ["chat", "activity"] as const) {
208
+ const parts = groups[pane];
209
+ if (!parts.length) continue;
210
+ let view = views[pane];
211
+ if (!view) {
212
+ view = Object.create(source) as Container;
213
+ const content = new Container();
214
+ Object.assign(view, {
215
+ children: [content], contentContainer: content,
216
+ // This is a partial view: rebuilding the assistant would restore both panes.
217
+ invalidate: () => content.invalidate(),
218
+ });
219
+ views[pane] = view;
220
+ }
221
+ const content = view.children[0] as Container;
222
+ content.children = parts;
223
+ result.push({
224
+ source, owner: source, component: view, kind: pane === "activity" ? "thinking" : runs.includes("dialogue") ? "dialogue" : "system", spacing, compact: true,
225
+ search: width => {
226
+ const kind = pane === "activity" ? "thinking" : "text";
227
+ const saved = (source.lastMessage?.content ?? []).filter(block => block.type === kind)
228
+ .map(block => kind === "text" ? block.text ?? "" : block.thinking ?? "").join("\n\n");
229
+ return {
230
+ label: pane === "activity" ? "Thinking" : "Assistant",
231
+ text: [saved, ...(pane === "chat" ? diagnostics.map(child => searchText(child.render(width).join("\n"))) : [])].filter(Boolean).join("\n\n"),
232
+ reveal: pane === "activity" ? () => source.setHideThinkingBlock(false) : undefined,
233
+ };
234
+ },
235
+ });
236
+ }
237
+ return result;
238
+ }
239
+ }
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 (ctx: ExtensionContext): Promise<void> => {
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 ctx.ui.custom<void>((tui, _theme, _keys, done) => {
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: () => [ctx.ui.theme.fg("accent", ctx.ui.theme.bold("pi-stereo-view settings")), ""],
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 => ctx.ui.theme.bg("customMessageBg", 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 = (ctx: ExtensionContext): void => {
77
+ const persist = (ui: ExtensionContext["ui"]): void => {
68
78
  const snapshot = { ...preferences };
69
79
  writes = writes.then(() => savePreferences(file, snapshot)).catch(error => {
70
- ctx.ui.notify("Could not save pi-stereo-view preferences: " + String(error), "error");
80
+ ui.notify("Could not save pi-stereo-view preferences: " + String(error), "error");
71
81
  });
72
82
  };
73
83
 
74
- pi.on("session_start", async (_event, ctx) => {
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
- closeSearch();
77
- layout?.dispose();
78
- unsubscribeInput?.();
79
- timestamps = new TranscriptTimestamps(ctx.sessionManager);
80
- try {
81
- preferences = await loadPreferences(file);
82
- } catch (error) {
83
- ctx.ui.notify("Could not read pi-stereo-view preferences; using defaults: " + String(error), "warning");
84
- preferences = { ...defaults };
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) return;
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 VStack([
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, stripTerminalSequences, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
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 = "activity" | "chat";
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
- const entries: SearchEntry[] = [];
154
- const add = (source: Component, label: string, text: string, reveal?: () => void): void => {
155
- if (text.trim()) entries.push({ source, label, text, reveal });
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 (this.source.children[2] as Container).children.includes(hit.entry.source);
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 && !chat.children.includes(this.excerpt.entry.source)) this.excerpt = undefined;
278
- if (this.searchAnchor && !chat.children.includes(this.searchAnchor.entry.source)) this.searchAnchor = undefined;
279
- const result: Component[] = this.pane !== "chat" ? this.source.children.slice(0, 2) : [];
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 = locateMatch ? result.reduce((count, child) => count + child.render(width).length, 0) : 0;
282
- let spacing: Component[] = [];
185
+ let row = 0;
283
186
  let visibleBlock = false;
284
- for (const component of chat.children) {
285
- const name = nameOf(component);
286
- if (name === "Spacer") {
287
- spacing.push(component);
288
- continue;
289
- }
290
- const native = assistant(component);
291
- let routed: Component | undefined;
292
- if (native) {
293
- const parts = assistantParts(native, this.pane);
294
- if (parts.length) {
295
- let view = this.views.get(native);
296
- if (!view) {
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
- } else {
309
- if (messagePane(name, compactionNotice(component, name, width)) === this.pane) routed = component;
201
+ this.compactBlocks.set(component, stamped);
202
+ visibleBlock = stamped.prepare(width, visibleBlock) || visibleBlock;
203
+ result.push(stamped);
310
204
  }
311
- if (routed) {
312
- const start = result.length;
313
- const wasVisible = visibleBlock;
314
- // Empty native assistant shells do not need a detached timestamp row.
315
- if (native && native.contentContainer.children.length === 0) result.push(...spacing, routed);
316
- else {
317
- let stamped = this.stamped.get(routed);
318
- if (!stamped) {
319
- stamped = new CompactMessage(routed, () => this.timestamps.label(component), this.theme, this.timestampGutter, this.showTimestamps);
320
- this.stamped.set(routed, stamped);
321
- }
322
- this.compactBlocks.set(component, stamped);
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
- this.positions.set(component, { row, lines, clock: this.compactBlocks.get(component)?.searchClock(), excerptRow });
346
- row += lines.length;
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));
@@ -1,4 +1,23 @@
1
- import type { Component } from "@earendil-works/pi-tui";
1
+ import { stripTerminalSequences, type Component } from "@earendil-works/pi-tui";
2
+
3
+ const imagePlacement = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
4
+
5
+ function nativeLines(component: Component, width: number): string[] {
6
+ const lines = component.render(width);
7
+ const tool = component as Component & {
8
+ toolDefinition?: { renderShell?: string; renderCall?: unknown; renderResult?: unknown };
9
+ selfRenderHeight?: number;
10
+ };
11
+ const definition = tool.toolDefinition;
12
+ const height = tool.selfRenderHeight;
13
+ if (component.constructor.name !== "ToolExecutionComponent" || definition?.renderShell !== "self" ||
14
+ typeof definition.renderCall !== "function" || typeof definition.renderResult !== "function" ||
15
+ typeof height !== "number" || !Number.isInteger(height) || height < 0) return lines;
16
+ // Native self shells prepend one row, then append result images outside the custom UI.
17
+ // An intentionally empty custom UI must not be made visible by those automatic images.
18
+ const content = height ? lines.slice(1, height + 1) : [];
19
+ return content.some(line => imagePlacement(line) || stripTerminalSequences(line).trim().length > 0) ? lines : [];
20
+ }
2
21
 
3
22
  interface Subscription {
4
23
  original: Component["invalidate"];
@@ -180,9 +199,9 @@ export class NativeRenderCache {
180
199
  const initial = components(this.component);
181
200
  const observed = this.watch(initial);
182
201
  const before = this.revision;
183
- const lines = this.component.render(width);
202
+ const lines = nativeLines(this.component, width);
184
203
  this.outputVersion++;
185
- this.imagePlacement = lines.some(line => line.includes("\x1b_G") || line.includes("\x1b]1337;File="));
204
+ this.imagePlacement = lines.some(imagePlacement);
186
205
  const links: NodeLinks[] = [];
187
206
  const after = components(this.component, links);
188
207
  const stableGraph = initial.length === after.length &&
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
+ }