- Added a `proseOnlyThinking` configuration setting to suppress raw code blocks in AI thinking traces. - Implemented `formatThinkingForDisplay` utility to replace code blocks with ellipses in the UI. - Integrated runtime toggling and live refreshing of message components via streaming reveal controllers. - Added a live tokens-per-second indicator to the assistant thinking pulse. - Verified logic with new unit and integration tests for thinking block presentation.
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
/**
|
|
* Fullscreen transcript viewer.
|
|
*
|
|
* `AgentHubOverlayComponent.openChat` mounts this as a `fullscreen` overlay
|
|
* (`ui.showOverlay(..., { fullscreen: true })`), so it borrows the terminal's
|
|
* alternate screen buffer (the vim/less idiom) and paints the whole screen — no
|
|
* compositing into the live transcript's scrollback. It renders a parked
|
|
* subagent / advisor / collab-guest transcript that has no live in-view session.
|
|
*
|
|
* The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild})
|
|
* rather than synced incrementally, so a growing file-backed transcript (the
|
|
* advisor appends while you watch) can never duplicate or misorder rows. Scroll
|
|
* is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail
|
|
* until the reader scrolls up.
|
|
*
|
|
* Local agents re-read the whole session file whenever its size or mtime changes
|
|
* (covering SessionManager's in-place rewrites, not just appends). Collab guests
|
|
* keep the incremental byte cursor the host's capped `readTranscript` requires
|
|
* and rebuild components from the accumulated entries.
|
|
*/
|
|
import * as fs from "node:fs";
|
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
|
|
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
|
|
import type { KeyId } from "../../config/keybindings";
|
|
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
|
import type { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
|
import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry";
|
|
import type { FileEntry, SessionMessageEntry } from "../../session/session-entries";
|
|
import { parseSessionEntries } from "../../session/session-loader";
|
|
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
|
import { getEditorTheme, theme } from "../theme/theme";
|
|
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
|
import type { AgentHubRemote } from "./agent-hub";
|
|
import { ChatTranscriptBuilder } from "./chat-transcript-builder";
|
|
import { DynamicBorder } from "./dynamic-border";
|
|
import { formatContextUsage } from "./status-line/context-thresholds";
|
|
|
|
export interface AgentTranscriptViewerDeps {
|
|
agentId: string;
|
|
registry: AgentRegistry;
|
|
/** Collab guest: read transcript from the host instead of a local file. */
|
|
remote?: AgentHubRemote;
|
|
/** Progress/cost snapshot source for the stats line. */
|
|
observers?: SessionObserverRegistry;
|
|
/** Revive+prompt path for messageable local agents. Lazy to avoid touching the global. */
|
|
lifecycle?: () => AgentLifecycleManager;
|
|
ui: TUI;
|
|
getTool?: (name: string) => AgentTool | undefined;
|
|
getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
|
|
cwd: string;
|
|
hideThinkingBlock?: () => boolean;
|
|
proseOnlyThinking?: () => boolean;
|
|
expandKeys: KeyId[];
|
|
/** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */
|
|
hubKeys: KeyId[];
|
|
requestRender: () => void;
|
|
/** Close just this viewer (Esc), returning to the hub table. */
|
|
onClose: () => void;
|
|
/** Close this viewer AND the hub (hub-toggle keys). */
|
|
onHubClose: () => void;
|
|
}
|
|
|
|
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
|
|
const POLL_MS = 250;
|
|
|
|
function statusBadge(status: AgentStatus): string {
|
|
switch (status) {
|
|
case "running":
|
|
return theme.fg("success", "running");
|
|
case "idle":
|
|
return theme.fg("accent", "idle");
|
|
case "parked":
|
|
return theme.fg("muted", "parked");
|
|
case "aborted":
|
|
return theme.fg("error", "aborted");
|
|
}
|
|
}
|
|
|
|
export class AgentTranscriptViewer implements Component {
|
|
#builder: ChatTranscriptBuilder;
|
|
#scrollView: ScrollView;
|
|
#followBottom = true;
|
|
#editor: Editor | undefined;
|
|
#notice: string | undefined;
|
|
#expanded = false;
|
|
|
|
// Local file transcript state: re-read when the file size or mtime changes.
|
|
#lastSignature = "";
|
|
// Remote transcript state (incremental; the host caps each read).
|
|
#remoteEntries: SessionMessageEntry[] = [];
|
|
#remoteBytes = 0;
|
|
#remoteFetchInFlight = false;
|
|
#remoteToken = 0;
|
|
#remoteUnavailable = false;
|
|
#hasRemoteData = false;
|
|
|
|
#model: string | undefined;
|
|
#pollTimer: NodeJS.Timeout | undefined;
|
|
#disposed = false;
|
|
|
|
constructor(private readonly deps: AgentTranscriptViewerDeps) {
|
|
this.#builder = new ChatTranscriptBuilder({
|
|
ui: deps.ui,
|
|
getTool: deps.getTool,
|
|
getMessageRenderer: deps.getMessageRenderer,
|
|
cwd: deps.cwd,
|
|
hideThinkingBlock: deps.hideThinkingBlock,
|
|
proseOnlyThinking: deps.proseOnlyThinking,
|
|
requestRender: deps.requestRender,
|
|
});
|
|
this.#scrollView = new ScrollView([], {
|
|
height: 10,
|
|
scrollbar: "auto",
|
|
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
|
|
});
|
|
if (this.#sendable) {
|
|
this.#editor = new Editor(getEditorTheme());
|
|
this.#editor.setMaxHeight(4);
|
|
this.#editor.onSubmit = text => this.#submit(text);
|
|
}
|
|
this.#refresh();
|
|
this.#pollTimer = setInterval(() => this.#refresh(), POLL_MS);
|
|
this.#pollTimer.unref?.();
|
|
}
|
|
|
|
/** Advisor transcripts are read-only; everything else may be messaged. */
|
|
get #sendable(): boolean {
|
|
const ref = this.deps.registry.get(this.deps.agentId);
|
|
if (!ref || ref.kind === "advisor") return false;
|
|
return Boolean(this.deps.remote || this.deps.lifecycle);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#disposed = true;
|
|
if (this.#pollTimer) {
|
|
clearInterval(this.#pollTimer);
|
|
this.#pollTimer = undefined;
|
|
}
|
|
this.#remoteToken++;
|
|
this.#builder.dispose();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Transcript loading
|
|
// ========================================================================
|
|
|
|
/** Re-read the transcript and rebuild components when it changed. */
|
|
#refresh(): void {
|
|
if (this.#disposed) return;
|
|
if (this.deps.remote) {
|
|
this.#fetchRemote();
|
|
return;
|
|
}
|
|
const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
|
|
if (!sessionFile) {
|
|
if (this.#lastSignature !== "none") {
|
|
this.#lastSignature = "none";
|
|
this.#rebuild([]);
|
|
}
|
|
return;
|
|
}
|
|
let signature: string;
|
|
try {
|
|
const stat = fs.statSync(sessionFile);
|
|
// Include the path: a different file with the same size/mtime must not alias.
|
|
signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
|
|
} catch {
|
|
// File deleted/rotated while open (e.g. the owning session was dropped):
|
|
// clear stale content once instead of freezing on it forever.
|
|
if (this.#lastSignature !== "missing") {
|
|
this.#lastSignature = "missing";
|
|
this.#model = undefined;
|
|
this.#rebuild([]);
|
|
}
|
|
return;
|
|
}
|
|
if (signature === this.#lastSignature) return;
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(sessionFile, "utf-8");
|
|
} catch (err) {
|
|
// Leave #lastSignature unchanged so a transient read error retries next poll.
|
|
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
return;
|
|
}
|
|
this.#lastSignature = signature;
|
|
this.#model = undefined;
|
|
this.#rebuild(this.#extractMessages(parseSessionEntries(text)));
|
|
}
|
|
|
|
#fetchRemote(): void {
|
|
const remote = this.deps.remote;
|
|
if (!remote || this.#remoteFetchInFlight) return;
|
|
const id = this.deps.agentId;
|
|
const fromByte = this.#remoteBytes;
|
|
this.#remoteFetchInFlight = true;
|
|
const token = ++this.#remoteToken;
|
|
void remote
|
|
.readTranscript(id, fromByte)
|
|
.then(result => {
|
|
if (token !== this.#remoteToken || this.#disposed) return;
|
|
this.#remoteFetchInFlight = false;
|
|
if (!result) {
|
|
if (!this.#hasRemoteData && !this.#remoteUnavailable) {
|
|
this.#remoteUnavailable = true;
|
|
this.deps.requestRender();
|
|
}
|
|
return;
|
|
}
|
|
if (result.newSize < fromByte) {
|
|
// Host transcript rotated/truncated — restart from 0.
|
|
this.#remoteBytes = 0;
|
|
this.#remoteEntries = [];
|
|
this.#fetchRemote();
|
|
return;
|
|
}
|
|
this.#remoteUnavailable = false;
|
|
const firstData = !this.#hasRemoteData;
|
|
this.#hasRemoteData = true;
|
|
const lastNewline = result.text.lastIndexOf("\n");
|
|
if (lastNewline >= 0) {
|
|
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
|
|
if (parsed.length > 0) {
|
|
this.#remoteEntries.push(...parsed);
|
|
this.#rebuild(this.#remoteEntries);
|
|
return;
|
|
}
|
|
}
|
|
// First completed fetch (even empty) clears the "Loading…" placeholder.
|
|
if (firstData) this.deps.requestRender();
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (token === this.#remoteToken) this.#remoteFetchInFlight = false;
|
|
logger.warn("transcript viewer: remote fetch failed", { id, error: String(error) });
|
|
});
|
|
}
|
|
|
|
/** Filter to message entries, tracking the model from the first assistant / a model_change. */
|
|
#extractMessages(entries: FileEntry[]): SessionMessageEntry[] {
|
|
const messages: SessionMessageEntry[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.type === "message") {
|
|
messages.push(entry);
|
|
if (!this.#model && entry.message.role === "assistant") this.#model = entry.message.model;
|
|
} else if (entry.type === "model_change") {
|
|
this.#model = entry.model;
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
#rebuild(entries: SessionMessageEntry[]): void {
|
|
this.#builder.rebuild(entries);
|
|
this.deps.requestRender();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Input
|
|
// ========================================================================
|
|
|
|
handleInput(data: string): void {
|
|
if (data.startsWith("\x1b[<")) {
|
|
const event = parseSgrMouse(data);
|
|
if (event?.wheel != null) {
|
|
this.#scrollView.scroll(event.wheel * 3);
|
|
this.#syncFollow();
|
|
this.deps.requestRender();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// The hub/observe toggle keys close the whole hub (matches the table view's
|
|
// toggle semantics), not just this viewer.
|
|
for (const key of this.deps.hubKeys) {
|
|
if (matchesKey(data, key)) {
|
|
this.deps.onHubClose();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (matchesKey(data, "escape")) {
|
|
if (this.#editor && this.#editor.getText().trim() !== "") {
|
|
this.#editor.setText("");
|
|
this.deps.requestRender();
|
|
return;
|
|
}
|
|
this.deps.onClose();
|
|
return;
|
|
}
|
|
|
|
for (const key of this.deps.expandKeys) {
|
|
if (matchesKey(data, key)) {
|
|
this.#expanded = !this.#expanded;
|
|
this.#builder.setExpanded(this.#expanded);
|
|
this.deps.requestRender();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Once the reader starts typing a message, the editor owns every key.
|
|
const editorEmpty = !this.#editor || this.#editor.getText().trim() === "";
|
|
if (editorEmpty && this.#handleScroll(data)) return;
|
|
|
|
if (this.#editor) {
|
|
this.#editor.handleInput(data);
|
|
this.deps.requestRender();
|
|
}
|
|
}
|
|
|
|
/** Returns true when the key was a scroll command. ScrollView owns the offset. */
|
|
#handleScroll(data: string): boolean {
|
|
if (this.#scrollView.handleScrollKey(data)) {
|
|
this.#syncFollow();
|
|
this.deps.requestRender();
|
|
return true;
|
|
}
|
|
if (data === "j" || matchesSelectDown(data)) {
|
|
this.#scrollView.scroll(1);
|
|
} else if (data === "k" || matchesSelectUp(data)) {
|
|
this.#scrollView.scroll(-1);
|
|
} else if (data === "g") {
|
|
this.#scrollView.scrollToTop();
|
|
} else if (data === "G") {
|
|
this.#scrollView.scrollToBottom();
|
|
} else {
|
|
return false;
|
|
}
|
|
this.#syncFollow();
|
|
this.deps.requestRender();
|
|
return true;
|
|
}
|
|
|
|
#syncFollow(): void {
|
|
this.#followBottom = this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset();
|
|
}
|
|
|
|
#submit(text: string): void {
|
|
const trimmed = text.trim();
|
|
this.#editor?.setText("");
|
|
if (!trimmed) return;
|
|
this.#notice = undefined;
|
|
const id = this.deps.agentId;
|
|
if (this.deps.remote) {
|
|
this.deps.remote.chat(id, trimmed);
|
|
this.deps.requestRender();
|
|
return;
|
|
}
|
|
const lifecycle = this.deps.lifecycle;
|
|
if (!lifecycle) return;
|
|
void (async () => {
|
|
try {
|
|
// Revives a parked agent; returns the live session for running/idle.
|
|
const session = await lifecycle().ensureLive(id);
|
|
// Steers a mid-turn agent; sends a normal prompt to an idle one.
|
|
await session.prompt(trimmed, { streamingBehavior: "steer" });
|
|
} catch (error) {
|
|
this.#notice = error instanceof Error ? error.message : String(error);
|
|
}
|
|
this.deps.requestRender();
|
|
})();
|
|
this.deps.requestRender();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Render
|
|
// ========================================================================
|
|
|
|
render(width: number): readonly string[] {
|
|
const termHeight = process.stdout.rows || 40;
|
|
// `innerWidth` widths the editor/notice chrome (gutter-prefixed below).
|
|
// `contentWidth` widths the transcript: ScrollView reserves the last column
|
|
// for the scrollbar, and the transcript components carry their own 1-col left
|
|
// gutter — so body rows are emitted WITHOUT an extra outer space, sharing that
|
|
// gutter with the header/footer (which add one). Stacking both shifted the body
|
|
// one column right of the title.
|
|
const innerWidth = Math.max(20, width - 2);
|
|
const contentWidth = Math.max(1, width - 1);
|
|
const ref = this.deps.registry.get(this.deps.agentId);
|
|
|
|
const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId);
|
|
const footerLines = this.#footerLines();
|
|
const noticeLine = this.#notice ? ` ${theme.fg("error", this.#notice)}` : undefined;
|
|
const editorLines = this.#editor ? this.#editor.render(innerWidth) : [];
|
|
|
|
// Chrome: top border + header rows + divider border + (notice) + editor + footer + bottom border.
|
|
const chrome = headerLines.length + 2 + editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
|
|
const viewportHeight = Math.max(3, termHeight - chrome);
|
|
|
|
const contentLines = this.#builder.isEmpty
|
|
? [` ${theme.fg("dim", this.#placeholder())}`]
|
|
: this.#builder.container.render(contentWidth);
|
|
this.#scrollView.setLines(contentLines);
|
|
this.#scrollView.setHeight(viewportHeight);
|
|
if (this.#followBottom) this.#scrollView.scrollToBottom();
|
|
|
|
const lines: string[] = [];
|
|
lines.push(...new DynamicBorder().render(width));
|
|
for (const headerLine of headerLines) lines.push(` ${headerLine}`);
|
|
lines.push(...new DynamicBorder().render(width));
|
|
for (const row of this.#scrollView.render(width)) lines.push(row);
|
|
if (noticeLine) lines.push(noticeLine);
|
|
for (const editorLine of editorLines) lines.push(` ${editorLine}`);
|
|
lines.push(...footerLines);
|
|
lines.push(...new DynamicBorder().render(width));
|
|
return lines;
|
|
}
|
|
|
|
#headerLines(status: AgentStatus | undefined, kind: string | undefined, parentId: string | undefined): string[] {
|
|
const lines = [theme.fg("accent", `Agent Hub ${theme.sep.dot} ${this.deps.agentId}`)];
|
|
if (status && kind) {
|
|
const kindTag = theme.fg("dim", ` ${parentId ? `${kind} ${theme.sep.dot} of ${parentId}` : kind}`);
|
|
const modelLabel = this.#model ? theme.fg("muted", `${theme.sep.dot}${this.#model}`) : "";
|
|
lines.push(`${theme.bold(this.deps.agentId)} ${statusBadge(status)}${kindTag}${modelLabel}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
#footerLines(): string[] {
|
|
const lines: string[] = [];
|
|
const statsLine = this.#statsLine();
|
|
if (statsLine) lines.push(` ${statsLine}`);
|
|
const hint = this.#editor
|
|
? `Enter:send Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand empty input → j/k:scroll g/G:top/bottom`
|
|
: `Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand j/k:scroll g/G:top/bottom`;
|
|
lines.push(` ${theme.fg("dim", hint)}`);
|
|
return lines;
|
|
}
|
|
|
|
#statsLine(): string {
|
|
const observed: ObservableSession | undefined = this.deps.observers
|
|
?.getSessions()
|
|
.find(s => s.id === this.deps.agentId);
|
|
const progress = observed?.progress;
|
|
if (!progress) return "";
|
|
const stats: string[] = [];
|
|
if (progress.contextTokens && progress.contextTokens > 0) {
|
|
stats.push(
|
|
progress.contextWindow && progress.contextWindow > 0
|
|
? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
|
|
: formatNumber(progress.contextTokens),
|
|
);
|
|
}
|
|
if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
|
|
const parts: string[] = [];
|
|
if (stats.length > 0 || progress.toolCount > 0) {
|
|
const toolStat =
|
|
progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : "";
|
|
parts.push(theme.fg("dim", [toolStat, ...stats].filter(Boolean).join(theme.sep.dot)));
|
|
}
|
|
if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
|
|
return parts.join(theme.sep.dot);
|
|
}
|
|
|
|
#placeholder(): string {
|
|
if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available.";
|
|
if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…";
|
|
if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
|
|
return "No messages yet.";
|
|
}
|
|
}
|