@oh-my-pi/pi-coding-agent 14.4.3 → 14.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,248 +2,276 @@
2
2
  * Session observer overlay component.
3
3
  *
4
4
  * Picker mode: lists main + active subagent sessions with live status.
5
- * Viewer mode: renders a read-only transcript of the selected subagent's session
6
- * by reading its JSONL session file — shows thinking, text, tool calls, results.
5
+ * Viewer mode: renders a scrollable, interactive transcript of the selected subagent's session
6
+ * by reading its JSONL session file — shows thinking, text, tool calls, results
7
+ * with expand/collapse per entry and breadcrumb navigation for nested sub-agents.
7
8
  *
8
9
  * Lifecycle:
9
10
  * - shortcut opens picker
10
11
  * - Enter on a subagent -> viewer
11
- * - shortcut while in viewer -> back to picker
12
- * - Esc from viewer -> back to picker
12
+ * - shortcut while in viewer -> back to picker (or pop breadcrumb)
13
+ * - Esc from viewer -> back to picker (or pop breadcrumb)
13
14
  * - Esc from picker -> close overlay
14
15
  * - Enter on main session -> close overlay (jump back)
15
16
  */
16
- import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai";
17
- import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
17
+ import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
+ import { Container, Markdown, type MarkdownTheme, matchesKey } from "@oh-my-pi/pi-tui";
18
19
  import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
19
20
  import type { KeyId } from "../../config/keybindings";
20
21
  import type { SessionMessageEntry } from "../../session/session-manager";
21
22
  import { parseSessionEntries } from "../../session/session-manager";
22
- import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
23
+ import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
23
24
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
24
- import { getMarkdownTheme, getSelectListTheme, theme } from "../theme/theme";
25
+ import { getMarkdownTheme, theme } from "../theme/theme";
25
26
  import { DynamicBorder } from "./dynamic-border";
26
27
 
27
- type Mode = "picker" | "viewer";
28
-
29
- /** Max thinking characters to show (long thinking can be huge) */
30
- const MAX_THINKING_CHARS = 600;
28
+ /** Max thinking characters in collapsed state */
29
+ const MAX_THINKING_CHARS_COLLAPSED = 200;
30
+ /** Max thinking characters in expanded state */
31
+ const MAX_THINKING_CHARS_EXPANDED = 4000;
31
32
  /** Max tool args characters to display */
32
- const MAX_TOOL_ARGS_CHARS = 200;
33
- /** Max tool result text to display */
34
- const MAX_TOOL_RESULT_CHARS = 300;
33
+ const MAX_TOOL_ARGS_CHARS = 500;
34
+ /** Lines per page for PageUp/PageDown */
35
+ const PAGE_SIZE = 15;
36
+ /** Left indent for content under entry headers */
37
+ const INDENT = " ";
38
+
39
+ /** Compute the max content width for the current terminal, accounting for indent and chrome. */
40
+ function contentWidth(indent = INDENT): number {
41
+ return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
42
+ }
43
+
44
+ /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
45
+ function sanitizeLine(text: string, maxWidth?: number): string {
46
+ return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth());
47
+ }
48
+
49
+ /** Represents a rendered entry in the viewer for selection/expand tracking */
50
+ interface ViewerEntry {
51
+ lineStart: number;
52
+ lineCount: number;
53
+ kind: "thinking" | "text" | "toolCall" | "user";
54
+ }
55
+
56
+ /** Breadcrumb item for nested session navigation */
57
+ interface BreadcrumbItem {
58
+ sessionId: string;
59
+ label: string;
60
+ sessionFile: string;
61
+ }
35
62
 
36
63
  export class SessionObserverOverlayComponent extends Container {
37
64
  #registry: SessionObserverRegistry;
38
65
  #onDone: () => void;
39
- #mode: Mode = "picker";
40
- #selectList: SelectList;
41
- #viewerContainer: Container;
42
66
  #selectedSessionId?: string;
43
67
  #observeKeys: KeyId[];
44
- /** Cached parsed transcript per session file to avoid reparsing on every refresh */
45
- #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[] };
46
- /** Live stats text component, placed after transcript to avoid above-viewport diffs */
47
- #statsText?: Text;
68
+ #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
69
+
70
+ // Scroll state
71
+ #scrollOffset = 0;
72
+ #renderedLines: string[] = [];
73
+ #viewportHeight = 20;
74
+ #wasAtBottom = true;
75
+
76
+ // Entry selection & expand/collapse
77
+ #viewerEntries: ViewerEntry[] = [];
78
+ #selectedEntryIndex = 0;
79
+ #expandedEntries = new Set<number>();
80
+
81
+ // Breadcrumb navigation
82
+ #navigationStack: BreadcrumbItem[] = [];
83
+
84
+ // Cached header/footer for viewer (rebuilt on refresh)
85
+ #viewerHeaderLines: string[] = [];
86
+ #viewerFooterLines: string[] = [];
87
+ // Markdown rendering
88
+ #mdTheme: MarkdownTheme = getMarkdownTheme();
48
89
 
49
90
  constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
50
91
  super();
51
92
  this.#registry = registry;
52
93
  this.#onDone = onDone;
53
94
  this.#observeKeys = observeKeys;
54
- this.#selectList = new SelectList([], 0, getSelectListTheme());
55
- this.#viewerContainer = new Container();
56
-
57
- this.#setupPicker();
58
- }
59
-
60
- #setupPicker(): void {
61
- this.#mode = "picker";
62
- this.children = [];
63
95
 
