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(¤t).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}