// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
 * Enhanced Console with Terminal UI Features
 *
 * Provides a Claude Code-like terminal experience with:
 * - Spinner animation during processing
 * - Output-above-spinner pattern
 * - Styled yes/no prompts
 * - Multiple choice menus with arrow key navigation
 * - Visual separators and formatting
 */

import {
    AppAgentEvent,
    DisplayAppendMode,
    DisplayContent,
    MessageContent,
} from "@typeagent/agent-sdk";
import { getContentForType } from "@typeagent/agent-sdk/helpers/display";
import type {
    RequestId,
    ClientIO,
    Dispatcher,
    IAgentMessage,
    TemplateEditConfig,
    PendingInteractionRequest,
    PendingInteractionResponse,
    PendingInteractionEntry,
    QueuedRequest,
    QueueCancelReason,
    QueueSnapshot,
} from "@typeagent/dispatcher-types";
import { QueueStateMirror } from "@typeagent/dispatcher-types";
import type { CompletionController } from "agent-dispatcher/helpers/completion";
import chalk from "chalk";
import fs from "fs";
import path from "path";
import os from "os";
import { EnhancedSpinner, ANSI, getDisplayWidth } from "interactive-app";
import { createInterface } from "readline/promises";
import readline from "readline";
import { convert } from "html-to-text";
import { marked } from "marked";
import { markedTerminal } from "marked-terminal";
import {
    isSlashCommand,
    handleSlashCommand,
    getVerboseIndicator,
    getConversationCommandContext,
    getSlashCompletions,
    getServerPort,
    getServerConnection,
    getCliConnectionId,
} from "./slashCommands.js";
import { handleConversationCommand } from "./conversationCommands.js";
import {
    setSpinnerAccessor,
    getDebugPanel,
    PromptRenderer,
} from "./debugInterceptor.js";
import { stopAgentServer } from "@typeagent/agent-server-client";
import { randomUUID } from "crypto";

// Track current processing state
let currentSpinner: EnhancedSpinner | null = null;

// Wire up the debug interceptor's spinner access
setSpinnerAccessor(() => currentSpinner);

// Pending choice promise — main loop awaits this before showing next prompt
let pendingChoicePromise: Promise<void> | null = null;

// The active readline interface, set by processCommandsEnhanced.
// undefined when using inline completions (stdin is in raw mode instead).
let activeRl: readline.promises.Interface | undefined;

/**
 * Ask a yes/no question using the active CLI readline (or raw stdin if inline
 * completions are enabled).  Always use this instead of creating a new
 * readline.Interface, which would compete with the main input loop.
 */
export async function confirmYesNo(question: string): Promise<boolean> {
    const answer = await question_internal(`${question} [y/N] `, activeRl);
    return answer.trim().toLowerCase() === "y";
}

// Active custom prompt renderer (set by questionWithCompletion)
let activePromptRenderer: PromptRenderer | null = null;

/**
 * Manages an ANSI scroll region so the prompt is anchored to the
 * bottom of the terminal.  Content written via writeContent() scrolls
 * naturally in the upper region; the prompt is redrawn in-place in the
 * fixed lower region.
 */
class TerminalLayout {
    private promptRows = 0;
    private active = false;
    private resizeHandler: (() => void) | undefined;

    /** Activate the scroll region, reserving `rows` at the bottom. */
    setup(rows: number) {
        this.promptRows = rows;
        this.active = true;
        // Push existing content upward so the fixed prompt region at
        // the bottom doesn't overwrite the last lines of output.
        // When the cursor is near the bottom of the terminal, these
        // newlines force the terminal to scroll, creating blank space
        // for the prompt area.
        process.stdout.write("\n".repeat(this.promptRows));
        const height = process.stdout.rows || 24;
        const scrollHeight = Math.max(1, height - this.promptRows);
        process.stdout.write(`\x1b[${scrollHeight};1H`);
        this.applyScrollRegion();
        // Listen for terminal resize
        this.resizeHandler = () => {
            if (this.active) {
                this.applyScrollRegion();
            }
        };
        process.stdout.on("resize", this.resizeHandler);
    }

    /** Update the number of reserved prompt rows (e.g. when input wraps). */
    setPromptRows(rows: number) {
        if (rows !== this.promptRows) {
            this.promptRows = rows;
            if (this.active) this.applyScrollRegion();
        }
    }

    private inlineBuffer: string = "";

    /** Write content into the scroll region (appears above the prompt). */
    writeContent(text: string) {
        if (!this.active) {
            process.stdout.write(text);
            return;
        }
        this.inlineBuffer = "";
        const height = process.stdout.rows || 24;
        const scrollBottom = height - this.promptRows;
        const promptStart = scrollBottom + 1;
        // Move to bottom of scroll region, scroll the region up by one line
        // with "\n", write the content, then reposition to the start of the
        // fixed region so render() can finalise the exact cursor position.
        process.stdout.write(
            `\x1b[${scrollBottom};1H` + "\n" + text + `\x1b[${promptStart};1H`,
        );
    }

    /** Append inline (streaming) text. Accumulates tokens in a buffer without
     *  writing to stdout. An empty-string flush signal (sent when streaming
     *  ends) commits the full buffer as a single block via writeContent().
     *  This avoids repeated in-place rewrites that produce duplicate output
     *  on passive observer clients sharing the same scroll-region state. */
    writeInline(text: string) {
        if (!this.active) {
            process.stdout.write(text);
            return;
        }
        if (text === "") {
            // End-of-stream flush signal — commit the accumulated buffer.
            this.flushInline();
        } else {
            this.inlineBuffer += text;
        }
    }

    /** Flush the inline buffer as a committed block line (called when the
     *  next message is a non-inline block). */
    flushInline() {
        if (!this.active || this.inlineBuffer === "") {
            return;
        }
        // writeContent() clears inlineBuffer as its first step.
        this.writeContent(this.inlineBuffer);
    }

    /** Draw content in the fixed bottom region at a given row offset (0-based from prompt start). */
    drawFixed(row: number, text: string) {
        if (!this.active) return;
        const height = process.stdout.rows || 24;
        const promptStart = height - this.promptRows + 1;
        process.stdout.write(`\x1b[${promptStart + row};1H\x1b[2K` + text);
    }

    /** Clear the entire fixed region. */
    clearFixed() {
        if (!this.active) return;
        const height = process.stdout.rows || 24;
        const promptStart = height - this.promptRows + 1;
        for (let r = promptStart; r <= height; r++) {
            process.stdout.write(`\x1b[${r};1H\x1b[2K`);
        }
    }

    /** Move cursor to a specific column on a given row of the fixed region. */
    moveCursorToFixed(row: number, col: number) {
        if (!this.active) return;
        const height = process.stdout.rows || 24;
        const promptStart = height - this.promptRows + 1;
        process.stdout.write(`\x1b[${promptStart + row};${col}H`);
    }

    /** Tear down the scroll region and restore normal terminal behavior. */
    cleanup() {
        if (!this.active) return;
        this.active = false;
        if (this.resizeHandler) {
            process.stdout.removeListener("resize", this.resizeHandler);
            this.resizeHandler = undefined;
        }
        // Reset scroll region to full terminal first
        process.stdout.write("\x1b[r");
        // Move to the row just after the scroll region ended and clear
        // everything from there to the bottom of the screen. This wipes
        // the old fixed region content (prompt/rule/hint).
        const height = process.stdout.rows || 24;
        const scrollBottom = Math.max(1, height - this.promptRows);
        process.stdout.write(`\x1b[${scrollBottom};1H`);
        // Emit a newline to advance past the last content line, then
        // clear from cursor to end of screen to remove all stale content.
        process.stdout.write("\n\x1b[J");
    }

    get isActive() {
        return this.active;
    }

    private applyScrollRegion() {
        const height = process.stdout.rows || 24;
        const scrollHeight = Math.max(1, height - this.promptRows);
        process.stdout.write(`\x1b[1;${scrollHeight}r`);
    }
}

// Shared terminal layout instance — activated when questionWithCompletion is running
let terminalLayout: TerminalLayout | null = null;

// Track the active request for cancellation support
let currentRequestId: string | undefined;
// Client-assigned ID for the current request; available immediately before
// processCommand() returns, so we can cancel before setUserRequest() arrives.
let currentClientRequestId: string | undefined;
let isProcessing = false;
let lastCtrlCTime = 0;
// Second ESC within this window triggers cancelAllInQueue; first ESC keeps its normal prompt-level meaning.
let lastEscapeTime = 0;
const DOUBLE_ESCAPE_WINDOW_MS = 1000;
// Dispatcher ref shared with input handlers (SIGINT, ESC, execution key listener) outside the bind closure.
const moduleDispatcherRef: { current?: Dispatcher } = {};

/**
 * Best-effort cancel of the currently running request. Prefers the server-known
 * requestId from the queue snapshot; falls back to our locally-known requestId
 * (post-setUserRequest), and finally the client-assigned id (pre-setUserRequest
 * race window). Returns `true` if a cancel was dispatched.
 *
 * Errors are swallowed: cancellation is fire-and-forget; the authoritative UI
 * update arrives via the `requestCancelled` broadcast.
 */
function tryCancelRunningHead(
    dispatcher: Dispatcher | undefined = moduleDispatcherRef.current,
): boolean {
    if (!dispatcher) return false;
    const running =
        queueMirror.snapshot?.running?.requestId ?? currentRequestId;
    if (running) {
        try {
            // cancelCommand returns Promise<CancelResult>; warn on rejection
            // so a wedged dispatcher doesn't silently drop the user's Esc.
            dispatcher.cancelCommand(running).catch((err) => {
                console.warn(`cancelCommand(${running}) rejected:`, err);
            });
        } catch (err) {
            // Sync throw — disconnected channel stub.
            console.warn(`cancelCommand(${running}) threw:`, err);
        }
        return true;
    }
    if (isProcessing && currentClientRequestId) {
        try {
            // cancelCommandByClientId is sync (void).
            dispatcher.cancelCommandByClientId(currentClientRequestId);
        } catch (err) {
            console.warn(
                `cancelCommandByClientId(${currentClientRequestId}) threw:`,
                err,
            );
        }
        return true;
    }
    return false;
}

// Mirror of the server's per-conversation queue. UI side effects live in the event handlers below.
const queueMirror = new QueueStateMirror();
// Tracks recent submits from THIS CLI so requestStarted doesn't double-print; pruned per access.
const RECENT_SUBMITTED_TTL_MS = 60_000;
const recentlySubmittedRequestIds = new Map<string, number>();