64
- this.addChild(new DynamicBorder());
65
- this.addChild(new Text(theme.bold(theme.fg("accent", "Session Observer")), 1, 0));
66
- this.addChild(new Spacer(1));
67
-
68
- const items = this.#buildPickerItems();
69
- this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
70
-
71
- this.#selectList.onSelect = item => {
72
- if (item.value === "main") {
73
- this.#onDone();
74
- return;
75
- }
76
- this.#selectedSessionId = item.value;
96
+ // Jump directly to the most recently active sub-agent
97
+ const mostRecent = this.#getMostRecentSubagent();
98
+ if (mostRecent) {
99
+ this.#selectedSessionId = mostRecent.id;
77
100
  this.#setupViewer();
78
- };
101
+ } else {
102
+ // No sub-agents — close immediately
103
+ queueMicrotask(() => this.#onDone());
104
+ }
105
+ }
79
106
 
80
- this.#selectList.onCancel = () => {
81
- this.#onDone();
82
- };
107
+ /** Find the most recently updated sub-agent session (prefer active ones) */
108
+ #getMostRecentSubagent(): ObservableSession | undefined {
109
+ const sessions = this.#registry.getSessions().filter(s => s.kind === "subagent");
110
+ if (sessions.length === 0) return undefined;
111
+ // Prefer active sessions, then sort by lastUpdate descending
112
+ const active = sessions.filter(s => s.status === "active");
113
+ const pool = active.length > 0 ? active : sessions;
114
+ return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
115
+ }
83
116
 
