// crates/atomcode-tuix/src/lib.rs

pub mod commands;
pub mod event_loop;
pub mod highlight;
pub mod i18n;
pub mod input;
pub mod markdown;
pub mod modals;
pub mod platform;
pub mod render;
pub mod sanitize;
pub mod state;
pub mod terminal;
pub mod terminal_bg;
#[cfg(test)]
pub mod test_term;
pub mod think;
pub mod trace;
pub mod width;

use anyhow::Result;
use atomcode_core::agent::{AgentHandle, AgentRuntimeFactory};
use atomcode_core::config::Config;
use crossterm::{
    event::{
        DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
    },
    execute,
};
use std::io;
use tokio::sync::mpsc;

use crate::commands::CommandRegistry;
use crate::event_loop::{run_loop, LoopCtx};
use crate::input::history::History;
use crate::input::reader;
use crate::render::{
    plain::PlainRenderer, retained::RetainedRenderer, worker::TaskRenderer, Renderer,
};
use crate::terminal::TerminalCaps;

/// RAII guard: enables raw mode + bracketed paste on construction,
/// unconditionally restores both on drop (even during panic).
struct TerminalGuard {
    raw_enabled: bool,
    paste_enabled: bool,
    /// Set when the Kitty keyboard protocol (CSI u) was successfully
    /// pushed. Guards the matching pop in Drop so we don't send a stray
    /// pop sequence on terminals that rejected the push.
    kbd_flags_pushed: bool,
}

impl TerminalGuard {
    /// Activate terminal capabilities. Returns `(guard, kbd_enhanced)` where
    /// `kbd_enhanced` indicates whether the Kitty keyboard protocol (CSI u)
    /// was successfully enabled. When false, terminals cannot distinguish
    /// Shift+Enter from plain Enter, and users should use Alt+Enter or
    /// Ctrl+Enter for newline insertion instead.
    fn activate(caps: TerminalCaps) -> Result<(Self, bool)> {
        use std::io::Write as _;
        let mut g = Self {
            raw_enabled: false,
            paste_enabled: false,
            kbd_flags_pushed: false,
        };
        if caps.raw_mode {
            crossterm::terminal::enable_raw_mode()?;
            g.raw_enabled = true;
        }
        if caps.bracketed_paste {
            execute!(io::stdout(), EnableBracketedPaste)?;
            g.paste_enabled = true;
        }
        // Enable Kitty keyboard protocol (CSI u / progressive enhancement)
        // so terminals that support it report modifier+Enter as a distinct
        // key event instead of collapsing Shift+Enter to plain Enter. Without
        // this, crossterm sees `Enter, NONE` on both Enter and Shift+Enter
        // and the input box can't insert a newline.
        //
        // `REPORT_EVENT_TYPES` is the second bit of the protocol and is what
        // actually makes OS key autorepeat distinguishable from fresh presses:
        // without it, every 30ms autorepeat tick reports as `KeyEventKind::Press`,
        // so holding Shift+Enter for a normal 150ms press-down inserts 5-10
        // newlines instead of one. With it enabled, autorepeats report as
        // `KeyEventKind::Repeat`, which `event_loop/mod.rs` treats the same
        // as `Press` so navigation keys (Left/Right/Backspace) auto-repeat
        // when held — Submit-on-Enter still fires only once because Submit
        // transitions phases.
        //
        // `execute!` is best-effort — terminals that don't support CSI u
        // (notably Apple Terminal.app, some Linux terminals) ignore the
        // sequence; we just don't set `kbd_flags_pushed` and Drop won't try
        // to pop. Terminals that support DISAMBIGUATE but not
        // REPORT_EVENT_TYPES ignore the extra bit silently — this never
        // makes things worse than before.
        let kbd_enhanced = caps.tty
            && execute!(
                io::stdout(),
                PushKeyboardEnhancementFlags(
                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
                        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
                )
            )
            .is_ok();
        if kbd_enhanced {
            g.kbd_flags_pushed = true;
        }
        // FIXED-FOOTER via DECSTBM. Scroll region `[1, H - footer_rows]`
        // is set by `AnsiRenderer` the first time it paints the footer;
        // body writes stream into that region while the footer stays
        // pinned at `[H - footer_rows + 1, H]`. This guard only clears
        // the screen on entry — the renderer owns scroll-region lifecycle
        // during normal operation, and this guard's Drop is the
        // belt-and-suspenders reset for panic / abrupt-exit paths where
        // the renderer worker didn't get to run `shutdown()`.
        if caps.tty {
            let stdout = io::stdout();
            let mut out = stdout.lock();
            // Per-row CUP+EL instead of `\x1b[2J` — iTerm2 3.5+ ignores
            // ED under some states; the renderer paths (reset / resize
            // / resume) all now use EL, so keep startup consistent.
            // Fall back to 24 rows if crossterm can't query size (very
            // rare; a wrong guess just under-clears a few trailing rows
            // at startup — the renderer will paint over anything below
            // that anyway).
            let (_, rows) = crossterm::terminal::size().unwrap_or((80, 24));
            use std::fmt::Write as _;
            let mut seq = String::with_capacity((rows as usize) * 8 + 4);
            for row in 1..=(rows as usize) {
                let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
            }
            seq.push_str("\x1b[H");
            let _ = out.write_all(seq.as_bytes());
            let _ = out.flush();
        }
        Ok((g, kbd_enhanced))
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        use std::io::Write as _;
        // Panic-safe final reset: `\x1b[?7h` re-enables autowrap (in
        // case a footer paint was interrupted mid-`\x1b[?7l/h` bracket),
        // `\x1b[r` releases any DECSTBM scroll region we set during
        // normal operation, then a CRLF parks the cursor on a fresh
        // line for the user's shell prompt. This runs even when the
        // renderer worker crashed before `shutdown` could clean up,
        // which is why it exists alongside the renderer's own
        // `clear_scroll_region` in `shutdown`.
        let stdout = io::stdout();
        let mut out = stdout.lock();
        let _ = write!(out, "\x1b[?7h\x1b[r\r\n");
        let _ = out.flush();
        if self.kbd_flags_pushed {
            let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
        }
        if self.paste_enabled {
            let _ = execute!(io::stdout(), DisableBracketedPaste);
        }
        if self.raw_enabled {
            let _ = crossterm::terminal::disable_raw_mode();
        }
    }
}