function pruneRecentSubmissions(): void {
    if (recentlySubmittedRequestIds.size === 0) return;
    const now = Date.now();
    for (const [id, ts] of recentlySubmittedRequestIds) {
        if (now - ts > RECENT_SUBMITTED_TTL_MS) {
            recentlySubmittedRequestIds.delete(id);
        }
    }
}

function rememberSubmittedId(id: string): void {
    pruneRecentSubmissions();
    recentlySubmittedRequestIds.set(id, Date.now());
}

function consumeSubmittedId(id: string): boolean {
    pruneRecentSubmissions();
    return recentlySubmittedRequestIds.delete(id);
}

/** Reset the recently-submitted tracker; call after reconnect/conversation switch. */
export function clearRecentSubmissions(): void {
    recentlySubmittedRequestIds.clear();
}

/** Returns true if `entry` was submitted by THIS CLI instance. */
function isOurEntry(entry: QueuedRequest): boolean {
    const ourId = getCliConnectionId();
    return ourId !== undefined && entry.originatorConnectionId === ourId;
}

/** Snapshot getter — used by /queue list, prompt rendering, and tests. */
export function getCliQueueState(): QueueSnapshot | undefined {
    return queueMirror.snapshot;
}

/** @internal Test-only accessors. Do NOT call from production code. */
export function __testGetCurrentRequestId(): string | undefined {
    return currentRequestId;
}
export function __testSetCurrentRequestId(id: string | undefined): void {
    currentRequestId = id;
}
export function __testGetRecentlySubmitted(): ReadonlyMap<string, number> {
    return recentlySubmittedRequestIds;
}
/**
 * @internal Activate a TerminalLayout + stub PromptRenderer so tests can
 * exercise inline-buffering / flush behavior without driving the readline UI.
 * Returns a teardown function — call it in afterEach to restore module state.
 */
export function __testActivateTerminalLayout(promptRows: number = 1): {
    teardown: () => void;
    getInlineBuffer: () => string;
    redrawCount: () => number;
} {
    const layout = new TerminalLayout();
    layout.setup(promptRows);
    terminalLayout = layout;
    let redraws = 0;
    const stub: PromptRenderer = {
        redraw: () => {
            redraws++;
        },
        rows: () => promptRows,
    } as unknown as PromptRenderer;
    activePromptRenderer = stub;
    return {
        teardown: () => {
            layout.cleanup();
            terminalLayout = null;
            activePromptRenderer = null;
        },
        getInlineBuffer: () => (layout as any).inlineBuffer as string,
        redrawCount: () => redraws,
    };
}

/** Bootstrap CLI queue state from a snapshot, or pass `undefined` to clear. */
export function applyQueueSnapshot(snapshot: QueueSnapshot | undefined): void {
    queueMirror.reset(snapshot);
}

/**
 * Cancel every running + queued request via the dispatcher. Best-effort:
 * exceptions are swallowed so one bad RPC can't strand siblings. The local
 * snapshot is left alone — server `requestCancelled` events will reconcile it.
 * `dispatcher` is passed explicitly so tests can supply a stub.
 */
export async function cancelAllInQueue(
    dispatcher: Dispatcher | undefined,
    snapshot: QueueSnapshot | undefined,
): Promise<{ cancelled: number; running: number; queued: number }> {
    if (!dispatcher || typeof dispatcher.cancelCommand !== "function") {
        return { cancelled: 0, running: 0, queued: 0 };
    }
    const targets: { id: string; from: "running" | "queued" }[] = [];
    if (snapshot?.running?.requestId) {
        targets.push({ id: snapshot.running.requestId, from: "running" });
    }
    for (const e of snapshot?.queued ?? []) {
        if (e.requestId) targets.push({ id: e.requestId, from: "queued" });
    }
    let cancelled = 0;
    let runningCancelled = 0;
    let queuedCancelled = 0;
    await Promise.all(
        targets.map(async (t) => {
            try {
                await dispatcher.cancelCommand(t.id);
                cancelled++;
                if (t.from === "running") runningCancelled++;
                else queuedCancelled++;
            } catch {
                // best-effort — server may have already drained this id
            }
        }),
    );
    return {
        cancelled,
        running: runningCancelled,
        queued: queuedCancelled,
    };
}

// Grammar log file path
const grammarLogPath = path.join(
    process.env.HOME || process.env.USERPROFILE || ".",
    ".typeagent",
    "grammar.log",
);

// Configure marked with terminal renderer for styled markdown output
marked.use(
    markedTerminal({
        // Customize colors and styles for terminal output
        code: chalk.yellow,
        blockquote: chalk.gray.italic,
        heading: chalk.bold.cyan,
        firstHeading: chalk.bold.magenta,
        strong: chalk.bold,
        em: chalk.italic,
        codespan: chalk.yellow,
        del: chalk.dim.strikethrough,
        link: chalk.blue.underline,
        href: chalk.blue.underline,
        listitem: chalk.reset,
        table: chalk.reset,
        tableHeader: chalk.bold,
    }),
);

/**
 * Convert markdown content to styled terminal output
 */
function convertMarkdownToTerminal(markdown: string): string {
    try {
        const result = marked.parse(markdown);
        // marked.parse can return string or Promise<string>
        // With markedTerminal it returns a string synchronously
        if (typeof result === "string") {
            return result.trim();
        }
        // Fallback if it somehow returns a promise
        return markdown;
    } catch (error) {
        console.warn("Markdown conversion failed:", error);
        return markdown;
    }
}

function logGrammarRule(message: string): void {
    try {
        // Ensure directory exists
        const dir = path.dirname(grammarLogPath);
        if (!fs.existsSync(dir)) {
            fs.mkdirSync(dir, { recursive: true });
        }
        // Append to log file with timestamp
        const timestamp = new Date().toISOString();
        fs.appendFileSync(grammarLogPath, `[${timestamp}] ${message}\n\n`);
    } catch {
        // Silently ignore logging errors
    }
}

function displayPadEnd(content: string, length: number): string {
    return `${content}${" ".repeat(length - getDisplayWidth(content))}`;
}

function messageContentToText(message: MessageContent): string {
    if (typeof message === "string") {
        return message;
    }

    if (message.length === 0) {
        return "";
    }
    if (typeof message[0] === "string") {
        return message.join("\n");
    }
    const table = message as string[][];
    let numColumns = 0;
    const maxColumnWidths: number[] = [];
    for (const row of table) {
        numColumns = Math.max(numColumns, row.length);
        for (let i = 0; i < row.length; i++) {
            maxColumnWidths[i] = Math.max(
                maxColumnWidths[i] ?? 0,
                getDisplayWidth(row[i]),
            );
        }
    }

    const displayRows = table.map((row) => {
        const items: string[] = [];
        for (let i = 0; i < numColumns; i++) {
            const content = i < row.length ? row[i] : "";
            items.push(displayPadEnd(content, maxColumnWidths[i]));
        }
        return `|${items.join("|")}|`;
    });
    displayRows.splice(
        1,
        0,
        `|${maxColumnWidths.map((w) => "-".repeat(w)).join("|")}|`,
    );
    return displayRows.join("\n");
}

/**
 * Start a processing spinner with the given message
 */
export function startProcessingSpinner(
    message: string = "Processing...",
): void {
    if (currentSpinner) {
        currentSpinner.stop();
    }
    currentSpinner = new EnhancedSpinner({ text: message });
    currentSpinner.start();
}

/**
 * Update the spinner text
 */
export function updateSpinnerText(message: string): void {
    if (currentSpinner) {
        currentSpinner.updateText(message);
    }
}

/**
 * Stop the spinner with a result
 */
export function stopSpinner(
    result: "success" | "fail" | "info" | "warn" = "success",
    message?: string,
): void {
    if (currentSpinner) {
        switch (result) {
            case "success":
                currentSpinner.succeed(message);
                break;
            case "fail":
                currentSpinner.fail(message);
                break;
            case "warn":
                currentSpinner.warn(message);
                break;
            case "info":
                currentSpinner.info(message);
                break;
        }
        currentSpinner = null;
    }
}

/**
 * Create an enhanced ClientIO with terminal UI features
 */
// Reason values passed to AbortController.abort() for pending interactions.
type InteractionResolvedReason = {
    kind: "resolved-by-other";
    response: unknown;
};
const INTERACTION_CANCELLED = "cancelled";

function isYesNoChoices(choices: string[]): boolean {
    return choices.length === 2 && choices[0] === "Yes" && choices[1] === "No";
}

/**
 * Parse a user's raw input string into a 0-based choice index.
 * Accepts:
 *   - 1-based numeric input ("1", "2", …)
 *   - "y" / "yes" → 0 and "n" / "no" → 1 when choices are ["Yes", "No"]
 * Falls back to defaultId (or 0) when the input is unrecognised.
 */
function parseChoiceInput(
    input: string,
    choices: string[],
    defaultId: number | undefined,
): number {
    const trimmed = input.trim().toLowerCase();
    if (isYesNoChoices(choices)) {
        if (trimmed === "y" || trimmed === "yes") return 0;
        if (trimmed === "n" || trimmed === "no") return 1;
    }
    const idx = parseInt(trimmed, 10) - 1;
    return idx >= 0 && idx < choices.length ? idx : (defaultId ?? 0);
}