84
- this.addChild(this.#selectList);
85
- this.addChild(new DynamicBorder());
117
+ override render(width: number): string[] {
118
+ return this.#renderViewer(width);
86
119
  }
87
120
 
88
121
  #setupViewer(): void {
89
- this.#mode = "viewer";
90
122
  this.children = [];
91
- this.#viewerContainer = new Container();
92
- this.#statsText = new Text("", 1, 0);
93
- this.#refreshViewer();
94
-
95
- this.addChild(new DynamicBorder());
96
- this.addChild(this.#viewerContainer);
97
- this.addChild(new Spacer(1));
98
- this.addChild(this.#statsText);
99
- this.addChild(new Text(theme.fg("dim", "Esc: back to picker | Ctrl+S: back to picker"), 1, 0));
100
- this.addChild(new DynamicBorder());
123
+ this.#scrollOffset = 0;
124
+ this.#selectedEntryIndex = 0;
125
+ this.#expandedEntries.clear();
126
+ this.#wasAtBottom = true;
127
+ this.#rebuildViewerContent();
128
+ // Auto-scroll to bottom and select last entry on init
129
+ if (this.#viewerEntries.length > 0) {
130
+ this.#selectedEntryIndex = this.#viewerEntries.length - 1;
131
+ this.#wasAtBottom = true;
132
+ this.#rebuildViewerContent();
133
+ }
101
134
  }
102
135
 
103
136
  /** Rebuild content from live registry data */
104
137
  refreshFromRegistry(): void {
105
- if (this.#mode === "picker") {
106
- this.#refreshPickerItems();
107
- } else if (this.#mode === "viewer" && this.#selectedSessionId) {
108
- this.#refreshViewer();
138
+ if (this.#selectedSessionId) {
139
+ // Keep auto-scrolling to bottom unless the user navigated away from the last entry
140
+ this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
141
+ this.#rebuildViewerContent();
109
142
  }
110
143
  }
111
144
 
112
- #refreshPickerItems(): void {
113
- // Preserve selection across refresh by matching on value
114
- const previousValue = this.#selectList.getSelectedItem()?.value;
115
-
116
- const items = this.#buildPickerItems();
117
- const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
118
- newList.onSelect = this.#selectList.onSelect;
119
- newList.onCancel = this.#selectList.onCancel;
145
+ /** Rebuild the transcript content lines (called on setup and refresh) */
146
+ #rebuildViewerContent(): void {
147
+ const sessions = this.#registry.getSessions();
148
+ const session = sessions.find(s => s.id === this.#selectedSessionId);
120
149
 
121
- if (previousValue) {
122
- const newIndex = items.findIndex(i => i.value === previousValue);
123
- if (newIndex >= 0) newList.setSelectedIndex(newIndex);
150
+ // Load transcript first so model info is available for header
151
+ let messageEntries: SessionMessageEntry[] | null = null;
152
+ if (session?.sessionFile) {
153
+ messageEntries = this.#loadTranscript(session.sessionFile);
124
154
  }
125
155
 
126
- const idx = this.children.indexOf(this.#selectList);
127
- if (idx >= 0) {
128
- this.children[idx] = newList;
156
+ // Header
157
+ this.#viewerHeaderLines = [];
158
+ const breadcrumb = this.#buildBreadcrumb(session);
159
+ this.#viewerHeaderLines.push(theme.fg("accent", breadcrumb));
160
+ if (session) {
161
+ const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
162
+ const statusText = theme.fg(statusColor, `[${session.status}]`);
163
+ const agentTag = session.agent ? theme.fg("dim", ` ${session.agent}`) : "";
164
+ const subagentIds = this.#getSubagentSessionIds();
165
+ const posIdx = subagentIds.indexOf(this.#selectedSessionId ?? "");
166
+ const posLabel =
167
+ subagentIds.length > 1 && posIdx >= 0 ? theme.fg("dim", ` (${posIdx + 1}/${subagentIds.length})`) : "";
168
+ const modelName = this.#transcriptCache?.model;
169
+ const modelLabel = modelName ? theme.fg("muted", ` · ${modelName}`) : "";
170
+ this.#viewerHeaderLines.push(`${theme.bold(session.label)} ${statusText}${agentTag}${posLabel}${modelLabel}`);
129
171
  }
130
- this.#selectList = newList;
131
- }
132
172
 
133
- #refreshViewer(): void {
134
- this.#viewerContainer.clear();
173
+ // Content
174
+ const contentLines: string[] = [];
175
+ this.#viewerEntries = [];
135
176
 
136
- const sessions = this.#registry.getSessions();
137
- const session = sessions.find(s => s.id === this.#selectedSessionId);
138
177
  if (!session) {
139
- this.#viewerContainer.addChild(new Text(theme.fg("dim", "Session no longer available."), 1, 0));
140
- this.#updateStats(undefined);
141
- return;
178
+ contentLines.push(theme.fg("dim", "Session no longer available."));
179
+ } else if (!session.sessionFile) {
180
+ contentLines.push(theme.fg("dim", "No session file available yet."));
181
+ } else if (!messageEntries) {
182
+ contentLines.push(theme.fg("dim", "Unable to read session file."));
183
+ } else if (messageEntries.length === 0) {
184
+ contentLines.push(theme.fg("dim", "No messages yet."));
185
+ } else {
186
+ this.#buildTranscriptLines(messageEntries, contentLines);
187
+ }
188
+ this.#renderedLines = contentLines;
189
+
190
+ // Footer
191
+ this.#viewerFooterLines = [];
192
+ const statsLine = this.#buildStatsLine(session);
193
+ if (statsLine) this.#viewerFooterLines.push(statsLine);
194
+ this.#viewerFooterLines.push(
195
+ theme.fg("dim", "j/k:scroll Enter:expand [/]/\u2190\u2192:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
196
+ );
197
+
198
+ // Auto-scroll to bottom if we were at bottom
199
+ if (this.#wasAtBottom) {
200
+ this.#scrollOffset = Math.max(0, contentLines.length - this.#viewportHeight);
142
201
  }
143
-
144
- this.#renderSessionHeader(session);
145
- this.#renderSessionTranscript(session);
146
- this.#updateStats(session);
147
202
  }
148
203
 
149
- #renderSessionHeader(session: ObservableSession): void {
150
- const c = this.#viewerContainer;
151
-
152
- // Header: label + status + [agent]
153
- const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
154
- const statusText = theme.fg(statusColor, session.status);
155
- const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : "";
156
- c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0));
204
+ /** Produce the final viewer output for the overlay system */
205
+ #renderViewer(width: number): string[] {
206
+ const termHeight = process.stdout.rows || 40;
157
207
 
158
- if (session.description) {
159
- c.addChild(new Text(theme.fg("muted", session.description), 1, 0));
160
- }
208
+ // Compute viewport: total height minus header chrome and footer chrome
209
+ // Header: border(1) + headerLines + border(1) = headerLines.length + 2
210
+ // Footer: spacer(1) + scrollInfo(1) + footerLines + border(1) = footerLines.length + 2
211
+ const headerChrome = this.#viewerHeaderLines.length + 2;
212
+ const footerChrome = this.#viewerFooterLines.length + 2;
213
+ this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
161
214
 
162
- if (session.sessionFile) {
163
- c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(session.sessionFile)}`), 1, 0));
164
- }
215
+ // Clamp scroll offset
216
+ const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
217
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
165
218
 
166
- c.addChild(new DynamicBorder());
167
- }
219
+ const lines: string[] = [];
168
220
 
169
- /** Update live stats in-place (below transcript, within viewport). */
170
- #updateStats(session: ObservableSession | undefined): void {
171
- if (!this.#statsText) return;
172
- const progress = session?.progress;
173
- if (!progress) {
174
- this.#statsText.setText("");
175
- return;
221
+ // --- Header ---
222
+ lines.push(...new DynamicBorder().render(width));
223
+ for (const hl of this.#viewerHeaderLines) {
224
+ lines.push(` ${hl}`);
176
225
  }
177
- const stats: string[] = [];
178
- if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
179
- if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
180
- if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
181
- this.#statsText.setText(stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : "");
182
- }
226
+ lines.push(...new DynamicBorder().render(width));
183
227
 
184
- /** Incrementally read and parse the session JSONL, caching already-parsed entries. */
185
- #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
186
- // Invalidate cache if session file changed (e.g. switched to different subagent)
187
- if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
188
- this.#transcriptCache = undefined;
228
+ // --- Scrolled content viewport ---
229
+ const visibleLines = this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight);
230
+ for (const vl of visibleLines) {
231
+ lines.push(` ${vl}`);
189
232
  }
190
-
191
- const fromByte = this.#transcriptCache?.bytesRead ?? 0;
192
- const result = readFileIncremental(sessionFile, fromByte);
193
- if (!result) {
194
- logger.debug("Session observer: failed to read session file", { path: sessionFile });
195
- return this.#transcriptCache?.entries ?? null;
233
+ // Pad to fill viewport if content is shorter
234
+ const pad = this.#viewportHeight - visibleLines.length;
235
+ for (let i = 0; i < pad; i++) {
236
+ lines.push("");
196
237
  }
197
238
 
198
- // File shrank (compaction or pruning rewrote it) — invalidate and re-read from scratch
199
- if (result.newSize < fromByte) {
200
- this.#transcriptCache = undefined;
201
- return this.#loadTranscript(sessionFile);
202
- }
203
-
204
- if (!this.#transcriptCache) {
205
- this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
239
+ // --- Footer ---
240
+ const scrollInfo =
241
+ this.#renderedLines.length > this.#viewportHeight
242
+ ? ` ${theme.fg("dim", `[${this.#scrollOffset + 1}-${Math.min(this.#scrollOffset + this.#viewportHeight, this.#renderedLines.length)}/${this.#renderedLines.length}]`)}`
243
+ : "";
244
+ lines.push("");
245
+ lines.push(` ${this.#viewerFooterLines[0] ?? ""}${scrollInfo}`);
246
+ for (let i = 1; i < this.#viewerFooterLines.length; i++) {
247
+ lines.push(` ${this.#viewerFooterLines[i]}`);
206
248
  }
249
+ lines.push(...new DynamicBorder().render(width));
207
250
 
208
- // Parse only new bytes, but only up to the last complete line.
209
- // A partial trailing record (mid-write) must not be consumed —
210
- // we leave those bytes for the next refresh.
211
- if (result.text.length > 0) {
212
- const lastNewline = result.text.lastIndexOf("\n");
213
- if (lastNewline >= 0) {
214
- const completeChunk = result.text.slice(0, lastNewline + 1);
215
- const newEntries = parseSessionEntries(completeChunk);
216
- for (const entry of newEntries) {
217
- if (entry.type === "message") {
218
- this.#transcriptCache.entries.push(entry as SessionMessageEntry);
219
- }
220
- }
221
- this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
222
- }
223
- // If no newline found, the entire chunk is partial — leave bytesRead unchanged
224
- }
225
- return this.#transcriptCache.entries;
251
+ return lines;
226
252
  }
227
253
 
228
- #renderSessionTranscript(session: ObservableSession): void {
229
- const c = this.#viewerContainer;
230
-
231
- if (!session.sessionFile) {
232
- c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0));
233
- return;
254
+ #buildBreadcrumb(session: ObservableSession | undefined): string {
255
+ const parts: string[] = ["Session Observer"];
256
+ for (const item of this.#navigationStack) {
257
+ parts.push(item.label);
234
258
  }
259
+ if (session) parts.push(session.label);
260
+ return parts.join(" > ");
261
+ }
235
262
 
236
- const messageEntries = this.#loadTranscript(session.sessionFile);
237
- if (!messageEntries) {
238
- c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0));
239
- return;
240
- }
241
- if (messageEntries.length === 0) {
242
- c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0));
243
- return;
244
- }
263
+ #buildStatsLine(session: ObservableSession | undefined): string {
264
+ const progress = session?.progress;
265
+ if (!progress) return "";
266
+ const stats: string[] = [];
267
+ if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
268
+ if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
269
+ if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
270
+ return stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : "";
271
+ }
245
272
 
