diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2dc720526..a5dfbdc94 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1179,6 +1179,8 @@ dependencies = [ "codex-protocol", "once_cell", "serde", + "tempfile", + "tokio", "toml", ] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index ba3ae1d52..a1272b6e5 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -15,26 +15,74 @@ path = "src/lib.rs" [lints] workspace = true +[features] +default = [] + +# Full feature set - enables all legacy functionality +full = [ + "app-server", + "cloud-tasks", + "login", + "feedback", + "backend-client", + "upstream-updates", + "mcp-server", + "chatgpt", + "responses-api-proxy", + "oss-providers", +] + +# App server functionality +app-server = ["dep:codex-app-server"] + +# Cloud tasks command +cloud-tasks = ["dep:codex-cloud-tasks"] + +# Login/logout commands - propagate to TUI +login = ["dep:codex-login", "codex-tui/login"] + +# Feedback feature - propagate to TUI (legacy OpenAI Sentry feedback) +# Future Nori feedback: https://github.com/tilework-tech/nori-cli/issues +feedback = ["codex-tui/feedback"] + +# Backend client feature - propagate to TUI +backend-client = ["codex-tui/backend-client"] + +# Upstream updates feature - propagate to TUI +upstream-updates = ["codex-tui/upstream-updates"] + +# OSS providers (Ollama, LM Studio) - propagate to TUI and codex-common +oss-providers = ["codex-tui/oss-providers", "codex-common/oss-providers"] + +# MCP server functionality +mcp-server = ["dep:codex-mcp-server", "dep:codex-rmcp-client"] + +# ChatGPT/apply command +chatgpt = ["dep:codex-chatgpt"] + +# Responses API proxy +responses-api-proxy = ["dep:codex-responses-api-proxy"] + [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-acp = { workspace = true } -codex-app-server = { workspace = true } +codex-app-server = { workspace = true, optional = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } -codex-chatgpt = { workspace = true } -codex-cloud-tasks = { path = "../cloud-tasks" } +codex-chatgpt = { workspace = true, optional = true } +codex-cloud-tasks = { path = "../cloud-tasks", optional = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } -codex-login = { workspace = true } -codex-mcp-server = { workspace = true } +codex-login = { workspace = true, optional = true } +codex-mcp-server = { workspace = true, optional = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } -codex-responses-api-proxy = { workspace = true } -codex-rmcp-client = { workspace = true } +codex-responses-api-proxy = { workspace = true, optional = true } +codex-rmcp-client = { workspace = true, optional = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } @@ -62,3 +110,12 @@ assert_matches = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } + +# Integration tests that require the mcp-server feature +[[test]] +name = "mcp_add_remove" +required-features = ["mcp-server"] + +[[test]] +name = "mcp_list" +required-features = ["mcp-server"] diff --git a/codex-rs/cli/docs.md b/codex-rs/cli/docs.md index 759b5ede8..891aa3ac1 100644 --- a/codex-rs/cli/docs.md +++ b/codex-rs/cli/docs.md @@ -10,13 +10,8 @@ The `codex-cli` crate is the main multitool binary that provides the `codex` com This crate is the primary entry point that ties together all other crates: -- **Dispatches to** `codex-tui` for interactive mode (default, no subcommand) -- **Dispatches to** `codex-exec` for `codex exec` non-interactive execution -- **Dispatches to** `codex-mcp-server` for `codex mcp-server` -- **Dispatches to** `codex-app-server` for `codex app-server` -- **Dispatches to** `codex-cloud-tasks` for `codex cloud` browsing -- **Uses** `codex-login` for authentication flows -- **Uses** `codex-chatgpt` for the `codex apply` command +- **Always included:** `codex-tui`, `codex-exec`, `codex-acp`, `codex-core` (minimal build) +- **Optional via features:** `codex-mcp-server`, `codex-app-server`, `codex-cloud-tasks`, `codex-login`, `codex-chatgpt`, `codex-responses-api-proxy` - **Uses** `codex-arg0` for arg0-based dispatch (Linux sandbox embedding) ### Core Implementation @@ -38,20 +33,20 @@ match subcommand { **Subcommands:** -| Subcommand | Alias | Description | -|------------|-------|-------------| -| `exec` | `e` | Run Codex non-interactively | -| `login` | | Manage authentication | -| `logout` | | Remove stored credentials | -| `mcp` | | Manage MCP server configurations | -| `mcp-server` | | Run as MCP server (stdio) | -| `app-server` | | Run app server (JSON-RPC stdio) | -| `resume` | | Resume previous session | -| `apply` | `a` | Apply latest Codex diff to working tree | -| `sandbox` | `debug` | Test sandbox enforcement | -| `cloud` | | Browse Codex Cloud tasks | -| `completion` | | Generate shell completions | -| `features` | | List feature flags | +| Subcommand | Alias | Description | Required Feature | +|------------|-------|-------------|------------------| +| `exec` | `e` | Run Codex non-interactively | (always) | +| `login` | | Manage authentication | `login` | +| `logout` | | Remove stored credentials | `login` | +| `mcp` | | Manage MCP server configurations | `mcp-server` | +| `mcp-server` | | Run as MCP server (stdio) | `mcp-server` | +| `app-server` | | Run app server (JSON-RPC stdio) | `app-server` | +| `resume` | | Resume previous session | (always) | +| `apply` | `a` | Apply latest Codex diff to working tree | `chatgpt` | +| `sandbox` | `debug` | Test sandbox enforcement | (always) | +| `cloud` | | Browse Codex Cloud tasks | `cloud-tasks` | +| `completion` | | Generate shell completions | (always) | +| `features` | | List feature flags | (always) | **Feature Toggles:** @@ -71,6 +66,44 @@ These translate to `-c features.=true/false` config overrides. ### Things to Know +**Cargo Feature Flags (Compile-time):** + +The CLI uses Cargo features to enable optional functionality. By default (`default = []`), only core functionality is included (TUI, exec, ACP). Optional features can be enabled individually or via the `full` meta-feature: + +| Feature | Dependencies | Enables | +|---------|--------------|---------| +| `full` | All features | Complete legacy binary | +| `app-server` | `codex-app-server` | `app-server` subcommand | +| `cloud-tasks` | `codex-cloud-tasks` | `cloud` subcommand | +| `login` | `codex-login`, `codex-tui/login` | `login`/`logout` subcommands + TUI login | +| `feedback` | `codex-tui/feedback` | Sentry feedback in TUI | +| `backend-client` | `codex-tui/backend-client` | Cloud tasks backend client | +| `upstream-updates` | `codex-tui/upstream-updates` | OpenAI update mechanism (vs Nori's) | +| `mcp-server` | `codex-mcp-server`, `codex-rmcp-client` | `mcp`, `mcp-server` subcommands | +| `chatgpt` | `codex-chatgpt` | `apply` subcommand | +| `responses-api-proxy` | `codex-responses-api-proxy` | `responses-api-proxy` subcommand | +| `oss-providers` | `codex-tui/oss-providers`, `codex-common/oss-providers` | Ollama/LM Studio local model support | + +**Feature Propagation to TUI:** + +Several CLI features propagate to the TUI crate for coordinated behavior: +- `login` -> `codex-tui/login`: Enables login screens and `/login` command in TUI +- `feedback` -> `codex-tui/feedback`: Enables Sentry feedback and `/feedback` command +- `backend-client` -> `codex-tui/backend-client`: Enables cloud tasks backend +- `upstream-updates` -> `codex-tui/upstream-updates`: Uses OpenAI update system instead of Nori's +- `oss-providers` -> `codex-tui/oss-providers` -> `codex-common/oss-providers`: Enables Ollama/LM Studio local model support + +Without these features, the TUI uses Nori-specific alternatives (e.g., GitHub Discussions for feedback, GitHub releases for updates). For OSS providers, the `codex-common` crate provides stub implementations that return `None` or errors when the feature is disabled. + +Build examples: +```bash +cargo build -p codex-cli # Minimal (TUI + exec + ACP only, Nori updates) +cargo build -p codex-cli --features full # All functionality (OpenAI-compatible) +cargo build -p codex-cli --features login,mcp-server # Selective +``` + +Feature-gated code uses `#[cfg(feature = "...")]` on imports, enum variants, match arms, and struct definitions in `main.rs`. Integration tests that require specific features use `required-features` in `Cargo.toml` (e.g., MCP tests require `mcp-server`). + **Sandbox Debugging:** The `debug_sandbox` module (in `debug_sandbox/`) provides: diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index e9f60eba7..01954a64f 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,5 +1,6 @@ pub mod debug_sandbox; mod exit_status; +#[cfg(feature = "login")] pub mod login; use clap::Parser; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2109f381a..92ed9232f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,25 +1,34 @@ -use clap::Args; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; use codex_acp::init_file_tracing; use codex_arg0::arg0_dispatch_or_else; +#[cfg(feature = "chatgpt")] use codex_chatgpt::apply_command::ApplyCommand; +#[cfg(feature = "chatgpt")] use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; +#[cfg(feature = "login")] use codex_cli::login::read_api_key_from_stdin; +#[cfg(feature = "login")] use codex_cli::login::run_login_status; +#[cfg(feature = "login")] use codex_cli::login::run_login_with_api_key; +#[cfg(feature = "login")] use codex_cli::login::run_login_with_chatgpt; +#[cfg(feature = "login")] use codex_cli::login::run_login_with_device_code; +#[cfg(feature = "login")] use codex_cli::login::run_logout; +#[cfg(feature = "cloud-tasks")] use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; use codex_execpolicy::ExecPolicyCheckCommand; +#[cfg(feature = "responses-api-proxy")] use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; @@ -28,10 +37,12 @@ use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; +#[cfg(feature = "mcp-server")] mod mcp_cmd; #[cfg(not(windows))] mod wsl_paths; +#[cfg(feature = "mcp-server")] use crate::mcp_cmd::McpCli; use codex_core::config::Config; @@ -74,18 +85,23 @@ enum Subcommand { Exec(ExecCli), /// Manage login. + #[cfg(feature = "login")] Login(LoginCommand), /// Remove stored authentication credentials. + #[cfg(feature = "login")] Logout(LogoutCommand), /// [experimental] Run Codex as an MCP server and manage MCP servers. + #[cfg(feature = "mcp-server")] Mcp(McpCli), /// [experimental] Run the Codex MCP server (stdio transport). + #[cfg(feature = "mcp-server")] McpServer, /// [experimental] Run the app server or related tooling. + #[cfg(feature = "app-server")] AppServer(AppServerCommand), /// Generate shell completion scripts. @@ -100,6 +116,7 @@ enum Subcommand { Execpolicy(ExecpolicyCommand), /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. + #[cfg(feature = "chatgpt")] #[clap(visible_alias = "a")] Apply(ApplyCommand), @@ -107,10 +124,12 @@ enum Subcommand { Resume(ResumeCommand), /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. + #[cfg(feature = "cloud-tasks")] #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), /// Internal: run the responses API proxy. + #[cfg(feature = "responses-api-proxy")] #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), @@ -181,6 +200,7 @@ enum ExecpolicySubcommand { Check(ExecPolicyCheckCommand), } +#[cfg(feature = "login")] #[derive(Debug, Parser)] struct LoginCommand { #[clap(skip)] @@ -216,18 +236,21 @@ struct LoginCommand { action: Option, } +#[cfg(feature = "login")] #[derive(Debug, clap::Subcommand)] enum LoginSubcommand { /// Show login status. Status, } +#[cfg(feature = "login")] #[derive(Debug, Parser)] struct LogoutCommand { #[clap(skip)] config_overrides: CliConfigOverrides, } +#[cfg(feature = "app-server")] #[derive(Debug, Parser)] struct AppServerCommand { /// Omit to run the app server; specify a subcommand for tooling. @@ -235,6 +258,7 @@ struct AppServerCommand { subcommand: Option, } +#[cfg(feature = "app-server")] #[derive(Debug, clap::Subcommand)] enum AppServerSubcommand { /// [experimental] Generate TypeScript bindings for the app server protocol. @@ -244,7 +268,8 @@ enum AppServerSubcommand { GenerateJsonSchema(GenerateJsonSchemaCommand), } -#[derive(Debug, Args)] +#[cfg(feature = "app-server")] +#[derive(Debug, clap::Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written #[arg(short = 'o', long = "out", value_name = "DIR")] @@ -255,7 +280,8 @@ struct GenerateTsCommand { prettier: Option, } -#[derive(Debug, Args)] +#[cfg(feature = "app-server")] +#[derive(Debug, clap::Args)] struct GenerateJsonSchemaCommand { /// Output directory where the schema bundle will be written #[arg(short = 'o', long = "out", value_name = "DIR")] @@ -458,14 +484,17 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } + #[cfg(feature = "mcp-server")] Some(Subcommand::McpServer) => { codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; } + #[cfg(feature = "mcp-server")] Some(Subcommand::Mcp(mut mcp_cli)) => { // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; } + #[cfg(feature = "app-server")] Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand { None => { codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; @@ -497,6 +526,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } + #[cfg(feature = "login")] Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( &mut login_cli.config_overrides, @@ -528,6 +558,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } } } + #[cfg(feature = "login")] Some(Subcommand::Logout(mut logout_cli)) => { prepend_config_flags( &mut logout_cli.config_overrides, @@ -538,6 +569,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Completion(completion_cli)) => { print_completion(completion_cli); } + #[cfg(feature = "cloud-tasks")] Some(Subcommand::Cloud(mut cloud_cli)) => { prepend_config_flags( &mut cloud_cli.config_overrides, @@ -583,6 +615,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?, }, + #[cfg(feature = "chatgpt")] Some(Subcommand::Apply(mut apply_cli)) => { prepend_config_flags( &mut apply_cli.config_overrides, @@ -590,6 +623,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); run_apply_command(apply_cli, None).await?; } + #[cfg(feature = "responses-api-proxy")] Some(Subcommand::ResponsesApiProxy(args)) => { tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 377d05448..99b2ab681 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -11,15 +11,22 @@ workspace = true clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } -codex-lmstudio = { workspace = true } -codex-ollama = { workspace = true } +codex-lmstudio = { workspace = true, optional = true } +codex-ollama = { workspace = true, optional = true } codex-protocol = { workspace = true } once_cell = { workspace = true } serde = { workspace = true, optional = true } toml = { workspace = true, optional = true } [features] +default = [] # Separate feature so that `clap` is not a mandatory dependency. cli = ["clap", "serde", "toml"] elapsed = [] sandbox_summary = [] +# OSS providers (Ollama, LM Studio) for local model support +oss-providers = ["dep:codex-ollama", "dep:codex-lmstudio"] + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/codex-rs/common/docs.md b/codex-rs/common/docs.md index 134b6fa38..bd2c5c61c 100644 --- a/codex-rs/common/docs.md +++ b/codex-rs/common/docs.md @@ -29,7 +29,7 @@ Common is a utility dependency for TUI, exec, and CLI: | `fuzzy_match` | always | Nucleo-based fuzzy matching | | `model_presets` | always | Available model definitions | | `approval_presets` | always | Approval + sandbox combinations | -| `oss` | always | OSS provider utilities | +| `oss` | always (stubs without `oss-providers`) | OSS provider utilities | | `elapsed` | `elapsed` | Duration formatting | ### Things to Know @@ -68,6 +68,12 @@ The `oss` module handles: - Default model selection per provider - Provider health verification (`ensure_oss_provider_ready()`) +The module uses conditional compilation based on the `oss-providers` feature: +- **With feature enabled:** Full provider support via `codex-ollama` and `codex-lmstudio` crates +- **With feature disabled:** Stub implementations that return `None` from `get_default_model_for_oss_provider()` and errors from `ensure_oss_provider_ready()` for known providers + +This follows the crate's pattern of providing API-compatible stubs when optional functionality is disabled. + **Format Env Display:** `format_env_display` provides utilities for formatting environment variables in status displays. diff --git a/codex-rs/common/src/oss.rs b/codex-rs/common/src/oss.rs index b2f511e47..1aceafa6e 100644 --- a/codex-rs/common/src/oss.rs +++ b/codex-rs/common/src/oss.rs @@ -1,10 +1,17 @@ //! OSS provider utilities shared between TUI and exec. +//! +//! When the `oss-providers` feature is enabled, this module provides full support for +//! Ollama and LM Studio providers. When disabled, stub implementations are provided +//! that return `None` or errors, matching the behavior when providers are unavailable. use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::config::Config; /// Returns the default model for a given OSS provider. +/// +/// When `oss-providers` feature is disabled, always returns `None`. +#[cfg(feature = "oss-providers")] pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> { match provider_id { LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL), @@ -13,7 +20,18 @@ pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static } } +/// Returns the default model for a given OSS provider. +/// +/// Stub implementation when `oss-providers` feature is disabled - always returns `None`. +#[cfg(not(feature = "oss-providers"))] +pub fn get_default_model_for_oss_provider(_provider_id: &str) -> Option<&'static str> { + None +} + /// Ensures the specified OSS provider is ready (models downloaded, service reachable). +/// +/// When `oss-providers` feature is disabled, returns an error for known providers. +#[cfg(feature = "oss-providers")] pub async fn ensure_oss_provider_ready( provider_id: &str, config: &Config, @@ -36,16 +54,37 @@ pub async fn ensure_oss_provider_ready( Ok(()) } +/// Ensures the specified OSS provider is ready (models downloaded, service reachable). +/// +/// Stub implementation when `oss-providers` feature is disabled - returns error for known providers. +#[cfg(not(feature = "oss-providers"))] +pub async fn ensure_oss_provider_ready( + provider_id: &str, + _config: &Config, +) -> Result<(), std::io::Error> { + match provider_id { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Err(std::io::Error::other( + "OSS providers are not available in this build (oss-providers feature disabled)", + )), + _ => { + // Unknown provider, skip setup + Ok(()) + } + } +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "oss-providers")] #[test] fn test_get_default_model_for_provider_lmstudio() { let result = get_default_model_for_oss_provider(LMSTUDIO_OSS_PROVIDER_ID); assert_eq!(result, Some(codex_lmstudio::DEFAULT_OSS_MODEL)); } + #[cfg(feature = "oss-providers")] #[test] fn test_get_default_model_for_provider_ollama() { let result = get_default_model_for_oss_provider(OLLAMA_OSS_PROVIDER_ID); @@ -57,4 +96,45 @@ mod tests { let result = get_default_model_for_oss_provider("unknown-provider"); assert_eq!(result, None); } + + /// Test that stub returns None for known providers when feature is disabled. + #[cfg(not(feature = "oss-providers"))] + #[test] + fn test_get_default_model_stub_returns_none() { + assert_eq!( + get_default_model_for_oss_provider(LMSTUDIO_OSS_PROVIDER_ID), + None + ); + assert_eq!( + get_default_model_for_oss_provider(OLLAMA_OSS_PROVIDER_ID), + None + ); + } + + /// Test that ensure_oss_provider_ready returns error for known providers when disabled. + #[cfg(not(feature = "oss-providers"))] + #[tokio::test] + async fn test_ensure_oss_provider_ready_stub_returns_error() { + use codex_core::config::Config; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let config = Config::load_from_base_config_with_overrides( + codex_core::config::ConfigToml::default(), + codex_core::config::ConfigOverrides::default(), + temp_dir.path().to_path_buf(), + ) + .unwrap(); + + let result = ensure_oss_provider_ready(LMSTUDIO_OSS_PROVIDER_ID, &config).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not available")); + + let result = ensure_oss_provider_ready(OLLAMA_OSS_PROVIDER_ID, &config).await; + assert!(result.is_err()); + + // Unknown provider should still succeed + let result = ensure_oss_provider_ready("unknown-provider", &config).await; + assert!(result.is_ok()); + } } diff --git a/codex-rs/tui-pty-e2e/tests/snapshots/startup__startup_welcome_dimensions_40x120.snap b/codex-rs/tui-pty-e2e/tests/snapshots/startup__startup_welcome_dimensions_40x120.snap index bd6a9d5f8..1c4556637 100644 --- a/codex-rs/tui-pty-e2e/tests/snapshots/startup__startup_welcome_dimensions_40x120.snap +++ b/codex-rs/tui-pty-e2e/tests/snapshots/startup__startup_welcome_dimensions_40x120.snap @@ -2,6 +2,9 @@ source: tui-pty-e2e/tests/startup.rs expression: normalize_for_input_snapshot(contents) --- +■ Operation 'ListCustomPrompts' is not supported in ACP mode + + › [DEFAULT_PROMPT] 100% context left · ? for shortcuts diff --git a/codex-rs/tui-pty-e2e/tests/snapshots/streaming__cancelled_stream.snap b/codex-rs/tui-pty-e2e/tests/snapshots/streaming__cancelled_stream.snap index 33adc8cc6..db425372a 100644 --- a/codex-rs/tui-pty-e2e/tests/snapshots/streaming__cancelled_stream.snap +++ b/codex-rs/tui-pty-e2e/tests/snapshots/streaming__cancelled_stream.snap @@ -18,4 +18,4 @@ went wrong? Hit `/feedback` to report the issue. › [DEFAULT_PROMPT] - 100% context left · ? for shortcuts + ctrl + c again to quit diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b13e09542..cbbe1d87d 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -13,11 +13,32 @@ name = "codex_tui" path = "src/lib.rs" [features] +default = [] + +# Full feature set - enables all upstream/legacy functionality +full = ["login", "feedback", "backend-client", "upstream-updates", "oss-providers"] + # Enable vt100-based tests (emulator) when running with `--features vt100-tests`. vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] +# ChatGPT/API login functionality +login = ["dep:codex-login"] + +# Feedback to Sentry (legacy OpenAI feedback system) +# Future Nori feedback: https://github.com/tilework-tech/nori-cli/issues +feedback = ["dep:codex-feedback"] + +# Backend client for cloud tasks +backend-client = ["dep:codex-backend-client"] + +# Upstream (OpenAI) update checking +upstream-updates = [] + +# OSS providers (Ollama, LM Studio) - forwarded to codex-common +oss-providers = ["codex-common/oss-providers"] + [lints] workspace = true @@ -31,16 +52,16 @@ codex-acp = { workspace = true } codex-ansi-escape = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } -codex-backend-client = { workspace = true } +codex-backend-client = { workspace = true, optional = true } codex-common = { workspace = true, features = [ "cli", "elapsed", "sandbox_summary", ] } codex-core = { workspace = true } -codex-feedback = { workspace = true } +codex-feedback = { workspace = true, optional = true } codex-file-search = { workspace = true } -codex-login = { workspace = true } +codex-login = { workspace = true, optional = true } codex-protocol = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diff --git a/codex-rs/tui/docs.md b/codex-rs/tui/docs.md index c14ba2268..c189c1401 100644 --- a/codex-rs/tui/docs.md +++ b/codex-rs/tui/docs.md @@ -14,9 +14,9 @@ TUI is one of the primary entry points, invoked when running `codex` without a s - **Depends on** `codex-acp` for ACP agent backend (alternative to HTTP-based LLM providers) - **Depends on** `codex-common` for CLI argument parsing and shared utilities - **Uses** `codex-protocol` types for events and messages -- **Integrates** `codex-feedback` for tracing/feedback collection +- **Optionally integrates** `codex-feedback`, `codex-login`, `codex-backend-client` via feature flags -The `cli/` crate's `main.rs` dispatches to `codex_tui::run_main()` for interactive mode. +The `cli/` crate's `main.rs` dispatches to `codex_tui::run_main()` for interactive mode. Feature flags propagate from CLI to TUI for coordinated modular builds. ### Core Implementation @@ -84,7 +84,7 @@ In `spawn_acp_agent()`, the main task must drop its `Arc` reference **Onboarding:** The `onboarding/` module handles first-run experience: -- Login screen (ChatGPT OAuth or API key) +- Login screen (ChatGPT OAuth or API key) - requires `login` feature - Trust screen (directory permission settings) - Windows WSL setup instructions @@ -95,6 +95,43 @@ The `onboarding/` module handles first-run experience: ### Things to Know +**Feature Flags Architecture:** + +The TUI crate uses Cargo feature flags to enable modular builds with two primary modes: + +| Feature | Optional Dep | Description | +|---------|-------------|-------------| +| `full` | - | Meta-feature enabling all optional features | +| `login` | `codex-login` | ChatGPT/API login functionality | +| `feedback` | `codex-feedback` | Sentry feedback integration | +| `backend-client` | `codex-backend-client` | Cloud tasks backend client | +| `upstream-updates` | - | OpenAI/Codex update checking mechanism | +| `oss-providers` | `codex-common/oss-providers` | Ollama/LM Studio local model support | + +Feature gating patterns: +- Import gating: `#[cfg(feature = "backend-client")] use codex_backend_client::Client` +- Struct field gating: `#[cfg(feature = "feedback")] feedback: CodexFeedback` +- Function parameter gating: `#[cfg(feature = "feedback")] feedback: CodexFeedback` in `App::run()` +- Enum variant gating: `AppEvent::Feedback` only exists with `feedback` feature +- Compatibility module pattern: `feedback_compat.rs` provides stub types when `feedback` feature is disabled + +**Feedback Compatibility Layer:** + +The `feedback_compat.rs` module provides API-compatible types when the `feedback` feature is disabled: +- **With `feedback` enabled:** Re-exports `CodexFeedback` and `CodexLogSnapshot` from `codex_feedback` +- **With `feedback` disabled:** Provides stub implementations with no-op behavior (e.g., `upload_feedback()` returns `Ok(())`, `make_writer()` returns a writer that discards output) + +This pattern allows TUI code to use feedback types unconditionally without `#[cfg]` attributes at every call site. The stub structure is designed as a placeholder for future Nori-specific feedback functionality. + +**Update System Selection:** + +The update checking system is selected at compile time via `upstream-updates`: +- With `upstream-updates`: Uses `update_action.rs`, `updates.rs`, `update_prompt.rs` from `@/codex-rs/tui/src/` +- Without `upstream-updates`: Uses Nori-specific versions from `@/codex-rs/tui/src/nori/` +- Re-exports in `lib.rs` provide unified access: `pub mod update_action` re-exports from either location + +Update modules are only compiled in release builds (`#[cfg(not(debug_assertions))]`) to avoid unnecessary checks during development. + **Rendering Patterns:** The crate uses Ratatui's `Stylize` trait for concise styling: diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2eaba17eb..619ef9bc2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -219,7 +219,8 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, - pub(crate) feedback: codex_feedback::CodexFeedback, + #[cfg(feature = "feedback")] + pub(crate) feedback: crate::feedback_compat::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -253,7 +254,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, - feedback: codex_feedback::CodexFeedback, + #[cfg(feature = "feedback")] feedback: crate::feedback_compat::CodexFeedback, ) -> Result { use tokio_stream::StreamExt; @@ -298,6 +299,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + #[cfg(feature = "feedback")] feedback: feedback.clone(), expected_model: None, // No filtering for fresh sessions }; @@ -322,6 +324,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + #[cfg(feature = "feedback")] feedback: feedback.clone(), expected_model: None, // No filtering for resumed sessions }; @@ -354,6 +357,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + #[cfg(feature = "feedback")] feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, @@ -477,6 +481,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + #[cfg(feature = "feedback")] feedback: self.feedback.clone(), expected_model: None, // No filtering for /new command }; @@ -611,12 +616,14 @@ impl App { failed_scan, ); } + #[cfg(feature = "feedback")] AppEvent::OpenFeedbackNote { category, include_logs, } => { self.chat_widget.open_feedback_note(category, include_logs); } + #[cfg(feature = "feedback")] AppEvent::OpenFeedbackConsent { category } => { self.chat_widget.open_feedback_consent(category); } @@ -947,6 +954,7 @@ impl App { initial_images: image_paths, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + #[cfg(feature = "feedback")] feedback: self.feedback.clone(), expected_model: Some(model_name.clone()), }; @@ -1140,7 +1148,8 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), - feedback: codex_feedback::CodexFeedback::new(), + #[cfg(feature = "feedback")] + feedback: crate::feedback_compat::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, @@ -1178,7 +1187,8 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), - feedback: codex_feedback::CodexFeedback::new(), + #[cfg(feature = "feedback")] + feedback: crate::feedback_compat::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 910b0bb8e..e7fe7c473 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -346,6 +346,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + #[cfg(feature = "feedback")] feedback: self.feedback.clone(), expected_model: None, // No filtering for backtracked conversations }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index caec1a780..8fcf65627 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -157,12 +157,14 @@ pub(crate) enum AppEvent { FullScreenApprovalRequest(ApprovalRequest), /// Open the feedback note entry overlay after the user selects a category. + #[cfg(feature = "feedback")] OpenFeedbackNote { category: FeedbackCategory, include_logs: bool, }, /// Open the upload consent popup for feedback after selecting a category. + #[cfg(feature = "feedback")] OpenFeedbackConsent { category: FeedbackCategory, }, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index c563ab8e9..ae0d6a624 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -34,7 +34,7 @@ const BASE_BUG_ISSUE_URL: &str = /// both logs and rollout with classification + metadata. pub(crate) struct FeedbackNoteView { category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, + snapshot: crate::feedback_compat::CodexLogSnapshot, rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -48,7 +48,7 @@ pub(crate) struct FeedbackNoteView { impl FeedbackNoteView { pub(crate) fn new( category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, + snapshot: crate::feedback_compat::CodexLogSnapshot, rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -507,7 +507,7 @@ mod tests { fn make_view(category: FeedbackCategory) -> FeedbackNoteView { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); + let snapshot = crate::feedback_compat::CodexFeedback::new().snapshot(None); FeedbackNoteView::new(category, snapshot, None, tx, true) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5dbfb210b..229995747 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,8 +28,11 @@ mod footer; mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; +#[cfg(feature = "feedback")] mod feedback_view; +#[cfg(feature = "feedback")] pub(crate) use feedback_view::feedback_selection_params; +#[cfg(feature = "feedback")] pub(crate) use feedback_view::feedback_upload_consent_params; mod paste_burst; pub mod popup_consts; @@ -37,6 +40,7 @@ mod queued_user_messages; mod scroll_state; mod selection_popup_common; mod textarea; +#[cfg(feature = "feedback")] pub(crate) use feedback_view::FeedbackNoteView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8ea7f7421..1992fd04b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::Duration; use codex_app_server_protocol::AuthMode; +#[cfg(feature = "backend-client")] use codex_backend_client::Client as BackendClient; use codex_core::config::Config; use codex_core::config::types::Notifications; @@ -255,7 +256,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, - pub(crate) feedback: codex_feedback::CodexFeedback, + #[cfg(feature = "feedback")] + pub(crate) feedback: crate::feedback_compat::CodexFeedback, /// Expected model name for this widget. When set, events from other models /// (e.g., from a previous agent) are ignored until SessionConfigured arrives /// with a matching model. This prevents race conditions when switching agents. @@ -321,7 +323,8 @@ pub(crate) struct ChatWidget { last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback - feedback: codex_feedback::CodexFeedback, + #[cfg(feature = "feedback")] + feedback: crate::feedback_compat::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, // Tracks incomplete ExecCells that were flushed before completion. @@ -419,6 +422,7 @@ impl ChatWidget { } } + #[cfg(feature = "feedback")] pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -442,6 +446,7 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(feature = "feedback")] pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), @@ -1249,6 +1254,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + #[cfg(feature = "feedback")] feedback, expected_model, } = common; @@ -1302,6 +1308,7 @@ impl ChatWidget { pre_review_token_info: None, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + #[cfg(feature = "feedback")] feedback, current_rollout_path: None, pending_exec_cells: PendingExecCellTracker::new(), @@ -1329,6 +1336,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + #[cfg(feature = "feedback")] feedback, expected_model, } = common; @@ -1384,6 +1392,7 @@ impl ChatWidget { pre_review_token_info: None, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + #[cfg(feature = "feedback")] feedback, current_rollout_path: None, pending_exec_cells: PendingExecCellTracker::new(), @@ -1506,6 +1515,7 @@ impl ChatWidget { return; } match cmd { + #[cfg(feature = "feedback")] SlashCommand::Feedback => { // Step 1: pick a category (UI built in feedback_view) let params = @@ -1513,6 +1523,12 @@ impl ChatWidget { self.bottom_pane.show_selection_view(params); self.request_redraw(); } + #[cfg(not(feature = "feedback"))] + SlashCommand::Feedback => { + // Show Nori-specific feedback message instead + use crate::nori::feedback; + self.add_info_message(feedback::feedback_message().to_string(), None); + } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } @@ -2087,6 +2103,7 @@ impl ChatWidget { } } + #[cfg(feature = "backend-client")] fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); @@ -2114,6 +2131,11 @@ impl ChatWidget { self.rate_limit_poller = Some(handle); } + #[cfg(not(feature = "backend-client"))] + fn prefetch_rate_limits(&mut self) { + // Rate limit prefetching requires backend-client feature + } + fn lower_cost_preset(&self) -> Option { let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); builtin_model_presets(auth_mode) @@ -3358,6 +3380,7 @@ fn extract_first_bold(s: &str) -> Option { None } +#[cfg(feature = "backend-client")] async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { match BackendClient::from_auth(base_url, &auth).await { Ok(client) => match client.get_rate_limits().await { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 203cd2573..c1eba3c73 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -313,7 +313,8 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, - feedback: codex_feedback::CodexFeedback::new(), + #[cfg(feature = "feedback")] + feedback: crate::feedback_compat::CodexFeedback::new(), expected_model: None, }; let mut w = ChatWidget::new(init, conversation_manager); @@ -376,7 +377,8 @@ fn make_chatwidget_manual() -> ( pre_review_token_info: None, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), - feedback: codex_feedback::CodexFeedback::new(), + #[cfg(feature = "feedback")] + feedback: crate::feedback_compat::CodexFeedback::new(), current_rollout_path: None, pending_exec_cells: PendingExecCellTracker::new(), pending_agent: None, @@ -1747,6 +1749,7 @@ fn single_reasoning_option_skips_selection() { ); } +#[cfg(feature = "feedback")] #[test] fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1758,6 +1761,7 @@ fn feedback_selection_popup_snapshot() { assert_snapshot!("feedback_selection_popup", popup); } +#[cfg(feature = "feedback")] #[test] fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/feedback_compat.rs b/codex-rs/tui/src/feedback_compat.rs new file mode 100644 index 000000000..5a72ba6ed --- /dev/null +++ b/codex-rs/tui/src/feedback_compat.rs @@ -0,0 +1,134 @@ +//! Compatibility layer for feedback functionality. +//! +//! When the `feedback` feature is enabled, this module re-exports types from `codex_feedback`. +//! When disabled, it provides stub implementations that compile but do nothing. +//! +//! ## Future Nori Feedback Integration +//! +//! This stub structure provides a placeholder for future Nori-specific feedback functionality. +//! When implementing Nori feedback: +//! 1. Create a new feature flag (e.g., `nori-feedback`) +//! 2. Add Nori-specific feedback implementation alongside or replacing the stub +//! 3. Track progress at: https://github.com/tilework-tech/nori-cli/issues + +#[cfg(feature = "feedback")] +pub use codex_feedback::CodexFeedback; +#[cfg(feature = "feedback")] +pub use codex_feedback::CodexLogSnapshot; + +#[cfg(not(feature = "feedback"))] +mod stub { + use std::io::Write; + + /// Stub implementation of CodexFeedback when feedback feature is disabled. + #[derive(Clone, Default)] + pub struct CodexFeedback; + + impl CodexFeedback { + pub fn new() -> Self { + Self + } + + pub fn make_writer(&self) -> impl Fn() -> StubWriter + Send + Sync + 'static { + || StubWriter + } + + pub fn snapshot( + &self, + _session_id: Option, + ) -> CodexLogSnapshot { + CodexLogSnapshot { + thread_id: String::new(), + } + } + } + + /// Stub writer that discards all output. + pub struct StubWriter; + + impl Write for StubWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Stub implementation of CodexLogSnapshot when feedback feature is disabled. + #[derive(Clone, Default)] + pub struct CodexLogSnapshot { + /// Stub thread ID field (always empty when feedback disabled). + pub thread_id: String, + } + + impl CodexLogSnapshot { + /// Stub upload_feedback that does nothing when feedback feature is disabled. + #[allow(unused_variables)] + pub fn upload_feedback( + &self, + classification: &str, + reason: Option<&str>, + include_logs: bool, + rollout_path: Option<&std::path::Path>, + session_source: Option, + ) -> anyhow::Result<()> { + // No-op when feedback is disabled + Ok(()) + } + } +} + +#[cfg(not(feature = "feedback"))] +pub use stub::CodexFeedback; +#[cfg(not(feature = "feedback"))] +pub use stub::CodexLogSnapshot; + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that CodexFeedback can be instantiated and used without panicking. + #[test] + fn feedback_can_be_created() { + let feedback = CodexFeedback::new(); + let _writer_fn = feedback.make_writer(); + let _snapshot = feedback.snapshot(None); + } + + /// Test that stub returns empty thread_id when feedback is disabled. + #[cfg(not(feature = "feedback"))] + #[test] + fn stub_snapshot_has_empty_thread_id() { + let feedback = CodexFeedback::new(); + let snapshot = feedback.snapshot(None); + assert!( + snapshot.thread_id.is_empty(), + "Stub should return empty thread_id" + ); + } + + /// Test that stub upload_feedback returns Ok when feedback is disabled. + #[cfg(not(feature = "feedback"))] + #[test] + fn stub_upload_feedback_returns_ok() { + let feedback = CodexFeedback::new(); + let snapshot = feedback.snapshot(None); + let result = snapshot.upload_feedback("test", Some("reason"), false, None, None); + assert!(result.is_ok(), "Stub should always return Ok"); + } + + /// Test that the stub writer accepts writes without error. + #[cfg(not(feature = "feedback"))] + #[test] + fn stub_writer_accepts_writes() { + use std::io::Write; + let feedback = CodexFeedback::new(); + let writer_fn = feedback.make_writer(); + let mut writer = writer_fn(); + let result = writer.write(b"test data"); + assert_eq!(result.unwrap(), 9); + assert!(writer.flush().is_ok()); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e3d339dec..095ea5300 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,6 +47,9 @@ pub mod custom_terminal; mod diff_render; mod exec_cell; mod exec_command; +// Feedback compatibility layer - provides stubs when feedback feature is disabled +// See feedback_compat.rs for future Nori feedback integration notes +mod feedback_compat; mod file_search; mod frames; mod get_git_diff; @@ -77,9 +80,40 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; + +// Upstream OpenAI/Codex update modules (only included with upstream-updates feature) +// The update_action module is available in all builds for the UpdateAction type +// The update_prompt and updates modules are only for release builds +#[cfg(feature = "upstream-updates")] pub mod update_action; +#[cfg(all(not(debug_assertions), feature = "upstream-updates"))] mod update_prompt; +#[cfg(all(not(debug_assertions), feature = "upstream-updates"))] mod updates; + +// Nori-specific update modules (only when NOT using upstream-updates) +// Re-export as pub mod for external access to UpdateAction type +#[cfg(not(feature = "upstream-updates"))] +pub mod update_action { + pub use super::nori::update_action::*; +} +// Re-export Nori updates module (release builds only) +#[cfg(all(not(debug_assertions), not(feature = "upstream-updates")))] +mod updates { + pub use super::nori::updates::*; +} + +// Re-export the appropriate update prompt functions based on feature (release builds only) +#[cfg(all(not(debug_assertions), feature = "upstream-updates"))] +pub(crate) use update_prompt::UpdatePromptOutcome; +#[cfg(all(not(debug_assertions), feature = "upstream-updates"))] +pub(crate) use update_prompt::run_update_prompt_if_needed; + +#[cfg(all(not(debug_assertions), not(feature = "upstream-updates")))] +pub(crate) use nori::update_prompt::UpdatePromptOutcome; +#[cfg(all(not(debug_assertions), not(feature = "upstream-updates")))] +pub(crate) use nori::update_prompt::run_update_prompt_if_needed; + mod version; mod wrapping; @@ -280,9 +314,12 @@ pub async fn run_main( .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); - let feedback = codex_feedback::CodexFeedback::new(); + #[cfg(feature = "feedback")] + let feedback = crate::feedback_compat::CodexFeedback::new(); + #[cfg(feature = "feedback")] let targets = Targets::new().with_default(tracing::Level::TRACE); + #[cfg(feature = "feedback")] let feedback_layer = tracing_subscriber::fmt::layer() .with_writer(feedback.make_writer()) .with_ansi(false) @@ -320,19 +357,29 @@ pub async fn run_main( tracing_subscriber::filter::filter_fn(codex_core::otel_init::codex_export_filter), ); + #[cfg(feature = "feedback")] let _ = tracing_subscriber::registry() .with(file_layer) .with(feedback_layer) .with(otel_layer) .try_init(); + #[cfg(not(feature = "feedback"))] + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(otel_layer) + .try_init(); } else { + #[cfg(feature = "feedback")] let _ = tracing_subscriber::registry() .with(file_layer) .with(feedback_layer) .try_init(); + #[cfg(not(feature = "feedback"))] + let _ = tracing_subscriber::registry().with(file_layer).try_init(); }; - run_ratatui_app( + #[cfg(feature = "feedback")] + return run_ratatui_app( cli, config, overrides, @@ -341,7 +388,12 @@ pub async fn run_main( feedback, ) .await - .map_err(|err| std::io::Error::other(err.to_string())) + .map_err(|err| std::io::Error::other(err.to_string())); + + #[cfg(not(feature = "feedback"))] + return run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) + .await + .map_err(|err| std::io::Error::other(err.to_string())); } async fn run_ratatui_app( @@ -350,7 +402,7 @@ async fn run_ratatui_app( overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, - feedback: codex_feedback::CodexFeedback, + #[cfg(feature = "feedback")] feedback: crate::feedback_compat::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -370,11 +422,9 @@ async fn run_ratatui_app( #[cfg(not(debug_assertions))] { - use crate::update_prompt::UpdatePromptOutcome; - let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); if !skip_update_prompt { - match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { + match run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { crate::tui::restore()?; @@ -504,6 +554,7 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; + #[cfg(feature = "feedback")] let app_result = App::run( &mut tui, auth_manager, @@ -515,6 +566,17 @@ async fn run_ratatui_app( feedback, ) .await; + #[cfg(not(feature = "feedback"))] + let app_result = App::run( + &mut tui, + auth_manager, + config, + active_profile, + prompt, + images, + resume_selection, + ) + .await; restore(); // Mark the end of the recorded session. diff --git a/codex-rs/tui/src/nori/docs.md b/codex-rs/tui/src/nori/docs.md index 6c30f0534..463b00c82 100644 --- a/codex-rs/tui/src/nori/docs.md +++ b/codex-rs/tui/src/nori/docs.md @@ -4,7 +4,7 @@ Path: @/codex-rs/tui/src/nori ### Overview -The `nori` module contains Nori-specific TUI customizations that replace or extend the default Codex UI behavior. Currently, the primary component is a branded session header that displays at the start of each TUI session. +The `nori` module contains Nori-specific TUI customizations that replace or extend the default Codex UI behavior. It provides branded session headers, agent picking, feedback redirection, and a Nori-specific update checking mechanism that queries GitHub releases instead of OpenAI's update system. ### How it fits into the larger codebase @@ -12,6 +12,8 @@ The `nori` module contains Nori-specific TUI customizations that replace or exte - **Replaces** the original `SessionHeaderHistoryCell` (preserved as dead code for potential future feature flag selection) - **Uses** `HistoryCell` trait from `@/codex-rs/tui/src/history_cell.rs` for consistent rendering - **Reads** `~/.nori-config.json` for Nori profile information +- **Conditionally compiled** based on feature flags - modules like `feedback.rs`, `updates.rs` are only included when their corresponding upstream features are disabled +- **Re-exported** by `@/codex-rs/tui/src/lib.rs` to provide unified access to update types regardless of which update system is active ### Core Implementation @@ -54,6 +56,33 @@ The banner uses green+bold for alphabetic characters and dark gray for structura - `acp_model_picker_params()` renders the `/model` fallback page that disables selection when ACP mode is active and points the user back to `/agent`. - `PendingAgentSelection` holds the selected model/display name pair so the App and `ChatWidget` can store it until the next prompt triggers `AppEvent::SubmitWithAgentSwitch`, at which point the conversation is rebuilt with the new model and the picker view is dismissed. +**Feedback Redirect (`feedback.rs`):** + +Compiled only when `feedback` feature is disabled (`#[cfg(not(feature = "feedback"))]`). Redirects `/feedback` command to GitHub Discussions instead of OpenAI's feedback system: +- `NORI_FEEDBACK_URL`: Points to `https://github.com/tilework-tech/nori-cli/discussions` +- `feedback_message()`: Returns user-facing message with the discussions URL + +**Update System (`update_action.rs`, `updates.rs`, `update_prompt.rs`):** + +Compiled only when `upstream-updates` feature is disabled. Provides Nori-specific update checking: + +`update_action.rs`: +- `UpdateAction` enum with `NpmGlobalLatest` and `Manual` variants +- `command_args()` returns the shell command to execute the update +- `get_update_action()` (release builds only) checks `NORI_MANAGED_BY_NPM` env var to determine update method + +`updates.rs` (release builds only): +- Queries `https://api.github.com/repos/tilework-tech/nori-cli/releases/latest` for version info +- Caches version data in `~/.codex/nori-version.json` with 20-hour refresh interval +- `get_upgrade_version()`: Background-refreshes cache and returns newer version if available +- `get_upgrade_version_for_popup()`: Returns version only if not previously dismissed +- `dismiss_version()`: Persists user's dismissal to avoid repeated prompts +- Tag format: expects `nori-v` (e.g., `nori-v1.2.3`) + +`update_prompt.rs` (release builds only): +- `run_update_prompt_if_needed()`: Displays update prompt UI when new version available +- Returns `UpdatePromptOutcome::Continue` or `UpdatePromptOutcome::RunUpdate(action)` + ### Things to Know **Profile Display:** @@ -70,4 +99,17 @@ The original Codex session header (`SessionHeaderHistoryCell`) is preserved with The session header uses a max inner width of 60 characters. Directory paths are center-truncated when they exceed available space (e.g., `~/a/b/…/y/z`). +**Conditional Compilation:** + +Module availability in `mod.rs` follows this pattern: + +``` +session_header.rs, agent_picker.rs -> Always included +feedback.rs -> #[cfg(not(feature = "feedback"))] +update_action.rs -> #[cfg(not(feature = "upstream-updates"))] +update_prompt.rs, updates.rs -> #[cfg(all(not(feature = "upstream-updates"), not(debug_assertions)))] +``` + +The `lib.rs` re-export logic ensures `UpdateAction` type is always available via `codex_tui::update_action::UpdateAction` regardless of which update system is compiled. + Created and maintained by Nori. diff --git a/codex-rs/tui/src/nori/feedback.rs b/codex-rs/tui/src/nori/feedback.rs new file mode 100644 index 000000000..f6a3872ad --- /dev/null +++ b/codex-rs/tui/src/nori/feedback.rs @@ -0,0 +1,8 @@ +//! Nori-specific feedback handling - redirects to GitHub Discussions + +pub const NORI_FEEDBACK_URL: &str = "https://github.com/tilework-tech/nori-cli/discussions"; + +pub fn feedback_message() -> &'static str { + "To report issues or provide feedback, please visit:\n\ + https://github.com/tilework-tech/nori-cli/discussions" +} diff --git a/codex-rs/tui/src/nori/mod.rs b/codex-rs/tui/src/nori/mod.rs index a68bee7c3..5b8b8e3d5 100644 --- a/codex-rs/tui/src/nori/mod.rs +++ b/codex-rs/tui/src/nori/mod.rs @@ -5,3 +5,15 @@ pub(crate) mod agent_picker; pub(crate) mod session_header; + +#[cfg(not(feature = "feedback"))] +pub(crate) mod feedback; + +// update_action is available in all builds for the UpdateAction type +// update_prompt and updates are only for release builds +#[cfg(not(feature = "upstream-updates"))] +pub(crate) mod update_action; +#[cfg(all(not(feature = "upstream-updates"), not(debug_assertions)))] +pub(crate) mod update_prompt; +#[cfg(all(not(feature = "upstream-updates"), not(debug_assertions)))] +pub(crate) mod updates; diff --git a/codex-rs/tui/src/nori/update_action.rs b/codex-rs/tui/src/nori/update_action.rs new file mode 100644 index 000000000..bd5c9da21 --- /dev/null +++ b/codex-rs/tui/src/nori/update_action.rs @@ -0,0 +1,68 @@ +//! Nori-specific update actions + +/// Update action for Nori CLI +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g nori-ai-cli@latest` + NpmGlobalLatest, + /// Manual update (show instructions) + Manual, +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "nori-ai-cli@latest"]), + UpdateAction::Manual => ( + "echo", + &["Please visit https://github.com/tilework-tech/nori-cli/releases"], + ), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + shlex::try_join(std::iter::once(command).chain(args.iter().copied())) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + } +} + +/// Returns the update action for the current installation. +/// +/// Unlike the upstream version which returns `None` for unknown installations, +/// this always returns `Some()` because Nori supports a manual update fallback +/// that directs users to GitHub releases. +#[cfg(not(debug_assertions))] +pub(crate) fn get_update_action() -> Option { + let managed_by_npm = std::env::var_os("NORI_MANAGED_BY_NPM").is_some(); + + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else { + // For other installations, show manual update option + Some(UpdateAction::Manual) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_update_command_is_correct() { + let action = UpdateAction::NpmGlobalLatest; + let (cmd, args) = action.command_args(); + assert_eq!(cmd, "npm"); + assert_eq!(args, &["install", "-g", "nori-ai-cli@latest"]); + } + + #[test] + fn manual_update_command_shows_url() { + let action = UpdateAction::Manual; + let (cmd, args) = action.command_args(); + assert_eq!(cmd, "echo"); + assert!(args[0].contains("tilework-tech/nori-cli")); + } +} diff --git a/codex-rs/tui/src/nori/update_prompt.rs b/codex-rs/tui/src/nori/update_prompt.rs new file mode 100644 index 000000000..e9f91fd5a --- /dev/null +++ b/codex-rs/tui/src/nori/update_prompt.rs @@ -0,0 +1,317 @@ +//! Nori-specific update prompt UI +//! +//! This module provides the update prompt screen for Nori CLI updates. + +#![cfg(not(debug_assertions))] + +use crate::history_cell::padded_emoji; +use crate::key_hint; +use crate::nori::update_action::UpdateAction; +use crate::nori::updates; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use codex_core::config::Config; +use color_eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::WidgetRef; +use tokio_stream::StreamExt; + +pub(crate) enum UpdatePromptOutcome { + Continue, + RunUpdate(UpdateAction), +} + +pub(crate) async fn run_update_prompt_if_needed( + tui: &mut Tui, + config: &Config, +) -> Result { + let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { + return Ok(UpdatePromptOutcome::Continue); + }; + let Some(update_action) = crate::nori::update_action::get_update_action() else { + return Ok(UpdatePromptOutcome::Continue); + }; + + let mut screen = + UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action); + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + } + } + } else { + break; + } + } + + match screen.selection() { + Some(UpdateSelection::UpdateNow) => { + tui.terminal.clear()?; + Ok(UpdatePromptOutcome::RunUpdate(update_action)) + } + Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue), + Some(UpdateSelection::DontRemind) => { + if let Err(err) = updates::dismiss_version(config, screen.latest_version()).await { + tracing::error!("Failed to persist update dismissal: {err}"); + } + Ok(UpdatePromptOutcome::Continue) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpdateSelection { + UpdateNow, + NotNow, + DontRemind, +} + +struct UpdatePromptScreen { + request_frame: FrameRequester, + latest_version: String, + current_version: String, + update_action: UpdateAction, + highlighted: UpdateSelection, + selection: Option, +} + +impl UpdatePromptScreen { + fn new( + request_frame: FrameRequester, + latest_version: String, + update_action: UpdateAction, + ) -> Self { + Self { + request_frame, + latest_version, + current_version: env!("CARGO_PKG_VERSION").to_string(), + update_action, + highlighted: UpdateSelection::UpdateNow, + selection: None, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.select(UpdateSelection::NotNow); + return; + } + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), + KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Char('1') => self.select(UpdateSelection::UpdateNow), + KeyCode::Char('2') => self.select(UpdateSelection::NotNow), + KeyCode::Char('3') => self.select(UpdateSelection::DontRemind), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(UpdateSelection::NotNow), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: UpdateSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: UpdateSelection) { + self.highlighted = selection; + self.selection = Some(selection); + self.request_frame.schedule_frame(); + } + + fn is_done(&self) -> bool { + self.selection.is_some() + } + + fn selection(&self) -> Option { + self.selection + } + + fn latest_version(&self) -> &str { + self.latest_version.as_str() + } +} + +impl UpdateSelection { + fn next(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::NotNow, + UpdateSelection::NotNow => UpdateSelection::DontRemind, + UpdateSelection::DontRemind => UpdateSelection::UpdateNow, + } + } + + fn prev(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::DontRemind, + UpdateSelection::NotNow => UpdateSelection::UpdateNow, + UpdateSelection::DontRemind => UpdateSelection::NotNow, + } + } +} + +impl WidgetRef for &UpdatePromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let update_command = self.update_action.command_str(); + + column.push(""); + column.push(Line::from(vec![ + padded_emoji(" ✨").bold().cyan(), + "Update available!".bold(), + " ".into(), + format!( + "{current} -> {latest}", + current = self.current_version, + latest = self.latest_version + ) + .dim(), + ])); + column.push(""); + column.push( + Line::from(vec![ + "Release notes: ".dim(), + "https://github.com/tilework-tech/nori-cli/releases/latest" + .dim() + .underlined(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Update now (runs `{update_command}`)"), + self.highlighted == UpdateSelection::UpdateNow, + )); + column.push(selection_option_row( + 1, + "Skip".to_string(), + self.highlighted == UpdateSelection::NotNow, + )); + column.push(selection_option_row( + 2, + "Skip until next version".to_string(), + self.highlighted == UpdateSelection::DontRemind, + )); + column.push(""); + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + + fn new_prompt() -> UpdatePromptScreen { + UpdatePromptScreen::new( + FrameRequester::test_dummy(), + "9.9.9".into(), + UpdateAction::NpmGlobalLatest, + ) + } + + #[test] + fn nori_update_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render update prompt"); + insta::assert_snapshot!("nori_update_prompt_modal", terminal.backend()); + } + + #[test] + fn nori_update_prompt_confirm_selects_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::UpdateNow)); + } + + #[test] + fn nori_update_prompt_dismiss_option_leaves_prompt_in_normal_state() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn nori_update_prompt_dont_remind_selects_dismissal() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::DontRemind)); + } + + #[test] + fn nori_update_prompt_ctrl_c_skips_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn nori_update_prompt_navigation_wraps_between_entries() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::DontRemind); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::UpdateNow); + } +} diff --git a/codex-rs/tui/src/nori/updates.rs b/codex-rs/tui/src/nori/updates.rs new file mode 100644 index 000000000..d37e95d94 --- /dev/null +++ b/codex-rs/tui/src/nori/updates.rs @@ -0,0 +1,190 @@ +//! Nori-specific update checking +//! +//! Checks for updates from the tilework-tech/nori-cli GitHub releases. + +#![cfg(not(debug_assertions))] + +use crate::nori::update_action::UpdateAction; +use chrono::DateTime; +use chrono::Duration; +use chrono::Utc; +use codex_core::config::Config; +use codex_core::default_client::create_client; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; + +use crate::version::CODEX_CLI_VERSION; + +const VERSION_FILENAME: &str = "nori-version.json"; +const LATEST_RELEASE_URL: &str = + "https://api.github.com/repos/tilework-tech/nori-cli/releases/latest"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct VersionInfo { + latest_version: String, + last_checked_at: DateTime, + #[serde(default)] + dismissed_version: Option, +} + +#[derive(Deserialize, Debug, Clone)] +struct ReleaseInfo { + tag_name: String, +} + +pub fn get_upgrade_version(config: &Config) -> Option { + if !config.check_for_update_on_startup { + return None; + } + + let version_file = version_filepath(config); + let info = read_version_info(&version_file).ok(); + + if match &info { + None => true, + Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20), + } { + // Refresh the cached latest version in the background + tokio::spawn(async move { + check_for_update(&version_file) + .await + .inspect_err(|e| tracing::error!("Failed to check for Nori update: {e}")) + }); + } + + info.and_then(|info| { + if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) { + Some(info.latest_version) + } else { + None + } + }) +} + +fn version_filepath(config: &Config) -> PathBuf { + config.codex_home.join(VERSION_FILENAME) +} + +fn read_version_info(version_file: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(version_file)?; + Ok(serde_json::from_str(&contents)?) +} + +async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { + let ReleaseInfo { tag_name } = create_client() + .get(LATEST_RELEASE_URL) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let latest_version = extract_version_from_tag(&tag_name)?; + + let prev_info = read_version_info(version_file).ok(); + let info = VersionInfo { + latest_version, + last_checked_at: Utc::now(), + dismissed_version: prev_info.and_then(|p| p.dismissed_version), + }; + + let json_line = format!("{}\n", serde_json::to_string(&info)?); + if let Some(parent) = version_file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(version_file, json_line).await?; + Ok(()) +} + +fn extract_version_from_tag(tag_name: &str) -> anyhow::Result { + tag_name + .strip_prefix("nori-v") + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("Failed to parse Nori tag name '{tag_name}'")) +} + +fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(l), Some(c)) => Some(l > c), + _ => None, + } +} + +fn parse_version(v: &str) -> Option<(u64, u64, u64)> { + let mut iter = v.trim().split('.'); + let maj = iter.next()?.parse::().ok()?; + let min = iter.next()?.parse::().ok()?; + let pat = iter.next()?.parse::().ok()?; + Some((maj, min, pat)) +} + +pub fn get_upgrade_version_for_popup(config: &Config) -> Option { + if !config.check_for_update_on_startup { + return None; + } + + let version_file = version_filepath(config); + let latest = get_upgrade_version(config)?; + + if let Ok(info) = read_version_info(&version_file) + && info.dismissed_version.as_deref() == Some(latest.as_str()) + { + return None; + } + Some(latest) +} + +pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> { + let version_file = version_filepath(config); + let mut info = match read_version_info(&version_file) { + Ok(info) => info, + Err(_) => return Ok(()), + }; + info.dismissed_version = Some(version.to_string()); + let json_line = format!("{}\n", serde_json::to_string(&info)?); + if let Some(parent) = version_file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(version_file, json_line).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_version_from_nori_tag() { + assert_eq!( + extract_version_from_tag("nori-v1.2.3").expect("failed to parse"), + "1.2.3" + ); + } + + #[test] + fn rejects_non_nori_tags() { + assert!(extract_version_from_tag("rust-v1.2.3").is_err()); + assert!(extract_version_from_tag("v1.2.3").is_err()); + } + + #[test] + fn version_comparison_works() { + assert_eq!(is_newer("1.0.1", "1.0.0"), Some(true)); + assert_eq!(is_newer("1.0.0", "1.0.1"), Some(false)); + assert_eq!(is_newer("2.0.0", "1.9.9"), Some(true)); + } + + #[test] + fn prerelease_version_is_not_considered_newer() { + assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); + assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); + } + + #[test] + fn whitespace_is_ignored() { + assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); + assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); + } +} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index d4cfd6d1f..cb17548de 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "login")] mod auth; pub mod onboarding_screen; mod trust_directory; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 47c7811a3..d935ade8a 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -11,11 +11,15 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; +#[cfg(feature = "login")] use codex_app_server_protocol::AuthMode; +#[cfg(feature = "login")] use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; +#[cfg(feature = "login")] use crate::onboarding::auth::AuthModeWidget; +#[cfg(feature = "login")] use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -25,11 +29,13 @@ use crate::tui::Tui; use crate::tui::TuiEvent; use color_eyre::eyre::Result; use std::sync::Arc; +#[cfg(feature = "login")] use std::sync::RwLock; #[allow(clippy::large_enum_variant)] enum Step { Welcome(WelcomeWidget), + #[cfg(feature = "login")] Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), } @@ -90,6 +96,7 @@ impl OnboardingScreen { tui.frame_requester(), config.animations, ))); + #[cfg(feature = "login")] if show_login_screen { let highlighted_mode = match forced_login_method { Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, @@ -109,6 +116,8 @@ impl OnboardingScreen { animations_enabled: config.animations, })) } + #[cfg(not(feature = "login"))] + let _ = (show_login_screen, auth_manager); let is_git_repo = get_git_repo_root(&cwd).is_some(); let highlighted = if is_git_repo { TrustDirectorySelection::Trust @@ -166,9 +175,17 @@ impl OnboardingScreen { } fn is_auth_in_progress(&self) -> bool { - self.steps.iter().any(|step| { - matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress) - }) + #[cfg(feature = "login")] + { + self.steps.iter().any(|step| { + matches!(step, Step::Auth(_)) + && matches!(step.get_step_state(), StepState::InProgress) + }) + } + #[cfg(not(feature = "login"))] + { + false + } } pub(crate) fn is_done(&self) -> bool { @@ -319,6 +336,7 @@ impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { Step::Welcome(widget) => widget.handle_key_event(key_event), + #[cfg(feature = "login")] Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } @@ -327,6 +345,7 @@ impl KeyboardHandler for Step { fn handle_paste(&mut self, pasted: String) { match self { Step::Welcome(_) => {} + #[cfg(feature = "login")] Step::Auth(widget) => widget.handle_paste(pasted), Step::TrustDirectory(widget) => widget.handle_paste(pasted), } @@ -337,6 +356,7 @@ impl StepStateProvider for Step { fn get_step_state(&self) -> StepState { match self { Step::Welcome(w) => w.get_step_state(), + #[cfg(feature = "login")] Step::Auth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), } @@ -349,6 +369,7 @@ impl WidgetRef for Step { Step::Welcome(widget) => { widget.render_ref(area, buf); } + #[cfg(feature = "login")] Step::Auth(widget) => { widget.render_ref(area, buf); } @@ -386,6 +407,7 @@ pub(crate) async fn run_onboarding_app( onboarding_screen.handle_paste(text); } TuiEvent::Draw => { + #[cfg(feature = "login")] if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { if let Step::Auth(w) = step { @@ -416,6 +438,8 @@ pub(crate) async fn run_onboarding_app( let _ = tui.terminal.clear(); did_full_clear_after_success = true; } + #[cfg(not(feature = "login"))] + let _ = did_full_clear_after_success; let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&onboarding_screen, frame.area()); }); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 2b45a6cd2..f44d3de57 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -89,6 +89,10 @@ impl SlashCommand { fn is_visible(self) -> bool { match self { SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), + #[cfg(not(feature = "login"))] + SlashCommand::Logout => false, + #[cfg(not(feature = "feedback"))] + SlashCommand::Feedback => false, _ => true, } } @@ -101,3 +105,56 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { .map(|c| (c.command(), c)) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(not(feature = "login"))] + fn logout_hidden_when_login_feature_disabled() { + let commands = built_in_slash_commands(); + let has_logout = commands.iter().any(|(_, cmd)| *cmd == SlashCommand::Logout); + assert!( + !has_logout, + "/logout should be hidden when login feature is disabled" + ); + } + + #[test] + #[cfg(feature = "login")] + fn logout_visible_when_login_feature_enabled() { + let commands = built_in_slash_commands(); + let has_logout = commands.iter().any(|(_, cmd)| *cmd == SlashCommand::Logout); + assert!( + has_logout, + "/logout should be visible when login feature is enabled" + ); + } + + #[test] + #[cfg(not(feature = "feedback"))] + fn feedback_hidden_when_feedback_feature_disabled() { + let commands = built_in_slash_commands(); + let has_feedback = commands + .iter() + .any(|(_, cmd)| *cmd == SlashCommand::Feedback); + assert!( + !has_feedback, + "/feedback should be hidden when feedback feature is disabled" + ); + } + + #[test] + #[cfg(feature = "feedback")] + fn feedback_visible_when_feedback_feature_enabled() { + let commands = built_in_slash_commands(); + let has_feedback = commands + .iter() + .any(|(_, cmd)| *cmd == SlashCommand::Feedback); + assert!( + has_feedback, + "/feedback should be visible when feedback feature is enabled" + ); + } +}