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 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, 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-stereo-view",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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,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 (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,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) 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
+ }
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, { ...scrollOptions, primary: true }, () => this.activate("activity"));
189
- this.chat = new PanelScrollView(chatDocument, scrollOptions, () => this.activate("chat"));
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 VStack([
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, 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));
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
+ }