export function createEnhancedClientIO(
    rl?: readline.promises.Interface,
    dispatcherRef?: { current?: Dispatcher },
): ClientIO {
    let lastAppendMode: DisplayAppendMode | undefined;

    // Active interaction prompts keyed by interactionId.  Each entry holds an
    // AbortController that, when aborted, dismisses the in-progress question().
    const activeInteractions = new Map<string, AbortController>();
    // Serial queue for interactions — ensures only one stdin prompt is active at a time.
    let interactionQueue: Promise<void> = Promise.resolve();

    function displayContent(
        content: DisplayContent,
        appendMode?: DisplayAppendMode,
    ) {
        let message: MessageContent;
        let colorFn = (s: string) => s;
        let contentType: string = "text";

        if (typeof content === "string" || Array.isArray(content)) {
            message = content;
        } else {
            // CLI prefers text alternates when available
            const textContent = getContentForType(content, "text");
            if (textContent !== undefined && content.type !== "text") {
                message = textContent;
                contentType = "text";
            } else {
                message = content.content;
                contentType = content.type || "text";
            }
            switch (content.kind) {
                case "status":
                    colorFn = chalk.grey;
                    break;
                case "error":
                    colorFn = chalk.red;
                    break;
                case "warning":
                    colorFn = chalk.yellow;
                    break;
                case "info":
                    colorFn = chalk.grey;
                    break;
                case "success":
                    colorFn = chalk.greenBright;
                    break;
                default:
                    colorFn = chalk.green;
                    break;
            }
        }

        // Convert content based on type
        let displayText: string;
        const textContent =
            typeof message === "string"
                ? message
                : messageContentToText(message);

        if (contentType === "markdown") {
            // Render markdown with terminal styling (already has colors from marked-terminal)
            displayText = convertMarkdownToTerminal(textContent);
        } else if (contentType === "html") {
            // Convert HTML to formatted text
            displayText = colorFn(convertHtmlToText(textContent));
        } else {
            displayText = colorFn(textContent);
        }

        // If spinner is active, write above it
        if (currentSpinner?.isActive()) {
            if (appendMode === "inline") {
                currentSpinner.appendStream(displayText);
            } else {
                // Flush any inline stream content before writing block content
                currentSpinner.flushStream();
                currentSpinner.writeAbove(displayText);
            }
        } else if (terminalLayout?.isActive && activePromptRenderer) {
            // Scroll region is active — write content into the scroll region.
            // The prompt stays anchored at the bottom automatically.
            if (appendMode !== "inline") {
                // Any accumulated inline buffer is intentionally discarded here —
                // writeContent() clears inlineBuffer before writing, so partial
                // streaming tokens won't be re-emitted with the new block content.
                terminalLayout.writeContent(displayText);
            } else {
                terminalLayout.writeInline(displayText);
            }
            activePromptRenderer.redraw();
        } else if (activePromptRenderer) {
            // Fallback: scroll region not active but prompt renderer is.
            const rows = activePromptRenderer.rows();
            for (let i = 0; i < rows; i++) {
                process.stdout.write("\x1b[1A\x1b[2K");
            }
            if (appendMode !== "inline") {
                if (lastAppendMode === "inline") {
                    process.stdout.write("\n");
                }
                process.stdout.write(displayText);
                process.stdout.write("\n");
            } else {
                process.stdout.write(displayText);
            }
            const dp = getDebugPanel();
            if (dp && dp.lineCount > 0) {
                dp.renderStaticSummary();
            }
            activePromptRenderer.redraw();
            process.stdout.write("\x1b[J");
        } else if (rl) {
            // Readline is active - write above the prompt
            // Clear current line, write content, then let readline redraw prompt
            readline.cursorTo(process.stdout, 0);
            readline.clearLine(process.stdout, 0);
            if (appendMode !== "inline") {
                if (lastAppendMode === "inline") {
                    process.stdout.write("\n");
                }
                process.stdout.write(displayText);
                process.stdout.write("\n");
            } else {
                process.stdout.write(displayText);
            }
            // Redraw the prompt
            rl.prompt(true);
        } else {
            if (appendMode !== "inline") {
                if (lastAppendMode === "inline") {
                    process.stdout.write("\n");
                }
                // Ensure content starts on a fresh line (cursor may be
                // mid-line after a spinner was cleared without a newline)
                process.stdout.write("\r\x1b[2K");
                process.stdout.write(displayText);
                process.stdout.write("\n");
            } else {
                process.stdout.write(displayText);
            }
        }

        lastAppendMode = appendMode;
    }

    // Route a setDisplay/appendDisplay message based on its render kind.
    //   - kind: "toast"  → toast banner (ephemeral, source-prefixed)
    //   - kind: "inline" → compact one-line entry (source-prefixed)
    //   - kind: "bubble" / absent → existing displayContent path; agent-initiated
    //     bubbles (clientRequestId starts with "agent-") get a "[<source>]" prefix
    //     so they're visually distinct from request-bound output.
    function renderAgentMessage(
        message: IAgentMessage,
        mode?: DisplayAppendMode,
    ): void {
        const kind = message.kind;
        if (kind === "toast") {
            displayToastNotification(
                message.message,
                message.source,
                Date.now(),
            );
            return;
        }
        if (kind === "inline") {
            displayInlineNotification(
                message.message,
                message.source,
                Date.now(),
            );
            return;
        }
        const clientId = message.requestId.clientRequestId as
            | string
            | undefined;
        if (clientId?.startsWith("agent-")) {
            const header = chalk.dim(`[${message.source}]`);
            if (currentSpinner?.isActive()) {
                currentSpinner.writeAbove(header);
            } else {
                displayContent(header);
            }
        }
        displayContent(message.message, mode);
    }

    function displayInlineNotification(
        data: string | DisplayContent,
        source: string,
        timestamp: number,
    ): void {
        const time = new Date(timestamp).toLocaleTimeString();
        const header = chalk.dim(`[${time}] ${source}:`);

        let content: string;
        if (isMarkdownContent(data)) {
            const markdownContent = extractContentString(data);
            content = convertMarkdownToTerminal(markdownContent);
        } else if (isHtmlContent(data)) {
            const htmlContent = extractHtmlString(data);
            content = convertHtmlToText(htmlContent);
        } else {
            content = formatDisplayContent(data);
        }

        const formattedMessage = `${header}\n  ${content}`;

        if (currentSpinner?.isActive()) {
            currentSpinner.writeAbove(formattedMessage);
        } else {
            displayContent(formattedMessage);
        }
    }

    function displayToastNotification(
        data: string | DisplayContent,
        source: string,
        timestamp: number,
    ): void {
        const toastHeader = chalk.bgCyan.black(" \u25B6 NOTIFICATION ");

        if (currentSpinner?.isActive()) {
            currentSpinner.writeAbove("");
            currentSpinner.writeAbove(toastHeader);
        } else {
            displayContent("");
            displayContent(toastHeader);
        }

        displayInlineNotification(data, source, timestamp);

        if (currentSpinner?.isActive()) {
            currentSpinner.writeAbove("");
        } else {
            displayContent("");
        }
    }

    return {
        clear(): void {
            console.clear();
        },
        exit(): void {
            if (currentSpinner) {
                currentSpinner.stop();
                currentSpinner = null;
            }
            process.exit(0);
        },
        shutdown(): void {
            if (currentSpinner) {
                currentSpinner.stop();
                currentSpinner = null;
            }
            const conn = getServerConnection();
            const port = getServerPort();
            const doShutdown = conn
                ? conn.shutdown().catch(() => {
                      // Graceful via existing connection failed —
                      // fall back to force kill via PID file.
                      if (port !== undefined) {
                          return stopAgentServer(port, true);
                      }
                  })
                : port !== undefined
                  ? stopAgentServer(port, true)
                  : Promise.resolve();

            doShutdown
                .catch(() => {
                    // Best-effort: server may already be stopped.
                })
                .finally(() => {
                    process.exit(0);
                });
        },

        // Display
        setUserRequest(requestId: RequestId) {
            currentRequestId = requestId.requestId;
        },
        setDisplayInfo() {
            // Ignored
        },
        setDisplay(message: IAgentMessage): void {
            renderAgentMessage(message);
        },
        appendDisplay(message: IAgentMessage, mode: DisplayAppendMode): void {
            renderAgentMessage(message, mode);
        },
        appendDiagnosticData(_requestId: RequestId, _data: any) {
            // Ignored
        },
        setDynamicDisplay(
            requestId: RequestId,
            source: string,
            actionIndex: number,
            displayId: string,
            nextRefreshMs: number,
        ): void {
            // REVIEW: Ignored.
        },

        // Input — unified question prompt (handles both yes/no and multi-choice)
        async question(
            _requestId: RequestId | undefined,
            message: string,
            choices: string[],
            defaultId?: number,
        ): Promise<number> {
            // Pause spinner during input
            const wasSpinning = currentSpinner?.isActive();
            if (wasSpinning) {
                currentSpinner!.stop();
            }

            const width = process.stdout.columns || 80;
            const line = ANSI.dim + "─".repeat(width) + ANSI.reset;

            process.stdout.write("\n");
            process.stdout.write(line + "\n");
            process.stdout.write(`${message}\n`);
            process.stdout.write(line + "\n\n");

            // Display choices with numbers
            choices.forEach((choice, index) => {
                const isDefault = index === defaultId;
                const prefix = isDefault
                    ? chalk.cyan(`▶ ${index + 1}.`)
                    : chalk.dim(`  ${index + 1}.`);
                const suffix = isDefault ? chalk.dim(" (default)") : "";
                process.stdout.write(`${prefix} ${choice}${suffix}\n`);
            });

            process.stdout.write("\n");
            const prompt = chalk.dim(
                isYesNoChoices(choices)
                    ? "Enter y/n or number (1-2): "
                    : `Enter number (1-${choices.length}): `,
            );
            const input = await question_internal(prompt, rl);

            process.stdout.write(line + "\n");

            // Resume spinner if it was active
            if (wasSpinning) {
                currentSpinner = new EnhancedSpinner({ text: "Processing..." });
                currentSpinner.start();
            }

            return parseChoiceInput(input, choices, defaultId);
        },

        async proposeAction(
            _requestId: RequestId,
            _actionTemplates: TemplateEditConfig,
            _source: string,
        ): Promise<unknown> {
            // TODO: Not implemented
            return undefined;
        },

        // Notification with visual styling
        notify(
            requestId: RequestId,
            event: string,
            data: any,
            source: string,
        ): void {
            const timestamp = Date.now();

            switch (event) {
                case AppAgentEvent.Error:
                    if (currentSpinner?.isActive()) {
                        currentSpinner.writeAbove(
                            `${chalk.red("✖")} ${chalk.red(data)}`,
                        );
                    } else {
                        console.error(chalk.red(`✖ ${data}`));
                    }
                    break;
                case AppAgentEvent.Warning:
                    if (currentSpinner?.isActive()) {
                        currentSpinner.writeAbove(
                            `${chalk.yellow("⚠")} ${chalk.yellow(data)}`,
                        );
                    } else {
                        console.warn(chalk.yellow(`⚠ ${data}`));
                    }
                    break;
                case AppAgentEvent.Info:
                    if (currentSpinner?.isActive()) {
                        currentSpinner.writeAbove(
                            `${chalk.cyan("ℹ")} ${data}`,
                        );
                    } else {
                        console.info(`ℹ ${data}`);
                    }
                    break;

                case AppAgentEvent.Inline:
                    displayInlineNotification(data, source, timestamp);
                    break;

                case AppAgentEvent.Toast:
                    displayToastNotification(data, source, timestamp);
                    break;

                case "grammarRule":
                    // Grammar rule notifications - log to file to avoid disrupting prompt
                    // View with: cat ~/.typeagent/grammar.log
                    if (data.success && data.rule) {
                        logGrammarRule(`+RULE ${data.message}\n${data.rule}`);
                    } else if (!data.success && data.rule) {
                        logGrammarRule(
                            `REJECTED ${data.message}\n${data.rule}`,
                        );
                    }
                    break;

                case "commandComplete": {
                    // Clear currentRequestId if the completed id matches so SIGINT doesn't target a stale id.
                    const completedId =
                        typeof requestId === "object" &&
                        requestId !== null &&
                        "requestId" in (requestId as any)
                            ? (requestId as { requestId: string }).requestId
                            : typeof requestId === "string"
                              ? requestId
                              : undefined;
                    if (
                        completedId !== undefined &&
                        currentRequestId === completedId
                    ) {
                        currentRequestId = undefined;
                    }
                    if (completedId !== undefined) {
                        consumeSubmittedId(completedId);
                    }
                    // Commit any buffered inline output: actionIO.appendDisplay
                    // defaults to "inline" mode and the non-blocking submit path
                    // has no spinner, so one-shot commands (e.g. @config agent)
                    // would otherwise leave their only message in inlineBuffer.
                    if (terminalLayout?.isActive) {
                        terminalLayout.flushInline();
                        activePromptRenderer?.redraw();
                    }
                    break;
                }

                default:
                // ignored.
            }
        },

        async openLocalView(requestId: RequestId, port: number): Promise<void> {
            const { default: open } = await import("open");
            await open(`http://localhost:${port}`);
        },
        async closeLocalView(
            requestId: RequestId,
            port: number,
        ): Promise<void> {
            // TODO: Ignored
        },
        requestChoice(
            _requestId: RequestId,
            choiceId: string,
            type: "yesNo" | "multiChoice",
            message: string,
            choices: string[],
            source: string,
        ): void {
            // Set up a promise so the main loop waits before
            // showing "Complete" and the next prompt.
            let resolveChoice: () => void;
            pendingChoicePromise = new Promise<void>((resolve) => {
                resolveChoice = resolve;
            });

            (async () => {
                try {
                    // Stop and clear spinner — the main loop will
                    // handle completion after the choice resolves.
                    if (currentSpinner) {
                        currentSpinner.stop();
                        currentSpinner = null;
                    }

                    const width = process.stdout.columns || 80;
                    const line = ANSI.dim + "─".repeat(width) + ANSI.reset;

                    process.stdout.write("\n");
                    process.stdout.write(line + "\n");

                    let response: boolean | number[];
                    if (type === "yesNo") {
                        const prompt = `${chalk.dim(`[${source}]`)} ${message} ${chalk.dim("(y/n)")} `;
                        const input = await question_internal(prompt, rl);
                        response =
                            input.toLowerCase() === "y" ||
                            input.toLowerCase() === "yes";
                    } else {
                        // multiChoice — show numbered list, accept comma-separated
                        process.stdout.write(
                            `${chalk.dim(`[${source}]`)} ${message}\n`,
                        );
                        for (let i = 0; i < choices.length; i++) {
                            process.stdout.write(
                                `  ${chalk.cyan(`${i + 1}.`)} ${choices[i]}\n`,
                            );
                        }
                        const input = await question_internal(
                            `${chalk.dim("Enter choice numbers (comma-separated):")} `,
                            rl,
                        );
                        response = input
                            .split(",")
                            .map((s) => parseInt(s.trim(), 10) - 1)
                            .filter(
                                (n) =>
                                    !isNaN(n) && n >= 0 && n < choices.length,
                            );
                    }

                    process.stdout.write(line + "\n");

                    if (dispatcherRef?.current) {
                        await dispatcherRef.current.respondToChoice(
                            choiceId,
                            response,
                        );
                    }
                } catch (err) {
                    console.error(chalk.red(`Choice error: ${err}`));
                } finally {
                    resolveChoice!();
                }
            })();
        },
        // Async deferred pattern — handle interactions pushed from the server
        requestInteraction(interaction: PendingInteractionRequest): void {
            interactionQueue = interactionQueue.then(async () => {
                if (!dispatcherRef?.current) {
                    return;
                }
                const dispatcher = dispatcherRef.current;

                if (interaction.type === "proposeAction") {
                    // Not supported in CLI yet
                    return;
                }

                const ac = new AbortController();
                activeInteractions.set(interaction.interactionId, ac);

                const wasSpinning = currentSpinner?.isActive();
                if (wasSpinning) {
                    currentSpinner!.stop();
                }

                const width = process.stdout.columns || 80;
                const line = ANSI.dim + "─".repeat(width) + ANSI.reset;

                let response: PendingInteractionResponse;
                try {
                    // question — unified prompt for both yes/no and multi-choice
                    displayContent(line);
                    let promptText = `${interaction.message}`;
                    interaction.choices.forEach((choice, i) => {
                        const isDefault = i === interaction.defaultId;
                        const prefix = chalk.cyan(`${i + 1}.`);
                        const suffix = isDefault ? chalk.dim(" (default)") : "";
                        promptText += `\n  ${prefix} ${choice}${suffix}`;
                    });
                    displayContent(promptText);

                    const prompt = chalk.dim(
                        isYesNoChoices(interaction.choices)
                            ? "Enter y/n or number (1-2): "
                            : `Enter number (1-${interaction.choices.length}): `,
                    );
                    const input = await question_internal(
                        prompt,
                        rl,
                        ac.signal,
                    );
                    displayContent(line);

                    const value = parseChoiceInput(
                        input,
                        interaction.choices,
                        interaction.defaultId,
                    );
                    response = {
                        interactionId: interaction.interactionId,
                        type: "question",
                        value,
                    };
                } catch {
                    // Aborted by interactionResolved or interactionCancelled.
                    const reason = ac.signal.reason;
                    if (
                        reason !== null &&
                        typeof reason === "object" &&
                        reason.kind === "resolved-by-other"
                    ) {
                        displayContent(
                            chalk.gray("[answered by another client]"),
                        );
                    } else {
                        displayContent(chalk.yellow("⚠  Cancelled"));
                    }

                    if (wasSpinning) {
                        currentSpinner = new EnhancedSpinner({
                            text: "Processing...",
                        });
                        currentSpinner.start();
                    }
                    activeInteractions.delete(interaction.interactionId);
                    return;
                }

                activeInteractions.delete(interaction.interactionId);

                if (wasSpinning) {
                    currentSpinner = new EnhancedSpinner({
                        text: "Processing...",
                    });
                    currentSpinner.start();
                }

                try {
                    await dispatcher.respondToInteraction(response);
                } catch {
                    // Interaction may have already timed out
                }
            });
        },
        interactionResolved(interactionId: string, response: unknown): void {
            const ac = activeInteractions.get(interactionId);
            if (ac) {
                activeInteractions.delete(interactionId);
                const reason: InteractionResolvedReason = {
                    kind: "resolved-by-other",
                    response,
                };
                ac.abort(reason);
            }
        },
        interactionCancelled(interactionId: string): void {
            const ac = activeInteractions.get(interactionId);
            if (ac) {
                activeInteractions.delete(interactionId);
                ac.abort(INTERACTION_CANCELLED);
            }
        },
        takeAction(requestId: RequestId, action: string, data: unknown): void {
            if (action === "open-folder") {
                import("open").then(({ default: open }) =>
                    open(data as string),
                );
                return;
            }
            if (action === "manage-conversation") {
                const payload = data as {
                    subcommand: string;
                    name?: string;
                    newName?: string;
                };
                const convCtx = getConversationCommandContext();
                if (!convCtx) {
                    console.error(
                        "Cannot manage conversations: not connected to agent server.",
                    );
                    return;
                }
                let args: string;
                switch (payload.subcommand) {
                    case "new":
                        args = payload.name ? `new "${payload.name}"` : "new";
                        break;
                    case "list":
                        args = "list";
                        break;
                    case "info":
                        args = "info";
                        break;
                    case "switch":
                        args = `switch "${payload.name}"`;
                        break;
                    case "delete":
                        args = `delete "${payload.name}"`;
                        break;
                    case "rename":
                        args = payload.name
                            ? `rename "${payload.name}" "${payload.newName}"`
                            : `rename "${payload.newName}"`;
                        break;
                    default:
                        console.error(
                            `Unknown conversation subcommand: ${payload.subcommand}`,
                        );
                        return;
                }
                pendingChoicePromise = handleConversationCommand(convCtx, args);
                // Stop the spinner immediately so the y/n prompt is visible.
                currentSpinner?.stop();
                currentSpinner = null;
                return;
            }
            throw new Error(`Action ${action} not supported`);
        },

        // Server-side queue push events
        requestQueued(entry: QueuedRequest, version: number): void {
            if (!queueMirror.applyQueued(entry, version).admitted) return;
            redrawPromptIfActive();
        },
        requestStarted(entry: QueuedRequest, version: number): void {
            if (!queueMirror.applyStarted(entry, version).admitted) return;
            // Suppress the marker if THIS CLI submitted the entry (otherwise we double-print).
            const isOurs =
                isOurEntry(entry) || consumeSubmittedId(entry.requestId);
            redrawPromptIfActive();
            if (isOurs) {
                return;
            }
            writeQueueLine(
                chalk.cyan("▶ running: ") +
                    chalk.dim(truncateText(entry.text)),
            );
        },
        requestCancelled(
            requestId: string,
            reason: QueueCancelReason,
            version: number,
        ): void {
            const result = queueMirror.applyCancelled(requestId, version);
            if (!result.admitted) return;
            // Clear currentRequestId if it matches so SIGINT doesn't target a stale id.
            if (currentRequestId === requestId) {
                currentRequestId = undefined;
            }
            consumeSubmittedId(requestId);
            redrawPromptIfActive();
            if (result.cancelledText !== undefined) {
                // Distinct rendering for server-stopping so users know the cancel wasn't theirs.
                const label =
                    reason === "server_stopping"
                        ? chalk.red("✗ server stopping: ")
                        : chalk.yellow("✗ cancelled: ");
                writeQueueLine(
                    label +
                        chalk.dim(truncateText(result.cancelledText)) +
                        chalk.dim(` (${reason})`),
                );
            }
        },
        queueStateChanged(snapshot: QueueSnapshot): void {
            const prevBadge = computeBadgeState(queueMirror.snapshot);
            if (!queueMirror.applyQueueStateChanged(snapshot).admitted) return;
            const nextBadge = computeBadgeState(queueMirror.snapshot);
            // Only repaint when the visible badge changed (avoids flicker on no-op snapshots).
            if (prevBadge !== nextBadge) {
                redrawPromptIfActive();
            }
        },
    };
}