246
- // Build a tool call ID -> tool result map for matching
273
+ #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
274
+ // Build a tool call ID -> tool result map
247
275
  const toolResults = new Map<string, ToolResultMessage>();
248
276
  for (const entry of messageEntries) {
249
277
  if (entry.message.role === "toolResult") {
@@ -251,13 +279,65 @@ export class SessionObserverOverlayComponent extends Container {
251
279
  }
252
280
  }
253
281
 
282
+ let entryIndex = 0;
254
283
  for (const entry of messageEntries) {
255
284
  const msg = entry.message;
256
285
 
257
286
  if (msg.role === "assistant") {
258
- this.#renderAssistantMessage(c, msg, toolResults);
287
+ // Handle error messages with empty content
288
+ if (msg.content.length === 0 && msg.errorMessage) {
289
+ const startLine = lines.length;
290
+ const isSelected = entryIndex === this.#selectedEntryIndex;
291
+ const cursor = isSelected ? theme.fg("accent", "▶") : " ";
292
+ lines.push("");
293
+ const errorLines = msg.errorMessage.split("\n");
294
+ const maxWidth = contentWidth();
295
+ lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`);
296
+ for (let i = 1; i < errorLines.length; i++) {
297
+ lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
298
+ }
299
+ this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" });
300
+ entryIndex++;
301
+ } else {
302
+ for (const content of msg.content) {
303
+ if (content.type === "thinking" && content.thinking.trim()) {
304
+ const startLine = lines.length;
305
+ const isExpanded = this.#expandedEntries.has(entryIndex);
306
+ const isSelected = entryIndex === this.#selectedEntryIndex;
307
+ this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected);
308
+ this.#viewerEntries.push({
309
+ lineStart: startLine,
310
+ lineCount: lines.length - startLine,
311
+ kind: "thinking",
312
+ });
313
+ entryIndex++;
314
+ } else if (content.type === "text" && content.text.trim()) {
315
+ const startLine = lines.length;
316
+ const isExpanded = this.#expandedEntries.has(entryIndex);
317
+ const isSelected = entryIndex === this.#selectedEntryIndex;
318
+ this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected);
319
+ this.#viewerEntries.push({
320
+ lineStart: startLine,
321
+ lineCount: lines.length - startLine,
322
+ kind: "text",
323
+ });
324
+ entryIndex++;
325
+ } else if (content.type === "toolCall") {
326
+ const startLine = lines.length;
327
+ const isExpanded = this.#expandedEntries.has(entryIndex);
328
+ const isSelected = entryIndex === this.#selectedEntryIndex;
329
+ const result = toolResults.get(content.id);
330
+ this.#renderToolCallLines(lines, content, result, isExpanded, isSelected);
331
+ this.#viewerEntries.push({
332
+ lineStart: startLine,
333
+ lineCount: lines.length - startLine,
334
+ kind: "toolCall",
335
+ });
336
+ entryIndex++;
337
+ }
338
+ }
339
+ }
259
340
  } else if (msg.role === "user" || msg.role === "developer") {
260
- // Show user/developer messages briefly
261
341
  const text =
262
342
  typeof msg.content === "string"
263
343
  ? msg.content
@@ -266,86 +346,176 @@ export class SessionObserverOverlayComponent extends Container {
266
346
  .map(b => b.text)
267
347
  .join("\n");
268
348
  if (text.trim()) {
349
+ const startLine = lines.length;
350
+ const isSelected = entryIndex === this.#selectedEntryIndex;
351
+ const isExpanded = this.#expandedEntries.has(entryIndex);
269
352
  const label = msg.role === "developer" ? "System" : "User";
270
- c.addChild(new Spacer(1));
271
- c.addChild(
272
- new Text(
273
- `${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", truncateToWidth(text.trim(), 80))}`,
274
- 1,
275
- 0,
276
- ),
277
- );
353
+ const cursor = isSelected ? theme.fg("accent", "▶") : " ";
354
+ lines.push("");
355
+ if (isExpanded) {
356
+ lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
357
+ const mdLines = this.#renderMarkdownToLines(text.trim());
358
+ for (const ml of mdLines) {
359
+ lines.push(ml);
360
+ }
361
+ } else {
362
+ const firstLine = text.trim().split("\n")[0];
363
+ const totalLines = text.trim().split("\n").length;
364
+ const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : "";
365
+ lines.push(
366
+ `${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`,
367
+ );
368
+ }
369
+ this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" });
370
+ entryIndex++;
278
371
  }
279
372
  }
280
- // toolResult entries are rendered inline with their tool calls above
281
373
  }
