1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
// 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>,
) -> 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 for terminals
// where the retained path's DECSTBM scroll region / cursor
// positioning misbehaves (legacy Windows conhost: footer scrolls
// off-screen, content duplicated, viewport drifts upward on each
// redraw).
//
// JetBrains' JediTerm (Android Studio, IntelliJ, PyCharm, GoLand —
// all share the same emulator) doesn't fully honour DECSTBM scroll
// regions or LF-within-region semantics in raw mode, so we treat
// `TERMINAL_EMULATOR=JetBrains-JediTerm` the same as
// `ATOMCODE_PLAIN=1`. `ATOMCODE_RETAIN=1` overrides the auto-fall-back
// for users who'd rather try the retained path.
//
// 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 = std::env::var("ATOMCODE_RETAIN")
.ok()
.filter(|v| !v.is_empty())
.is_some();
// `ATOMCODE_ALT=1` is the user-explicit opt-in to the alt-screen
// renderer. Phase 5: JediTerm / legacy-conhost auto-detection now
// also routes here (was: PlainRenderer). ATOMCODE_PLAIN=1 still
// wins over both — it's the informed-user choice for the bare
// CI-style baseline.
let force_alt_env = std::env::var("ATOMCODE_ALT")
.ok()
.filter(|v| !v.is_empty())
.is_some();
let is_jediterm = std::env::var("TERMINAL_EMULATOR")
.map(|v| v == "JetBrains-JediTerm")
.unwrap_or(false);
// Legacy Windows console (cmd.exe / classic conhost) detection.
// Windows conhost has supported VT processing since the 2016
// Anniversary Update — but its DECSTBM scroll-region implementation
// diverges from xterm in ways that break our retained renderer:
// body rows that scroll out of the region get re-emitted into
// scrollback on the next paint, so users see the SAME content
// pair-up TWICE when they Page-Up. terminal.rs already names this
// for the unicode-symbols fallback; we use it here to route to
// alt-screen (DECSTBM-free).
//
// Distinguishing legacy conhost from modern Windows terminals:
// * Windows Terminal sets `WT_SESSION` (well-behaved, retained OK)
// * VS Code / Hyper / WezTerm / mintty / etc. set `TERM_PROGRAM`
// * Plain cmd.exe / PowerShell-in-conhost set neither → legacy
//
// Skip when JediTerm is already detected — JetBrains' embedded
// terminal on Windows would otherwise match BOTH heuristics
// (no TERM_PROGRAM either) and we'd print two hints.
let is_legacy_conhost = cfg!(windows)
&& !is_jediterm
&& std::env::var("WT_SESSION").is_err()
&& std::env::var("TERM_PROGRAM").is_err();
// Phase 5 routing matrix:
// ATOMCODE_PLAIN=1 → PlainRenderer (user opt-in)
// ATOMCODE_RETAIN=1 → RetainedRenderer (user opt-in)
// ATOMCODE_ALT=1 → AltScreenRenderer (user opt-in)
// JediTerm / legacy conhost (no opt-in) → AltScreenRenderer (auto)
// default tty → RetainedRenderer
//
// `force_plain` survives only as a route to PlainRenderer when
// explicitly asked for via ATOMCODE_PLAIN=1. The auto-detect
// path no longer routes there (Phase 4 made plain-on-tty work,
// Phase 5 upgrades the auto-fallback to alt-screen so users
// get the full UI).
let force_plain = force_plain_env;
let auto_alt_screen = !force_plain_env && !force_retain && (is_jediterm || is_legacy_conhost);
// Marker env vars so the event loop can render a one-line hint
// explaining what just happened and how to recover. Only set
// when the auto-fallback fired — if the user explicitly opted
// in via ATOMCODE_PLAIN they already know; lecturing would be
// noise.
//
// The conhost banner used to fire here too (gated on
// is_legacy_conhost), but as of v4.22 alt-screen on conhost
// covers wheel-scroll + PageUp/Down + ?1006 SGR mouse
// coordinates well enough that the wall-of-text hint became
// dead weight — users see it once and immediately want it
// gone. Removed in favour of the universal `\<Enter>` hint
// (kbd_hint block in event_loop) which is one line and
// terminal-agnostic.
if is_jediterm && !force_retain && !force_plain_env {
std::env::set_var("ATOMCODE_JEDITERM_FALLBACK", "1");
}
// 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 JediTerm / conhost /
// ATOMCODE_PLAIN=1 force_plain paths.
let was_real_tty = caps.tty;
// `want_alt_screen` is decided AFTER force_plain so a user who
// sets both ATOMCODE_PLAIN=1 and ATOMCODE_ALT=1 lands on plain
// (informed-choice priority — they explicitly opted into the
// bare baseline). Also requires a real TTY: alt-screen on a
// pipe / CI sink is meaningless.
//
// Phase 5: auto-detection (JediTerm / conhost) also lands here,
// so JetBrains-IDE / cmd.exe users get the full UI without
// setting any env var. Manual `ATOMCODE_RETAIN=1` still bypasses
// (lets the curious try retained on those terminals despite the
// known DECSTBM issues).
let want_alt_screen = (force_alt_env || auto_alt_screen) && !force_plain_env && was_real_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 that JediTerm
// mishandles, and PlainRenderer doesn't need either). Skip the
// mutation when alt-screen is winning — alt-screen needs raw
// mode + bracketed paste + tty intact for full UI.
if force_plain && !want_alt_screen {
caps.raw_mode = false;
caps.bracketed_paste = false;
caps.tty = false;
}
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 100ms. Responsive emulators (iTerm2,
// WezTerm, Alacritty, Kitty, Windows Terminal, VSCode integrated)
// reply on first byte well under the budget; non-responsive
// terminals (macOS Terminal.app, Windows conhost, SSH through
// relays that strip OSC) silently default to dark — matches the
// legacy behaviour, never makes things worse.
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(100),
)
.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 → retained-mode Ink-style cell-diff renderer.
// Non-TTY (pipe, CI, dumb terminal, force_plain) → PlainRenderer,
// which just writes plain text without ANSI cursor positioning.
//
// `is_plain_renderer` mirrors the predicate that picks PlainRenderer
// below — neither alt-screen wanted nor caps.tty means plain. 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 = !want_alt_screen && !caps.tty;
let inner: Box<dyn Renderer> = if want_alt_screen {
// Alt-screen renderer: takes over the alternate screen buffer
// (`\x1b[?1049h`) so it can use absolute cursor positioning
// without depending on DECSTBM scroll regions. Trade-off:
// host terminal's native scrollback is unavailable while the
// app runs (in-app PageUp/PageDown ships in Phase 2).
// Slow-paint flag controls per-frame cursor hide/show in
// alt-screen renderer. JediTerm + legacy conhost process CUP
// sequences synchronously and need the hide to avoid a visible
// cursor trail through paint_body's per-row CUPs; everywhere
// else we leave the cursor visible to avoid the per-frame
// toggle reading as flicker on hardware cursors.
let slow_paint = is_jediterm || is_legacy_conhost;
Box::new(crate::render::alt_screen::AltScreenRenderer::new(
caps, slow_paint,
))
} else 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; JediTerm supports all three, CI / pipe don't);
// (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(¤t).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();
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)),
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_codingplan: 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,
};
// 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(|_| ())
}