/** Stable key for the visible badge state, used to detect meaningful changes. */
function computeBadgeState(snap: QueueSnapshot | undefined): string {
    if (!snap) return "none";
    const queuedCount = snap.queued.length;
    const runningOther =
        snap.running &&
        snap.running.requestId !== currentRequestId &&
        !recentlySubmittedRequestIds.has(snap.running.requestId)
            ? 1
            : 0;
    return `q${queuedCount}r${runningOther}p${snap.paused ? 1 : 0}`;
}

/** Redraw the active prompt; no-op when no interactive UI is active. */
function redrawPromptIfActive(): void {
    if (terminalLayout?.isActive && activePromptRenderer) {
        activePromptRenderer.redraw();
        return;
    }
    if (activeRl) {
        activeRl.prompt(true);
    }
}

/** Best-effort truncate for one-line markers. */
function truncateText(text: string, max: number = 60): string {
    const single = text.replace(/\s+/g, " ").trim();
    return single.length > max ? single.slice(0, max - 1) + "…" : single;
}

/** Print a queue-related one-liner without disturbing readline state. */
function writeQueueLine(text: string): void {
    if (terminalLayout?.isActive) {
        terminalLayout.writeContent(text);
        activePromptRenderer?.redraw();
        return;
    }
    if (activeRl) {
        readline.cursorTo(process.stdout, 0);
        readline.clearLine(process.stdout, 0);
        process.stdout.write(text + "\n");
        activeRl.prompt(true);
        return;
    }
    process.stdout.write(text + "\n");
}