282
374
  }
283
375
 
284
- #renderAssistantMessage(c: Container, msg: AssistantMessage, toolResults: Map<string, ToolResultMessage>): void {
285
- for (const content of msg.content) {
286
- if (content.type === "thinking" && content.thinking.trim()) {
287
- const thinking = content.thinking.trim();
288
- c.addChild(new Spacer(1));
289
- if (thinking.length > MAX_THINKING_CHARS) {
290
- // Show truncated thinking as markdown for proper formatting
291
- const truncated = `${thinking.slice(0, MAX_THINKING_CHARS)}...`;
292
- c.addChild(
293
- new Markdown(truncated, 1, 0, getMarkdownTheme(), {
294
- color: (t: string) => theme.fg("thinkingText", t),
295
- italic: true,
296
- }),
297
- );
298
- } else {
299
- c.addChild(
300
- new Markdown(thinking, 1, 0, getMarkdownTheme(), {
301
- color: (t: string) => theme.fg("thinkingText", t),
302
- italic: true,
303
- }),
304
- );
305
- }
306
- } else if (content.type === "text" && content.text.trim()) {
307
- c.addChild(new Spacer(1));
308
- c.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
309
- } else if (content.type === "toolCall") {
310
- this.#renderToolCall(c, content, toolResults);
376
+ /** Render markdown text into indented lines using the theme's markdown renderer */
377
+ #renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
378
+ const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
379
+ const md = new Markdown(text, 0, 0, this.#mdTheme);
380
+ const rendered = md.render(width);
381
+ return rendered.map(line => `${indent}${line.trimEnd()}`);
382
+ }
383
+
384
+ #renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
385
+ const cursor = selected ? theme.fg("accent", "▶") : " ";
386
+ const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
387
+ const truncated = thinking.length > maxChars;
388
+ const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
389
+
390
+ lines.push("");
391
+ lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`);
392
+
393
+ const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking;
394
+ if (expanded) {
395
+ // Expanded thinking: render as markdown for readable formatting
396
+ const mdLines = this.#renderMarkdownToLines(displayText);
397
+ const maxLines = 100;
398
+ for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) {
399
+ lines.push(mdLines[i]);
400
+ }
401
+ if (mdLines.length > maxLines) {
402
+ lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`);
403
+ }
404
+ } else {
405
+ // Collapsed thinking: brief italic preview
406
+ const thinkingLines = displayText.split("\n");
407
+ const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
408
+ for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) {
409
+ lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`);
410
+ }
411
+ if (thinkingLines.length > maxLines) {
412
+ lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`);
311
413
  }
312
414
  }
313
415
  }
314
416
 
315
- #renderToolCall(
316
- c: Container,
417
+ #renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
418
+ const cursor = selected ? theme.fg("accent", "▶") : " ";
419
+
420
+ lines.push("");
421
+ lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
422
+
423
+ if (expanded) {
424
+ // Expanded: full markdown rendering
425
+ const mdLines = this.#renderMarkdownToLines(text);
426
+ for (const ml of mdLines) {
427
+ lines.push(ml);
428
+ }
429
+ } else {
430
+ // Collapsed: first few lines as plain text
431
+ const textLines = text.split("\n");
432
+ const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
433
+ const maxWidth = contentWidth();
434
+ for (let i = 0; i < Math.min(textLines.length, maxLines); i++) {
435
+ lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`);
436
+ }
437
+ if (textLines.length > maxLines) {
438
+ lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`);
439
+ }
440
+ }
441
+ }
442
+
443
+ #renderToolCallLines(
444
+ lines: string[],
317
445
  call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
