Files
oh-my-pi/packages/coding-agent/src/modes/components/session-observer-overlay.ts
T
can1357 2d0f62eb69 refactor(coding-agent): replaced list count indicators with ScrollView
- Wrapped selector and overlay list windows in ScrollView for proportional right-edge scrollbars.
- Dropped numeric position/count footers in favor of the scrollbar.
- Simplified SelectList status line to search hints only.
- Added ED3-risk autocomplete repaint test for POSIX terminals.
2026-06-06 14:20:41 +02:00

853 lines
30 KiB
TypeScript

/**
* Session observer overlay component.
*
* Picker mode: lists main + active subagent sessions with live status.
* Viewer mode: renders a scrollable, interactive transcript of the selected subagent's session
* by reading its JSONL session file — shows thinking, text, tool calls, results
* with expand/collapse per entry and breadcrumb navigation for nested sub-agents.
*
* Lifecycle:
* - shortcut opens picker
* - Enter on a subagent -> viewer
* - shortcut while in viewer -> back to picker (or pop breadcrumb)
* - Esc from viewer -> back to picker (or pop breadcrumb)
* - Esc from picker -> close overlay
* - Enter on main session -> close overlay (jump back)
*/
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
import { Container, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
import type { KeyId } from "../../config/keybindings";
import { isSilentAbort } from "../../session/messages";
import type { SessionMessageEntry } from "../../session/session-manager";
import { parseSessionEntries } from "../../session/session-manager";
import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { toPathList } from "../../tools/search";
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
import { getMarkdownTheme, theme } from "../theme/theme";
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
import { DynamicBorder } from "./dynamic-border";
import { formatContextUsage } from "./status-line/context-thresholds";
/** Max thinking characters in collapsed state */
const MAX_THINKING_CHARS_COLLAPSED = 200;
/** Max thinking characters in expanded state */
const MAX_THINKING_CHARS_EXPANDED = 4000;
/** Max tool args characters to display */
const MAX_TOOL_ARGS_CHARS = 500;
/** Lines per page for PageUp/PageDown */
const PAGE_SIZE = 15;
/** Left indent for content under entry headers */
const INDENT = " ";
/** Compute the max content width for the current terminal, accounting for indent and chrome. */
function contentWidth(indent = INDENT): number {
return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
}
/** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
function sanitizeLine(text: string, maxWidth?: number): string {
return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth());
}
/** Represents a rendered entry in the viewer for selection/expand tracking */
interface ViewerEntry {
lineStart: number;
lineCount: number;
kind: "thinking" | "text" | "toolCall" | "user";
}
/** Breadcrumb item for nested session navigation */
interface BreadcrumbItem {
sessionId: string;
label: string;
sessionFile: string;
}
export class SessionObserverOverlayComponent extends Container {
#registry: SessionObserverRegistry;
#onDone: () => void;
#selectedSessionId?: string;
#observeKeys: KeyId[];
#transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
// Scroll state
#scrollOffset = 0;
#renderedLines: string[] = [];
#viewportHeight = 20;
#wasAtBottom = true;
// Entry selection & expand/collapse
#viewerEntries: ViewerEntry[] = [];
#selectedEntryIndex = 0;
#expandedEntries = new Set<number>();
// Breadcrumb navigation
#navigationStack: BreadcrumbItem[] = [];
// Cached header/footer for viewer (rebuilt on refresh)
#viewerHeaderLines: string[] = [];
#viewerFooterLines: string[] = [];
// Markdown rendering
#mdTheme: MarkdownTheme = getMarkdownTheme();
constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
super();
this.#registry = registry;
this.#onDone = onDone;
this.#observeKeys = observeKeys;
// Jump directly to the most recently active sub-agent
const mostRecent = this.#getMostRecentSubagent();
if (mostRecent) {
this.#selectedSessionId = mostRecent.id;
this.#setupViewer();
} else {
// No sub-agents — close immediately
queueMicrotask(() => this.#onDone());
}
}
/** Find the most recently updated sub-agent session (prefer active ones) */
#getMostRecentSubagent(): ObservableSession | undefined {
const sessions = this.#registry.getSessions().filter(s => s.kind === "subagent");
if (sessions.length === 0) return undefined;
// Prefer active sessions, then sort by lastUpdate descending
const active = sessions.filter(s => s.status === "active");
const pool = active.length > 0 ? active : sessions;
return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
}
override render(width: number): string[] {
return this.#renderViewer(width);
}
#setupViewer(): void {
this.children = [];
this.#scrollOffset = 0;
this.#selectedEntryIndex = 0;
this.#expandedEntries.clear();
this.#wasAtBottom = true;
this.#rebuildViewerContent();
// Auto-scroll to bottom and select last entry on init
if (this.#viewerEntries.length > 0) {
this.#selectedEntryIndex = this.#viewerEntries.length - 1;
this.#wasAtBottom = true;
this.#rebuildViewerContent();
}
}
/** Rebuild content from live registry data */
refreshFromRegistry(): void {
if (this.#selectedSessionId) {
// Keep auto-scrolling to bottom unless the user navigated away from the last entry
this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
this.#rebuildViewerContent();
}
}
/** Rebuild the transcript content lines (called on setup and refresh) */
#rebuildViewerContent(): void {
const sessions = this.#registry.getSessions();
const session = sessions.find(s => s.id === this.#selectedSessionId);
// Load transcript first so model info is available for header
let messageEntries: SessionMessageEntry[] | null = null;
if (session?.sessionFile) {
messageEntries = this.#loadTranscript(session.sessionFile);
}
// Header
this.#viewerHeaderLines = [];
const breadcrumb = this.#buildBreadcrumb(session);
this.#viewerHeaderLines.push(theme.fg("accent", breadcrumb));
if (session) {
const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
const statusText = theme.fg(statusColor, `[${session.status}]`);
const agentTag = session.agent ? theme.fg("dim", ` ${session.agent}`) : "";
const subagentIds = this.#getSubagentSessionIds();
const posIdx = subagentIds.indexOf(this.#selectedSessionId ?? "");
const posLabel =
subagentIds.length > 1 && posIdx >= 0 ? theme.fg("dim", ` (${posIdx + 1}/${subagentIds.length})`) : "";
const modelName = this.#transcriptCache?.model;
const modelLabel = modelName ? theme.fg("muted", ` · ${modelName}`) : "";
this.#viewerHeaderLines.push(`${theme.bold(session.label)} ${statusText}${agentTag}${posLabel}${modelLabel}`);
}
// Content
const contentLines: string[] = [];
this.#viewerEntries = [];
if (!session) {
contentLines.push(theme.fg("dim", "Session no longer available."));
} else if (!session.sessionFile) {
contentLines.push(theme.fg("dim", "No session file available yet."));
} else if (!messageEntries) {
contentLines.push(theme.fg("dim", "Unable to read session file."));
} else if (messageEntries.length === 0) {
contentLines.push(theme.fg("dim", "No messages yet."));
} else {
this.#buildTranscriptLines(messageEntries, contentLines);
}
this.#renderedLines = contentLines;
// Footer
this.#viewerFooterLines = [];
const statsLine = this.#buildStatsLine(session);
if (statsLine) this.#viewerFooterLines.push(statsLine);
this.#viewerFooterLines.push(
theme.fg("dim", "j/k:scroll Enter:expand [/]/←→:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
);
// Auto-scroll to bottom if we were at bottom
if (this.#wasAtBottom) {
this.#scrollOffset = Math.max(0, contentLines.length - this.#viewportHeight);
}
}
/** Produce the final viewer output for the overlay system */
#renderViewer(width: number): string[] {
const termHeight = process.stdout.rows || 40;
// Compute viewport: total height minus header chrome and footer chrome
// Header: border(1) + headerLines + border(1) = headerLines.length + 2
// Footer: spacer(1) + scrollInfo(1) + footerLines + border(1) = footerLines.length + 2
const headerChrome = this.#viewerHeaderLines.length + 2;
const footerChrome = this.#viewerFooterLines.length + 2;
this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
// Clamp scroll offset
const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
const lines: string[] = [];
// --- Header ---
lines.push(...new DynamicBorder().render(width));
for (const hl of this.#viewerHeaderLines) {
lines.push(` ${hl}`);
}
lines.push(...new DynamicBorder().render(width));
// --- Scrolled content viewport ---
const sv = new ScrollView(
this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
{
height: this.#viewportHeight,
scrollbar: "auto",
totalRows: this.#renderedLines.length,
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
},
);
sv.setScrollOffset(this.#scrollOffset);
for (const row of sv.render(Math.max(1, width - 1))) lines.push(` ${row}`);
// --- Footer ---
lines.push("");
lines.push(` ${this.#viewerFooterLines[0] ?? ""}`);
for (let i = 1; i < this.#viewerFooterLines.length; i++) {
lines.push(` ${this.#viewerFooterLines[i]}`);
}
lines.push(...new DynamicBorder().render(width));
return lines;
}
#buildBreadcrumb(session: ObservableSession | undefined): string {
const parts: string[] = ["Session Observer"];
for (const item of this.#navigationStack) {
parts.push(item.label);
}
if (session) parts.push(session.label);
return parts.join(" > ");
}
#buildStatsLine(session: ObservableSession | undefined): string {
const progress = session?.progress;
if (!progress) return "";
const stats: string[] = [];
// Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
if (progress.contextTokens && progress.contextTokens > 0) {
const ctx =
progress.contextWindow && progress.contextWindow > 0
? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
: `${formatNumber(progress.contextTokens)}`;
stats.push(ctx);
}
if (progress.durationMs > 0) {
stats.push(formatDuration(progress.durationMs));
}
const parts: string[] = [];
if (stats.length > 0 || progress.toolCount > 0) {
const toolCountStat =
progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined;
const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment));
parts.push(theme.fg("dim", statSegments.join(theme.sep.dot)));
}
if (progress.cost > 0) {
parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
}
return parts.join(theme.sep.dot);
}
#buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
// Build a tool call ID -> tool result map
const toolResults = new Map<string, ToolResultMessage>();
for (const entry of messageEntries) {
if (entry.message.role === "toolResult") {
toolResults.set(entry.message.toolCallId, entry.message);
}
}
let entryIndex = 0;
for (const entry of messageEntries) {
const msg = entry.message;
if (msg.role === "assistant") {
// Handle error messages with empty content
if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
const startLine = lines.length;
const isSelected = entryIndex === this.#selectedEntryIndex;
const cursor = isSelected ? theme.fg("accent", "▶") : " ";
lines.push("");
const errorLines = msg.errorMessage.split("\n");
const maxWidth = contentWidth();
lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`);
for (let i = 1; i < errorLines.length; i++) {
lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
}
this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" });
entryIndex++;
} else {
for (const content of msg.content) {
if (content.type === "thinking" && content.thinking.trim()) {
const startLine = lines.length;
const isExpanded = this.#expandedEntries.has(entryIndex);
const isSelected = entryIndex === this.#selectedEntryIndex;
this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected);
this.#viewerEntries.push({
lineStart: startLine,
lineCount: lines.length - startLine,
kind: "thinking",
});
entryIndex++;
} else if (content.type === "text" && content.text.trim()) {
const startLine = lines.length;
const isExpanded = this.#expandedEntries.has(entryIndex);
const isSelected = entryIndex === this.#selectedEntryIndex;
this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected);
this.#viewerEntries.push({
lineStart: startLine,
lineCount: lines.length - startLine,
kind: "text",
});
entryIndex++;
} else if (content.type === "toolCall") {
const startLine = lines.length;
const isExpanded = this.#expandedEntries.has(entryIndex);
const isSelected = entryIndex === this.#selectedEntryIndex;
const result = toolResults.get(content.id);
this.#renderToolCallLines(lines, content, result, isExpanded, isSelected);
this.#viewerEntries.push({
lineStart: startLine,
lineCount: lines.length - startLine,
kind: "toolCall",
});
entryIndex++;
}
}
}
} else if (msg.role === "user" || msg.role === "developer") {
const text =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map(b => b.text)
.join("\n");
if (text.trim()) {
const startLine = lines.length;
const isSelected = entryIndex === this.#selectedEntryIndex;
const isExpanded = this.#expandedEntries.has(entryIndex);
const label = msg.role === "developer" ? "System" : "User";
const cursor = isSelected ? theme.fg("accent", "▶") : " ";
lines.push("");
if (isExpanded) {
lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
const mdLines = this.#renderMarkdownToLines(text.trim());
for (const ml of mdLines) {
lines.push(ml);
}
} else {
const firstLine = text.trim().split("\n")[0];
const totalLines = text.trim().split("\n").length;
const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : "";
lines.push(
`${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`,
);
}
this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" });
entryIndex++;
}
}
}
}
/** Render markdown text into indented lines using the theme's markdown renderer */
#renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
const md = new Markdown(text, 0, 0, this.#mdTheme);
const rendered = md.render(width);
return rendered.map(line => `${indent}${line.trimEnd()}`);
}
#renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
const cursor = selected ? theme.fg("accent", "▶") : " ";
const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
const truncated = thinking.length > maxChars;
const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
lines.push("");
lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`);
const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking;
if (expanded) {
// Expanded thinking: render as markdown for readable formatting
const mdLines = this.#renderMarkdownToLines(displayText);
const maxLines = 100;
for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) {
lines.push(mdLines[i]);
}
if (mdLines.length > maxLines) {
lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`);
}
} else {
// Collapsed thinking: brief italic preview
const thinkingLines = displayText.split("\n");
const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) {
lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`);
}
if (thinkingLines.length > maxLines) {
lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`);
}
}
}
#renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
const cursor = selected ? theme.fg("accent", "▶") : " ";
lines.push("");
lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
if (expanded) {
// Expanded: full markdown rendering
const mdLines = this.#renderMarkdownToLines(text);
for (const ml of mdLines) {
lines.push(ml);
}
} else {
// Collapsed: first few lines as plain text
const textLines = text.split("\n");
const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
const maxWidth = contentWidth();
for (let i = 0; i < Math.min(textLines.length, maxLines); i++) {
lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`);
}
if (textLines.length > maxLines) {
lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`);
}
}
}
#renderToolCallLines(
lines: string[],
call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
result: ToolResultMessage | undefined,
expanded: boolean,
selected: boolean,
): void {
const cursor = selected ? theme.fg("accent", "▶") : " ";
lines.push("");
// Tool call header
const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
lines.push(`${cursor} ${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
// Key arguments
const argSummary = this.#formatToolArgs(call.name, call.arguments);
if (argSummary) {
lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`);
}
// Tool result
if (result) {
this.#renderToolResultLines(lines, result, expanded);
}
}
#renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void {
const textParts = result.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map(p => p.text);
const text = textParts.join("\n").trim();
if (result.isError) {
const errorLines = text.split("\n");
const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
const maxWidth = contentWidth();
lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`);
for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) {
lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
}
if (errorLines.length > maxErrorLines) {
lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`);
}
return;
}
if (!text) {
lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`);
return;
}
const resultLines = text.split("\n");
const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
// Status line
const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`;
if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) {
lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`);
return;
}
lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`);
const displayLines = resultLines.slice(0, maxLines);
for (const rl of displayLines) {
lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`);
}
if (resultLines.length > maxLines) {
lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`);
}
}
#formatToolArgs(toolName: string, args: Record<string, unknown>): string {
switch (toolName) {
case "read":
case "write":
case "edit":
return args.path ? `path: ${args.path}` : "";
case "search": {
const searchPathsInput =
typeof args.paths === "string" || Array.isArray(args.paths)
? args.paths
: typeof args.path === "string"
? args.path
: undefined;
const searchPaths = toPathList(searchPathsInput);
return [
args.pattern ? `pattern: ${args.pattern}` : "",
searchPaths.length > 0 ? `paths: ${searchPaths.join(", ")}` : "",
]
.filter(Boolean)
.join(", ");
}
case "find":
return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
case "bash": {
const cmd = args.command;
return typeof cmd === "string" ? replaceTabs(cmd) : "";
}
case "lsp":
return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
case "ast_grep":
case "ast_edit":
return args.path ? `path: ${args.path}` : "";
case "task": {
const tasks = args.tasks;
return Array.isArray(tasks) ? `${tasks.length} task(s)` : "";
}
default: {
const parts: string[] = [];
let total = 0;
for (const [key, value] of Object.entries(args)) {
if (key.startsWith("_")) continue;
const v = typeof value === "string" ? value : JSON.stringify(value);
const entry = `${key}: ${replaceTabs(v ?? "")}`;
if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
parts.push(entry);
total += entry.length;
}
return parts.join(", ");
}
}
}
#loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
this.#transcriptCache = undefined;
}
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
const result = readFileIncremental(sessionFile, fromByte);
if (!result) {
logger.debug("Session observer: failed to read session file", { path: sessionFile });
return this.#transcriptCache?.entries ?? null;
}
if (result.newSize < fromByte) {
this.#transcriptCache = undefined;
return this.#loadTranscript(sessionFile);
}
if (!this.#transcriptCache) {
this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
}
if (result.text.length > 0) {
const lastNewline = result.text.lastIndexOf("\n");
if (lastNewline >= 0) {
const completeChunk = result.text.slice(0, lastNewline + 1);
const newEntries = parseSessionEntries(completeChunk);
for (const entry of newEntries) {
if (entry.type === "message") {
this.#transcriptCache.entries.push(entry);
// Extract model from first assistant message
const msg = entry.message;
if (!this.#transcriptCache.model && msg.role === "assistant") {
this.#transcriptCache.model = msg.model;
}
} else if (entry.type === "model_change") {
this.#transcriptCache.model = entry.model;
}
}
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
}
}
return this.#transcriptCache.entries;
}
#navigateBack(): boolean {
if (this.#navigationStack.length === 0) return false;
const prev = this.#navigationStack.pop()!;
this.#selectedSessionId = prev.sessionId;
this.#transcriptCache = undefined;
this.#scrollOffset = 0;
this.#selectedEntryIndex = 0;
this.#expandedEntries.clear();
this.#rebuildViewerContent();
return true;
}
handleInput(keyData: string): void {
// Ctrl+S (observe key) always closes the overlay
for (const key of this.#observeKeys) {
if (matchesKey(keyData, key)) {
this.#onDone();
return;
}
}
this.#handleViewerInput(keyData);
}
#handleViewerInput(keyData: string): void {
const entryCount = this.#viewerEntries.length;
// Escape — pop breadcrumb navigation or close overlay
if (matchesKey(keyData, "escape")) {
if (!this.#navigateBack()) {
this.#onDone();
}
return;
}
// j / down — move selection down
if (keyData === "j" || matchesSelectDown(keyData)) {
if (entryCount > 0) {
this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
}
this.#rebuildAndScroll();
return;
}
// k / up — move selection up
if (keyData === "k" || matchesSelectUp(keyData)) {
if (entryCount > 0) {
this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
}
this.#rebuildAndScroll();
return;
}
// Page Down
if (matchesKey(keyData, "pageDown")) {
if (entryCount > 0) {
const prevIndex = this.#selectedEntryIndex;
this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
// If selection didn't move (bottom of list or single oversized entry), fall back to line scroll
if (this.#selectedEntryIndex === prevIndex) {
this.#scrollOffset = Math.min(
this.#scrollOffset + PAGE_SIZE,
Math.max(0, this.#renderedLines.length - this.#viewportHeight),
);
}
} else {
this.#scrollOffset = Math.min(
this.#scrollOffset + PAGE_SIZE,
Math.max(0, this.#renderedLines.length - this.#viewportHeight),
);
}
this.#rebuildAndScroll();
return;
}
// Page Up
if (matchesKey(keyData, "pageUp")) {
if (entryCount > 0) {
const prevIndex = this.#selectedEntryIndex;
this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
// If selection didn't move (top of list or single oversized entry), fall back to line scroll
if (this.#selectedEntryIndex === prevIndex) {
this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
}
} else {
this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
}
this.#rebuildAndScroll();
return;
}
// Enter — toggle expand/collapse, or dive into nested session
if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
// Toggle expand/collapse
if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
this.#expandedEntries.delete(this.#selectedEntryIndex);
} else {
this.#expandedEntries.add(this.#selectedEntryIndex);
}
this.#rebuildAndScroll();
}
return;
}
// G — jump to bottom
if (keyData === "G") {
if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
this.#rebuildAndScroll();
return;
}
// g — jump to top
if (keyData === "g") {
this.#selectedEntryIndex = 0;
this.#scrollOffset = 0;
this.#rebuildAndScroll();
return;
}
// ] / → / Tab — next sub-agent session
if (keyData === "]" || matchesKey(keyData, "tab") || matchesKey(keyData, "right")) {
this.#cycleSession(1);
return;
}
// [ / ← / Shift+Tab — previous sub-agent session
if (keyData === "[" || matchesKey(keyData, "shift+tab") || matchesKey(keyData, "left")) {
this.#cycleSession(-1);
return;
}
}
/** Get the ordered list of sub-agent session IDs (excludes main) */
#getSubagentSessionIds(): string[] {
return this.#registry
.getSessions()
.filter(s => s.kind === "subagent")
.map(s => s.id);
}
/** Cycle to next (+1) or previous (-1) sub-agent session */
#cycleSession(direction: 1 | -1): void {
const ids = this.#getSubagentSessionIds();
if (ids.length <= 1) return;
const currentIdx = ids.indexOf(this.#selectedSessionId ?? "");
if (currentIdx < 0) return;
const nextIdx = (currentIdx + direction + ids.length) % ids.length;
this.#selectedSessionId = ids[nextIdx];
this.#transcriptCache = undefined;
this.#scrollOffset = 0;
this.#selectedEntryIndex = 0;
this.#expandedEntries.clear();
this.#wasAtBottom = true;
this.#rebuildViewerContent();
// Auto-scroll to bottom: select last entry
if (this.#viewerEntries.length > 0) {
this.#selectedEntryIndex = this.#viewerEntries.length - 1;
this.#wasAtBottom = true;
this.#rebuildViewerContent();
}
}
/** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
#rebuildAndScroll(): void {
// Resume auto-scrolling once selection returns to the last entry
this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
this.#rebuildViewerContent();
this.#scrollToSelectedEntry();
}
#scrollToSelectedEntry(): void {
if (this.#viewerEntries.length === 0) return;
const entry = this.#viewerEntries[this.#selectedEntryIndex];
if (!entry) return;
const entryTop = entry.lineStart;
const entryBottom = entry.lineStart + entry.lineCount;
if (entry.lineCount >= this.#viewportHeight) {
// Entry taller than viewport: only snap when it's completely out of view.
// If the viewport overlaps the entry at all, the user may be paging within it.
if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
// Viewport is entirely above the entry — snap to entry top
this.#scrollOffset = Math.max(0, entryTop - 1);
} else if (this.#scrollOffset >= entryBottom) {
// Viewport is entirely below the entry — snap to show entry bottom
this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
}
// Otherwise: viewport overlaps the entry — don't override manual scroll
} else {
// Entry fits in viewport: ensure it's fully visible
if (entryTop < this.#scrollOffset) {
this.#scrollOffset = Math.max(0, entryTop - 1);
}
if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
}
}
}
}
// Sync helpers for render path
import * as fs from "node:fs";
function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
try {
const stat = fs.statSync(filePath);
if (stat.size <= fromByte) return { text: "", newSize: stat.size };
const buf = Buffer.alloc(stat.size - fromByte);
const fd = fs.openSync(filePath, "r");
try {
fs.readSync(fd, buf, 0, buf.length, fromByte);
} finally {
fs.closeSync(fd);
}
return { text: buf.toString("utf-8"), newSize: stat.size };
} catch {
return null;
}
}