// Helper functions

function isMarkdownContent(data: string | DisplayContent): boolean {
    if (typeof data === "object" && data !== null && "type" in data) {
        return data.type === "markdown";
    }
    return false;
}

function isHtmlContent(data: string | DisplayContent): boolean {
    if (typeof data === "object" && data !== null && "type" in data) {
        return data.type === "html";
    }
    if (typeof data === "string") {
        return /<[^>]+>/.test(data);
    }
    if (typeof data === "object" && data !== null && "content" in data) {
        const content = data.content;
        if (typeof content === "string") {
            return /<[^>]+>/.test(content);
        }
    }
    return false;
}

function extractContentString(data: string | DisplayContent): string {
    if (typeof data === "string") {
        return data;
    }
    if (typeof data === "object" && data !== null && "content" in data) {
        if (typeof data.content === "string") {
            return data.content;
        }
        if (Array.isArray(data.content)) {
            return data.content.join("\n");
        }
    }
    return String(data);
}

function extractHtmlString(data: string | DisplayContent): string {
    if (typeof data === "string") {
        return data;
    }
    if (typeof data === "object" && data !== null && "content" in data) {
        if (typeof data.content === "string") {
            return data.content;
        }
    }
    return String(data);
}

function convertHtmlToText(html: string): string {
    try {
        const result = convert(html, {
            wordwrap: 100,
            preserveNewlines: false,
            selectors: [
                // Skip images - we can't display them in terminal
                {
                    selector: "img",
                    format: "skip",
                },
                // Skip CSS animations and loading indicators
                {
                    selector: ".loading",
                    format: "skip",
                },
                {
                    selector: ".wait-dot",
                    format: "skip",
                },
                // Style tags should be stripped
                {
                    selector: "style",
                    format: "skip",
                },
                // General div and p formatting
                {
                    selector: "div",
                    format: "block",
                    options: { leadingLineBreaks: 1, trailingLineBreaks: 0 },
                },
                {
                    selector: "p",
                    format: "block",
                    options: { leadingLineBreaks: 1, trailingLineBreaks: 1 },
                },
                // Links - show text only
                {
                    selector: "a",
                    format: "inline",
                    options: {
                        linkBrackets: false,
                        hideLinkHrefIfSameAsText: true,
                        ignoreHref: true,
                    },
                },
                // Spans - inline
                {
                    selector: "span",
                    format: "inline",
                },
            ],
        });

        // Clean up excessive whitespace while preserving structure
        let cleaned = result
            .replace(/\n{3,}/g, "\n\n") // Max 2 consecutive newlines
            .replace(/[ \t]+/g, " ") // Collapse horizontal whitespace
            .replace(/^ +/gm, "") // Remove leading spaces on lines
            .trim();

        // Apply terminal colors for track formatting
        // Track titles get highlighted
        cleaned = cleaned.replace(
            /^(\d+\.\s*)(.+?)(\s+Artists:)/gm,
            (_, num, title, artists) =>
                `${chalk.cyan(num)}${chalk.bold(title)}${chalk.dim(artists)}`,
        );

        // Album lines get dimmed
        cleaned = cleaned.replace(/^(Album:.+)$/gm, chalk.dim("$1"));

        return cleaned;
    } catch (error) {
        console.warn("HTML-to-text conversion failed:", error);
        return extractHtmlString(html)
            .replace(/<[^>]*>/g, " ")
            .replace(/\s+/g, " ")
            .trim();
    }
}

function formatDisplayContent(content: string | DisplayContent): string {
    if (typeof content === "string") {
        return content;
    }
    if (Array.isArray(content)) {
        return messageContentToText(content);
    }
    if (content && typeof content === "object" && "content" in content) {
        return messageContentToText(content.content);
    }
    return String(content);
}

/**
 * Interactive input with inline gray completion suggestions (like GitHub Copilot)
 * Shows first completion grayed out after cursor, arrows cycle through, Tab accepts
 */