318
- toolResults: Map<string, ToolResultMessage>,
446
+ result: ToolResultMessage | undefined,
447
+ expanded: boolean,
448
+ selected: boolean,
319
449
  ): void {
320
- c.addChild(new Spacer(1));
321
-
322
- // Tool call header with intent
323
- const intentStr = call.intent ? theme.fg("dim", ` ${truncateToWidth(call.intent, 50)}`) : "";
324
- c.addChild(new Text(`${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`, 1, 0));
325
-
326
- // Key arguments (skip very long ones, show summary)
327
- const argEntries = Object.entries(call.arguments);
328
- if (argEntries.length > 0) {
329
- const argSummary = this.#formatToolArgs(call.name, call.arguments);
330
- if (argSummary) {
331
- c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0));
332
- }
450
+ const cursor = selected ? theme.fg("accent", "▶") : " ";
451
+ lines.push("");
452
+
453
+ // Tool call header
454
+ const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
455
+ lines.push(`${cursor} ${theme.fg("accent", "\u25B8")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
456
+
457
+ // Key arguments
458
+ const argSummary = this.#formatToolArgs(call.name, call.arguments);
459
+ if (argSummary) {
460
+ lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`);
333
461
  }
334
462
 
335
- // Inline tool result
336
- const result = toolResults.get(call.id);
463
+ // Tool result
337
464
  if (result) {
338
- this.#renderToolResult(c, result);
465
+ this.#renderToolResultLines(lines, result, expanded);
466
+ }
467
+ }
468
+
469
+ #renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void {
470
+ const textParts = result.content
471
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
472
+ .map(p => p.text);
473
+ const text = textParts.join("\n").trim();
474
+
475
+ if (result.isError) {
476
+ const errorLines = text.split("\n");
477
+ const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
478
+ const maxWidth = contentWidth();
479
+ lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`);
480
+ for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) {
481
+ lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
482
+ }
483
+ if (errorLines.length > maxErrorLines) {
484
+ lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`);
485
+ }
486
+ return;
487
+ }
488
+
489
+ if (!text) {
490
+ lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`);
491
+ return;
492
+ }
493
+
494
+ const resultLines = text.split("\n");
495
+ const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
496
+
497
+ // Status line
498
+ const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`;
499
+
500
+ if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) {
501
+ lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`);
502
+ return;
503
+ }
504
+
505
+ lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`);
506
+ const displayLines = resultLines.slice(0, maxLines);
507
+ for (const rl of displayLines) {
508
+ lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`);
509
+ }
510
+ if (resultLines.length > maxLines) {
511
+ lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`);
339
512
  }
340
513
  }
341
514
 
342
515
  #formatToolArgs(toolName: string, args: Record<string, unknown>): string {
343
- // Show the most relevant arg for common tools
344
516
  switch (toolName) {
345
517
  case "read":
346
- return args.path ? `path: ${args.path}` : "";
347
518
  case "write":
348
- return args.path ? `path: ${args.path}` : "";
349
519
  case "edit":
350
520
  return args.path ? `path: ${args.path}` : "";
351
521
  case "grep":
@@ -356,10 +526,7 @@ export class SessionObserverOverlayComponent extends Container {
356
526
  return args.pattern ? `pattern: ${args.pattern}` : "";
357
527
  case "bash": {
358
528
  const cmd = args.command;
359
- if (typeof cmd === "string") {
360
- return truncateToWidth(replaceTabs(cmd), 70);
361
- }
362
- return "";
529
+ return typeof cmd === "string" ? replaceTabs(cmd) : "";
363
530
  }
364
531
  case "lsp":
365
532
  return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
@@ -368,19 +535,15 @@ export class SessionObserverOverlayComponent extends Container {
368
535
  return args.path ? `path: ${args.path}` : "";
369
536
  case "task": {
370
537
  const tasks = args.tasks;
371
- if (Array.isArray(tasks)) {
372
- return `${tasks.length} task(s)`;
373
- }
374
- return "";
538
+ return Array.isArray(tasks) ? `${tasks.length} task(s)` : "";
375
539
  }
376
540
  default: {
377
- // Generic: show first few args truncated
378
541
  const parts: string[] = [];
379
542
  let total = 0;
380
543
  for (const [key, value] of Object.entries(args)) {
381
544
  if (key.startsWith("_")) continue;
382
545
  const v = typeof value === "string" ? value : JSON.stringify(value);
383
- const entry = `${key}: ${truncateToWidth(replaceTabs(v ?? ""), 40)}`;
546
+ const entry = `${key}: ${replaceTabs(v ?? "")}`;
384
547
  if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
385
548
  parts.push(entry);
386
549
  total += entry.length;
@@ -390,79 +553,256 @@ export class SessionObserverOverlayComponent extends Container {
390
553
  }
391
554
  }
392
555
 
393
- #renderToolResult(c: Container, result: ToolResultMessage): void {
394
- const textParts = result.content
395
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
396
- .map(p => p.text);
397
- const text = textParts.join("\n").trim();
556
+ #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
557
+ if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
558
+ this.#transcriptCache = undefined;
559
+ }
398
560
 
399
- if (result.isError) {
400
- const preview = truncateToWidth(replaceTabs(text || "Error"), 70);
401
- c.addChild(new Text(` ${theme.fg("error", `✗ ${preview}`)}`, 1, 0));
402
- } else if (text) {
403
- // Show brief result preview
404
- const lines = text.split("\n");
405
- if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) {
406
- c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0));
407
- } else {
408
- c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0));
409
- }
410
- } else {
411
- c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0));
561
+ const fromByte = this.#transcriptCache?.bytesRead ?? 0;
562
+ const result = readFileIncremental(sessionFile, fromByte);
563
+ if (!result) {
564
+ logger.debug("Session observer: failed to read session file", { path: sessionFile });
565
+ return this.#transcriptCache?.entries ?? null;
412
566
  }
