Skip to main content

atomcode_tuix/
lib.rs

1// crates/atomcode-tuix/src/lib.rs
2
3pub mod commands;
4pub mod event_loop;
5pub mod highlight;
6pub mod i18n;
7pub mod input;
8pub mod markdown;
9pub mod modals;
10pub mod platform;
11pub mod render;
12pub mod sanitize;
13pub mod state;
14pub mod terminal;
15pub mod terminal_bg;
16#[cfg(test)]
17pub mod test_term;
18pub mod think;
19pub mod trace;
20pub mod width;
21
22use anyhow::Result;
23use atomcode_core::agent::{AgentHandle, AgentRuntimeFactory};
24use atomcode_core::config::Config;
25use crossterm::{
26    event::{
27        DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
28        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
29    },
30    execute,
31};
32use std::io;
33use tokio::sync::mpsc;
34
35use crate::commands::CommandRegistry;
36use crate::event_loop::{run_loop, LoopCtx};
37use crate::input::history::History;
38use crate::input::reader;
39use crate::render::{
40    plain::PlainRenderer, retained::RetainedRenderer, worker::TaskRenderer, Renderer,
41};
42use crate::terminal::TerminalCaps;
43
44/// RAII guard: enables raw mode + bracketed paste on construction,
45/// unconditionally restores both on drop (even during panic).
46struct TerminalGuard {
47    raw_enabled: bool,
48    paste_enabled: bool,
49    /// Set when the Kitty keyboard protocol (CSI u) was successfully
50    /// pushed. Guards the matching pop in Drop so we don't send a stray
51    /// pop sequence on terminals that rejected the push.
52    kbd_flags_pushed: bool,
53}
54
55impl TerminalGuard {
56    /// Activate terminal capabilities. Returns `(guard, kbd_enhanced)` where
57    /// `kbd_enhanced` indicates whether the Kitty keyboard protocol (CSI u)
58    /// was successfully enabled. When false, terminals cannot distinguish
59    /// Shift+Enter from plain Enter, and users should use Alt+Enter or
60    /// Ctrl+Enter for newline insertion instead.
61    fn activate(caps: TerminalCaps) -> Result<(Self, bool)> {
62        use std::io::Write as _;
63        let mut g = Self {
64            raw_enabled: false,
65            paste_enabled: false,
66            kbd_flags_pushed: false,
67        };
68        if caps.raw_mode {
69            crossterm::terminal::enable_raw_mode()?;
70            g.raw_enabled = true;
71        }
72        if caps.bracketed_paste {
73            execute!(io::stdout(), EnableBracketedPaste)?;
74            g.paste_enabled = true;
75        }
76        // Enable Kitty keyboard protocol (CSI u / progressive enhancement)
77        // so terminals that support it report modifier+Enter as a distinct
78        // key event instead of collapsing Shift+Enter to plain Enter. Without
79        // this, crossterm sees `Enter, NONE` on both Enter and Shift+Enter
80        // and the input box can't insert a newline.
81        //
82        // `REPORT_EVENT_TYPES` is the second bit of the protocol and is what
83        // actually makes OS key autorepeat distinguishable from fresh presses:
84        // without it, every 30ms autorepeat tick reports as `KeyEventKind::Press`,
85        // so holding Shift+Enter for a normal 150ms press-down inserts 5-10
86        // newlines instead of one. With it enabled, autorepeats report as
87        // `KeyEventKind::Repeat`, which `event_loop/mod.rs` treats the same
88        // as `Press` so navigation keys (Left/Right/Backspace) auto-repeat
89        // when held — Submit-on-Enter still fires only once because Submit
90        // transitions phases.
91        //
92        // `execute!` is best-effort — terminals that don't support CSI u
93        // (notably Apple Terminal.app, some Linux terminals) ignore the
94        // sequence; we just don't set `kbd_flags_pushed` and Drop won't try
95        // to pop. Terminals that support DISAMBIGUATE but not
96        // REPORT_EVENT_TYPES ignore the extra bit silently — this never
97        // makes things worse than before.
98        let kbd_enhanced = caps.tty
99            && execute!(
100                io::stdout(),
101                PushKeyboardEnhancementFlags(
102                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
103                        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
104                )
105            )
106            .is_ok();
107        if kbd_enhanced {
108            g.kbd_flags_pushed = true;
109        }
110        // FIXED-FOOTER via DECSTBM. Scroll region `[1, H - footer_rows]`
111        // is set by `AnsiRenderer` the first time it paints the footer;
112        // body writes stream into that region while the footer stays
113        // pinned at `[H - footer_rows + 1, H]`. This guard only clears
114        // the screen on entry — the renderer owns scroll-region lifecycle
115        // during normal operation, and this guard's Drop is the
116        // belt-and-suspenders reset for panic / abrupt-exit paths where
117        // the renderer worker didn't get to run `shutdown()`.
118        if caps.tty {
119            let stdout = io::stdout();
120            let mut out = stdout.lock();
121            // Per-row CUP+EL instead of `\x1b[2J` — iTerm2 3.5+ ignores
122            // ED under some states; the renderer paths (reset / resize
123            // / resume) all now use EL, so keep startup consistent.
124            // Fall back to 24 rows if crossterm can't query size (very
125            // rare; a wrong guess just under-clears a few trailing rows
126            // at startup — the renderer will paint over anything below
127            // that anyway).
128            let (_, rows) = crossterm::terminal::size().unwrap_or((80, 24));
129            use std::fmt::Write as _;
130            let mut seq = String::with_capacity((rows as usize) * 8 + 4);
131            for row in 1..=(rows as usize) {
132                let _ = write!(seq, "\x1b[{};1H\x1b[K", row);
133            }
134            seq.push_str("\x1b[H");
135            let _ = out.write_all(seq.as_bytes());
136            let _ = out.flush();
137        }
138        Ok((g, kbd_enhanced))
139    }
140}
141
142impl Drop for TerminalGuard {
143    fn drop(&mut self) {
144        use std::io::Write as _;
145        // Panic-safe final reset: `\x1b[?7h` re-enables autowrap (in
146        // case a footer paint was interrupted mid-`\x1b[?7l/h` bracket),
147        // `\x1b[r` releases any DECSTBM scroll region we set during
148        // normal operation, then a CRLF parks the cursor on a fresh
149        // line for the user's shell prompt. This runs even when the
150        // renderer worker crashed before `shutdown` could clean up,
151        // which is why it exists alongside the renderer's own
152        // `clear_scroll_region` in `shutdown`.
153        let stdout = io::stdout();
154        let mut out = stdout.lock();
155        let _ = write!(out, "\x1b[?7h\x1b[r\r\n");
156        let _ = out.flush();
157        if self.kbd_flags_pushed {
158            let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
159        }
160        if self.paste_enabled {
161            let _ = execute!(io::stdout(), DisableBracketedPaste);
162        }
163        if self.raw_enabled {
164            let _ = crossterm::terminal::disable_raw_mode();
165        }
166    }
167}
168
169pub async fn run(
170    config: Config,
171    model_name: String,
172    agent_handle: AgentHandle,
173    runtime_factory: AgentRuntimeFactory,
174    working_dir: std::path::PathBuf,
175    session_to_continue: Option<atomcode_core::session::Session>,
176    mcp_registry: Option<std::sync::Arc<atomcode_core::mcp::McpRegistry>>,
177    mcp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::mcp::McpConnectEvent>>,
178    lsp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::lsp::LspConnectEvent>>,
179    telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
180) -> Result<()> {
181    let mut caps = TerminalCaps::probe();
182
183    // Decide force_plain BEFORE activating TerminalGuard. Plain mode
184    // is incompatible with raw-mode setup: PlainRenderer emits `\n`
185    // (LF only) via `writeln!`, but raw mode disables the kernel's
186    // ONLCR translation, so LF moves down without returning to col 1.
187    // Result: every printed line stair-steps diagonally to the right,
188    // exactly matching the bug observed in JediTerm where the welcome
189    // banner ends near col 68 and subsequent MCP status lines start
190    // there instead of at col 0.
191    //
192    // `ATOMCODE_PLAIN=1` (or any non-empty value) is the user-facing
193    // escape hatch — forces PlainRenderer even on a TTY for terminals
194    // where the retained path's DECSTBM scroll region / cursor
195    // positioning misbehaves (legacy Windows conhost: footer scrolls
196    // off-screen, content duplicated, viewport drifts upward on each
197    // redraw).
198    //
199    // JetBrains' JediTerm (Android Studio, IntelliJ, PyCharm, GoLand —
200    // all share the same emulator) doesn't fully honour DECSTBM scroll
201    // regions or LF-within-region semantics in raw mode, so we treat
202    // `TERMINAL_EMULATOR=JetBrains-JediTerm` the same as
203    // `ATOMCODE_PLAIN=1`. `ATOMCODE_RETAIN=1` overrides the auto-fall-back
204    // for users who'd rather try the retained path.
205    //
206    // The trade-off when force_plain is on: no pinned input box, no
207    // live spinner, no slash-menu palette — but text + commands +
208    // agent flow all work, which is the floor.
209    let force_plain_env = std::env::var("ATOMCODE_PLAIN")
210        .ok()
211        .filter(|v| !v.is_empty())
212        .is_some();
213    let force_retain = std::env::var("ATOMCODE_RETAIN")
214        .ok()
215        .filter(|v| !v.is_empty())
216        .is_some();
217    // `ATOMCODE_ALT=1` is the user-explicit opt-in to the alt-screen
218    // renderer. Phase 5: JediTerm / legacy-conhost auto-detection now
219    // also routes here (was: PlainRenderer). ATOMCODE_PLAIN=1 still
220    // wins over both — it's the informed-user choice for the bare
221    // CI-style baseline.
222    let force_alt_env = std::env::var("ATOMCODE_ALT")
223        .ok()
224        .filter(|v| !v.is_empty())
225        .is_some();
226    let is_jediterm = std::env::var("TERMINAL_EMULATOR")
227        .map(|v| v == "JetBrains-JediTerm")
228        .unwrap_or(false);
229
230    // Legacy Windows console (cmd.exe / classic conhost) detection.
231    // Windows conhost has supported VT processing since the 2016
232    // Anniversary Update — but its DECSTBM scroll-region implementation
233    // diverges from xterm in ways that break our retained renderer:
234    // body rows that scroll out of the region get re-emitted into
235    // scrollback on the next paint, so users see the SAME content
236    // pair-up TWICE when they Page-Up. terminal.rs already names this
237    // for the unicode-symbols fallback; we use it here to route to
238    // alt-screen (DECSTBM-free).
239    //
240    // Distinguishing legacy conhost from modern Windows terminals:
241    //   * Windows Terminal sets `WT_SESSION` (well-behaved, retained OK)
242    //   * VS Code / Hyper / WezTerm / mintty / etc. set `TERM_PROGRAM`
243    //   * Plain cmd.exe / PowerShell-in-conhost set neither → legacy
244    //
245    // Skip when JediTerm is already detected — JetBrains' embedded
246    // terminal on Windows would otherwise match BOTH heuristics
247    // (no TERM_PROGRAM either) and we'd print two hints.
248    let is_legacy_conhost = cfg!(windows)
249        && !is_jediterm
250        && std::env::var("WT_SESSION").is_err()
251        && std::env::var("TERM_PROGRAM").is_err();
252
253    // Phase 5 routing matrix:
254    //   ATOMCODE_PLAIN=1                              → PlainRenderer (user opt-in)
255    //   ATOMCODE_RETAIN=1                             → RetainedRenderer (user opt-in)
256    //   ATOMCODE_ALT=1                                → AltScreenRenderer (user opt-in)
257    //   JediTerm / legacy conhost (no opt-in)         → AltScreenRenderer (auto)
258    //   default tty                                   → RetainedRenderer
259    //
260    // `force_plain` survives only as a route to PlainRenderer when
261    // explicitly asked for via ATOMCODE_PLAIN=1. The auto-detect
262    // path no longer routes there (Phase 4 made plain-on-tty work,
263    // Phase 5 upgrades the auto-fallback to alt-screen so users
264    // get the full UI).
265    let force_plain = force_plain_env;
266    let auto_alt_screen = !force_plain_env && !force_retain && (is_jediterm || is_legacy_conhost);
267
268    // Marker env vars so the event loop can render a one-line hint
269    // explaining what just happened and how to recover. Only set
270    // when the auto-fallback fired — if the user explicitly opted
271    // in via ATOMCODE_PLAIN they already know; lecturing would be
272    // noise.
273    //
274    // The conhost banner used to fire here too (gated on
275    // is_legacy_conhost), but as of v4.22 alt-screen on conhost
276    // covers wheel-scroll + PageUp/Down + ?1006 SGR mouse
277    // coordinates well enough that the wall-of-text hint became
278    // dead weight — users see it once and immediately want it
279    // gone. Removed in favour of the universal `\<Enter>` hint
280    // (kbd_hint block in event_loop) which is one line and
281    // terminal-agnostic.
282    if is_jediterm && !force_retain && !force_plain_env {
283        std::env::set_var("ATOMCODE_JEDITERM_FALLBACK", "1");
284    }
285
286    // Capture whether stdout was a real TTY BEFORE we mutate caps.
287    // PlainRenderer needs this to know whether the kernel will echo
288    // user input (cooked-mode, real TTY) or not (pipe / CI). Used
289    // below when constructing PlainRenderer so the User-line render
290    // doesn't duplicate cooked-mode echoes on JediTerm / conhost /
291    // ATOMCODE_PLAIN=1 force_plain paths.
292    let was_real_tty = caps.tty;
293
294    // `want_alt_screen` is decided AFTER force_plain so a user who
295    // sets both ATOMCODE_PLAIN=1 and ATOMCODE_ALT=1 lands on plain
296    // (informed-choice priority — they explicitly opted into the
297    // bare baseline). Also requires a real TTY: alt-screen on a
298    // pipe / CI sink is meaningless.
299    //
300    // Phase 5: auto-detection (JediTerm / conhost) also lands here,
301    // so JetBrains-IDE / cmd.exe users get the full UI without
302    // setting any env var. Manual `ATOMCODE_RETAIN=1` still bypasses
303    // (lets the curious try retained on those terminals despite the
304    // known DECSTBM issues).
305    let want_alt_screen = (force_alt_env || auto_alt_screen) && !force_plain_env && was_real_tty;
306
307    // When force_plain wins, strip raw-mode-related capabilities so
308    // every downstream branch (TerminalGuard activate, reader spawn,
309    // renderer choice) consistently picks the cooked-mode / Plain
310    // path. `tty=false` also skips Kitty enhancement push and the
311    // startup screen clear (both emit CSI sequences that JediTerm
312    // mishandles, and PlainRenderer doesn't need either). Skip the
313    // mutation when alt-screen is winning — alt-screen needs raw
314    // mode + bracketed paste + tty intact for full UI.
315    if force_plain && !want_alt_screen {
316        caps.raw_mode = false;
317        caps.bracketed_paste = false;
318        caps.tty = false;
319    }
320
321    let (_guard, kbd_enhanced) = TerminalGuard::activate(caps)?;
322
323    // Pick the colour palette now that raw mode is on (OSC 11 detection
324    // requires it — otherwise the response is line-buffered and never
325    // reaches us before timeout).
326    //
327    // - `Light` / `Dark`: explicit, skip detection.
328    // - `Auto`: query the terminal background; fall back to `dark` if
329    //   it doesn't reply within 100ms. Responsive emulators (iTerm2,
330    //   WezTerm, Alacritty, Kitty, Windows Terminal, VSCode integrated)
331    //   reply on first byte well under the budget; non-responsive
332    //   terminals (macOS Terminal.app, Windows conhost, SSH through
333    //   relays that strip OSC) silently default to dark — matches the
334    //   legacy behaviour, never makes things worse.
335    let theme_light = match config.ui.theme {
336        atomcode_core::config::UiTheme::Light => true,
337        atomcode_core::config::UiTheme::Dark => false,
338        atomcode_core::config::UiTheme::Auto => {
339            if caps.colors {
340                crate::terminal_bg::detect_light(
341                    std::time::Duration::from_millis(100),
342                )
343                .unwrap_or(false)
344            } else {
345                false
346            }
347        }
348    };
349    crate::highlight::theme::set_theme_mode(theme_light);
350
351    // If the terminal doesn't support Kitty keyboard protocol (CSI u),
352    // set an env var so the event loop can show a hint on startup.
353    // Shift+Enter won't work for newline insertion; users should use
354    // Alt+Enter or Ctrl+Enter instead.
355    if !kbd_enhanced {
356        std::env::set_var("ATOMCODE_KBD_NOT_ENHANCED", "1");
357    }
358
359    // Pick the inner renderer by terminal capability, then wrap it in
360    // a `TaskRenderer` so all ANSI I/O happens on a dedicated OS thread.
361    // Slow terminals (Mac Terminal.app processing a 4KB footer payload)
362    // no longer block the event loop — the event loop sends `UiLine`s
363    // through a channel and moves on.
364    // TTY → retained-mode Ink-style cell-diff renderer.
365    // Non-TTY (pipe, CI, dumb terminal, force_plain) → PlainRenderer,
366    // which just writes plain text without ANSI cursor positioning.
367    //
368    // `is_plain_renderer` mirrors the predicate that picks PlainRenderer
369    // below — neither alt-screen wanted nor caps.tty means plain. Threaded
370    // into LoopCtx so non-interactive sessions (CI, pipe, dumb TERM) can
371    // skip the OnboardingWizard auto-trigger; the modal would otherwise
372    // try to draw a Cyan-bordered box into a stdout that no human is
373    // watching.
374    let is_plain_renderer = !want_alt_screen && !caps.tty;
375    let inner: Box<dyn Renderer> = if want_alt_screen {
376        // Alt-screen renderer: takes over the alternate screen buffer
377        // (`\x1b[?1049h`) so it can use absolute cursor positioning
378        // without depending on DECSTBM scroll regions. Trade-off:
379        // host terminal's native scrollback is unavailable while the
380        // app runs (in-app PageUp/PageDown ships in Phase 2).
381        // Slow-paint flag controls per-frame cursor hide/show in
382        // alt-screen renderer. JediTerm + legacy conhost process CUP
383        // sequences synchronously and need the hide to avoid a visible
384        // cursor trail through paint_body's per-row CUPs; everywhere
385        // else we leave the cursor visible to avoid the per-frame
386        // toggle reading as flicker on hardware cursors.
387        let slow_paint = is_jediterm || is_legacy_conhost;
388        Box::new(crate::render::alt_screen::AltScreenRenderer::new(
389            caps, slow_paint,
390        ))
391    } else if caps.tty {
392        Box::new(RetainedRenderer::new(caps))
393    } else {
394        // Pass caps + the ORIGINAL tty value so PlainRenderer can:
395        // (a) gate colours / unicode / spinner on caps.{colors,
396        //     unicode_symbols, spinner} (these survive the force_plain
397        //     mutation; JediTerm supports all three, CI / pipe don't);
398        // (b) decide whether to suppress UiLine::User echo based on
399        //     `was_real_tty` — true means the kernel does cooked-mode
400        //     echo for us (so re-rendering would duplicate the line),
401        //     false means we're piping and need to render it ourselves.
402        Box::new(PlainRenderer::with_writer_caps_and_interactive(
403            std::io::BufWriter::new(std::io::stdout()),
404            caps,
405            was_real_tty,
406        ))
407    };
408    let mut renderer: Box<dyn Renderer> = Box::new(TaskRenderer::new(inner));
409
410    // Input thread (only spawn when raw-mode/TTY available; pipe mode
411    // reads stdin directly). `reader_handle` exposes Pause / Resume so
412    // the OAuth login flow (and any future child-process handoff) can
413    // stop us from racing the child for stdin bytes. Pipe mode doesn't
414    // need that — no browser handoff there — so it stays as a plain
415    // JoinHandle held separately.
416    let (input_tx, input_rx) = mpsc::unbounded_channel();
417    let mut reader_handle: Option<reader::ReaderHandle> = None;
418    let mut pipe_reader: Option<std::thread::JoinHandle<()>> = None;
419    if caps.raw_mode {
420        reader_handle = Some(reader::spawn(input_tx.clone()));
421    } else {
422        // For pipe mode, spawn a line-based reader on a blocking thread.
423        pipe_reader = Some(std::thread::spawn(move || {
424            use std::io::BufRead;
425            let stdin = std::io::stdin();
426            let lock = stdin.lock();
427            for line in lock.lines().map_while(Result::ok) {
428                // Synthesize a key-by-key paste so the loop handles it uniformly.
429                if input_tx.send(input::InputEvent::Paste(line)).is_err() {
430                    return;
431                }
432                // Then an Enter key to commit.
433                let enter = crossterm::event::KeyEvent {
434                    code: crossterm::event::KeyCode::Enter,
435                    modifiers: crossterm::event::KeyModifiers::NONE,
436                    kind: crossterm::event::KeyEventKind::Press,
437                    state: crossterm::event::KeyEventState::NONE,
438                };
439                if input_tx.send(input::InputEvent::Key(enter)).is_err() {
440                    return;
441                }
442            }
443            let _ = input_tx.send(input::InputEvent::Eof);
444        }));
445    };
446
447    // `default_path()` now always returns Some (tempdir fallback lives
448    // inside `platform::history_path`), so the explicit else-branch
449    // with a hardcoded Unix path is gone — Windows used to fall here
450    // and then fail to write to `/tmp`.
451    let history = {
452        let path = History::default_path()
453            .unwrap_or_else(crate::platform::history_path);
454        let cache = crate::platform::image_cache_dir();
455        crate::input::history::History::load_with_cache(path, cache)
456    };
457
458    let session_manager = atomcode_core::session::SessionManager::new(&working_dir);
459    // Fresh session by default; `/resume` replaces this on load.
460    let current_session = atomcode_core::session::Session::default_session(working_dir.clone());
461
462    // Passive "new version available" check. Detached — never blocks
463    // startup; on any error returns None silently. On a positive hit
464    // the task (a) stores the version in the shared mutex and (b) sends
465    // a wake pulse so the event loop redraws the status row immediately
466    // instead of waiting for the user's next keystroke.
467    let update_hint = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
468    let (wake_tx, wake_rx) = tokio::sync::mpsc::channel::<()>(1);
469    // Background OAuth poll → event-loop channel. Unbounded so the
470    // poll thread never blocks waiting for the consumer (poll thread
471    // is std::thread, can't `await`). One event per spawned task,
472    // capacity is irrelevant — even an unbounded channel is essentially
473    // empty here.
474    let (oauth_event_tx, oauth_event_rx) =
475        tokio::sync::mpsc::unbounded_channel::<crate::event_loop::oauth_poll::OauthEvent>();
476
477    // Seed the hint from any prior-session staged upgrade so the user
478    // sees the pending status on the very first frame rather than
479    // waiting for the next poll to rediscover it.
480    if let Ok(Some(pending)) = atomcode_core::self_update::read_pending() {
481        if let Ok(mut g) = update_hint.lock() {
482            *g = Some(pending.version);
483        }
484    }
485
486    {
487        let slot = update_hint.clone();
488        let wake = wake_tx.clone();
489        tokio::spawn(async move {
490            let current = format!("v{}", env!("CARGO_PKG_VERSION"));
491            if let Some(latest) = atomcode_core::version_check::check_latest(&current).await {
492                if let Ok(mut g) = slot.lock() {
493                    *g = Some(latest);
494                }
495                let _ = wake.try_send(());
496            }
497        });
498    }
499
500    // NOTE: the in-process deferred-upgrade poll used to live here. It
501    // was moved out into a detached setsid'd subprocess spawned from
502    // `main.rs` (see `spawn_detached_upgrade_prep`). Rationale: the old
503    // task was tied to this tokio runtime, so any Ctrl+C / quick exit
504    // cancelled the download mid-flight and `pending.json` was never
505    // written — making "exit and restart to auto-upgrade" silently do
506    // nothing. The detached subprocess survives parent exits. Running
507    // both would race on `staged_path` (no temp-rename in
508    // `download_and_verify`), so the in-process copy is gone entirely.
509    //
510    // Trade-off: a session that runs through a whole release cycle
511    // (>1 h) won't re-stage the newer version mid-session. We accept
512    // that — `/upgrade` still works manually, and the update hint from
513    // the one-shot `version_check` above still surfaces the availability.
514
515    // Long-lived progress channel for /upgrade. The sender is cloned
516    // into each spawned upgrade task; the receiver stays in the event
517    // loop's select!. Unbounded because progress events are tiny and
518    // we never want the upgrade task to block on UI backpressure.
519    let (upgrade_tx, upgrade_rx) =
520        tokio::sync::mpsc::unbounded_channel::<atomcode_core::self_update::UpgradeEvent>();
521    // Mirror channel for /plugin add|update|install so git latency never
522    // stalls the input loop. See LoopCtx::plugin_job_tx for the rationale.
523    let (plugin_job_tx, plugin_job_rx) =
524        tokio::sync::mpsc::unbounded_channel::<atomcode_core::plugin::PluginJobEvent>();
525
526    // Seed the recent-project-dirs ring from disk and guarantee the
527    // current working dir sits at index 0 so the `/cd` picker always
528    // has at least one entry (the dir the user just launched into).
529    let recent_dirs = {
530        let mut dirs = event_loop::commands::load_recent_dirs();
531        event_loop::commands::push_recent_dir(&mut dirs, working_dir.clone());
532        event_loop::commands::save_recent_dirs(&dirs);
533        dirs
534    };
535
536    let custom_commands = atomcode_core::commands::CustomCommandRegistry::load(&working_dir);
537    // Same Arc the agent loop holds — reload() calls there propagate
538    // here automatically, so the slash menu reflects newly-installed
539    // skills without re-plumbing.
540    let foreground_runtime_id = event_loop::bg_runtime::RuntimeId::new(1);
541    let agent_client = agent_handle.client.clone();
542    let skill_registry = agent_client.skill_registry.clone();
543    let (runtime_event_tx, runtime_event_rx) =
544        tokio::sync::mpsc::unbounded_channel::<event_loop::bg_runtime::RuntimeEvent>();
545    event_loop::bg_runtime::spawn_event_forwarder(
546        foreground_runtime_id,
547        agent_handle.event_rx,
548        runtime_event_tx.clone(),
549    );
550    let bg_manager = event_loop::bg_runtime::BgRuntimeManager::new(
551        current_session.clone(),
552        foreground_runtime_id,
553        agent_client.clone(),
554    );
555
556    let file_index_root = working_dir.clone();
557    let ctx = LoopCtx {
558        config,
559        model_name,
560        agent: agent_client,
561        runtime_factory,
562        bg_manager,
563        foreground_runtime_id,
564        runtime_event_tx,
565        runtime_event_rx,
566        working_dir,
567        previous_dir: None,
568        recent_dirs,
569        history,
570        input_rx,
571        commands: CommandRegistry::builtin(),
572        session_manager,
573        current_session,
574        update_hint,
575        monitor_warning: std::sync::Arc::new(std::sync::Mutex::new(None)),
576        monitor_last_check_at: None,
577        usage_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
578        usage_last_check_at: None,
579        // Seed with whatever's on disk now — any NEWER mtime observed
580        // later means another atomcode process resynced and our drift
581        // warning (if any) is stale.
582        monitor_last_sync_seen: atomcode_core::coding_plan::read_last_sync(),
583        wake_rx,
584        wake_tx: wake_tx.clone(),
585        oauth_event_rx,
586        oauth_event_tx,
587        reader: reader_handle,
588        upgrade_tx,
589        upgrade_rx,
590        plugin_job_tx,
591        plugin_job_rx,
592        pending_new_issue: None,
593        pending_run_codingplan: false,
594        pending_open_provider_wizard: false,
595        mcp_registry,
596        mcp_connect_rx,
597        mcp_reload: None,
598        lsp_connect_rx,
599        telemetry,
600        worktree_original_dir: None,
601        custom_commands,
602        skill_registry,
603        caps,
604        replay_on_start: session_to_continue,
605        file_index: crate::event_loop::file_index::FileIndex::new(file_index_root),
606        current_session_id: None,
607        clipboard_check: std::sync::Arc::new(std::sync::Mutex::new(
608            crate::event_loop::ClipboardCheckState::default(),
609        )),
610        is_plain_renderer,
611    };
612
613    // CodingPlan drift monitor — kick off a startup check if the current
614    // default provider is CodingPlan-managed. Non-CodingPlan users skip
615    // this entirely (no HTTP, no state touched). Check runs in the
616    // background via tokio::spawn → the warning shows up on the next
617    // footer repaint once it resolves.
618    if event_loop::monitor::is_codingplan_provider(&ctx.config.default_provider) {
619        event_loop::monitor::spawn_check(
620            ctx.config.clone(),
621            ctx.model_name.clone(),
622            ctx.monitor_warning.clone(),
623            ctx.wake_tx.clone(),
624        );
625    }
626
627    let result = run_loop(ctx, renderer.as_mut()).await;
628
629    // Must shut down the renderer BEFORE re-exec: the alternate screen is
630    // still active and raw mode is on — if we spawn a child while the
631    // terminal is in that state, the new process inherits a garbled TTY.
632    renderer.shutdown();
633    drop(pipe_reader); // pipe-mode thread exits on next channel send failure
634
635    // If /upgrade succeeded, the live binary has been replaced on disk.
636    // Re-exec into the new version so the user gets a seamless upgrade
637    // without manually restarting. This mirrors the startup-time upgrade
638    // path in main.rs (apply_pending_upgrade → re_exec_self).
639    //
640    // The exe path comes from `ExitReason::UpgradeRestart { exe }`, which
641    // was captured *before* `replace_binary` renamed the running binary.
642    // On Windows, `std::env::current_exe()` would return the renamed
643    // `.atomcode.rolling` path after the swap, so we MUST use this saved
644    // value instead.
645    if let Ok(event_loop::ExitReason::UpgradeRestart { exe }) = &result {
646        // Set env var so the new process can show a one-time "upgraded" banner
647        // on the welcome screen.
648        std::env::set_var("ATOMCODE_UPGRADED_FROM", format!("v{}", env!("CARGO_PKG_VERSION")));
649        match atomcode_core::self_update::re_exec_self(Some(exe)) {
650            Ok(_infallible) => unreachable!("re_exec_self returned Ok"),
651            Err(e) => {
652                // Re-exec failed. The upgrade is on disk, so the user just
653                // needs to start atomcode again — don't treat this as fatal.
654                eprintln!(
655                    "Upgrade applied but re-exec failed ({}). The new version will be used on the next launch.",
656                    e
657                );
658                std::env::remove_var("ATOMCODE_UPGRADED_FROM");
659            }
660        }
661    }
662
663    result.map(|_| ())
664}