async function questionWithCompletion(
    message: string,
    controller: CompletionController | undefined,
    history: string[] = [],
): Promise<string> {
    return new Promise<string>((resolve) => {
        let input = "";
        let cursorPos = 0; // Position within input string (0 = before first char)
        let filteredCompletions: string[] = []; // Render-cache populated from controller
        let completionIndex = 0;
        let historyIndex = history.length; // Start past the end (current empty input)
        let savedInput = ""; // Saves current input when navigating history
        let filterStartIndex = -1; // Where filtering begins (render-cache)
        let completionPrefix = ""; // Fixed prefix before completions (render-cache)
        let slashCompletionsDismissed = false; // Set by Escape; cleared when input changes
        const stdin = process.stdin;
        const stdout = process.stdout;

        // Enable raw mode for character-by-character input
        const wasRaw = stdin.isRaw;
        if (stdin.isTTY) {
            stdin.setRawMode(true);
        }
        stdin.resume();
        stdin.setEncoding("utf8");

        let prevInputRows = 1;
        const layout = new TerminalLayout();
        terminalLayout = layout;

        // Fixed region layout:
        //   row 0: separator (─)
        //   row 1..inputRows: prompt + input (may wrap)
        //   row inputRows+1: bottom rule (─)
        //   row inputRows+2: hint line
        const EXTRA_ROWS = 3; // separator + bottom rule + hint

        const render = () => {
            // Recompute completions from controller state each frame.
            if (controller) {
                if (isSlashCommand(input) && !slashCompletionsDismissed) {
                    filteredCompletions = getSlashCompletions(input);
                    filterStartIndex = 0;
                    completionPrefix = "";
                } else {
                    const state = controller.getCompletionState();
                    if (state) {
                        filteredCompletions = state.items.map(
                            (i) => i.selectedText,
                        );
                        filterStartIndex = state.anchorIndex;
                        completionPrefix = input.substring(
                            0,
                            state.anchorIndex,
                        );
                    } else {
                        filteredCompletions = [];
                        filterStartIndex = -1;
                        completionPrefix = "";
                    }
                }
                if (completionIndex >= filteredCompletions.length) {
                    completionIndex = 0;
                }
            }

            // Re-derive the live queue badge per render so it updates immediately on queue mutations.
            const badge = formatQueueBadge();
            const livePrompt = badge + message;
            const promptText = chalk.cyanBright(livePrompt);
            const width = process.stdout.columns || 80;

            const promptDisplayWidth = getDisplayWidth(livePrompt);
            const inputLineWidth = promptDisplayWidth + getDisplayWidth(input);
            const inputRows = Math.max(1, Math.ceil(inputLineWidth / width));
            const totalRows = inputRows + EXTRA_ROWS;

            // Update scroll region if prompt height changed
            layout.setPromptRows(totalRows);

            // drawFixed() clears each line before writing, so a separate
            // clearFixed() pass is not needed and would cause visible
            // flicker (blank frame between clear and redraw).  Only clear
            // stale rows left over when the prompt shrinks (e.g. input
            // un-wraps to fewer lines).
            const prevTotal = prevInputRows + EXTRA_ROWS;
            if (totalRows < prevTotal) {
                for (let r = totalRows; r < prevTotal; r++) {
                    layout.drawFixed(r, "");
                }
            }

            // Row 0: separator
            layout.drawFixed(0, ANSI.dim + "─".repeat(width) + ANSI.reset);

            // Row 1: prompt + input + ghost completion text
            let inputLine = promptText + input;
            if (
                filteredCompletions.length > 0 &&
                completionIndex < filteredCompletions.length
            ) {
                const completion = filteredCompletions[completionIndex];
                const fullCompletion =
                    completionPrefix +
                    (filterStartIndex > completionPrefix.length ? " " : "") +
                    completion;
                if (fullCompletion.length > input.length) {
                    const suggestion = fullCompletion.slice(input.length);
                    const counter = ` ${completionIndex + 1}/${filteredCompletions.length}`;
                    inputLine += chalk.dim(suggestion + counter);
                }
            }
            layout.drawFixed(1, inputLine);

            // Bottom rule
            layout.drawFixed(
                inputRows + 1,
                ANSI.dim + "─".repeat(width) + ANSI.reset,
            );

            // Hint line
            let hint: string;
            const queueSnap = queueMirror.snapshot;
            const queueBusy =
                queueSnap !== undefined &&
                (queueSnap.running ? 1 : 0) + queueSnap.queued.length > 0;
            const queueHint = queueBusy ? " · esc esc clear queue" : "";
            if (filteredCompletions.length > 0) {
                hint = "↑↓ completions · tab accept · esc clear" + queueHint;
            } else if (input.length > 0) {
                hint = "↑↓ history · esc clear" + queueHint;
            } else {
                hint = "↑↓ history · /help commands" + queueHint;
            }
            layout.drawFixed(inputRows + 2, "  " + chalk.dim(hint));

            // Position cursor in the input line
            const cursorAbsCol =
                promptDisplayWidth +
                getDisplayWidth(input.substring(0, cursorPos));
            const cursorRow = Math.floor(cursorAbsCol / width);
            layout.moveCursorToFixed(1 + cursorRow, (cursorAbsCol % width) + 1);
            stdout.write(ANSI.showCursor);

            prevInputRows = inputRows;
        };

        // Activate the scroll region and draw initial prompt
        layout.setup(prevInputRows + EXTRA_ROWS);
        // Re-render when completions arrive asynchronously from the dispatcher.
        if (controller) {
            controller.setOnUpdate(() => render());
        }
        render();

        const panel = getDebugPanel();
        const promptRenderer: PromptRenderer = {
            rows: () => prevInputRows + EXTRA_ROWS,
            redraw: () => render(),
        };
        panel?.setPromptRenderer(promptRenderer);
        activePromptRenderer = promptRenderer;

        // Handle keypresses
        const onData = async (chunk: Buffer) => {
            const data = chunk.toString();

            // Auto-dismiss framed debug panel on any key except Ctrl+D
            const dpForDismiss = getDebugPanel();
            if (dpForDismiss?.isPanelVisible && data.charCodeAt(0) !== 4) {
                dpForDismiss.dismissPanel();
            }

            // Handle multi-byte sequences
            if (data.startsWith("\x1b[")) {
                // Arrow keys
                if (data === "\x1b[A") {
                    if (filteredCompletions.length > 0) {
                        // Cycle to previous completion
                        completionIndex =
                            (completionIndex - 1 + filteredCompletions.length) %
                            filteredCompletions.length;
                        render();
                    } else if (history.length > 0 && historyIndex > 0) {
                        // Navigate to previous history entry
                        if (historyIndex === history.length) {
                            savedInput = input;
                        }
                        historyIndex--;
                        input = history[historyIndex];
                        cursorPos = input.length;
                        slashCompletionsDismissed = false;
                        render();
                    }
                    return;
                } else if (data === "\x1b[B") {
                    if (filteredCompletions.length > 0) {
                        // Cycle to next completion
                        completionIndex =
                            (completionIndex + 1) % filteredCompletions.length;
                        render();
                    } else if (historyIndex < history.length) {
                        // Navigate to next history entry
                        historyIndex++;
                        input =
                            historyIndex === history.length
                                ? savedInput
                                : history[historyIndex];
                        cursorPos = input.length;
                        slashCompletionsDismissed = false;
                        render();
                    }
                    return;
                } else if (data === "\x1b[C") {
                    // Arrow Right - move cursor right
                    if (cursorPos < input.length) {
                        cursorPos++;
                        render();
                    }
                    return;
                } else if (data === "\x1b[D") {
                    // Arrow Left - move cursor left
                    if (cursorPos > 0) {
                        cursorPos--;
                        render();
                    }
                    return;
                }
                return;
            }

            if (data === "\x1b") {
                // Double-Escape within DOUBLE_ESCAPE_WINDOW_MS cancels all running + queued requests.
                // The normal single-Escape action still runs so the prompt is left clean.
                const now = Date.now();
                const isDouble =
                    now - lastEscapeTime <= DOUBLE_ESCAPE_WINDOW_MS;
                lastEscapeTime = isDouble ? 0 : now;
                // Single-Escape priority: completions → input text → running command.
                // The last branch restores the pre-queue legacy behavior where a
                // bare Escape at an empty prompt cancels what's currently executing.
                const hadCompletions =
                    !!controller && filteredCompletions.length > 0;
                const hadInput = input.length > 0;
                if (hadCompletions) {
                    if (isSlashCommand(input)) {
                        // Slash completions are recomputed every render; use a
                        // flag to suppress them until the input changes.
                        slashCompletionsDismissed = true;
                    } else {
                        controller!.dismiss(input, "forward");
                    }
                } else if (hadInput) {
                    // Esc with text but no completions — clear input.
                    // Also hide the controller in case a fetch is in-flight
                    // that would otherwise resolve and show stale completions.
                    controller?.hide();
                    input = "";
                    cursorPos = 0;
                    historyIndex = history.length;
                    slashCompletionsDismissed = false;
                } else if (!isDouble) {
                    // Empty prompt, no completions, single press — cancel the
                    // currently running command if any. Double-Escape is handled
                    // separately below and clears the whole queue.
                    tryCancelRunningHead();
                }
                if (isDouble) {
                    const snap = queueMirror.snapshot;
                    const total =
                        (snap?.running ? 1 : 0) + (snap?.queued.length ?? 0);
                    if (total > 0) {
                        // Fire-and-forget; errors are absorbed by cancelAllInQueue.
                        void cancelAllInQueue(
                            moduleDispatcherRef.current,
                            snap,
                        ).then((res) => {
                            const parts: string[] = [];
                            if (res.running > 0) parts.push("1 running");
                            if (res.queued > 0)
                                parts.push(`${res.queued} queued`);
                            writeQueueLine(
                                chalk.yellow(
                                    `✗ Queue cleared: ${res.cancelled} cancelled (${parts.join(", ")})`,
                                ),
                            );
                        });
                    } else {
                        writeQueueLine(chalk.dim("Queue already empty."));
                    }
                }
                render();
                return;
            }

            const code = data.charCodeAt(0);

            if (code === 3) {
                // Ctrl+C — clear bottom rule + hint before exit
                stdout.write("\n\n\x1b[K\n");
                cleanup();
                process.exit(0);
            } else if (code === 4) {
                // Ctrl+D — cycle debug panel: off → compact → full → off
                const dp = getDebugPanel();
                if (dp && dp.lineCount > 0) {
                    if (!dp.isPanelVisible) {
                        // Off → compact
                        dp.toggleAtPrompt();
                        const panelLines = dp.renderFramedPanel();
                        for (const line of panelLines) {
                            layout.writeContent(line);
                        }
                    } else if (!dp.isPanelFull) {
                        // Compact → full
                        dp.toggleFullMode();
                        const panelLines = dp.renderFramedPanel(true);
                        for (const line of panelLines) {
                            layout.writeContent(line);
                        }
                    } else {
                        // Full → off
                        dp.toggleFullMode();
                        dp.toggleAtPrompt();
                    }
                    render();
                }
                return;
            } else if (code === 13) {
                // Enter - accept completion (if any) AND submit
                if (
                    filteredCompletions.length > 0 &&
                    completionIndex < filteredCompletions.length
                ) {
                    const completion = filteredCompletions[completionIndex];
                    input =
                        completionPrefix +
                        (filterStartIndex > completionPrefix.length
                            ? " "
                            : "") +
                        completion;
                }
                if (controller) {
                    controller.accept();
                }
                // Tear down the scroll region and write the finalized input
                // into the normal terminal flow so it appears in scrollback.
                cleanup();
                const width = process.stdout.columns || 80;
                const inputDisplay = `❯ ${input}`;
                const visibleLen = getDisplayWidth(inputDisplay);
                const pad = Math.max(0, width - visibleLen);
                stdout.write(
                    "\n" +
                        chalk.bgGray.white.bold(
                            inputDisplay + " ".repeat(pad),
                        ) +
                        "\n\n",
                );
                resolve(input);
            } else if (code === 9) {
                // Tab - accept current completion and continue
                if (
                    filteredCompletions.length > 0 &&
                    completionIndex < filteredCompletions.length
                ) {
                    const completion = filteredCompletions[completionIndex];
                    input =
                        completionPrefix +
                        (filterStartIndex > completionPrefix.length
                            ? " "
                            : "") +
                        completion;
                    cursorPos = input.length; // Move cursor to end
                    slashCompletionsDismissed = false;
                    if (controller) {
                        controller.accept();
                    }
                    render();
                }
            } else if (code === 127 || code === 8) {
                // Backspace - delete character before cursor
                if (cursorPos > 0) {
                    input =
                        input.slice(0, cursorPos - 1) + input.slice(cursorPos);
                    cursorPos--;
                    slashCompletionsDismissed = false;
                    if (controller) {
                        controller.update(input, "backward");
                    }
                    render();
                }
            } else if (code >= 32 && code < 127) {
                // Printable ASCII character - insert at cursor position
                input =
                    input.slice(0, cursorPos) + data + input.slice(cursorPos);
                cursorPos++;
                slashCompletionsDismissed = false;
                if (controller) {
                    controller.update(input, "forward");
                }
                render();
            }
        };

        const cleanup = () => {
            layout.cleanup();
            terminalLayout = null;
            panel?.setPromptRenderer(null);
            activePromptRenderer = null;
            if (controller) {
                controller.setOnUpdate(() => {});
            }
            stdin.removeListener("data", onData);
            if (stdin.isTTY) {
                stdin.setRawMode(wasRaw || false);
            }
            stdin.pause();
        };

        stdin.on("data", onData);
    });
}

