- 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.
853 lines
30 KiB
TypeScript
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;
|
|
}
|
|
}
|