agcodex_tui/
lib.rs

1// Forbid accidental stdout/stderr writes in the *library* portion of the TUI.
2// The standalone `codex-tui` binary prints a short help message before the
3// alternate‑screen mode starts; that file opts‑out locally via `allow`.
4#![deny(clippy::print_stdout, clippy::print_stderr)]
5#![deny(clippy::disallowed_methods)]
6use agcodex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
7use agcodex_core::config::Config;
8use agcodex_core::config::ConfigOverrides;
9use agcodex_core::config::ConfigToml;
10use agcodex_core::config::find_agcodex_home;
11use agcodex_core::config::load_config_as_toml_with_cli_overrides;
12use agcodex_core::modes::ModeManager;
13use agcodex_core::modes::OperatingMode;
14use agcodex_core::protocol::AskForApproval;
15use agcodex_core::protocol::SandboxPolicy;
16use agcodex_login::AuthMode;
17use agcodex_login::CodexAuth;
18use agcodex_ollama::DEFAULT_OSS_MODEL;
19use agcodex_protocol::config_types::SandboxMode;
20use app::App;
21use std::fs::OpenOptions;
22use std::path::PathBuf;
23use tracing::error;
24use tracing_appender::non_blocking;
25use tracing_subscriber::EnvFilter;
26use tracing_subscriber::prelude::*;
27
28mod app;
29mod app_event;
30mod app_event_sender;
31mod bottom_pane;
32mod chatwidget;
33mod citation_regex;
34mod cli;
35mod common;
36pub mod custom_terminal;
37mod dialogs;
38mod diff_render;
39mod exec_command;
40mod features;
41mod file_search;
42mod get_git_diff;
43mod history_cell;
44pub mod insert_history;
45pub mod live_wrap;
46mod markdown;
47mod markdown_stream;
48mod notification;
49pub mod onboarding;
50mod render;
51mod session_log;
52mod shimmer;
53mod slash_command;
54mod status_indicator_widget;
55mod streaming;
56mod text_formatting;
57mod tui;
58mod user_approval_widget;
59mod widgets;
60
61// Internal vt100-based replay tests live as a separate source file to keep them
62// close to the widget code. Include them in unit tests.
63#[cfg(test)]
64mod chatwidget_stream_tests;
65
66#[cfg(not(debug_assertions))]
67mod updates;
68#[cfg(not(debug_assertions))]
69use color_eyre::owo_colors::OwoColorize;
70
71pub use cli::Cli;
72pub use dialogs::LoadSessionBrowser;
73pub use dialogs::LoadSessionState;
74pub use dialogs::SaveSessionDialog;
75pub use dialogs::SaveSessionState;
76pub use features::HistoryBrowser;
77pub use features::MessageJump;
78pub use features::RoleFilter;
79pub use widgets::SessionBrowser;
80pub use widgets::SessionSwitcher;
81pub use widgets::SessionSwitcherState;
82
83// (tests access modules directly within the crate)
84
85pub async fn run_main(
86    cli: Cli,
87    codex_linux_sandbox_exe: Option<PathBuf>,
88) -> std::io::Result<agcodex_core::protocol::TokenUsage> {
89    let (sandbox_mode, approval_policy) = if cli.full_auto {
90        (
91            Some(SandboxMode::WorkspaceWrite),
92            Some(AskForApproval::OnFailure),
93        )
94    } else if cli.dangerously_bypass_approvals_and_sandbox {
95        (
96            Some(SandboxMode::DangerFullAccess),
97            Some(AskForApproval::Never),
98        )
99    } else {
100        (
101            cli.sandbox_mode.map(Into::<SandboxMode>::into),
102            cli.approval_policy.map(Into::into),
103        )
104    };
105
106    // When using `--oss`, let the bootstrapper pick the model (defaulting to
107    // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
108    // `oss` model provider.
109    let model = if let Some(model) = &cli.model {
110        Some(model.clone())
111    } else if cli.oss {
112        Some(DEFAULT_OSS_MODEL.to_owned())
113    } else {
114        None // No model specified, will use the default.
115    };
116
117    let model_provider_override = if cli.oss {
118        Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned())
119    } else {
120        None
121    };
122
123    // Initialize operating mode from CLI. Default to Build.
124    let operating_mode = match cli.mode.as_deref() {
125        Some(s) => match s.to_lowercase().as_str() {
126            "plan" => OperatingMode::Plan,
127            "review" => OperatingMode::Review,
128            _ => OperatingMode::Build,
129        },
130        None => OperatingMode::Build,
131    };
132    let mode_manager = ModeManager::new(operating_mode);
133
134    // canonicalize the cwd
135    let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
136
137    let overrides = ConfigOverrides {
138        model,
139        approval_policy,
140        sandbox_mode,
141        cwd,
142        model_provider: model_provider_override,
143        config_profile: cli.config_profile.clone(),
144        codex_linux_sandbox_exe,
145        base_instructions: Some(mode_manager.prompt_suffix().to_string()),
146        include_plan_tool: Some(true),
147        include_apply_patch_tool: None,
148        disable_response_storage: cli.oss.then_some(true),
149        show_raw_agent_reasoning: cli.oss.then_some(true),
150    };
151
152    // Parse `-c` overrides from the CLI.
153    let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
154        Ok(v) => v,
155        #[allow(clippy::print_stderr)]
156        Err(e) => {
157            eprintln!("Error parsing -c overrides: {e}");
158            std::process::exit(1);
159        }
160    };
161
162    let mut config = {
163        // Load configuration and support CLI overrides.
164
165        #[allow(clippy::print_stderr)]
166        match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
167            Ok(config) => config,
168            Err(err) => {
169                eprintln!("Error loading configuration: {err}");
170                std::process::exit(1);
171            }
172        }
173    };
174
175    // we load config.toml here to determine project state.
176    #[allow(clippy::print_stderr)]
177    let config_toml = {
178        let codex_home = match find_agcodex_home() {
179            Ok(codex_home) => codex_home,
180            Err(err) => {
181                eprintln!("Error finding codex home: {err}");
182                std::process::exit(1);
183            }
184        };
185
186        match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) {
187            Ok(config_toml) => config_toml,
188            Err(err) => {
189                eprintln!("Error loading config.toml: {err}");
190                std::process::exit(1);
191            }
192        }
193    };
194
195    let should_show_trust_screen = determine_repo_trust_state(
196        &mut config,
197        &config_toml,
198        approval_policy,
199        sandbox_mode,
200        cli.config_profile.clone(),
201    )?;
202
203    let log_dir = agcodex_core::config::log_dir(&config)?;
204    std::fs::create_dir_all(&log_dir)?;
205    // Open (or create) your log file, appending to it.
206    let mut log_file_opts = OpenOptions::new();
207    log_file_opts.create(true).append(true);
208
209    // Ensure the file is only readable and writable by the current user.
210    // Doing the equivalent to `chmod 600` on Windows is quite a bit more code
211    // and requires the Windows API crates, so we can reconsider that when
212    // Codex CLI is officially supported on Windows.
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::OpenOptionsExt;
216        log_file_opts.mode(0o600);
217    }
218
219    let log_file = log_file_opts.open(log_dir.join("agcodex-tui.log"))?;
220
221    // Wrap file in non‑blocking writer.
222    let (non_blocking, _guard) = non_blocking(log_file);
223
224    // use RUST_LOG env var, default to info for codex crates.
225    let env_filter = || {
226        EnvFilter::try_from_default_env()
227            .unwrap_or_else(|_| EnvFilter::new("agcodex_core=info,codex_tui=info"))
228    };
229
230    // Build layered subscriber:
231    let file_layer = tracing_subscriber::fmt::layer()
232        .with_writer(non_blocking)
233        .with_target(false)
234        .with_filter(env_filter());
235
236    if cli.oss {
237        agcodex_ollama::ensure_oss_ready(&config)
238            .await
239            .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
240    }
241
242    let _ = tracing_subscriber::registry().with(file_layer).try_init();
243
244    #[allow(clippy::print_stderr)]
245    #[cfg(not(debug_assertions))]
246    if let Some(latest_version) = updates::get_upgrade_version(&config) {
247        let current_version = env!("CARGO_PKG_VERSION");
248        let exe = std::env::current_exe()?;
249        let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
250
251        eprintln!(
252            "{} {current_version} -> {latest_version}.",
253            "✨⬆️ Update available!".bold().cyan()
254        );
255
256        if managed_by_npm {
257            let npm_cmd = "npm install -g @openai/codex@latest";
258            eprintln!("Run {} to update.", npm_cmd.cyan().on_black());
259        } else if cfg!(target_os = "macos")
260            && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
261        {
262            let brew_cmd = "brew upgrade codex";
263            eprintln!("Run {} to update.", brew_cmd.cyan().on_black());
264        } else {
265            eprintln!(
266                "See {} for the latest releases and installation options.",
267                "https://github.com/openai/codex/releases/latest"
268                    .cyan()
269                    .on_black()
270            );
271        }
272
273        eprintln!("");
274    }
275
276    run_ratatui_app(cli, config, should_show_trust_screen)
277        .map_err(|err| std::io::Error::other(err.to_string()))
278}
279
280fn run_ratatui_app(
281    cli: Cli,
282    config: Config,
283    should_show_trust_screen: bool,
284) -> color_eyre::Result<agcodex_core::protocol::TokenUsage> {
285    color_eyre::install()?;
286
287    // Forward panic reports through tracing so they appear in the UI status
288    // line, but do not swallow the default/color-eyre panic handler.
289    // Chain to the previous hook so users still get a rich panic report
290    // (including backtraces) after we restore the terminal.
291    let prev_hook = std::panic::take_hook();
292    std::panic::set_hook(Box::new(move |info| {
293        tracing::error!("panic: {info}");
294        prev_hook(info);
295    }));
296    let mut terminal = tui::init(&config)?;
297    terminal.clear()?;
298
299    // Initialize high-fidelity session event logging if enabled.
300    session_log::maybe_init(&config);
301
302    let Cli { prompt, images, .. } = cli;
303    let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
304
305    let app_result = app.run(&mut terminal);
306    let usage = app.token_usage();
307
308    restore();
309    // Mark the end of the recorded session.
310    session_log::log_session_end();
311    // ignore error when collecting usage – report underlying error instead
312    app_result.map(|_| usage)
313}
314
315#[expect(
316    clippy::print_stderr,
317    reason = "TUI should no longer be displayed, so we can write to stderr."
318)]
319fn restore() {
320    if let Err(err) = tui::restore() {
321        eprintln!(
322            "failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
323        );
324    }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum LoginStatus {
329    AuthMode(AuthMode),
330    NotAuthenticated,
331}
332
333fn get_login_status(config: &Config) -> LoginStatus {
334    if config.model_provider.requires_openai_auth {
335        // Reading the OpenAI API key is an async operation because it may need
336        // to refresh the token. Block on it.
337        let codex_home = config.codex_home.clone();
338        match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) {
339            Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
340            Ok(None) => LoginStatus::NotAuthenticated,
341            Err(err) => {
342                error!("Failed to read auth.json: {err}");
343                LoginStatus::NotAuthenticated
344            }
345        }
346    } else {
347        LoginStatus::NotAuthenticated
348    }
349}
350
351/// Determine if user has configured a sandbox / approval policy,
352/// or if the current cwd project is trusted, and updates the config
353/// accordingly.
354fn determine_repo_trust_state(
355    config: &mut Config,
356    config_toml: &ConfigToml,
357    approval_policy_overide: Option<AskForApproval>,
358    sandbox_mode_override: Option<SandboxMode>,
359    config_profile_override: Option<String>,
360) -> std::io::Result<bool> {
361    let config_profile = config_toml.get_config_profile(config_profile_override)?;
362
363    if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {
364        // if the user has overridden either approval policy or sandbox mode,
365        // skip the trust flow
366        Ok(false)
367    } else if config_profile.approval_policy.is_some() {
368        // if the user has specified settings in a config profile, skip the trust flow
369        // todo: profile sandbox mode?
370        Ok(false)
371    } else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() {
372        // if the user has specified either approval policy or sandbox mode in config.toml
373        // skip the trust flow
374        Ok(false)
375    } else if config_toml.is_cwd_trusted(&config.cwd) {
376        // if the current cwd project is trusted and no config has been set
377        // skip the trust flow and set the approval policy and sandbox mode
378        config.approval_policy = AskForApproval::OnRequest;
379        config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
380        Ok(false)
381    } else {
382        // if none of the above conditions are met, show the trust screen
383        Ok(true)
384    }
385}