413
- }
414
567
 
415
- #buildPickerItems(): SelectItem[] {
416
- const sessions = this.#registry.getSessions();
417
- return sessions.map(s => {
418
- const statusIcon =
419
- s.status === "active" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : "○";
420
- const statusColor = s.status === "active" ? "success" : s.status === "failed" ? "error" : "dim";
421
- const prefix = theme.fg(statusColor, statusIcon);
422
- const agentSuffix = s.agent ? theme.fg("dim", ` [${s.agent}]`) : "";
423
- const label = s.kind === "main" ? `${prefix} ${s.label} (return)` : `${prefix} ${s.label}${agentSuffix}`;
424
-
425
- // Show current activity in the picker description for subagents
426
- let description = s.description;
427
- if (s.progress?.currentTool) {
428
- const intent = s.progress.lastIntent;
429
- description = intent ? `${s.progress.currentTool}: ${truncateToWidth(intent, 40)}` : s.progress.currentTool;
568
+ if (result.newSize < fromByte) {
569
+ this.#transcriptCache = undefined;
570
+ return this.#loadTranscript(sessionFile);
571
+ }
572
+
573
+ if (!this.#transcriptCache) {
574
+ this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
575
+ }
576
+
577
+ if (result.text.length > 0) {
578
+ const lastNewline = result.text.lastIndexOf("\n");
579
+ if (lastNewline >= 0) {
580
+ const completeChunk = result.text.slice(0, lastNewline + 1);
581
+ const newEntries = parseSessionEntries(completeChunk);
582
+ for (const entry of newEntries) {
583
+ if (entry.type === "message") {
584
+ this.#transcriptCache.entries.push(entry);
585
+ // Extract model from first assistant message
586
+ const msg = entry.message;
587
+ if (!this.#transcriptCache.model && msg.role === "assistant") {
588
+ this.#transcriptCache.model = msg.model;
589
+ }
590
+ } else if (entry.type === "model_change") {
591
+ this.#transcriptCache.model = entry.model;
592
+ }
593
+ }
594
+ this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
430
595
  }
596
+ }
597
+ return this.#transcriptCache.entries;
598
+ }
431
599
 
432
- return { value: s.id, label, description };
433
- });
600
+ #navigateBack(): boolean {
601
+ if (this.#navigationStack.length === 0) return false;
602
+ const prev = this.#navigationStack.pop()!;
603
+ this.#selectedSessionId = prev.sessionId;
604
+ this.#transcriptCache = undefined;
605
+ this.#scrollOffset = 0;
606
+ this.#selectedEntryIndex = 0;
607
+ this.#expandedEntries.clear();
608
+ this.#rebuildViewerContent();
609
+ return true;
434
610
  }
435
611
 
