Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/ultra/ui.ts

Raw
// ultra — live progress sinks (Task 7). TWO surface-specific views over the
// shared `progress.ts` reducer, render + input only, NO orchestration:
//
//   • TOOL sink  (Layer 7a) — a `renderResult(result, opts, theme, ctx)` that
//     folds NOTHING itself: it reads the reducer state off `result.details`
//     (pushed through the SDK `onUpdate` channel by the engine) and draws a
//     collapsed/expanded board. Read-only — no `controls`, no steering, and it
//     NEVER renders per-agent bodies/transcripts. Matches Pi tool rendering.
//
//   • COMMAND overlay (Layers 7b/7c) — a `ctx.ui.custom` `Component` that folds
//     the `onProgress` stream itself, renders the board + a focused-agent
//     drill-in transcript (follow-mode + throttle), and is the ONLY surface
//     with per-agent steering/abort. Modeled on `devil/ui.ts`.
//
// Both sinks consume only what `progress.ts` defines; this module never
// constructs an SDK session and never holds orchestration logic.

import type {
	AgentToolResult,
	Theme,
	ToolDefinition,
	ToolRenderResultOptions,
} from "@earendil-works/pi-coding-agent";
import {
	createBashToolDefinition,
	createEditToolDefinition,
	createFindToolDefinition,
	createGrepToolDefinition,
	createLsToolDefinition,
	createPowerShellToolDefinition,
	createReadToolDefinition,
	createWriteToolDefinition,
	getMarkdownTheme,
	keyHint,
	ToolExecutionComponent,
} from "@earendil-works/pi-coding-agent";
import {
	type Component,
	Markdown,
	matchesKey,
	Text,
	type TUI,
	truncateToWidth,
} from "@earendil-works/pi-tui";
import {
	formatStructuredText,
	formatUsage,
	formatValueMarkdown,
	summarizeValue,
} from "./display.ts";
import {
	type AgentControls,
	type AgentStatus,
	initialReducerState,
	type PlannedPhase,
	type ReducerState,
	reduceProgress,
	type TranscriptItem,
	type UltraProgressEvent,
} from "./progress.ts";
import { formatUltraResultMarkdown } from "./result.ts";
import type { TokenUsage } from "./runner.ts";

// ---------------------------------------------------------------------------
// Shared board helpers (used by both sinks)
// ---------------------------------------------------------------------------

const STATUS_ICON: Record<AgentStatus, string> = {
	queued: "󰅐",
	running: "󰐌",
	done: "󰗠",
	dropped: "󰅙",
};

const PHASE_ICON = {
	"agents-pending": "󰭙",
	"condition-pending": "󰘬",
	skipped: "−",
} as const;