async function question_internal(
    message: string,
    rl?: readline.promises.Interface,
    signal?: AbortSignal,
): Promise<string> {
    if (rl) {
        return rl.question(message, { signal });
    }

    // No readline interface — stdin is owned by questionWithCompletion in raw
    // mode.  Read character-by-character directly so we don't conflict.
    if (signal?.aborted) {
        return Promise.reject(signal.reason);
    }
    return new Promise<string>((resolve, reject) => {
        const stdin = process.stdin;
        // If the scroll region is active (e.g. secondary client waiting at the
        // input prompt), write the interaction prompt into the scrollable content
        // area so it doesn't overwrite the fixed prompt region.
        if (terminalLayout?.isActive) {
            terminalLayout.writeContent(message);
            activePromptRenderer?.redraw();
        } else {
            process.stdout.write(message);
        }

        let input = "";
        const wasRaw = stdin.isRaw;
        if (stdin.isTTY) {
            stdin.setRawMode(true);
        }
        stdin.resume();
        stdin.setEncoding("utf8");

        const cleanup = () => {
            stdin.removeListener("data", onData);
            if (stdin.isTTY) {
                stdin.setRawMode(wasRaw || false);
            }
        };

        const onAbort = () => {
            cleanup();
            reject(signal!.reason);
        };
        signal?.addEventListener("abort", onAbort, { once: true });

        const onData = (data: string) => {
            const code = data.charCodeAt(0);
            if (data === "\r" || data === "\n") {
                // Enter — commit
                signal?.removeEventListener("abort", onAbort);
                cleanup();
                process.stdout.write("\n");
                resolve(input);
            } else if (code === 0x03) {
                // Ctrl+C — treat as empty input
                signal?.removeEventListener("abort", onAbort);
                cleanup();
                process.stdout.write("\n");
                resolve("");
            } else if (code === 0x7f || code === 0x08) {
                // Backspace
                if (input.length > 0) {
                    input = input.slice(0, -1);
                    process.stdout.write("\b \b");
                }
            } else if (code >= 32 && code < 127) {
                input += data;
                process.stdout.write(data);
            }
        };

        stdin.on("data", onData);
    });
}

/**
 * Initialize enhanced console with cancellation support.
 * During command execution, Escape and Ctrl+C trigger command cancellation.
 * Double Ctrl+C within 1 second exits the process.
 */
function initializeEnhancedConsole(
    _rl?: readline.promises.Interface,
    dispatcherRef?: { current?: Dispatcher },
) {
    process.on("SIGINT", () => {
        if (isProcessing && dispatcherRef?.current) {
            const now = Date.now();
            if (now - lastCtrlCTime < 1000) {
                // Double Ctrl+C — force exit
                if (currentSpinner) {
                    currentSpinner.stop();
                    currentSpinner = null;
                }
                process.exit(0);
            }
            lastCtrlCTime = now;
            if (currentRequestId) {
                dispatcherRef.current.cancelCommand(currentRequestId);
            } else if (currentClientRequestId) {
                dispatcherRef.current.cancelCommandByClientId(
                    currentClientRequestId,
                );
            }
            return;
        }
        if (currentSpinner) {
            currentSpinner.stop();
            currentSpinner = null;
        }
        process.exit(0);
    });
}

let usingEnhancedConsole = false;

// ── Startup Banner ──────────────────────────────────────────────────────

// Logo mark — hexagonal badge with floating "T" (4 lines tall, 12 chars wide)
const LOGO_LINES = [
    "  ╱▔▔▔▔▔╲   ",
    " ╱ ▀▀█▀▀ ╲  ",
    " ╲   █   ╱  ",
    "  ╲▁▁▁▁▁╱   ",
];
const LOGO_WIDTH = 12;