436
612
  handleInput(keyData: string): void {
613
+ // Ctrl+S (observe key) always closes the overlay
437
614
  for (const key of this.#observeKeys) {
438
615
  if (matchesKey(keyData, key)) {
439
- if (this.#mode === "viewer") {
440
- this.#setupPicker();
441
- return;
442
- }
443
616
  this.#onDone();
444
617
  return;
445
618
  }
446
619
  }
447
620
 
448
- if (this.#mode === "picker") {
449
- this.#selectList.handleInput(keyData);
450
- } else if (this.#mode === "viewer") {
451
- if (matchesKey(keyData, "escape")) {
452
- this.#setupPicker();
453
- return;
621
+ this.#handleViewerInput(keyData);
622
+ }
623
+
624
+ #handleViewerInput(keyData: string): void {
625
+ const entryCount = this.#viewerEntries.length;
626
+
627
+ // Escape — pop breadcrumb navigation or close overlay
628
+ if (matchesKey(keyData, "escape")) {
629
+ if (!this.#navigateBack()) {
630
+ this.#onDone();
631
+ }
632
+ return;
633
+ }
634
+
635
+ // j / down — move selection down
636
+ if (keyData === "j" || matchesKey(keyData, "down")) {
637
+ if (entryCount > 0) {
638
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
639
+ }
640
+ this.#rebuildAndScroll();
641
+ return;
642
+ }
643
+
644
+ // k / up — move selection up
645
+ if (keyData === "k" || matchesKey(keyData, "up")) {
646
+ if (entryCount > 0) {
647
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
648
+ }
649
+ this.#rebuildAndScroll();
650
+ return;
651
+ }
652
+
653
+ // Page Down
654
+ if (matchesKey(keyData, "pageDown")) {
655
+ if (entryCount > 0) {
656
+ const prevIndex = this.#selectedEntryIndex;
657
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
658
+ // If selection didn't move (bottom of list or single oversized entry), fall back to line scroll
659
+ if (this.#selectedEntryIndex === prevIndex) {
660
+ this.#scrollOffset = Math.min(
661
+ this.#scrollOffset + PAGE_SIZE,
662
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
663
+ );
664
+ }
665
+ } else {
666
+ this.#scrollOffset = Math.min(
667
+ this.#scrollOffset + PAGE_SIZE,
668
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
669
+ );
670
+ }
671
+ this.#rebuildAndScroll();
672
+ return;
673
+ }
674
+
675
+ // Page Up
676
+ if (matchesKey(keyData, "pageUp")) {
677
+ if (entryCount > 0) {
678
+ const prevIndex = this.#selectedEntryIndex;
679
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
680
+ // If selection didn't move (top of list or single oversized entry), fall back to line scroll
681
+ if (this.#selectedEntryIndex === prevIndex) {
682
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
683
+ }
684
+ } else {
685
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
686
+ }
687
+ this.#rebuildAndScroll();
688
+ return;
689
+ }
690
+
691
+ // Enter — toggle expand/collapse, or dive into nested session
692
+ if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
693
+ if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
694
+ // Toggle expand/collapse
695
+ if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
696
+ this.#expandedEntries.delete(this.#selectedEntryIndex);
697
+ } else {
698
+ this.#expandedEntries.add(this.#selectedEntryIndex);
699
+ }
700
+ this.#rebuildAndScroll();
701
+ }
702
+ return;
703
+ }
704
+
705
+ // G — jump to bottom
706
+ if (keyData === "G") {
707
+ if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
708
+ this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
709
+ this.#rebuildAndScroll();
710
+ return;
711
+ }
712
+
713
+ // g — jump to top
714
+ if (keyData === "g") {
715
+ this.#selectedEntryIndex = 0;
716
+ this.#scrollOffset = 0;
717
+ this.#rebuildAndScroll();
718
+ return;
719
+ }
720
+
721
+ // ] / → / Tab — next sub-agent session
722
+ if (keyData === "]" || matchesKey(keyData, "tab") || matchesKey(keyData, "right")) {
723
+ this.#cycleSession(1);
724
+ return;
725
+ }
726
+
727
+ // [ / ← / Shift+Tab — previous sub-agent session
728
+ if (keyData === "[" || matchesKey(keyData, "shift+tab") || matchesKey(keyData, "left")) {
729
+ this.#cycleSession(-1);
730
+ return;
731
+ }
732
+ }
733
+
734
+ /** Get the ordered list of sub-agent session IDs (excludes main) */
735
+ #getSubagentSessionIds(): string[] {
736
+ return this.#registry
737
+ .getSessions()
738
+ .filter(s => s.kind === "subagent")
739
+ .map(s => s.id);
740
+ }
741
+
742
+ /** Cycle to next (+1) or previous (-1) sub-agent session */
743
+ #cycleSession(direction: 1 | -1): void {
744
+ const ids = this.#getSubagentSessionIds();
745
+ if (ids.length <= 1) return;
746
+ const currentIdx = ids.indexOf(this.#selectedSessionId ?? "");
747
+ if (currentIdx < 0) return;
748
+ const nextIdx = (currentIdx + direction + ids.length) % ids.length;
749
+ this.#selectedSessionId = ids[nextIdx];
750
+ this.#transcriptCache = undefined;
751
+ this.#scrollOffset = 0;
752
+ this.#selectedEntryIndex = 0;
753
+ this.#expandedEntries.clear();
754
+ this.#wasAtBottom = true;
755
+ this.#rebuildViewerContent();
756
+ // Auto-scroll to bottom: select last entry
757
+ if (this.#viewerEntries.length > 0) {
758
+ this.#selectedEntryIndex = this.#viewerEntries.length - 1;
759
+ this.#wasAtBottom = true;
760
+ this.#rebuildViewerContent();
761
+ }
762
+ }
763
+
764
+ /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
765
+ #rebuildAndScroll(): void {
766
+ // Resume auto-scrolling once selection returns to the last entry
767
+ this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
768
+ this.#rebuildViewerContent();
769
+ this.#scrollToSelectedEntry();
770
+ }
771
+
772
+ #scrollToSelectedEntry(): void {
773
+ if (this.#viewerEntries.length === 0) return;
774
+ const entry = this.#viewerEntries[this.#selectedEntryIndex];
775
+ if (!entry) return;
776
+
777
+ const entryTop = entry.lineStart;
778
+ const entryBottom = entry.lineStart + entry.lineCount;
779
+
780
+ if (entry.lineCount >= this.#viewportHeight) {
781
+ // Entry taller than viewport: only snap when it's completely out of view.
782
+ // If the viewport overlaps the entry at all, the user may be paging within it.
783
+ if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
784
+ // Viewport is entirely above the entry — snap to entry top
785
+ this.#scrollOffset = Math.max(0, entryTop - 1);
786
+ } else if (this.#scrollOffset >= entryBottom) {
787
+ // Viewport is entirely below the entry — snap to show entry bottom
788
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
789
+ }
790
+ // Otherwise: viewport overlaps the entry — don't override manual scroll
791
+ } else {
792
+ // Entry fits in viewport: ensure it's fully visible
793
+ if (entryTop < this.#scrollOffset) {
794
+ this.#scrollOffset = Math.max(0, entryTop - 1);
795
+ }
796
+ if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
797
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
454
798
  }
455
799
  }
456
800
  }
457
801
  }
458
802
 
459
- // Sync helpers for render path — avoid async in component rendering
803
+ // Sync helpers for render path
460
804
  import * as fs from "node:fs";
461
805
 
462
- /**
463
- * Read new bytes from a file starting at the given byte offset.
464
- * Returns the new text and updated file size, or null on error.
465
- */
466
806
  function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
467
807
  try {
468
808
  const stat = fs.statSync(filePath);