const RESET = /\x1b\[0m/g;

function fitLine(text: string, width: number): string {
	const w = Math.max(1, width);
	const out = truncateToWidth(text, w);
	return text.includes("\x1b") ? out : out.replace(RESET, "");
}

/** `phase N/len` from the reducer's ordered phase plan. */
function phaseCounter(state: ReducerState): string {
	if (state.phases.length === 0) return "";
	const idx = state.phase
		? state.phases.findIndex((phase) => phase.phase === state.phase) + 1
		: 0;
	return `phase ${idx}/${state.phases.length}`;
}

/** One-line aggregate with terminal, active, queued, and later work counts. */
function summaryLine(state: ReducerState): string {
	const parts: string[] = [];
	const pc = phaseCounter(state);
	if (pc) parts.push(pc);
	parts.push(`${state.done}/${state.total} done`);
	if (state.running > 0) parts.push(`${state.running} active`);
	if (state.queued > 0) parts.push(`${state.queued} queued`);
	if (state.later > 0)
		parts.push(`${state.later} later phase${state.later === 1 ? "" : "s"}`);
	if (state.dropped > 0) parts.push(`${state.dropped}✗`);
	return parts.join(" · ");
}

function headerLine(state: ReducerState, brand = "ultra"): string {
	const wallTime =
		state.startedAt === undefined
			? undefined
			: `${elapsed(state.startedAt, state.endedAt)} elapsed`;
	return [brand, state.workflowName, wallTime].filter(Boolean).join(" · ");
}

function descriptionLine(state: ReducerState): string | undefined {
	return state.workflowDescription;
}

function elapsed(startedAt: number, endedAt = Date.now()): string {
	const seconds = Math.max(0, Math.floor((endedAt - startedAt) / 1000));
	if (seconds < 60) return `${seconds}s`;
	return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}

type AgentRow = ReducerState["rows"][number];
type AgentBoardLine = {
	text: string;
	color: "accent" | "error" | "dim";
	bold?: boolean;
};

function agentModel(row: AgentRow): string | undefined {
	const model = row.tier
		? `${row.tier}${row.model ? ` → ${row.model}` : ""}`
		: (row.model ?? row.failure?.model);
	const thinkingLevel = row.thinkingLevel ?? row.failure?.thinkingLevel;
	return [model, thinkingLevel].filter(Boolean).join(" · ") || undefined;
}

function agentMetadata(row: AgentRow, state: ReducerState): string {
	const task = row.summary?.trim() || row.agentId;
	const parts = [task === row.agentId ? undefined : row.agentId, row.status];
	if (row.usage) parts.push(formatUsage(row.usage).summary);
	if (row.failure)
		parts.push(
			`${row.failure.retryable ? "retryable" : "terminal"} · ${row.failure.attempts} attempt${row.failure.attempts === 1 ? "" : "s"}`,
		);
	const model = agentModel(row);
	if (model) parts.push(model);
	if (row.startedAt !== undefined)
		parts.push(elapsed(row.startedAt, row.endedAt));
	if (row.cached) parts.push("checkpoint reused");
	if (row.status === "queued") parts.push(queuedDetail(state, row.phase));
	return parts.filter(Boolean).join(" · ");
}

function agentBoardLines(
	row: AgentRow,
	state: ReducerState,
	prefix: string,
): AgentBoardLine[] {
	const task = row.summary?.trim() || row.agentId;
	const color =
		row.status === "running"
			? "accent"
			: row.status === "dropped"
				? "error"
				: "dim";
	const primary = `${prefix}${STATUS_ICON[row.status]} ${task}`;
	const metadata = agentMetadata(row, state);
	if (row.status === "done" || row.status === "queued") {
		return [
			{ text: primary, color },
			{
				text: `${" ".repeat(prefix.length + 2)}${metadata}`,
				color: "dim",
			},
		];
	}
	const activity = row.failure
		? `${row.failure.code}: ${row.failure.message}`
		: row.action?.trim();
	const secondary = [activity, metadata].filter(Boolean);
	return [
		{ text: primary, color, bold: true },
		...secondary.map(
			(text): AgentBoardLine => ({
				text: `${" ".repeat(prefix.length + 2)}${text}`,
				color: "dim",
			}),
		),
	];
}

type DisplayRow =
	| { kind: "agent"; row: ReducerState["rows"][number] }
	| { kind: "phase"; phase: PlannedPhase; expanded: boolean };

function rowsInPhase(state: ReducerState, phase: string) {
	return state.rows.filter((row) => row.phase === phase);
}

function displayRows(
	state: ReducerState,
	expanded: (phase: PlannedPhase) => boolean,
): DisplayRow[] {
	const out: DisplayRow[] = [];
	const seen = new Set<string>();
	for (const phase of state.phases) {
		const open = expanded(phase);
		out.push({ kind: "phase", phase, expanded: open });
		if (!open) continue;
		for (const row of rowsInPhase(state, phase.phase)) {
			seen.add(row.agentId);
			out.push({ kind: "agent", row });
		}
	}
	for (const row of state.rows) {
		if (
			!seen.has(row.agentId) &&
			!state.phases.some((p) => p.phase === row.phase)
		)
			out.push({ kind: "agent", row });
	}
	return out;
}

function phaseLabel(
	state: ReducerState,
	phase: PlannedPhase,
	expanded: boolean,
): string {
	if (phase.status === "agents-pending")
		return `${PHASE_ICON[phase.status]} ${phase.phase} · agents not planned yet`;
	if (phase.status === "condition-pending")
		return `${PHASE_ICON[phase.status]} ${phase.phase} · conditional`;
	if (phase.status === "skipped")
		return `${PHASE_ICON[phase.status]} ${phase.phase} · skipped`;

	const rows = rowsInPhase(state, phase.phase);
	const done = rows.filter((row) => row.status === "done").length;
	const running = rows.filter((row) => row.status === "running").length;
	const dropped = rows.filter((row) => row.status === "dropped").length;
	const queued = rows.filter((row) => row.status === "queued").length;
	const counts = [
		`${done}/${rows.length} done`,
		running ? `${running} active` : "",
		queued ? `${queued} queued` : "",
		dropped ? `${dropped} dropped` : "",
	]
		.filter(Boolean)
		.join(" · ");
	if (phase.status === "done")
		return `${dropped ? STATUS_ICON.dropped : STATUS_ICON.done} ${phase.phase}${counts ? ` · ${counts}` : " · done"}`;
	if (state.phase === phase.phase)
		return `${expanded ? "▼" : "▶"} ${phase.phase}${counts ? ` · ${counts}` : ""}`;
	return `${STATUS_ICON.queued} ${phase.phase}${rows.length ? ` · ${rows.length} queued` : ""}`;
}

function phaseDetail(phase: PlannedPhase): string | undefined {
	if (phase.status === "agents-pending")
		return "earlier results determine which agents are needed";
	if (phase.status === "condition-pending")
		return "earlier results determine whether this phase runs";
	return undefined;
}

function queuedDetail(state: ReducerState, phase: string): string {
	return state.phase === phase
		? "waiting for concurrency slot"
		: "starts when phase begins";
}

function automaticPhase(state: ReducerState): string | undefined {
	const current = state.phases.find(
		(phase) => phase.phase === state.phase && phase.status === "resolved",
	);
	return (current ?? state.phases.find((phase) => phase.status === "resolved"))
		?.phase;
}

function toolPhaseExpanded(state: ReducerState, phase: PlannedPhase): boolean {
	if (rowsInPhase(state, phase.phase).some((row) => row.status === "dropped"))
		return true;
	return phase.phase === automaticPhase(state);
}

function aggregateUsage(state: ReducerState): TokenUsage | undefined {
	if (state.workflowResult?.tokenUsage) return state.workflowResult.tokenUsage;
	let usage: TokenUsage | undefined;
	for (const row of state.rows) {
		if (!row.usage) continue;
		usage ??= {
			input: 0,
			output: 0,
			total: 0,
			cacheRead: 0,
			cacheWrite: 0,
			cost: 0,
		};
		usage.input += row.usage.input;
		usage.output += row.usage.output;
		usage.total += row.usage.total;
		usage.cacheRead += row.usage.cacheRead;
		usage.cacheWrite += row.usage.cacheWrite;
		usage.cost += row.usage.cost;
	}
	return usage;
}

function metricsLines(state: ReducerState, expanded: boolean): string[] {
	if (state.total === 0) return [];
	const usage = aggregateUsage(state);
	const formatted = usage ? formatUsage(usage) : undefined;
	const resumed = state.rows.filter((row) => row.cached).length;
	if (!usage && state.done === 0 && resumed === 0) return [];
	return [
		[
			formatted?.summary,
			`checkpoint ${state.done}/${state.total}`,
			resumed ? `${resumed} resumed` : "",
		]
			.filter(Boolean)
			.join(" · "),
		...(expanded && formatted ? [formatted.detail] : []),
	];
}

function divider(label: string, width: number): string {
	const w = Math.max(1, width);
	const text = ` ${label} `;
	if (text.length >= w) return fitLine(label, w);
	const left = Math.floor((w - text.length) / 2);
	const right = w - text.length - left;
	return `${"─".repeat(left)}${text}${"─".repeat(right)}`;
}

/** The collapsed expand hint. Guarded: `keyHint` needs an initialised theme. */
function expandHint(): string {
	try {
		return keyHint("app.tools.expand", "to expand");
	} catch {
		return "to expand";
	}
}

// ---------------------------------------------------------------------------
// Layer 7a — TOOL sink: `renderResult` over the reducer state
// ---------------------------------------------------------------------------

/**
 * Pure board renderer for the tool surface. Collapsed = aggregate counts +
 * bounded phase and run summaries + the expand hint (never action or transcript
 * bodies). Expanded = phase-first summaries with active and failed phases opened,
 * plus model/usage metadata and the final outcome. The drill-in transcript is
 * the OVERLAY's job, never the tool sink's, so `state.transcripts` is never read here.
 */
function renderToolBoard(
	state: ReducerState,
	options: ToolRenderResultOptions,
	theme: Theme,
	width: number,
): string[] {
	const w = Math.max(1, width);
	const lines: string[] = [];

	const header = `${headerLine(state, theme.bold("ultra"))} · ${summaryLine(state)}`;
	lines.push(theme.fg("accent", fitLine(header, w)));
	const description = descriptionLine(state);
	if (description) lines.push(theme.fg("muted", fitLine(description, w)));
	for (const metrics of metricsLines(state, options.expanded))
		lines.push(theme.fg("muted", fitLine(metrics, w)));

	if (!options.expanded) {
		const focus = Math.max(
			0,
			state.phases.findIndex((phase) => phase.phase === state.phase),
		);
		const first = Math.max(0, Math.min(focus - 1, state.phases.length - 3));
		if (first > 0)
			lines.push(theme.fg("dim", fitLine(`  ${first} earlier phases`, w)));
		for (const phase of state.phases.slice(first, first + 3))
			lines.push(
				theme.fg("muted", fitLine(`  ${phaseLabel(state, phase, false)}`, w)),
			);
		const remaining = state.phases.length - first - 3;
		if (remaining > 0)
			lines.push(theme.fg("dim", fitLine(`  ${remaining} later phases`, w)));

		const current =
			state.phase ??
			state.phases.find((phase) => phase.status === "resolved")?.phase;
		const runs = current ? rowsInPhase(state, current) : [];
		const ranked = (["running", "dropped", "queued", "done"] as const).flatMap(
			(status) => runs.filter((row) => row.status === status),
		);
		for (const row of ranked.slice(0, 3)) {
			const summary = row.summary?.trim();
			const label = summary ? `${row.agentId} · ${summary}` : row.agentId;
			lines.push(
				theme.fg(
					row.status === "dropped" ? "error" : "muted",
					fitLine(`    ${STATUS_ICON[row.status]} ${label}`, w),
				),
			);
		}
		if (ranked.length > 3)
			lines.push(
				theme.fg("dim", fitLine(`    ${ranked.length - 3} more runs`, w)),
			);
		if (state.workflowResult?.fullOutputPath)
			lines.push(
				theme.fg(
					"muted",
					fitLine(`Evidence: ${state.workflowResult.fullOutputPath}`, w),
				),
			);
		lines.push(theme.fg("muted", fitLine(expandHint(), w)));
		return lines.map((l) => fitLine(l, w));
	}

	// Expanded: one phase row each, with only active or failed phases opened.
	for (const display of displayRows(state, (phase) =>
		toolPhaseExpanded(state, phase),
	)) {
		if (display.kind === "phase") {
			const color =
				display.phase.status === "done"
					? "success"
					: state.phase === display.phase.phase
						? "accent"
						: "dim";
			lines.push(
				theme.fg(
					color,
					fitLine(phaseLabel(state, display.phase, display.expanded), w),
				),
			);
			const detail = phaseDetail(display.phase);
			if (detail) lines.push(theme.fg("muted", fitLine(`  └ ${detail}`, w)));
			continue;
		}
		for (const line of agentBoardLines(display.row, state, "  ")) {
			const text = line.bold ? theme.bold(line.text) : line.text;
			lines.push(theme.fg(line.color, fitLine(text, w)));
		}
	}

	if (state.workflowResult) {
		lines.push(theme.fg("border", divider("outcome", w)));
		lines.push(
			...new Markdown(
				formatUltraResultMarkdown(state.workflowResult),
				0,
				0,
				getMarkdownTheme(),
			).render(w),
		);
	}

	return lines.map((l) => fitLine(l, w));
}

/**
 * A throwaway `Component` that draws the tool-surface board for ONE render.
 * It holds no cache: every `render(width)` recomputes from the snapshot it was
 * built with, so a fresh `renderResult` call (one per `onUpdate`, P2) with fresh
 * `details` always tracks the latest reducer state (no frozen snapshot).
 */
class UltraResultComponent implements Component {
	constructor(
		private readonly state: ReducerState,
		private readonly options: ToolRenderResultOptions,
		private readonly theme: Theme,
	) {}

	render(width: number): string[] {
		return renderToolBoard(this.state, this.options, this.theme, width);
	}

	invalidate(): void {
		// No cached state — nothing to drop.
	}
}

/**
 * Accept `result.details` only when it structurally IS a reducer state. It
 * reaches us over an SDK-controlled channel (structured-cloned per `onUpdate`
 * tick, and re-hydrated from session persistence on resume), so a truthy-but-
 * foreign value can arrive — a resumed/foreign tool result, a payload from
 * another producer, a serialization wrapper. The board reads `state.phases`
 * and `state.rows` unguarded, so anything lacking them falls back to an empty
 * board. This is the single choke point: an uncaught throw in `renderResult`
 * escapes tool-error handling and crashes the whole TUI render loop.
 */
function asReducerState(details: unknown): ReducerState {
	const d = details as Partial<ReducerState> | null | undefined;
	const phaseStatuses: readonly string[] = [
		"resolved",
		"agents-pending",
		"condition-pending",
		"done",
		"skipped",
	];
	const agentStatuses: readonly string[] = [
		"queued",
		"running",
		"done",
		"dropped",
	];
	const phasesValid =
		Array.isArray(d?.phases) &&
		d.phases.every(
			(phase) =>
				typeof phase === "object" &&
				phase !== null &&
				typeof phase.phase === "string" &&
				phaseStatuses.includes(phase.status) &&
				Array.isArray(phase.agentIds) &&
				phase.agentIds.every((agentId: unknown) => typeof agentId === "string"),
		);
	const rowsValid =
		Array.isArray(d?.rows) &&
		d.rows.every(
			(row) =>
				typeof row === "object" &&
				row !== null &&
				typeof row.agentId === "string" &&
				typeof row.phase === "string" &&
				agentStatuses.includes(row.status),
		);
	return d && phasesValid && rowsValid
		? (d as ReducerState)
		: initialReducerState();
}

/**
 * Tool-surface `renderResult`: reads the reducer state off `result.details`
 * (pushed through `onUpdate`) and returns a board `Component`. Read-only — no
 * `controls`, no steering. SDK errors have text content, not reducer details;
 * render them before the board fallback so failed calls never look successful.
 */
export function renderUltraResult(
	result: AgentToolResult<ReducerState | undefined>,
	options: ToolRenderResultOptions,
	theme: Theme,
	context?: { isError: boolean },
): Component {
	if (context?.isError) {
		const message =
			result.content
				.filter((part) => part.type === "text")
				.map((part) => part.text)
				.join("\n")
				.trim() || "Workflow failed without an error message.";
		if (options.expanded)
			return new Text(theme.fg("error", `ultra · failed\n${message}`), 0, 0);
		return {
			render: (width) => [
				theme.fg(
					"error",
					fitLine(
						`ultra · failed: ${message.replace(/\s+/g, " ")}`,
						Math.min(width, 160),
					),
				),
				theme.fg("muted", fitLine(expandHint(), width)),
			],
			invalidate() {},
		};
	}
	return new UltraResultComponent(
		asReducerState(result?.details),
		options,
		theme,
	);
}

// ---------------------------------------------------------------------------
// Layers 7b/7c — COMMAND overlay: board + drill-in transcript + steer/abort
// ---------------------------------------------------------------------------

/**
 * Per-agent controls as the OVERLAY consumes them. Extends the locked
 * `AgentControls` (`{steer, abort}`) with an optional `followUp` for re-prompting
 * an agent that has already finished. The runner binds `followUp` on a finished
 * agent's controls (cross-task contract — see module notes); when absent the
 * follow-up affordance is simply inert.
 */
export interface OverlayAgentControls extends AgentControls {
	followUp?(text: string): Promise<void> | void;
}

/**
 * Minimal host surface the overlay needs. The real `TUI` (passed by
 * `ctx.ui.custom`) satisfies this structurally; tests inject a fake with a
 * `requestRender` spy + a fixed terminal size.
 */
export interface OverlayHost {
	requestRender(): void;
	terminal: { rows: number; columns: number };
}

export interface UltraOverlayOptions {
	host: OverlayHost;
	theme: Theme;
	/** Confirmed `Esc Esc` run-level abort through the command path's AbortController. */
	onRunAbort: () => void;
	/** Injectable clock for the render throttle (default `Date.now`). */
	now?: () => number;
	/** Render-throttle window in ms (default 80, matching devil). */
	throttleMs?: number;
	workflowName?: string;
	workflowDescription?: string;
	/** Optional Pi TUI handle; when present, tool transcript rows use Pi's native tool renderer. */
	toolUi?: TUI;
	toolsExpanded?: () => boolean;
	hideToolPreviews?: boolean;
	isToolsExpandKey?: (data: string) => boolean;
	onToolsExpand?: () => void;
	cwd?: string;
}

const DEFAULT_THROTTLE_MS = 80;

// Use Pi's native views for source/code tools; generic tools get a field/list view.
const nativeToolDefinitions = new Map<
	string,
	(
		cwd: string,
	) => NonNullable<ConstructorParameters<typeof ToolExecutionComponent>[4]>
>([
	["read", createReadToolDefinition],
	["write", createWriteToolDefinition],
	["edit", createEditToolDefinition],
	["bash", createBashToolDefinition],
	["powershell", createPowerShellToolDefinition],
	["grep", createGrepToolDefinition],
	["find", createFindToolDefinition],
	["ls", createLsToolDefinition],
]);

/** Render transcript markdown with Pi's own markdown renderer. */
function transcriptLines(text: string, width: number): string[] {
	if (!text) return [];
	return new Markdown(text, 0, 0, getMarkdownTheme()).render(
		Math.max(1, width),
	);
}

function normalizeToolResult(item: TranscriptItem & { kind: "tool" }) {
	const r = item.result as
		| { content?: unknown; details?: unknown; isError?: boolean }
		| undefined;
	const content = Array.isArray(r?.content)
		? (r.content as Array<{
				type: string;
				text?: string;
				data?: string;
				mimeType?: string;
			}>)
		: item.fallback
			? [{ type: "text", text: item.fallback }]
			: [];
	return {
		content,
		details: r?.details,
		isError: item.isError ?? r?.isError ?? false,
	};
}

/**
 * The `ctx.ui.custom` overlay (command surface only). Folds the `onProgress`
 * stream with the shared reducer, renders the board + a focused-agent drill-in
 * transcript (follow-mode), and exposes per-agent steer/abort + global abort.
 * Render + input only: it holds NO orchestration — the run is driven elsewhere
 * and pushes events in via `handleProgress`.
 */
export class UltraOverlayComponent implements Component {
	private state: ReducerState;
	private focusKey: string | undefined;
	private readonly expandedPhases = new Set<string>();
	private readonly collapsedPhases = new Set<string>();
	private followMode = true;
	private scrollOffset = 0;
	private lastMaxScroll = 0;
	private lastRenderAt = Number.NEGATIVE_INFINITY;
	private steerMode = false;
	private steerBuffer = "";
	private steeredFlag = false;
	private abortArmedAt: number | undefined;
	private readonly transcriptTools = new Map<
		string,
		{
			component: ToolExecutionComponent;
			args: unknown;
			result: unknown;
			isError: boolean | undefined;
			isPartial: boolean | undefined;
			expanded: boolean;
		}
	>();

	private readonly host: OverlayHost;
	private readonly theme: Theme;
	private readonly onRunAbort: () => void;
	private readonly now: () => number;
	private readonly throttleMs: number;
	private readonly toolUi: TUI | undefined;
	private readonly toolsExpanded: () => boolean;
	private readonly hideToolPreviews: boolean;
	private readonly isToolsExpandKey: (data: string) => boolean;
	private readonly onToolsExpand: () => void;
	private readonly cwd: string | undefined;

	constructor(options: UltraOverlayOptions) {
		this.state = initialReducerState({
			workflowName: options.workflowName,
			workflowDescription: options.workflowDescription,
		});
		this.host = options.host;
		this.theme = options.theme;
		this.onRunAbort = options.onRunAbort;
		this.now = options.now ?? Date.now;
		this.throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
		this.toolUi = options.toolUi;
		this.toolsExpanded = options.toolsExpanded ?? (() => false);
		this.hideToolPreviews = options.hideToolPreviews ?? false;
		this.isToolsExpandKey = options.isToolsExpandKey ?? (() => false);
		this.onToolsExpand = options.onToolsExpand ?? (() => {});
		this.cwd = options.cwd;
	}

	/** True once the human has submitted a steer this run (enqueue-time taint). */
	get steered(): boolean {
		return this.steeredFlag;
	}

	/** Test/inspection accessor for the folded reducer state. */
	get reducerState(): ReducerState {
		return this.state;
	}

	// ---- progress sink: fold + throttled re-render -----------------------

	handleProgress(event: UltraProgressEvent): void {
		const previousPhase = this.state.phase;
		this.state = reduceProgress(this.state, event);
		if (event.kind === "start" && event.phase !== previousPhase) {
			if (previousPhase) {
				this.expandedPhases.delete(previousPhase);
				this.collapsedPhases.delete(previousPhase);
			}
			this.collapsedPhases.delete(event.phase);
			this.focusKey = `agent:${event.agentId}`;
			this.followMode = true;
			this.scrollOffset = 0;
		}
		this.ensureVisibleFocus();
		const now = this.now();
		// Terminal events bypass the throttle so the final board is never lost
		// behind the render window.
		const force = event.kind === "end" || event.kind === "phase";
		if (force || now - this.lastRenderAt >= this.throttleMs) {
			this.lastRenderAt = now;
			this.host.requestRender();
		}
	}

	// ---- input ------------------------------------------------------------

	handleInput(data: string): void {
		if (this.isToolsExpandKey(data)) {
			this.onToolsExpand();
			this.host.requestRender();
			return;
		}
		if (this.steerMode) {
			this.handleSteerInput(data);
			return;
		}
		if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
			const now = this.now();
			if (this.abortArmedAt !== undefined && now - this.abortArmedAt <= 1500) {
				this.abortArmedAt = undefined;
				this.onRunAbort();
			} else {
				this.abortArmedAt = now;
				this.host.requestRender();
			}
			return;
		}
		this.abortArmedAt = undefined;
		if (matchesKey(data, "tab")) {
			this.focusBy(1, true);
		} else if (matchesKey(data, "down")) {
			this.focusBy(1, false);
		} else if (matchesKey(data, "up")) {
			this.focusBy(-1, false);
		} else if (matchesKey(data, "return") || matchesKey(data, "enter")) {
			this.toggleFocusedPhase();
		} else if (
			matchesKey(data, "pageUp") ||
			matchesKey(data, "alt+up") ||
			matchesKey(data, "ctrl+u")
		) {
			this.scrollBy(-10);
		} else if (
			matchesKey(data, "pageDown") ||
			matchesKey(data, "alt+down") ||
			matchesKey(data, "ctrl+d")
		) {
			this.scrollBy(10);
		} else if (data === "s") {
			this.openSteer();
		} else if (data === "x") {
			this.abortFocused();
		}
	}

	private phaseExpanded(phase: PlannedPhase): boolean {
		if (this.collapsedPhases.has(phase.phase)) return false;
		if (this.expandedPhases.has(phase.phase)) return true;
		return phase.phase === automaticPhase(this.state);
	}

	private visibleRows(): DisplayRow[] {
		return displayRows(this.state, (phase) => this.phaseExpanded(phase));
	}

	private displayKey(display: DisplayRow): string {
		return display.kind === "phase"
			? `phase:${display.phase.phase}`
			: `agent:${display.row.agentId}`;
	}

	private ensureVisibleFocus(): void {
		const visible = this.visibleRows();
		if (visible.length === 0) {
			this.focusKey = undefined;
			return;
		}
		if (
			this.focusKey &&
			visible.some((row) => this.displayKey(row) === this.focusKey)
		)
			return;
		const firstAgent = visible.find((row) => row.kind === "agent");
		this.focusKey = this.displayKey(firstAgent ?? visible[0]);
	}

	private focusBy(delta: number, agentsOnly: boolean): void {
		const candidates = this.visibleRows().filter(
			(row) => !agentsOnly || row.kind === "agent",
		);
		if (candidates.length === 0) return;
		const current = candidates.findIndex(
			(row) => this.displayKey(row) === this.focusKey,
		);
		const next =
			current < 0
				? 0
				: agentsOnly
					? (current + delta + candidates.length) % candidates.length
					: Math.min(candidates.length - 1, Math.max(0, current + delta));
		this.focusKey = this.displayKey(candidates[next]);
		this.followMode = true;
		this.scrollOffset = 0;
		this.host.requestRender();
	}

	private toggleFocusedPhase(): void {
		const phase = this.focusedPhase();
		if (!phase) return;
		if (this.phaseExpanded(phase)) {
			this.expandedPhases.delete(phase.phase);
			this.collapsedPhases.add(phase.phase);
			this.focusKey = `phase:${phase.phase}`;
		} else {
			this.collapsedPhases.delete(phase.phase);
			this.expandedPhases.add(phase.phase);
		}
		this.host.requestRender();
	}

	private scrollBy(delta: number): void {
		if (delta < 0) {
			this.followMode = false;
			this.scrollOffset = Math.max(0, this.scrollOffset + delta);
		} else {
			this.scrollOffset += delta;
			if (this.scrollOffset >= this.lastMaxScroll) this.followMode = true;
		}
		this.host.requestRender();
	}

	private focusedRow() {
		if (!this.focusKey?.startsWith("agent:")) return undefined;
		const id = this.focusKey.slice("agent:".length);
		return this.state.rows.find((row) => row.agentId === id);
	}

	private focusedPhase(): PlannedPhase | undefined {
		if (!this.focusKey) return undefined;
		const row = this.focusedRow();
		const name = row?.phase ?? this.focusKey.slice("phase:".length);
		return this.state.phases.find((phase) => phase.phase === name);
	}

	private openSteer(): void {
		if (!this.focusedRow()) return;
		this.steerMode = true;
		this.steerBuffer = "";
		this.host.requestRender();
	}

	private abortFocused(): void {
		const controls = this.focusedRow()?.controls as
			| OverlayAgentControls
			| undefined;
		if (controls?.abort) void Promise.resolve(controls.abort()).catch(() => {});
	}

	private handleSteerInput(data: string): void {
		if (matchesKey(data, "escape")) {
			// Cancel the buffer back to normal mode — does NOT abort the run.
			this.steerMode = false;
			this.steerBuffer = "";
			this.host.requestRender();
			return;
		}
		if (matchesKey(data, "return") || matchesKey(data, "enter")) {
			this.submitSteer();
			return;
		}
		if (matchesKey(data, "backspace")) {
			this.steerBuffer = this.steerBuffer.slice(0, -1);
			this.host.requestRender();
			return;
		}
		// Accept a single printable character (excludes control bytes + DEL).
		if (data.length === 1) {
			const code = data.charCodeAt(0);
			if (code >= 0x20 && code !== 0x7f) {
				this.steerBuffer += data;
				this.host.requestRender();
			}
		}
	}

	private submitSteer(): void {
		const text = this.steerBuffer;
		const row = this.focusedRow();
		this.steerMode = false;
		this.steerBuffer = "";
		this.host.requestRender();
		if (!row || !text) return;
		const controls = row.controls as OverlayAgentControls | undefined;
		if (row.status === "running") {
			// Taint the run synchronously, at enqueue time (before the steer
			// promise settles) — this is what the test asserts and what v2
			// caching keys off.
			this.steeredFlag = true;
			if (controls?.steer)
				void Promise.resolve(controls.steer(text)).catch(() => {});
		} else {
			// A finished agent is re-prompted as a follow-up, not steered.
			if (controls?.followUp)
				void Promise.resolve(controls.followUp(text)).catch(() => {});
		}
	}

	private transcriptTool(
		agentId: string,
		item: TranscriptItem & { kind: "tool" },
		index: number,
	): ToolExecutionComponent | undefined {
		if (!this.toolUi || !this.cwd) return undefined;
		const key = `${agentId}:${item.toolCallId ?? index}`;
		const expanded = this.toolsExpanded();
		let cached = this.transcriptTools.get(key);
		if (!cached) {
			const component = new ToolExecutionComponent(
				item.toolName,
				item.toolCallId ?? key,
				item.args ?? {},
				undefined,
				nativeToolDefinitions.get(item.toolName)?.(this.cwd) ??
					({
						renderCall: (args, _theme, context) =>
							new Markdown(
								`### ${item.toolName}\n${context.expanded ? formatValueMarkdown(args) : summarizeValue(args)}`,
								0,
								0,
								getMarkdownTheme(),
							),
						renderResult: (result, options, theme, context) => {
							if (!options.expanded)
								return new Text(
									theme.fg(
										context.isError ? "error" : "dim",
										context.isError
											? "Failed"
											: options.isPartial
												? "Running"
												: "Finished",
									),
									0,
									0,
								);
							const parts = result.content.flatMap((part) =>
								part.type === "text" ? [formatStructuredText(part.text)] : [],
							);
							if (result.details !== undefined)
								parts.push("### Details", formatValueMarkdown(result.details));
							return new Markdown(parts.join("\n\n"), 0, 0, getMarkdownTheme());
						},
					} satisfies Pick<ToolDefinition, "renderCall" | "renderResult">),
				this.toolUi,
				this.cwd,
			);
			component.markExecutionStarted();
			component.setArgsComplete();
			component.setExpanded(expanded);
			cached = {
				component,
				args: item.args,
				result: undefined,
				isError: undefined,
				isPartial: undefined,
				expanded,
			};
			this.transcriptTools.set(key, cached);
		} else if (item.args !== undefined && item.args !== cached.args) {
			cached.component.updateArgs(item.args);
			cached.args = item.args;
		}
		const expansionChanged = expanded !== cached.expanded;
		if (expansionChanged) {
			cached.component.setExpanded(expanded);
			cached.expanded = expanded;
		}
		if (
			item.result !== undefined &&
			(item.result !== cached.result ||
				item.isError !== cached.isError ||
				item.isPartial !== cached.isPartial ||
				expansionChanged)
		) {
			const result = normalizeToolResult(item);
			cached.component.updateResult(
				expanded ? result : { ...result, content: [] },
				item.isPartial ?? false,
			);
			cached.result = item.result;
			cached.isError = item.isError;
			cached.isPartial = item.isPartial;
		}
		return cached.component;
	}

	private transcriptBodyLines(
		agentId: string | undefined,
		width: number,
	): string[] {
		if (!agentId) return [];
		const items = this.state.transcriptItems[agentId];
		if (!items || items.length === 0) {
			return transcriptLines(this.state.transcripts[agentId] ?? "", width);
		}
		const lines: string[] = [];
		let text = "";
		const flushText = () => {
			if (!text) return;
			lines.push(...transcriptLines(text, width));
			text = "";
		};
		for (const [index, item] of items.entries()) {
			if (item.kind === "text") {
				text += item.text;
				continue;
			}
			flushText();
			if (item.kind === "user") {
				lines.push(this.theme.fg("accent", "user"));
				lines.push(...transcriptLines(item.text, width));
				continue;
			}
			if (this.hideToolPreviews && !this.toolsExpanded()) continue;
			const tool = this.transcriptTool(agentId, item, index);
			if (tool) lines.push(...tool.render(width));
			else if (item.fallback)
				lines.push(...transcriptLines(`> ${item.fallback}`, width));
		}
		flushText();
		return lines;
	}

	// ---- render -----------------------------------------------------------

	render(width: number): string[] {
		const w = Math.max(1, width);
		const state = this.state;
		const lines: string[] = [];

		const header = [headerLine(state), summaryLine(state)]
			.filter(Boolean)
			.join(" · ");
		lines.push(this.theme.fg("accent", fitLine(header, w)));
		for (const context of [
			descriptionLine(state),
			...metricsLines(state, this.toolsExpanded()),
		]) {
			if (context) lines.push(this.theme.fg("muted", fitLine(context, w)));
		}

		const maxLines = Math.max(1, this.host.terminal.rows - 3);
		const fixedAfterRows = 4; // transcript divider + body allowance + two footer rows
		const workBudget = Math.max(0, maxLines - lines.length - fixedAfterRows);
		const display = this.visibleRows();
		const groups = display.map((item) => {
			const focused = this.displayKey(item) === this.focusKey;
			if (item.kind === "phase") {
				const marker = focused ? "▶" : " ";
				const color = focused
					? "accent"
					: item.phase.status === "done"
						? "success"
						: "dim";
				return [
					this.theme.fg(
						color,
						fitLine(
							`${marker} ${phaseLabel(state, item.phase, item.expanded)}`,
							w,
						),
					),
				];
			}
			const marker = focused ? "▶" : " ";
			return agentBoardLines(item.row, state, `${marker}   `).map((line) => {
				const text =
					line.bold || focused ? this.theme.bold(line.text) : line.text;
				return this.theme.fg(line.color, fitLine(text, w));
			});
		});
		const focusedDisplay = Math.max(
			0,
			display.findIndex((item) => this.displayKey(item) === this.focusKey),
		);
		let groupStart = focusedDisplay;
		let groupEnd = Math.min(groups.length, focusedDisplay + 1);
		let used = groups[focusedDisplay]?.length ?? 0;
		if (used <= workBudget) {
			while (groupStart > 0 || groupEnd < groups.length) {
				let expanded = false;
				if (
					groupStart > 0 &&
					used + groups[groupStart - 1].length <= workBudget
				) {
					groupStart--;
					used += groups[groupStart].length;
					expanded = true;
				}
				if (
					groupEnd < groups.length &&
					used + groups[groupEnd].length <= workBudget
				) {
					used += groups[groupEnd].length;
					groupEnd++;
					expanded = true;
				}
				if (!expanded) break;
			}
		}
		const workLines = groups.slice(groupStart, groupEnd).flat();
		lines.push(...workLines.slice(0, workBudget));

		const focusedRow = this.focusedRow();
		const focusedPhase = this.focusedPhase();
		lines.push(
			this.theme.fg(
				"border",
				divider(
					focusedRow
						? `transcript: ${focusedRow.agentId}`
						: `phase: ${focusedPhase?.phase ?? "—"}`,
					w,
				),
			),
		);

		const body = this.transcriptBodyLines(focusedRow?.agentId, w - 2);

		// Reserve both footer rows and keep a 3-row terminal safety margin.
		const maxVisible = Math.max(0, maxLines - lines.length - 2);
		const maxScroll = Math.max(0, body.length - maxVisible);
		this.lastMaxScroll = maxScroll;
		if (this.followMode) this.scrollOffset = maxScroll;
		this.scrollOffset = Math.min(Math.max(0, this.scrollOffset), maxScroll);
		for (const l of body.slice(
			this.scrollOffset,
			this.scrollOffset + maxVisible,
		)) {
			lines.push(this.theme.fg("dim", fitLine(`  ${l}`, w)));
		}

		if (this.steerMode) {
			const verb =
				focusedRow && focusedRow.status === "running" ? "steer" : "follow-up";
			lines.push(
				this.theme.fg(
					"warning",
					fitLine(
						`${verb} ${focusedRow?.agentId ?? ""}> ${this.steerBuffer}`,
						w,
					),
				),
			);
		} else if (
			this.abortArmedAt !== undefined &&
			this.now() - this.abortArmedAt <= 1500
		) {
			lines.push(
				this.theme.fg(
					"warning",
					fitLine("Press Esc again to abort all agents", w),
				),
			);
		} else {
			const follow = this.followMode ? "follow" : "paused";
			lines.push(
				this.theme.fg(
					"dim",
					fitLine(
						`↑↓ focus · Tab agents · Enter phase · Alt+↑/↓ transcript (${follow})`,
						w,
					),
				),
				this.theme.fg(
					"dim",
					fitLine(
						`s steer · x abort agent · ${keyHint("app.tools.expand", "tools")} · Esc Esc abort all`,
						w,
					),
				),
			);
		}

		return lines.map((l) => fitLine(l, w));
	}

	invalidate(): void {
		for (const { component } of this.transcriptTools.values())
			component.invalidate();
	}
}