pub async fn run(
    config: Config,
    model_name: String,
    agent_handle: AgentHandle,
    runtime_factory: AgentRuntimeFactory,
    working_dir: std::path::PathBuf,
    session_to_continue: Option<atomcode_core::session::Session>,
    mcp_registry: Option<std::sync::Arc<atomcode_core::mcp::McpRegistry>>,
    mcp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::mcp::McpConnectEvent>>,
    lsp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::lsp::LspConnectEvent>>,
    telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
    dangerously_skip_permissions: bool,
) -> Result<()> {
    let mut caps = TerminalCaps::probe();

    // Decide force_plain BEFORE activating TerminalGuard. Plain mode
    // is incompatible with raw-mode setup: PlainRenderer emits `\n`
    // (LF only) via `writeln!`, but raw mode disables the kernel's
    // ONLCR translation, so LF moves down without returning to col 1.
    // Result: every printed line stair-steps diagonally to the right,
    // exactly matching the bug observed in JediTerm where the welcome
    // banner ends near col 68 and subsequent MCP status lines start
    // there instead of at col 0.
    //
    // `ATOMCODE_PLAIN=1` (or any non-empty value) is the user-facing
    // escape hatch — forces PlainRenderer even on a TTY. Useful for
    // logging, CI capture, or any environment where the append-only
    // retained renderer's ANSI sequences are unwanted.
    //
    // The trade-off when force_plain is on: no pinned input box, no
    // live spinner, no slash-menu palette — but text + commands +
    // agent flow all work, which is the floor.
    let force_plain_env = std::env::var("ATOMCODE_PLAIN")
        .ok()
        .filter(|v| !v.is_empty())
        .is_some();
    let force_retain_env = std::env::var("ATOMCODE_RETAIN")
        .ok()
        .filter(|v| !v.is_empty())
        .is_some();
    // Phase 6 routing matrix (append-only retained renderer):
    //   ATOMCODE_PLAIN=1   → PlainRenderer (user opt-in, CI-style baseline)
    //   ATOMCODE_RETAIN=1  → RetainedRenderer (override sticky non-TTY probe)
    //   tty                → RetainedRenderer (append-only; no DECSTBM)
    //   non-tty            → PlainRenderer
    //
    // `ATOMCODE_RETAIN` exists for hosts where `is_terminal()` lies — the
    // best-known case is pwsh7 on native Win10 conhost: pwsh wraps stdout
    // in a ConPTY pipe even when the parent host is plain conhost, so
    // `std::io::stdout().is_terminal()` returns false. Users see the TUI
    // collapse into PlainRenderer (no input footer) and raw SGR bytes
    // leak as `[31m...[0m` (raw-mode → VT processing was never enabled).
    // Setting ATOMCODE_RETAIN=1 forces the retained path and lets the
    // raw-mode init call SetConsoleMode(ENABLE_VIRTUAL_TERMINAL_PROCESSING)
    // via crossterm, which fixes both symptoms.
    //
    // PLAIN beats RETAIN — if both are set the user explicitly asked for
    // the cooked-mode baseline.
    let force_plain = force_plain_env;
    let force_retain = force_retain_env && !force_plain;

    // Capture whether stdout was a real TTY BEFORE we mutate caps.
    // PlainRenderer needs this to know whether the kernel will echo
    // user input (cooked-mode, real TTY) or not (pipe / CI). Used
    // below when constructing PlainRenderer so the User-line render
    // doesn't duplicate cooked-mode echoes on the ATOMCODE_PLAIN=1
    // force_plain path.
    let was_real_tty = caps.tty;

    // When force_plain wins, strip raw-mode-related capabilities so
    // every downstream branch (TerminalGuard activate, reader spawn,
    // renderer choice) consistently picks the cooked-mode / Plain
    // path. `tty=false` also skips Kitty enhancement push and the
    // startup screen clear (both emit CSI sequences PlainRenderer
    // doesn't need).
    if force_plain {
        caps.raw_mode = false;
        caps.bracketed_paste = false;
        caps.tty = false;
    } else if force_retain {
        // Flip caps positive so TerminalGuard enables raw mode (→
        // VT processing on Win), the reader thread spawns, and the
        // renderer-choice branch picks Retained. If the probe lied
        // about TTY this corrects it; if it didn't lie, this is a
        // no-op assignment.
        caps.tty = true;
        caps.raw_mode = true;
        caps.bracketed_paste = true;
        caps.scroll_region = true;
    }

    let (_guard, kbd_enhanced) = TerminalGuard::activate(caps)?;

    // Pick the colour palette now that raw mode is on (OSC 11 detection
    // requires it — otherwise the response is line-buffered and never
    // reaches us before timeout).
    //
    // - `Light` / `Dark`: explicit, skip detection.
    // - `Auto`: query the terminal background; fall back to `dark` if
    //   it doesn't reply within 60ms. Responsive emulators (iTerm2,
    //   WezTerm, Alacritty, Kitty, Windows Terminal, VSCode integrated)
    //   reply on first byte well under the budget — local TTY round
    //   trips are <10ms in practice. Non-responsive terminals (macOS
    //   Terminal.app, Windows conhost, SSH through relays that strip
    //   OSC) silently default to dark. The 60ms initial deadline + 80ms
    //   tail-drain in `terminal_bg::detect_light` together cover slow
    //   responders up to ~140ms; the previous 100ms initial was
    //   over-budget for what local terminals actually need.
    let theme_light = match config.ui.theme {
        atomcode_core::config::UiTheme::Light => true,
        atomcode_core::config::UiTheme::Dark => false,
        atomcode_core::config::UiTheme::Auto => {
            if caps.colors {
                crate::terminal_bg::detect_light(
                    std::time::Duration::from_millis(60),
                )
                .unwrap_or(false)
            } else {
                false
            }
        }
    };
    crate::highlight::theme::set_theme_mode(theme_light);

    // If the terminal doesn't support Kitty keyboard protocol (CSI u),
    // set an env var so the event loop can show a hint on startup.
    // Shift+Enter won't work for newline insertion; users should use
    // Alt+Enter or Ctrl+Enter instead.
    if !kbd_enhanced {
        std::env::set_var("ATOMCODE_KBD_NOT_ENHANCED", "1");
    }

    // Pick the inner renderer by terminal capability, then wrap it in
    // a `TaskRenderer` so all ANSI I/O happens on a dedicated OS thread.
    // Slow terminals (Mac Terminal.app processing a 4KB footer payload)
    // no longer block the event loop — the event loop sends `UiLine`s
    // through a channel and moves on.
    //
    // TTY    → RetainedRenderer (append-only Ink-style cell-diff renderer).
    // Non-TTY → PlainRenderer (pipe, CI, dumb terminal, ATOMCODE_PLAIN=1).
    //
    // Since Phase 5 the retained renderer is fully append-only and no
    // longer relies on DECSTBM scroll regions, so JediTerm and legacy
    // Windows conhost — the two terminals that previously needed an
    // alt-screen path — can run retained too. There is no more
    // alt-screen branch here.
    //
    // `is_plain_renderer` is threaded into LoopCtx so non-interactive
    // sessions (CI, pipe, dumb TERM) can skip the OnboardingWizard
    // auto-trigger; the modal would otherwise try to draw a
    // Cyan-bordered box into a stdout that no human is watching.
    let is_plain_renderer = !caps.tty;
    let inner: Box<dyn Renderer> = if caps.tty {
        Box::new(RetainedRenderer::new(caps))
    } else {
        // Pass caps + the ORIGINAL tty value so PlainRenderer can:
        // (a) gate colours / unicode / spinner on caps.{colors,
        //     unicode_symbols, spinner} (these survive the force_plain
        //     mutation; CI / pipe don't have them);
        // (b) decide whether to suppress UiLine::User echo based on
        //     `was_real_tty` — true means the kernel does cooked-mode
        //     echo for us (so re-rendering would duplicate the line),
        //     false means we're piping and need to render it ourselves.
        Box::new(PlainRenderer::with_writer_caps_and_interactive(
            std::io::BufWriter::new(std::io::stdout()),
            caps,
            was_real_tty,
        ))
    };
    let mut renderer: Box<dyn Renderer> = Box::new(TaskRenderer::new(inner));

    // Input thread (only spawn when raw-mode/TTY available; pipe mode
    // reads stdin directly). `reader_handle` exposes Pause / Resume so
    // the OAuth login flow (and any future child-process handoff) can
    // stop us from racing the child for stdin bytes. Pipe mode doesn't
    // need that — no browser handoff there — so it stays as a plain
    // JoinHandle held separately.
    let (input_tx, input_rx) = mpsc::unbounded_channel();
    let mut reader_handle: Option<reader::ReaderHandle> = None;
    let mut pipe_reader: Option<std::thread::JoinHandle<()>> = None;
    if caps.raw_mode {
        reader_handle = Some(reader::spawn(input_tx.clone()));
    } else {
        // For pipe mode, spawn a line-based reader on a blocking thread.
        pipe_reader = Some(std::thread::spawn(move || {
            use std::io::BufRead;
            let stdin = std::io::stdin();
            let lock = stdin.lock();
            for line in lock.lines().map_while(Result::ok) {
                // Synthesize a key-by-key paste so the loop handles it uniformly.
                if input_tx.send(input::InputEvent::Paste(line)).is_err() {
                    return;
                }
                // Then an Enter key to commit.
                let enter = crossterm::event::KeyEvent {
                    code: crossterm::event::KeyCode::Enter,
                    modifiers: crossterm::event::KeyModifiers::NONE,
                    kind: crossterm::event::KeyEventKind::Press,
                    state: crossterm::event::KeyEventState::NONE,
                };
                if input_tx.send(input::InputEvent::Key(enter)).is_err() {
                    return;
                }
            }
            let _ = input_tx.send(input::InputEvent::Eof);
        }));
    };

    // `default_path()` now always returns Some (tempdir fallback lives
    // inside `platform::history_path`), so the explicit else-branch
    // with a hardcoded Unix path is gone — Windows used to fall here
    // and then fail to write to `/tmp`.
    let history = {
        let path = History::default_path()
            .unwrap_or_else(crate::platform::history_path);
        let cache = crate::platform::image_cache_dir();
        crate::input::history::History::load_with_cache(path, cache)
    };

    let session_manager = atomcode_core::session::SessionManager::new(&working_dir);
    // Fresh session by default; `/resume` replaces this on load.
    let current_session = atomcode_core::session::Session::default_session(working_dir.clone());

    // Passive "new version available" check. Detached — never blocks
    // startup; on any error returns None silently. On a positive hit
    // the task (a) stores the version in the shared mutex and (b) sends
    // a wake pulse so the event loop redraws the status row immediately
    // instead of waiting for the user's next keystroke.
    let update_hint = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
    let (wake_tx, wake_rx) = tokio::sync::mpsc::channel::<()>(1);
    // Background OAuth poll → event-loop channel. Unbounded so the
    // poll thread never blocks waiting for the consumer (poll thread
    // is std::thread, can't `await`). One event per spawned task,
    // capacity is irrelevant — even an unbounded channel is essentially
    // empty here.
    let (oauth_event_tx, oauth_event_rx) =
        tokio::sync::mpsc::unbounded_channel::<crate::event_loop::oauth_poll::OauthEvent>();

    // Seed the hint from any prior-session staged upgrade so the user
    // sees the pending status on the very first frame rather than
    // waiting for the next poll to rediscover it.
    if let Ok(Some(pending)) = atomcode_core::self_update::read_pending() {
        if let Ok(mut g) = update_hint.lock() {
            *g = Some(pending.version);
        }
    }

    {
        let slot = update_hint.clone();
        let wake = wake_tx.clone();
        tokio::spawn(async move {
            let current = format!("v{}", env!("CARGO_PKG_VERSION"));
            if let Some(latest) = atomcode_core::version_check::check_latest(&current).await {
                if let Ok(mut g) = slot.lock() {
                    *g = Some(latest);
                }
                let _ = wake.try_send(());
            }
        });
    }

    // NOTE: the in-process deferred-upgrade poll used to live here. It
    // was moved out into a detached setsid'd subprocess spawned from
    // `main.rs` (see `spawn_detached_upgrade_prep`). Rationale: the old
    // task was tied to this tokio runtime, so any Ctrl+C / quick exit
    // cancelled the download mid-flight and `pending.json` was never
    // written — making "exit and restart to auto-upgrade" silently do
    // nothing. The detached subprocess survives parent exits. Running
    // both would race on `staged_path` (no temp-rename in
    // `download_and_verify`), so the in-process copy is gone entirely.
    //
    // Trade-off: a session that runs through a whole release cycle
    // (>1 h) won't re-stage the newer version mid-session. We accept
    // that — `/upgrade` still works manually, and the update hint from
    // the one-shot `version_check` above still surfaces the availability.

    // Long-lived progress channel for /upgrade. The sender is cloned
    // into each spawned upgrade task; the receiver stays in the event
    // loop's select!. Unbounded because progress events are tiny and
    // we never want the upgrade task to block on UI backpressure.
    let (upgrade_tx, upgrade_rx) =
        tokio::sync::mpsc::unbounded_channel::<atomcode_core::self_update::UpgradeEvent>();
    // Mirror channel for /plugin add|update|install so git latency never
    // stalls the input loop. See LoopCtx::plugin_job_tx for the rationale.
    let (plugin_job_tx, plugin_job_rx) =
        tokio::sync::mpsc::unbounded_channel::<atomcode_core::plugin::PluginJobEvent>();

    // Seed the recent-project-dirs ring from disk and guarantee the
    // current working dir sits at index 0 so the `/cd` picker always
    // has at least one entry (the dir the user just launched into).
    let recent_dirs = {
        let mut dirs = event_loop::commands::load_recent_dirs();
        event_loop::commands::push_recent_dir(&mut dirs, working_dir.clone());
        event_loop::commands::save_recent_dirs(&dirs);
        dirs
    };

    let custom_commands = atomcode_core::commands::CustomCommandRegistry::load(&working_dir);
    // Same Arc the agent loop holds — reload() calls there propagate
    // here automatically, so the slash menu reflects newly-installed
    // skills without re-plumbing.
    let foreground_runtime_id = event_loop::bg_runtime::RuntimeId::new(1);
    let agent_client = agent_handle.client.clone();
    let skill_registry = agent_client.skill_registry.clone();

    // ── Plugin marketplace bootstrap (detached) ──
    //
    // Auto-install of the default skills marketplace + post-self-upgrade
    // `git pull` of every installed marketplace. Both run git subprocesses
    // inline (1–3s warm, 5–10s on first clone) — keeping them on the
    // critical path used to delay the input box by the same amount. Now
    // we hand the work to `spawn_blocking`, refresh the shared
    // `SkillRegistry` once the disk side-effects land, then forward each
    // resulting `PluginJobEvent` through `plugin_job_tx` so the event
    // loop's existing `handle_plugin_job_event` renders the same toast
    // the synchronous `/plugin install` path would emit (e.g.
    // "marketplace `atomcode` added at abc1234 (3 plugins)" ).
    // The user sees the install land as a regular body row instead of
    // a silent file-system mutation. Worst case the user types `/`
    // before the install settles — they see an empty / partial menu
    // and the toast arrives a beat later; acceptable trade-off vs.
    // burning 5–10s on every first launch.
    {
        let cfg = config.clone();
        let registry = skill_registry.clone();
        let work_dir = working_dir.clone();
        let wake = wake_tx.clone();
        let job_tx = plugin_job_tx.clone();
        tokio::spawn(async move {
            let events = tokio::task::spawn_blocking(move || {
                let events = atomcode_core::plugin::bootstrap::run_startup_hooks(&cfg);
                // Refresh the shared SkillRegistry from disk so the
                // freshly-installed skills are visible to the slash
                // menu + agent loop without a restart.
                if let Ok(mut guard) = registry.write() {
                    let _ = guard.reload(&work_dir);
                }
                events
            })
            .await
            .unwrap_or_default();
            for ev in events {
                let _ = job_tx.send(ev);
            }
            let _ = wake.try_send(());
        });
    }

    let (runtime_event_tx, runtime_event_rx) =
        tokio::sync::mpsc::unbounded_channel::<event_loop::bg_runtime::RuntimeEvent>();
    event_loop::bg_runtime::spawn_event_forwarder(
        foreground_runtime_id,
        agent_handle.event_rx,
        runtime_event_tx.clone(),
    );
    let bg_manager = event_loop::bg_runtime::BgRuntimeManager::new(
        current_session.clone(),
        foreground_runtime_id,
        agent_client.clone(),
    );

    let file_index_root = working_dir.clone();
    let ctx = LoopCtx {
        config,
        model_name,
        agent: agent_client,
        runtime_factory,
        bg_manager,
        foreground_runtime_id,
        runtime_event_tx,
        runtime_event_rx,
        working_dir,
        previous_dir: None,
        recent_dirs,
        history,
        input_rx,
        commands: CommandRegistry::builtin(),
        session_manager,
        current_session,
        update_hint,
        monitor_warning: std::sync::Arc::new(std::sync::Mutex::new(None)),
        hook_warning_hint: std::sync::Arc::new(std::sync::Mutex::new(None)),
        monitor_last_check_at: None,
        usage_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
        usage_last_check_at: None,
        // Seed with whatever's on disk now — any NEWER mtime observed
        // later means another atomcode process resynced and our drift
        // warning (if any) is stale.
        monitor_last_sync_seen: atomcode_core::coding_plan::read_last_sync(),
        wake_rx,
        wake_tx: wake_tx.clone(),
        oauth_event_rx,
        oauth_event_tx,
        reader: reader_handle,
        upgrade_tx,
        upgrade_rx,
        plugin_job_tx,
        plugin_job_rx,
        pending_new_issue: None,
        pending_run_login_setup: false,
        pending_open_provider_wizard: false,
        mcp_registry,
        mcp_connect_rx,
        mcp_reload: None,
        lsp_connect_rx,
        telemetry,
        worktree_original_dir: None,
        custom_commands,
        skill_registry,
        caps,
        replay_on_start: session_to_continue,
        file_index: crate::event_loop::file_index::FileIndex::new(file_index_root),
        current_session_id: None,
        clipboard_check: std::sync::Arc::new(std::sync::Mutex::new(
            crate::event_loop::ClipboardCheckState::default(),
        )),
        is_plain_renderer,
        dangerously_skip_permissions,
        pending_guide_topic: None,
        sync_session: None,
        sync_forwarder: None,
    };

    // CodingPlan drift monitor — kick off a startup check if the current
    // default provider is CodingPlan-managed. Non-CodingPlan users skip
    // this entirely (no HTTP, no state touched). Check runs in the
    // background via tokio::spawn → the warning shows up on the next
    // footer repaint once it resolves.
    if event_loop::monitor::is_codingplan_provider(&ctx.config.default_provider) {
        event_loop::monitor::spawn_check(
            ctx.config.clone(),
            ctx.model_name.clone(),
            ctx.monitor_warning.clone(),
            ctx.wake_tx.clone(),
        );
    }

    let result = run_loop(ctx, renderer.as_mut()).await;

    // Must shut down the renderer BEFORE re-exec: the alternate screen is
    // still active and raw mode is on — if we spawn a child while the
    // terminal is in that state, the new process inherits a garbled TTY.
    renderer.shutdown();
    drop(pipe_reader); // pipe-mode thread exits on next channel send failure

    // If /upgrade succeeded, the live binary has been replaced on disk.
    // Re-exec into the new version so the user gets a seamless upgrade
    // without manually restarting. This mirrors the startup-time upgrade
    // path in main.rs (apply_pending_upgrade → re_exec_self).
    //
    // The exe path comes from `ExitReason::UpgradeRestart { exe }`, which
    // was captured *before* `replace_binary` renamed the running binary.
    // On Windows, `std::env::current_exe()` would return the renamed
    // `.atomcode.rolling` path after the swap, so we MUST use this saved
    // value instead.
    if let Ok(event_loop::ExitReason::UpgradeRestart { exe }) = &result {
        // Set env var so the new process can show a one-time "upgraded" banner
        // on the welcome screen.
        std::env::set_var("ATOMCODE_UPGRADED_FROM", format!("v{}", env!("CARGO_PKG_VERSION")));
        match atomcode_core::self_update::re_exec_self(Some(exe)) {
            Ok(_infallible) => unreachable!("re_exec_self returned Ok"),
            Err(e) => {
                // Re-exec failed. The upgrade is on disk, so the user just
                // needs to start atomcode again — don't treat this as fatal.
                eprintln!(
                    "Upgrade applied but re-exec failed ({}). The new version will be used on the next launch.",
                    e
                );
                std::env::remove_var("ATOMCODE_UPGRADED_FROM");
            }
        }
    }

    result.map(|_| ())
}