function renderStartupBanner(): void {
    const width = process.stdout.columns || 80;
    const innerWidth = width - 4; // "│  " + "  │"
    const version = "0.0.1";

    // Content lines to render alongside the logo
    const contentLines = [
        chalk.cyan.bold("TypeAgent") + chalk.dim(` v${version}`),
        chalk.dim("Your personal AI assistant"),
        "",
        chalk.dim("Type a request or use /help to see commands."),
    ];

    const hintLine =
        "  " +
        chalk.dim(
            "/help commands · /verbose debug · ctrl+d debug · ctrl+c exit",
        );

    // Build the box
    const top = chalk.dim("╭" + "─".repeat(width - 2) + "╮");
    const bottom = chalk.dim("╰" + "─".repeat(width - 2) + "╯");

    const lines: string[] = [];
    lines.push(top);
    // Empty line before logo
    lines.push(chalk.dim("│") + " ".repeat(width - 2) + chalk.dim("│"));

    // Render logo + content side by side
    const totalRows = Math.max(LOGO_LINES.length, contentLines.length);
    for (let i = 0; i < totalRows; i++) {
        const logo =
            i < LOGO_LINES.length ? LOGO_LINES[i] : " ".repeat(LOGO_WIDTH);
        const coloredLogo = chalk.cyan(logo);
        const content = i < contentLines.length ? contentLines[i] : "";
        const contentVisible = content.replace(
            // eslint-disable-next-line no-control-regex
            /\x1b\[[0-9;]*m/g,
            "",
        );
        const padding = Math.max(
            0,
            innerWidth - LOGO_WIDTH - contentVisible.length,
        );
        lines.push(
            chalk.dim("│") +
                "  " +
                coloredLogo +
                content +
                " ".repeat(padding) +
                chalk.dim("│"),
        );
    }

    // Empty line before close
    lines.push(chalk.dim("│") + " ".repeat(width - 2) + chalk.dim("│"));
    lines.push(bottom);
    lines.push(hintLine);
    lines.push("");

    for (const line of lines) {
        process.stdout.write(line + "\n");
    }
}

/**
 * Wrapper for using enhanced console ClientIO
 */
export async function withEnhancedConsoleClientIO(
    callback: (
        clientIO: ClientIO,
        bindDispatcher: (d: Dispatcher) => void,
    ) => Promise<void>,
    rl?: readline.promises.Interface,
) {
    if (usingEnhancedConsole) {
        throw new Error("Cannot have multiple enhanced console clients");
    }
    usingEnhancedConsole = true;

    try {
        const dispatcherRef: { current?: Dispatcher } = {};
        initializeEnhancedConsole(rl, dispatcherRef);

        // Show welcome banner
        renderStartupBanner();
        await callback(
            createEnhancedClientIO(rl, dispatcherRef),
            (d: Dispatcher) => {
                dispatcherRef.current = d;
                // Mirror to module-level ref so out-of-closure handlers (double-Escape, SIGINT) can cancel.
                moduleDispatcherRef.current = d;
            },
        );
    } finally {
        if (currentSpinner) {
            currentSpinner.stop();
            currentSpinner = null;
        }
        process.stdin.pause();
        usingEnhancedConsole = false;
    }
}

/**
 * Enable raw mode on stdin during command execution so we can detect
 * Escape and Ctrl+C for cancellation. Returns a cleanup function.
 */
function startExecutionKeyListener(
    dispatcher: Dispatcher | undefined,
): () => void {
    if (!dispatcher || !process.stdin.isTTY) {
        return () => {};
    }

    const tryCancelRunning = (): boolean => tryCancelRunningHead(dispatcher);

    const printNoRunningHint = () => {
        const hasQueued = (queueMirror.snapshot?.queued.length ?? 0) > 0;
        if (hasQueued) {
            writeQueueLine(
                chalk.dim(
                    "No request currently running. Use /queue cancel <id> to cancel queued requests.",
                ),
            );
        }
    };

    const onData = (data: Buffer | string) => {
        // stdin encoding may be set to utf8 by questionWithCompletion,
        // so data can be a string. Use charCodeAt for consistent handling.
        const code = typeof data === "string" ? data.charCodeAt(0) : data[0];
        if (data.length === 1 && code === 0x1b) {
            // Escape key — cancel current command
            if (!tryCancelRunning()) {
                printNoRunningHint();
            }
        } else if (data.length === 1 && code === 4) {
            // Ctrl+D — toggle debug panel expand/collapse
            const panel = getDebugPanel();
            if (panel && panel.lineCount > 0) {
                panel.toggle();
            }
        } else if (data.length === 1 && code === 3) {
            // Ctrl+C during raw mode — cancel or double-press exit
            const now = Date.now();
            if (now - lastCtrlCTime < 1000) {
                if (currentSpinner) {
                    currentSpinner.stop();
                    currentSpinner = null;
                }
                process.exit(0);
            }
            lastCtrlCTime = now;
            if (!tryCancelRunning()) {
                printNoRunningHint();
            }
        }
    };

    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.on("data", onData);

    return () => {
        process.stdin.removeListener("data", onData);
        // Don't change raw mode or pause here — let the next input handler
        // (questionWithCompletion) manage stdin state when it starts.
    };
}

/**
 * Enhanced command processor with spinner support and tab completion
 */
export async function processCommandsEnhanced<T>(
    interactivePrompt: string | ((context: T) => string | Promise<string>),
    processCommand: (
        request: string,
        context: T,
        clientRequestId: string,
    ) => Promise<any>,
    context: T,
    inputs?: string[],
    completionController?: CompletionController,
    dispatcherForCancel?: Dispatcher,
    isCompletionEnabled?: () => boolean,
) {
    // Always resolve the dispatcher at call time so @conversation switch
    // (which rebinds via bindDispatcher) doesn't leave us holding a closed
    // channel from the previously joined conversation.
    const getDispatcher = (): Dispatcher | undefined =>
        moduleDispatcherRef.current ?? dispatcherForCancel;

    const fs = await import("node:fs");
    const historyFile = path.join(
        os.homedir(),
        ".typeagent",
        "command_history.json",
    );
    let history: string[] = [];
    if (fs.existsSync(historyFile)) {
        const hh = JSON.parse(
            fs.readFileSync(historyFile, { encoding: "utf-8" }),
        );
        history = hh.commands;
    }

    // Only create readline when not using inline completions.
    // questionWithCompletion manages stdin directly; a readline attached
    // to the same stdin would consume events and break input.
    const rl = completionController
        ? undefined
        : createInterface({
              input: process.stdin,
              output: process.stdout,
              history,
              terminal: true,
          });
    activeRl = rl;

    // Queue-mode Ctrl+C handler: the non-blocking submit path bypasses raw-mode,
    // so install a readline SIGINT handler that cancels the queue head.
    // Double-press-to-exit is preserved.
    if (rl) {
        rl.on("SIGINT", () => {
            const now = Date.now();
            if (now - lastCtrlCTime < 1000) {
                process.exit(0);
            }
            lastCtrlCTime = now;
            if (tryCancelRunningHead(getDispatcher())) {
                return;
            }
            const hasQueued = (queueMirror.snapshot?.queued.length ?? 0) > 0;
            const msg = hasQueued
                ? "No request currently running. Use /queue cancel <id> to cancel queued requests."
                : "Nothing running. Use /queue cancel <id> to cancel queued requests.";
            writeQueueLine(chalk.dim(msg));
        });
    }

    const promptColor = chalk.cyanBright;

    while (true) {
        const prompt =
            typeof interactivePrompt === "function"
                ? await interactivePrompt(context)
                : interactivePrompt;

        const width = process.stdout.columns || 80;

        let request: string;
        if (inputs) {
            process.stdout.write(
                ANSI.dim + "─".repeat(width) + ANSI.reset + "\n",
            );
            request = getNextInput(prompt, inputs, promptColor);
        } else if (completionController) {
            // Always use questionWithCompletion regardless of whether autocomplete is
            // enabled — it owns stdin in raw mode for the lifetime of the session.
            // Switching to readline mid-session causes stdin ownership conflicts
            // (doubled echo, hanging input) because readline and questionWithCompletion
            // use incompatible stdin modes. Instead, pass undefined as the controller
            // when autocomplete is disabled: questionWithCompletion handles that case
            // gracefully, giving normal input with no completion suggestions.
            request = await questionWithCompletion(
                promptColor(prompt),
                isCompletionEnabled && !isCompletionEnabled()
                    ? undefined
                    : completionController,
                history,
            );
        } else {
            process.stdout.write(
                ANSI.dim + "─".repeat(width) + ANSI.reset + "\n",
            );
            request = await question_internal(promptColor(prompt), rl);
            if (request.length) {
                process.stdout.write(
                    ANSI.dim + "─".repeat(width) + ANSI.reset + "\n",
                );
            }
        }

        // Skip empty input - just loop back to show prompt again
        if (!request.length) {
            continue;
        }

        if (
            request.toLowerCase() === "quit" ||
            request.toLowerCase() === "exit"
        ) {
            break;
        }

        // Handle slash commands before sending to dispatcher
        if (isSlashCommand(request)) {
            try {
                const slashResult = await handleSlashCommand(request, (cmd) =>
                    processCommand(cmd, context, randomUUID()),
                );
                if (slashResult.exit) {
                    break;
                }
            } catch (error) {
                console.log(chalk.red(`Error: ${error}`));
            }
            history.push(request);
            console.log("");
            continue;
        }

        try {
            // Reset debug panel for this command
            const panel = getDebugPanel();
            panel?.reset();

            // Non-blocking submit path: submitCommand resolves on enqueue.
            // Output/completion arrive via setDisplay, commandComplete, and
            // queue lifecycle events.
            const liveDispatcher = getDispatcher();
            if (liveDispatcher) {
                isProcessing = true;
                currentRequestId = undefined;
                const clientRequestId = randomUUID();
                currentClientRequestId = clientRequestId;
                try {
                    let result;
                    try {
                        result = await liveDispatcher.submitCommand(
                            request,
                            undefined,
                            undefined,
                            clientRequestId,
                        );
                    } catch (err) {
                        console.log(chalk.red(`### ERROR: ${err}`));
                        history.push(request);
                        console.log("");
                        continue;
                    }
                    // submitCommand returns a discriminated SubmitResult; branch on typed failures.
                    if (result && result.ok === false) {
                        if (result.error === "queue_full") {
                            console.log(
                                chalk.yellow(
                                    `### Queue is full (${result.maxDepth}). Try again after some complete.`,
                                ),
                            );
                        } else if (result.error === "server_stopping") {
                            console.log(
                                chalk.red(
                                    "### Server is shutting down. Your request was not accepted.",
                                ),
                            );
                        } else {
                            console.log(
                                chalk.red(
                                    `### Request rejected: ${(result as any).error}`,
                                ),
                            );
                        }
                        history.push(request);
                        console.log("");
                        continue;
                    }
                    const entry = result?.ok ? result.entry : undefined;
                    if (entry) {
                        // Track so requestStarted doesn't double-print and badge doesn't double-count.
                        rememberSubmittedId(entry.requestId);
                        currentRequestId = entry.requestId;
                    }
                } finally {
                    isProcessing = false;
                    currentClientRequestId = undefined;
                }
                history.push(request);
                console.log("");
                fs.writeFileSync(
                    historyFile,
                    JSON.stringify({ commands: history }),
                );
                continue;
            }

            // Legacy path used only when the caller supplied no dispatcher
            // (e.g. tests). Routes the command through the caller-provided
            // processCommand callback synchronously.
            // Start spinner for processing
            startProcessingSpinner("Processing request...");
            // Show execution hint below spinner
            const debugHint = panel ? " · ctrl+d debug" : "";
            const execHint = chalk.dim(
                `  esc cancel · ctrl+c cancel (2× exit)${debugHint}`,
            );
            process.stdout.write("\n" + execHint + "\x1b[K\x1b[1A\r");

            isProcessing = true;
            currentRequestId = undefined;
            currentClientRequestId = randomUUID();
            const stopKeyListener = startExecutionKeyListener(getDispatcher());
            let result: any;
            try {
                result = await processCommand(
                    request,
                    context,
                    currentClientRequestId,
                );
            } finally {
                stopKeyListener();
                isProcessing = false;
                currentClientRequestId = undefined;
            }

            // Stop live panel rendering but keep the buffer for post-command review
            panel?.stopRendering();
            process.stdout.write("\n\x1b[K\x1b[1A");

            // Wait for any pending choice prompt to complete
            // before showing "Complete" and the next prompt.
            // Stop the spinner first so it doesn't overwrite the prompt.
            if (pendingChoicePromise) {
                currentSpinner?.stop();
                currentSpinner = null;
                await pendingChoicePromise;
                pendingChoicePromise = null;
            }

            if (result?.cancelled) {
                stopSpinner("warn", " Cancelled");
            } else {
                stopSpinner("success", "Complete");
            }

            // Show static collapsed debug summary after spinner result
            if (panel && panel.lineCount > 0) {
                panel.renderStaticSummary();
            }

            history.push(request);
        } catch (error) {
            isProcessing = false;
            getDebugPanel()?.stopRendering();
            stopSpinner("fail", "Error");
            console.log(chalk.red("### ERROR:"));
            console.log(error);
        }

        console.log("");

        // save command history
        fs.writeFileSync(historyFile, JSON.stringify({ commands: history }));
    }
}

function getNextInput(
    prompt: string,
    inputs: string[],
    promptColor: (s: string) => string,
): string {
    while (true) {
        let input = inputs.shift();
        if (input === undefined) {
            return "exit";
        }
        input = input.trim();
        if (input.length === 0) {
            continue;
        }
        if (!input.startsWith("#")) {
            console.log(`${promptColor(prompt)}${input}`);
            return input;
        }
        console.log(chalk.green(input));
    }
}

/**
 * Live `(queue: N) ` prefix for the interactive prompt; empty when nothing to surface.
 * Re-derived at render time so it stays in sync with the queue mirror between Enter presses.
 * A `running` entry that matches `currentRequestId` or was recently submitted from THIS
 * CLI is not counted — the spinner / "▶ running" marker already conveys it.
 */
export function formatQueueBadge(snap?: QueueSnapshot | undefined): string {
    const s = snap ?? queueMirror.snapshot;
    const queuedCount = s?.queued.length ?? 0;
    const runningOther =
        s?.running &&
        s.running.requestId !== currentRequestId &&
        !recentlySubmittedRequestIds.has(s.running.requestId)
            ? 1
            : 0;
    const total = queuedCount + runningOther;
    return total > 0 ? chalk.yellow(`(queue: ${total}) `) : "";
}

/**
 * Get styled console prompt.
 * Returns the *static* portion only; the live `(queue: N)` badge is prepended
 * at render time by `questionWithCompletion` via `formatQueueBadge()`.
 */
export function getEnhancedConsolePrompt(_text: string): string {
    return `${getVerboseIndicator()}❯ `;
}

/**
 * Replay display history entries from a previous (or concurrent) conversation onto
 * the given clientIO so that the user sees the chat history when joining.
 *
 * User requests are printed as styled prompt lines; agent display entries are
 * forwarded through setDisplay / appendDisplay exactly as live messages would
 * be. Display-info entries are ephemeral and skipped. Notifications are
 * ephemeral by default and not in the log; persist:true notifications are
 * present and get re-emitted through clientIO.notify on replay.
 */
export async function replayDisplayHistory(
    dispatcher: Dispatcher,
    clientIO: ClientIO,
    sessionName?: string,
): Promise<void> {
    const entries = await dispatcher.getDisplayHistory();
    if (entries.length === 0) {
        if (sessionName !== undefined) {
            console.log(
                chalk.dim(`Connected to conversation '${sessionName}'.`),
            );
        }
        return;
    }

    const width = process.stdout.columns || 80;
    process.stdout.write(
        chalk.dim(
            "─── conversation history " + "─".repeat(Math.max(0, width - 25)),
        ) + "\n",
    );

    const pendingInteractions = new Map<string, PendingInteractionEntry>();

    for (const entry of entries) {
        switch (entry.type) {
            case "user-request":
                process.stdout.write(
                    chalk.cyanBright(`❯ `) + chalk.dim(entry.command) + "\n",
                );
                break;
            case "set-display":
                clientIO.setDisplay(entry.message);
                break;
            case "append-display":
                clientIO.appendDisplay(entry.message, entry.mode);
                break;
            case "pending-interaction":
                pendingInteractions.set(entry.interactionId, entry);
                break;
            case "interaction-resolved": {
                const pending = pendingInteractions.get(entry.interactionId);
                if (pending?.message !== undefined) {
                    let answerStr: string;
                    if (
                        pending.choices !== undefined &&
                        typeof entry.response === "number"
                    ) {
                        answerStr =
                            pending.choices[entry.response] ??
                            String(entry.response);
                    } else {
                        answerStr = entry.response ? "yes" : "no";
                    }
                    process.stdout.write(
                        chalk.dim(`${pending.message} → `) +
                            chalk.cyan(answerStr) +
                            "\n",
                    );
                }
                pendingInteractions.delete(entry.interactionId);
                break;
            }
            case "interaction-cancelled": {
                const pending = pendingInteractions.get(entry.interactionId);
                if (pending?.message !== undefined) {
                    process.stdout.write(
                        chalk.dim(`${pending.message} → `) +
                            chalk.yellow("[cancelled]") +
                            "\n",
                    );
                }
                pendingInteractions.delete(entry.interactionId);
                break;
            }
            case "notify":
                // Only persist:true notifications are in the log; replay them
                // through the same channel they were emitted on.
                clientIO.notify(
                    entry.notificationId,
                    entry.event,
                    entry.data,
                    entry.source,
                );
                break;
            // set-display-info entries are ephemeral — skip them
        }
    }

    process.stdout.write(
        chalk.dim("─── now " + "─".repeat(Math.max(0, width - 8))) + "\n",
    );
    if (sessionName !== undefined) {
        console.log(chalk.dim(`Connected to conversation '${sessionName}'.`));
    }
}
