运行时与 TUI 入口
从启动器、重连计划、TTY 交接看 Grok Build 怎样把终端体验做成可恢复运行时。
启动器
入口先准备渲染、内存钩子、崩溃处理和 telemetry,再进入参数解析。关键线索在 sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1456、sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1489、sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1530、sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1597。这说明它把运行环境准备和命令分派分层,而不是直接启动一个交互循环。
▸启动准备
1456fn main() { 1457 xai_grok_pager_minimal::install(); 1458 #[cfg(all(feature = "jemalloc", unix))] 1459 xai_grok_pager::memory_release::install_release_hook(purge_jemalloc_retained_pages); 1460 #[cfg(all(feature = "jemalloc", unix))] 1461 { 1462 xai_grok_pager::memory_trace::install_allocator_stats_provider(jemalloc_allocator_stats); 1463 xai_grok_pager::memory_trace::install_allocator_dump_provider(jemalloc_stats_dump); 1464 } 1465 #[cfg(all(feature = "jemalloc", unix))] 1466 install_heap_profile_hooks(); 1467 if let Some(code) = xai_grok_pager::app::mermaid_worker::maybe_run_render_subprocess() { 1468 std::process::exit(code); 1469 } 1470 xai_grok_pager::memory_trace::start( 1471 xai_grok_shell::util::grok_home::grok_home().join("memtrace"), 1472 ); 1473 raise_fd_limit(); 1474 if let Err(e) = xai_grok_config::validate_requirements() { 1475 eprintln!("Couldn't start Grok: {e}"); 1476 eprintln!(); 1477 eprintln!( 1478 "Update Grok to a version the policy allows, or ask your administrator \ 1479 to fix the managed requirements." 1480 ); 1481 std::process::exit(2); 1482 } 1483 let _sentry_guard = xai_grok_telemetry::sentry::init(xai_grok_telemetry::sentry::Config { 1484 client: "grok-pager", 1485 client_version: PAGER_CLIENT_VERSION, 1486 release: env!("VERSION_WITH_COMMIT"), 1487 disabled: xai_grok_shell::agent::config::is_error_reporting_disabled_sync(), 1488 }); 1489 xai_grok_pager::docs::extract_user_guide_docs(&xai_grok_shell::util::grok_home::grok_home()); 1490 xai_crash_handler::install_terminal_restore_only(); 1491 if xai_grok_shell::util::config::load_crash_handler_enabled_sync() { 1492 let crash_dir = xai_grok_shell::util::grok_home::grok_home().join("crash"); 1493 if let Some(report) = xai_crash_handler::check_previous_crash(&crash_dir) { 1494 eprintln!("Grok crashed during your last session."); 1495 eprintln!(" Signal: {}", report.signal_name); 1496 eprintln!(" Version: {}", report.app_version); 1497 eprintln!(" Report: {}", report.report_path.display()); 1498 eprintln!(); 1499 } 1500 if !xai_crash_handler::install(xai_crash_handler::CrashHandlerConfig { 1501 app_version: env!("VERSION_WITH_COMMIT").to_string(), 1502 crash_dir: crash_dir.clone(), 1503 }) { 1504 eprintln!( 1505 "warning: crash handler enabled but failed to install (check permissions on {})", 1506 crash_dir.display() 1507 ); 1508 } 1509 } 1510 let crashed = xai_grok_shell::active_sessions::collect_crashed().unwrap_or_default(); 1511 if !crashed.is_empty() { 1512 tracing::info!( 1513 count = crashed.len(), 1514 "Found crashed sessions from a previous run" 1515 ); 1516 } 1517 let runtime = tokio::runtime::Builder::new_multi_thread() 1518 .enable_all() 1519 .build() 1520 .unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}")); 1521 let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE); 1522 xai_grok_telemetry::debug_log::flush(); 1523 if let Err(e) = result { 1524 xai_tty_utils::restore_native_stderr(); 1525 eprintln!("Error: {e:#}"); 1526 drop(_sentry_guard); 1527 std::process::exit(1); 1528 }
▸参数解析
1530async fn async_main() -> Result<()> { 1531 let _ = rustls::crypto::ring::default_provider().install_default(); 1532 let mut args = PagerArgs::parse_and_apply_cwd()?; 1533 if let Some(ref mode) = args.compaction_mode { 1534 unsafe { std::env::set_var("GROK_COMPACTION_MODE", mode) }; 1535 } 1536 if let Some(ref detail) = args.compaction_detail { 1537 unsafe { std::env::set_var("GROK_COMPACTION_DETAIL", detail) }; 1538 } 1539 if args.chat() { 1540 unsafe { 1541 std::env::set_var(xai_grok_shell::agent::chat_modes::GROK_CHAT_MODE_ENV, "1"); 1542 } 1543 } 1544 if let Some(ref socket) = args.leader_socket { 1545 unsafe { std::env::set_var(xai_grok_shell::leader::LEADER_SOCKET_ENV, socket) }; 1546 } 1547 if let Some(ref path) = args.debug_file { 1548 unsafe { 1549 std::env::set_var("GROK_DEBUG_LOG", path); 1550 std::env::remove_var("GROK_LOG_FILE"); 1551 } 1552 } 1553 if args.debug || args.debug_file.is_some() { 1554 let set_if_unset = |k: &str, v: &str| { 1555 if std::env::var_os(k).is_none() { 1556 unsafe { std::env::set_var(k, v) }; 1557 } 1558 }; 1559 set_if_unset("GROK_DEBUG_LOG", "1"); 1560 set_if_unset("GROK_HOOKS_LOG", "1"); 1561 } 1562 if let Some(Command::Completions { shell }) = &args.command { 1563 xai_grok_pager::completions_cmd::run(*shell); 1564 return Ok(()); 1565 } 1566 if let Some(Command::Wrap(ref wrap_args)) = args.command { 1567 return xai_grok_pager::wrap_cmd::run(wrap_args); 1568 } 1569 let saved_profile = args.saved_resume_profile(); 1570 let sandbox_profile_arg = match args.startup_sandbox_profile(saved_profile.as_deref()) { 1571 xai_grok_pager::app::cli::SandboxStartup::Apply(profile) => profile, 1572 xai_grok_pager::app::cli::SandboxStartup::Conflict { requested, saved } => { 1573 eprintln!( 1574 "error: cannot resume this session under sandbox profile '{requested}' — \ 1575 it was created with '{saved}'. Omit --sandbox to resume with '{saved}', \ 1576 or start a new session to use '{requested}'." 1577 ); 1578 std::process::exit(1); 1579 } 1580 }; 1581 xai_grok_shell::config::apply_sandbox( 1582 None, 1583 sandbox_profile_arg.as_deref(), 1584 args.cwd.as_deref(), 1585 ); 1586 flag_dashboard_at_startup_if_requested(&mut args)?; 1587 let is_interactive = args.command.is_none() 1588 && args.single.is_none() 1589 && args.prompt_json.is_none() 1590 && args.prompt_file.is_none(); 1591 xai_grok_shell::http::set_client_name(if is_interactive { 1592 xai_grok_workspace::permission::ClientType::GrokPager 1593 } else { 1594 xai_grok_workspace::permission::ClientType::Generic 1595 }); 1596 let update_config = build_update_config(); 1597 if let Some(command) = args.command.take() { 1598 match command {
继续向下看,sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1598 是顶层命令分派起点,sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1700 仍在同一 command surface 内处理子命令;TUI 侧 sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:320 和 sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:411 则继续证明事件循环负责的是长期会话,而不是一次性命令。
重连恢复
TUI 的关键是恢复已有 agent 状态。sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:97 构造 reconnect plan,sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:115 保存 cwd/yolo/auto/cursor,sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:126 汇总恢复结果,sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:153 继续处理 folder trust。读这几段可以看到产品把“恢复”当成一等流程。
▸恢复计划
97fn plan_reconnect_load( 98 agent: &super::agent_view::AgentView, 99 fallback_cwd: &std::path::Path, 100) -> Option<ReconnectLoadPlan> { 101 let session_id = agent.session.session_id.clone()?; 102 let cwd = if agent.session.cwd.as_os_str().is_empty() { 103 fallback_cwd.to_path_buf() 104 } else { 105 agent.session.cwd.clone() 106 }; 107 let yolo = agent.session.is_yolo(); 108 // Set BOTH yoloMode and autoMode explicitly. The leader's capability injection 109 // only fills ABSENT keys, so omitting autoMode here lets a stale launch-time 110 // `ClientCapabilities.auto_mode` re-enable Auto after the user left it (e.g. 111 // Shift+Tab to Ask). Auto is per-agent (symmetric with yolo) — derive it from 112 // this agent's own `auto_mode` so a background tab reconnects with ITS mode, 113 // not the active tab's global `current_ui` mirror. 114 let auto = super::dispatch::effective_auto(yolo, agent.session.is_auto()); 115 let mut meta = serde_json::json!({ "yoloMode": yolo, "autoMode": auto }); 116 if let Some(ref cursor) = agent.last_seen_event_id { 117 meta["cursor"] = serde_json::Value::String(cursor.clone()); 118 } 119 Some(ReconnectLoadPlan { 120 session_id, 121 cwd, 122 meta, 123 }) 124} 125 126/// Resolve the two post-reconnect restore outcomes from the per-agent 127/// `session/load` results. 128/// 129/// - `all_restored` (AND across every reloaded tab, plus `init_ok`) drives the 130/// user-facing toast: it reports whether the WHOLE reconnect came back. 131/// - `active_restored` is per-agent: the ACTIVE tab's OWN reload succeeded. It 132/// gates that tab's post-reconnect queue drain. Gating the drain on 133/// `all_restored` would let one failed background tab strand prompts queued 134/// on a healthy active tab — the drain (`dispatch_drain_queue`) only ever 135/// touches the active agent, so a background failure has no bearing on it. 136/// 137/// `loads` maps each reloaded agent to `(success, running_prompt_id)`; an agent 138/// in `pending_agent_ids` but absent from `loads` is treated as failed 139/// (mirrors the `unwrap_or((false, _))` at the finalize site). 140fn reconnect_restore_outcome( 141 init_ok: bool, 142 pending_agent_ids: &[super::agent::AgentId], 143 loads: &std::collections::HashMap<super::agent::AgentId, (bool, Option<String>)>, 144 active_agent_id: Option<super::agent::AgentId>, 145) -> (bool, bool) { 146 let load_ok = |id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, _)| *ok) }; 147 let all_restored = init_ok && pending_agent_ids.iter().all(load_ok); 148 let active_restored = init_ok 149 && active_agent_id.is_some_and(|aid| pending_agent_ids.contains(&aid) && load_ok(&aid)); 150 (all_restored, active_restored)
▸信任状态
153/// Compute the folder-trust verdict for the session cwd and seed 154/// [`AppView::trust_state`]. Pager-side mirror of the agent's resolve: read the 155/// local store, scan for repo-local code-exec config, and run the pure 156/// [`decide`](xai_grok_workspace::folder_trust::decide) precedence. 157/// 158/// `TrustOutcome::Prompt` (interactive + untrusted + repo configs present) 159/// becomes `TrustState::Pending` (show the question); everything else becomes 160/// `TrustState::Done`. The feature-off fast path (kill-switch / opt-out / 161/// local build) short-circuits before any I/O. 162fn seed_trust_state( 163 app: &mut AppView, 164 remote: Option<&xai_grok_shell::util::config::RemoteSettings>, 165) { 166 use std::io::IsTerminal; 167 use xai_grok_workspace::folder_trust::{ 168 TrustOutcome, decide, decide_inputs_with_interactive, feature_enabled, 169 }; 170 use xai_grok_workspace::trust::workspace_key; 171 172 let feature = feature_enabled(remote); 173 if !feature { 174 app.trust_state = TrustState::Done; 175 return; 176 } 177 178 // The cwd the user launched in == the process cwd == `app.cwd` (set at 179 // construction), matching the `--trust` grant's `std::env::current_dir()`. 180 let cwd = app.cwd.clone(); 181 let key = workspace_key(&cwd); 182 // Reuse the canonical gather (store trust + repo-config scan) but pass the 183 // pager's stdin-only interactivity: the TUI prompts via the rendered 184 // question + crossterm keyboard, NOT stderr (the pager redirects native 185 // stderr at startup, so the engine's `stdin && stderr` would be false here 186 // and the question would never show). TTY stdin => user can answer; 187 // otherwise fail closed (no prompt). 188 let inputs = decide_inputs_with_interactive(&cwd, &key, std::io::stdin().is_terminal()); 189 app.trust_state = match decide(feature, &inputs) { 190 TrustOutcome::Prompt => TrustState::Pending { workspace: key }, 191 TrustOutcome::Trusted | TrustOutcome::Untrusted => TrustState::Done, 192 }; 193}
终端交接
当 agent 需要让子进程接管终端时,事件循环显式暂停渲染并恢复 raw mode/alt screen。sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:195 到 sources/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs:260 是避免 TUI 与 shell 输出互相污染的核心,sources/grok-build/crates/codegen/xai-grok-pager-bin/src/main.rs:1777 又显示 headless 单轮走的是同一启动器之外的独立分支。
▸子进程暂停
195/// Suspend the inline TUI, run a blocking child that takes over the tty 196/// (`$EDITOR`, `$PAGER`, …), then restore. Shared by the editor and transcript 197/// suspend paths so the subtle reader-park + raw-mode + alt-screen handoff lives 198/// in one place. 199/// 200/// The reader thread is parked first so the child (which inherits this tty) 201/// keeps every keystroke instead of racing the reader; on return, buffered 202/// terminal query replies and any pre-park keystroke are drained before the 203/// reader resumes. 204/// 205/// The frame **writer** thread is then drained (bounded) before the child 206/// starts: frames are written to the tty asynchronously, and the frame that 207/// armed this suspend (e.g. minimal's final `/transcript` pump slice) is 208/// typically queued microseconds before we get here. Un-drained, those bytes 209/// race the child's own output — they can land on the child's alternate 210/// screen (so the main screen never receives them; on an inline viewport a 211/// commit's scroll then leaves every following row off by its height) or tear 212/// around the alt-screen switch, printing escape fragments (`[`…) that the 213/// renderer's diff can't see and thus never repairs. 214/// 215/// In minimal mode the physical cursor is probed (`ESC[6n`) right before the 216/// child runs and right after it exits — while the reader thread is still 217/// parked, so the replies can't be stolen. The returned "cursor after the 218/// child, iff it moved" tells the caller whether the child *printed to the 219/// main screen* (cat-style pager: cursor left below its output) or *restored 220/// it* (less-style alt-screen pager: `rmcup` puts the cursor back exactly 221/// where it was): the caller re-anchors the live region below the new output 222/// in the first case and repaints in place in the second. 223fn suspend_for_child( 224 screen_mode: crate::app::ScreenMode, 225 writer_sync: &crate::render::draw::WriterSync, 226 input_paused: &std::sync::atomic::AtomicBool, 227 reader_parked: &std::sync::atomic::AtomicBool, 228 input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crossterm::event::Event>, 229 run_child: impl FnOnce(), 230) -> Option<(u16, u16)> { 231 use std::sync::atomic::Ordering; 232 // Pause the reader thread, then wait for a FRESH park (reader provably out of 233 // crossterm) so the main thread is the sole poll/read caller; bounded so a 234 // dead reader can't hang us. 235 input_paused.store(true, Ordering::Release); 236 reader_parked.store(false, Ordering::Release); 237 let park_deadline = std::time::Instant::now() + Duration::from_millis(500); 238 while !reader_parked.load(Ordering::Acquire) && std::time::Instant::now() < park_deadline { 239 std::thread::sleep(Duration::from_millis(5)); 240 } 241 // Every queued frame must be ON the tty before the child takes it (and, in 242 // fullscreen, before LeaveAlternateScreen below — mirroring teardown's 243 // "no late frame after LeaveAlternateScreen" drain). Bounded like the 244 // reader park so a wedged pty can't hang the suspend. 245 if !writer_sync.wait_drained(Duration::from_millis(750)) { 246 tracing::warn!("suspend: frame writer not drained within 750ms; proceeding"); 247 } 248 // Pre-child cursor probe (minimal only — minimal's startup already proved 249 // this terminal answers CPR). Reader is parked, so the reply is ours. 250 let pre_cursor = screen_mode 251 .is_minimal() 252 .then(|| crossterm::cursor::position().ok()) 253 .flatten(); 254 if screen_mode.is_fullscreen() { 255 xai_grok_shell::util::with_locked_stderr(|stderr| { 256 let _ = crossterm::execute!(stderr, crossterm::terminal::LeaveAlternateScreen); 257 }); 258 } 259 let _ = crossterm::terminal::disable_raw_mode(); 260 run_child();
▸Headless 单轮
1777 let headless_prompt = xai_grok_pager::headless::HeadlessPrompt::from_args( 1778 args.single.as_deref(), 1779 args.prompt_json.as_deref(), 1780 args.prompt_file.as_deref(), 1781 )?; 1782 if let Some(prompt) = headless_prompt { 1783 init_tracing_simple(HEADLESS_ENTRYPOINT); 1784 let _otel_guard = xai_grok_telemetry::otel_layer::otel_guard(); 1785 enforce_minimum_version_or_exit(&update_config).await; 1786 let launch_yolo = xai_grok_shell::util::config::effective_yolo_for_launch( 1787 args.yolo, 1788 args.permission_mode_flag.as_deref(), 1789 None, 1790 ); 1791 if let Some(warning) = launch_yolo.blocked_warning { 1792 eprintln!("grok: {warning}"); 1793 } 1794 let json_schema = args 1795 .json_schema 1796 .as_deref() 1797 .map(xai_grok_pager::headless::parse_json_schema) 1798 .transpose()?; 1799 if json_schema.is_some() { 1800 if args.output_format == xai_grok_pager::headless::OutputFormat::Plain { 1801 args.output_format = xai_grok_pager::headless::OutputFormat::Json; 1802 } 1803 if args.self_verify { 1804 anyhow::bail!( 1805 "--json-schema and --self-verify cannot be used together: \ 1806 verification output would corrupt the structured response" 1807 ); 1808 } 1809 } 1810 return xai_grok_pager::headless::run_single_turn( 1811 prompt, 1812 args.verbatim, 1813 xai_grok_pager::headless::HeadlessOptions { 1814 session_id: args.session_id.clone(), 1815 resume: args.resume_session.or(args.load_session), 1816 cwd: args.cwd, 1817 yolo: launch_yolo.yolo, 1818 trust: args.trust, 1819 output_format: args.output_format, 1820 json_schema, 1821 model: args.model, 1822 rules: args.rules, 1823 system_prompt_override: args.system_prompt_override.clone(), 1824 continue_last_session: args.continue_last_session, 1825 fork_session: args.fork_session,