diff --git a/.github/workflows/codex-cli-bundles.yml b/.github/workflows/codex-cli-bundles.yml new file mode 100644 index 000000000000..1dd59a1f6aef --- /dev/null +++ b/.github/workflows/codex-cli-bundles.yml @@ -0,0 +1,65 @@ +name: codex-cli-builds + +on: + push: + branches: + - main + +concurrency: + group: codex-cli-builds-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + build: + name: Build – ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x86_64 + runner: ubuntu-24.04 + system: x86_64-linux + - name: darwin-x86_64 + runner: macos-13 + system: x86_64-darwin + - name: darwin-aarch64 + runner: macos-14 + system: aarch64-darwin + steps: + - uses: actions/checkout@v5 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v13 + with: + extra-conf: | + experimental-features = nix-command flakes + + - name: Cache Nix directories + uses: actions/cache@v4 + with: + path: | + ~/.cache/nix + ~/.local/share/nix + ~/.local/state/nix + ~/Library/Caches/nix + key: ${{ runner.os }}-nix-${{ matrix.system }}-${{ hashFiles('flake.lock') }} + restore-keys: | + ${{ runner.os }}-nix-${{ matrix.system }}- + ${{ runner.os }}-nix- + + - name: Use magic Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v7 + + - name: Use Cachix cache + uses: cachix/cachix-action@v15 + with: + name: joshsymonds + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + push: true + + - name: Build codex-cli derivation + run: | + cachix watch-exec joshsymonds -- \ + nix build --print-build-logs --log-format raw --no-link --print-out-paths \ + .#packages.${{ matrix.system }}.codex-cli diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml deleted file mode 100644 index 1a900bffd2ae..000000000000 --- a/.github/workflows/rust-ci.yml +++ /dev/null @@ -1,488 +0,0 @@ -name: rust-ci -on: - pull_request: {} - push: - branches: - - main - workflow_dispatch: - -# CI builds in debug (dev) for faster signal. - -jobs: - # --- Detect what changed to detect which tests to run (always runs) ------------------------------------- - changed: - name: Detect changed areas - runs-on: ubuntu-24.04 - outputs: - codex: ${{ steps.detect.outputs.codex }} - workflows: ${{ steps.detect.outputs.workflows }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Detect changed paths (no external action) - id: detect - shell: bash - run: | - set -euo pipefail - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - BASE_SHA='${{ github.event.pull_request.base.sha }}' - echo "Base SHA: $BASE_SHA" - # List files changed between base and current HEAD (merge-base aware) - mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA"...HEAD) - else - # On push / manual runs, default to running everything - files=("codex-rs/force" ".github/force") - fi - - codex=false - workflows=false - for f in "${files[@]}"; do - [[ $f == codex-rs/* ]] && codex=true - [[ $f == .github/* ]] && workflows=true - done - - echo "codex=$codex" >> "$GITHUB_OUTPUT" - echo "workflows=$workflows" >> "$GITHUB_OUTPUT" - - # --- CI that doesn't need specific targets --------------------------------- - general: - name: Format / etc - runs-on: ubuntu-24.04 - needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.90 - with: - components: rustfmt - - name: cargo fmt - run: cargo fmt -- --config imports_granularity=Item --check - - name: Verify codegen for mcp-types - run: ./mcp-types/check_lib_rs.py - - cargo_shear: - name: cargo shear - runs-on: ubuntu-24.04 - needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.90 - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: cargo-shear - version: 1.5.1 - - name: cargo shear - run: cargo shear - - # --- CI to validate on different os/targets -------------------------------- - lint_build: - name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - needs: changed - # Keep job-level if to avoid spinning up runners when not needed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - env: - # Speed up repeated builds across CI runs by caching compiled objects. - RUSTC_WRAPPER: sccache - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-14 - target: aarch64-apple-darwin - profile: dev - - runner: macos-14 - target: x86_64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - profile: dev - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - - runner: windows-latest - target: x86_64-pc-windows-msvc - profile: dev - - runner: windows-11-arm - target: aarch64-pc-windows-msvc - profile: dev - - # Also run representative release builds on Mac and Linux because - # there could be release-only build errors we want to catch. - # Hopefully this also pre-populates the build cache to speed up - # releases. - - runner: macos-14 - target: aarch64-apple-darwin - profile: release - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - profile: release - - runner: windows-latest - target: x86_64-pc-windows-msvc - profile: release - - runner: windows-11-arm - target: aarch64-pc-windows-msvc - profile: release - - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.90 - with: - targets: ${{ matrix.target }} - components: clippy - - # Explicit cache restore: split cargo home vs target, so we can - # avoid caching the large target dir on the gnu-dev job. - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - # Install and restore sccache cache - - name: Install sccache - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Restore sccache cache (fallback) - if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@v4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Prepare APT cache directories (musl) - shell: bash - run: | - set -euo pipefail - sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists - sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Restore APT cache (musl) - id: cache_apt_restore - uses: actions/cache/restore@v4 - with: - path: | - /var/cache/apt - key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools - env: - DEBIAN_FRONTEND: noninteractive - shell: bash - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends musl-tools pkg-config - - - name: Install cargo-chef - if: ${{ matrix.profile == 'release' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: cargo-chef - version: 0.1.71 - - - name: Pre-warm dependency cache (cargo-chef) - if: ${{ matrix.profile == 'release' }} - shell: bash - run: | - set -euo pipefail - RECIPE="${RUNNER_TEMP}/chef-recipe.json" - cargo chef prepare --recipe-path "$RECIPE" - cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release --all-features - - - name: cargo clippy - id: clippy - run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} -- -D warnings - - # Running `cargo build` from the workspace root builds the workspace using - # the union of all features from third-party crates. This can mask errors - # where individual crates have underspecified features. To avoid this, we - # run `cargo check` for each crate individually, though because this is - # slower, we only do this for the x86_64-unknown-linux-gnu target. - - name: cargo check individual crates - id: cargo_check_all_crates - if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release' }} - continue-on-error: true - run: | - find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 \ - | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo check --profile ${{ matrix.profile }}' - - # Save caches explicitly; make non-fatal so cache packaging - # never fails the overall job. Only save when key wasn't hit. - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - - - name: sccache stats - if: always() - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Save APT cache (musl) - if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: | - /var/cache/apt - key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - - # Fail the job if any of the previous steps failed. - - name: verify all steps passed - if: | - steps.clippy.outcome == 'failure' || - steps.cargo_check_all_crates.outcome == 'failure' - run: | - echo "One or more checks failed (clippy or cargo_check_all_crates). See logs for details." - exit 1 - - tests: - name: Tests — ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - env: - RUSTC_WRAPPER: sccache - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-14 - target: aarch64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - - runner: windows-latest - target: x86_64-pc-windows-msvc - profile: dev - - runner: windows-11-arm - target: aarch64-pc-windows-msvc - profile: dev - - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.90 - with: - targets: ${{ matrix.target }} - - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - name: Install sccache - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Restore sccache cache (fallback) - if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@v4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: nextest - version: 0.9.103 - - - name: tests - id: test - continue-on-error: true - run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test - env: - RUST_BACKTRACE: 1 - - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - - - name: sccache stats - if: always() - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (tests)"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: verify tests passed - if: steps.test.outcome == 'failure' - run: | - echo "Tests failed. See logs for details." - exit 1 - - # --- Gatherer job that you mark as the ONLY required status ----------------- - results: - name: CI results (required) - needs: [changed, general, cargo_shear, lint_build, tests] - if: always() - runs-on: ubuntu-24.04 - steps: - - name: Summarize - shell: bash - run: | - echo "general: ${{ needs.general.result }}" - echo "shear : ${{ needs.cargo_shear.result }}" - echo "lint : ${{ needs.lint_build.result }}" - echo "tests : ${{ needs.tests.result }}" - - # If nothing relevant changed (PR touching only root README, etc.), - # declare success regardless of other jobs. - if [[ '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' && '${{ github.event_name }}' != 'push' ]]; then - echo 'No relevant changes -> CI not required.' - exit 0 - fi - - # Otherwise require the jobs to have succeeded - [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } - [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } - [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } - [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } - - - name: sccache summary note - if: always() - run: | - echo "Per-job sccache stats are attached to each matrix job's Step Summary." diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 35424d822ad4..4659978ddbc4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1068,7 +1068,6 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", - "codex-arg0", "codex-async-utils", "codex-file-search", "codex-git", @@ -1083,12 +1082,12 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", - "ctor 0.5.0", "dirs", "dunce", "env-flags", "escargot", "eventsource-stream", + "fs2", "futures", "http", "image", @@ -1453,6 +1452,7 @@ dependencies = [ "diffy", "dirs", "dunce", + "hostname", "image", "insta", "itertools 0.14.0", @@ -2526,6 +2526,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index c50c69aa3f51..a9a1bfbbf9ca 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -121,6 +121,7 @@ escargot = "0.5" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } http = "1.3.1" +hostname = "0.4.0" icu_decimal = "2.1" icu_locale_core = "2.1" icu_provider = { version = "2.1", features = ["sync"] } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index deddc068cd4b..31bfd5944938 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -2,6 +2,7 @@ edition = "2024" name = "codex-cli" version = { workspace = true } +build = "build.rs" [[bin]] name = "codex" diff --git a/codex-rs/cli/build.rs b/codex-rs/cli/build.rs new file mode 100644 index 000000000000..0321714373aa --- /dev/null +++ b/codex-rs/cli/build.rs @@ -0,0 +1,38 @@ +use std::env; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + let sha = env_sha() + .or_else(git_sha) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=CODEX_CLI_GIT_SHA={sha}"); +} + +fn env_sha() -> Option { + let value = env::var("CODEX_BUILD_GIT_SHA").ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +fn git_sha() -> Option { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").ok()?; + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(manifest_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let sha = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if sha.is_empty() { + return None; + } + Some(sha) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 75a7cb8e443a..9e75e2ab9a7d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -36,14 +36,21 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::features::is_known_feature_key; +const CLI_VERSION: &str = concat!( + env!("CARGO_PKG_VERSION"), + " (", + env!("CODEX_CLI_GIT_SHA"), + ")" +); + /// Codex CLI /// /// If no subcommand is specified, options will be forwarded to the interactive CLI. #[derive(Debug, Parser)] #[clap( author, - version, - // If a sub‑command is given, ignore requirements of the default args. + version = CLI_VERSION, + // If a sub-command is given, ignore requirements of the default args. subcommand_negates_reqs = true, // The executable is sometimes invoked via a platform‑specific name like // `codex-x86_64-unknown-linux-musl`, but the help output should always use @@ -726,6 +733,13 @@ mod tests { } } + #[test] + fn version_flag_reports_commit_sha() { + let command = MultitoolCli::command(); + let version = command.get_version().expect("version should be set"); + assert_eq!(CLI_VERSION, version); + } + #[test] fn format_exit_messages_skips_zero_usage() { let exit_info = AppExitInfo { diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 6839504be587..b37b8cdb7769 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -32,7 +32,6 @@ codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-utils-tokenizer = { workspace = true } -codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } @@ -84,6 +83,8 @@ tree-sitter-bash = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } +fs2 = "0.4.3" +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } [target.'cfg(target_os = "linux")'.dependencies] @@ -104,9 +105,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } -codex-arg0 = { workspace = true } core_test_support = { workspace = true } -ctor = { workspace = true } escargot = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } maplit = { workspace = true } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0dc9d12667e4..85e87f218c84 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -59,9 +59,9 @@ pub mod profile; pub mod types; #[cfg(target_os = "windows")] -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex"; #[cfg(not(target_os = "windows"))] -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5-codex"; +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5-codex"; pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5-codex"; @@ -159,6 +159,8 @@ pub struct Config { /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals /// and turn completions when not focused. pub tui_notifications: Notifications, + /// Toggle for the bespoke Codex status line rendering. + pub tui_custom_statusline: bool, /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are @@ -1165,6 +1167,11 @@ impl Config { .as_ref() .map(|t| t.notifications.clone()) .unwrap_or_default(), + tui_custom_statusline: cfg + .tui + .as_ref() + .map(|t| t.custom_statusline) + .unwrap_or_else(|| Tui::default().custom_statusline), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2904,6 +2911,7 @@ model_verbosity = "high" notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), + tui_custom_statusline: true, otel: OtelConfig::default(), }, o3_profile_config @@ -2975,6 +2983,7 @@ model_verbosity = "high" notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), + tui_custom_statusline: true, otel: OtelConfig::default(), }; @@ -3061,6 +3070,7 @@ model_verbosity = "high" notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), + tui_custom_statusline: true, otel: OtelConfig::default(), }; @@ -3133,6 +3143,7 @@ model_verbosity = "high" notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), + tui_custom_statusline: true, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index be47b20b0652..1cdda2570336 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -343,12 +343,30 @@ impl Default for Notifications { } /// Collection of settings that are specific to the TUI. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Deserialize, Debug, Clone, PartialEq)] pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `false`. #[serde(default)] pub notifications: Notifications, + /// Enable the custom Codex status line presentation. + #[serde(default = "Tui::default_custom_statusline")] + pub custom_statusline: bool, +} + +impl Default for Tui { + fn default() -> Self { + Self { + notifications: Notifications::default(), + custom_statusline: Tui::default_custom_statusline(), + } + } +} + +impl Tui { + const fn default_custom_statusline() -> bool { + true + } } /// Settings for notices we display to users via the tui and app-server clients diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5229d00606a3..93521ac755f8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -83,6 +83,7 @@ mod tasks; mod user_notification; mod user_shell_command; pub mod util; +pub mod workspace_state; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use command_safety::is_safe_command; diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index e96d8d5026f5..57ecd0a4ebf1 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -27,6 +27,8 @@ use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; +use fs2::FileExt; + use crate::config::Config; use crate::config::types::HistoryPersistence; @@ -112,19 +114,21 @@ pub(crate) async fn append_entry( // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { - // Retry a few times to avoid indefinite blocking when contended. for _ in 0..MAX_RETRIES { - match history_file.try_lock() { + match FileExt::try_lock_exclusive(&history_file) { Ok(()) => { - // While holding the exclusive lock, write the full line. - history_file.write_all(line.as_bytes())?; - history_file.flush()?; + let write_result = history_file + .write_all(line.as_bytes()) + .and_then(|_| history_file.flush()); + let unlock_result = FileExt::unlock(&history_file); + write_result?; + unlock_result?; return Ok(()); } - Err(std::fs::TryLockError::WouldBlock) => { + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(RETRY_SLEEP); } - Err(e) => return Err(e.into()), + Err(err) => return Err(err), } } @@ -216,15 +220,15 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { + let mut found = None; let reader = BufReader::new(&file); for (idx, line_res) in reader.lines().enumerate() { let line = match line_res { Ok(l) => l, Err(e) => { + let _ = FileExt::unlock(&file); tracing::warn!(error = %e, "failed to read line from history file"); return None; } @@ -232,18 +236,22 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option(&line) { - Ok(entry) => return Some(entry), + Ok(entry) => { + found = Some(entry); + break; + } Err(e) => { + let _ = FileExt::unlock(&file); tracing::warn!(error = %e, "failed to parse history entry"); return None; } } } } - // Not found at requested offset. - return None; + let _ = FileExt::unlock(&file); + return found; } - Err(std::fs::TryLockError::WouldBlock) => { + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(RETRY_SLEEP); } Err(e) => { diff --git a/codex-rs/core/src/workspace_state.rs b/codex-rs/core/src/workspace_state.rs new file mode 100644 index 000000000000..338059cc2652 --- /dev/null +++ b/codex-rs/core/src/workspace_state.rs @@ -0,0 +1,145 @@ +use crate::protocol_config_types::ReasoningEffort; +use serde::Deserialize; +use serde::Serialize; +use sha1::Digest; +use sha1::Sha1; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use tempfile::NamedTempFile; +use tracing::warn; + +const WORKSPACE_STATE_DIR: &str = "workspace_state"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkspaceState { + pub model: Option, + pub model_reasoning_effort: Option, + #[serde(default)] + pub mcp_servers: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkspaceMcpServerState { + pub enabled: Option, +} + +fn workspace_state_path(codex_home: &Path, workspace: &Path) -> PathBuf { + let canonical = dunce::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + let mut hasher = Sha1::new(); + hasher.update(canonical.as_os_str().to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let filename = format!("{digest:x}.toml"); + codex_home.join(WORKSPACE_STATE_DIR).join(filename) +} + +pub fn load_workspace_state( + codex_home: &Path, + workspace: &Path, +) -> std::io::Result { + let path = workspace_state_path(codex_home, workspace); + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorkspaceState::default()); + } + Err(err) => return Err(err), + }; + + match toml::from_str::(&contents) { + Ok(state) => Ok(state), + Err(err) => { + warn!( + "Failed to parse workspace state from {}: {err}", + path.display() + ); + Ok(WorkspaceState::default()) + } + } +} + +fn persist_workspace_state( + codex_home: &Path, + workspace: &Path, + mut state: WorkspaceState, +) -> std::io::Result<()> { + // Avoid storing empty MCP server entries with no data. + state.mcp_servers.retain(|_, entry| entry.enabled.is_some()); + + let path = workspace_state_path(codex_home, workspace); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut temp = + NamedTempFile::new_in(path.parent().ok_or_else(|| { + std::io::Error::other("missing parent dir") + })?)?; + let serialized = toml::to_string_pretty(&state) + .map_err(std::io::Error::other)?; + temp.write_all(serialized.as_bytes())?; + temp.flush()?; + temp.persist(&path).map_err(|err| err.error)?; + Ok(()) +} + +pub fn persist_model_selection( + codex_home: &Path, + workspace: &Path, + model: &str, + effort: Option, +) -> std::io::Result<()> { + let mut state = load_workspace_state(codex_home, workspace)?; + state.model = Some(model.to_string()); + state.model_reasoning_effort = effort; + persist_workspace_state(codex_home, workspace, state) +} + +pub fn persist_mcp_enabled( + codex_home: &Path, + workspace: &Path, + server: &str, + enabled: bool, +) -> std::io::Result<()> { + let mut state = load_workspace_state(codex_home, workspace)?; + state + .mcp_servers + .entry(server.to_string()) + .or_default() + .enabled = Some(enabled); + persist_workspace_state(codex_home, workspace, state) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn persists_and_loads_workspace_state() -> std::io::Result<()> { + let codex_home = TempDir::new().expect("tempdir"); + let workspace = TempDir::new().expect("workspace"); + + persist_model_selection( + codex_home.path(), + workspace.path(), + "gpt-5-codex", + Some(ReasoningEffort::High), + )?; + persist_mcp_enabled(codex_home.path(), workspace.path(), "docs", false)?; + + let state = load_workspace_state(codex_home.path(), workspace.path())?; + assert_eq!(state.model.as_deref(), Some("gpt-5-codex")); + assert_eq!(state.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + state + .mcp_servers + .get("docs") + .and_then(|entry| entry.enabled), + Some(false) + ); + Ok(()) + } +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2d0b0f013aa1..73a368563c76 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -392,11 +392,12 @@ impl SandboxPolicy { // Linux or Windows, but supporting it here gives users a way to // provide the model with their own temporary directory without // having to hardcode it in the config. - if !exclude_tmpdir_env_var - && let Some(tmpdir) = std::env::var_os("TMPDIR") - && !tmpdir.is_empty() - { - roots.push(PathBuf::from(tmpdir)); + if !exclude_tmpdir_env_var { + if let Some(tmpdir) = std::env::var_os("TMPDIR") { + if !tmpdir.is_empty() { + roots.push(PathBuf::from(tmpdir)); + } + } } // For each root, compute subpaths that should remain read-only. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b524d8bfd485..97642e5e97d7 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -47,6 +47,7 @@ diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } +hostname = { workspace = true } itertools = { workspace = true } lazy_static = { workspace = true } mcp-types = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 817445c59ad1..c13d735c696c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -207,6 +207,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + status_renderer: None, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -230,6 +231,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + status_renderer: None, }; ChatWidget::new_from_existing( init, @@ -374,6 +376,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + status_renderer: None, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); @@ -473,6 +476,14 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::StatusLineGit(snapshot) => { + self.chat_widget.update_statusline_git(snapshot); + tui.frame_requester().schedule_frame(); + } + AppEvent::StatusLineKubeContext(context) => { + self.chat_widget.update_statusline_kube_context(context); + tui.frame_requester().schedule_frame(); + } AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b161867e445c..5eaf745e499d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + status_renderer: None, }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 39485faa9321..3c9be3bf592c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -8,6 +8,7 @@ use codex_file_search::FileMatch; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; +use crate::statusline::StatusLineGitSnapshot; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -41,6 +42,11 @@ pub(crate) enum AppEvent { matches: Vec, }, + /// Background Git detection updates for the custom status line. + StatusLineGit(Option), + /// Background kube context updates for the custom status line. + StatusLineKubeContext(Option), + /// Result of computing a `/diff` command. DiffResult(String), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 446563a1921c..05f8cf356734 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3,15 +3,13 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; +use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -35,8 +33,6 @@ use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; -use crate::render::Insets; -use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; @@ -121,7 +117,40 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_SPACING_HEIGHT: u16 = 0; +// Explicit layout constants keep the composer structure easy to follow. +const TOP_PADDING_HEIGHT: u16 = 1; +const BOTTOM_PADDING_HEIGHT: u16 = 1; +const BOTTOM_MARGIN_HEIGHT: u16 = 0; +const TEXTAREA_RIGHT_MARGIN: u16 = 1; +const FOOTER_SPACING_HEIGHT: u16 = 1; + +#[derive(Clone, Copy, Debug)] +struct ComposerRenderLayout { + top_padding: Rect, + composer_rect: Rect, + textarea_rect: Rect, + footer_area: Rect, + bottom_margin: Rect, + popup_rect: Rect, + footer_hint_height: u16, + footer_spacing: u16, +} + +fn fill_rect_with_style(buf: &mut Buffer, rect: Rect, style: Style) { + if rect.width == 0 || rect.height == 0 { + return; + } + let bottom = rect.y.saturating_add(rect.height); + let right = rect.x.saturating_add(rect.width); + for y in rect.y..bottom { + for x in rect.x..right { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_symbol(" "); + cell.set_style(style); + } + } + } +} impl ChatComposer { pub fn new( @@ -161,24 +190,129 @@ impl ChatComposer { this } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { + pub fn desired_height(&self, width: u16) -> u16 { + let text_width = width.saturating_sub(LIVE_PREFIX_COLS + TEXTAREA_RIGHT_MARGIN); + let text_height = self.textarea.desired_height(text_width); + let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() .unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - let popup_constraint = match &self.active_popup { - ActivePopup::Command(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) + let footer_total_height = match &self.active_popup { + ActivePopup::None => footer_hint_height + footer_spacing, + ActivePopup::Command(c) => c.calculate_required_height(width).max(1), + ActivePopup::File(c) => c.calculate_required_height().max(1), + }; + + text_height + + TOP_PADDING_HEIGHT + + BOTTOM_PADDING_HEIGHT + + BOTTOM_MARGIN_HEIGHT + + footer_total_height + } + + fn render_layout(&self, area: Rect) -> ComposerRenderLayout { + let mut footer_hint_height = 0; + let mut footer_spacing = 0; + let footer_reserved_height = match &self.active_popup { + ActivePopup::Command(popup) => popup.calculate_required_height(area.width).max(1), + ActivePopup::File(popup) => popup.calculate_required_height().max(1), + ActivePopup::None => { + footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(self.footer_props())); + footer_spacing = Self::footer_spacing(footer_hint_height); + footer_hint_height + footer_spacing } - ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::None => Constraint::Max(footer_total_height), }; - let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); - let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); - [composer_rect, textarea_rect, popup_rect] + + let available_height = area.height; + let mut text_height = available_height + .saturating_sub( + TOP_PADDING_HEIGHT + + BOTTOM_PADDING_HEIGHT + + BOTTOM_MARGIN_HEIGHT + + footer_reserved_height, + ) + .max(1); + let footer_height = footer_reserved_height; + let bottom_padding_height = BOTTOM_PADDING_HEIGHT; + let bottom_margin_height = BOTTOM_MARGIN_HEIGHT; + + let used_height = TOP_PADDING_HEIGHT + + text_height + + bottom_padding_height + + bottom_margin_height + + footer_height; + if used_height < available_height { + text_height += available_height - used_height; + } + + let mut cursor = area.y; + let top_padding = Rect::new(area.x, cursor, area.width, TOP_PADDING_HEIGHT); + cursor = cursor.saturating_add(TOP_PADDING_HEIGHT); + + let text_start = cursor; + let textarea_width = area + .width + .saturating_sub(LIVE_PREFIX_COLS + TEXTAREA_RIGHT_MARGIN); + let textarea_rect = Rect::new( + area.x.saturating_add(LIVE_PREFIX_COLS), + text_start, + textarea_width, + text_height, + ); + cursor = cursor.saturating_add(text_height); + + let bottom_padding_start = cursor; + cursor = cursor.saturating_add(bottom_padding_height); + + let footer_area = Rect::new(area.x, cursor, area.width, footer_height); + cursor = cursor.saturating_add(footer_height); + + let bottom_margin = Rect::new(area.x, cursor, area.width, bottom_margin_height); + + let composer_rect = Rect::new( + area.x, + text_start, + area.width, + text_height.saturating_add(bottom_padding_height), + ); + + let popup_rect = Rect::new( + area.x, + bottom_padding_start, + area.width, + bottom_padding_height + .saturating_add(footer_height) + .saturating_add(bottom_margin_height), + ); + + ComposerRenderLayout { + top_padding, + composer_rect, + textarea_rect, + footer_area, + bottom_margin, + popup_rect, + footer_hint_height, + footer_spacing, + } + } + + fn layout_areas(&self, area: Rect) -> [Rect; 3] { + let layout = self.render_layout(area); + [ + layout.composer_rect, + layout.textarea_rect, + layout.popup_rect, + ] + } + + #[cfg(test)] + pub(crate) fn layout_areas_for_tests(&self, area: Rect) -> [Rect; 3] { + self.layout_areas(area) } fn footer_spacing(footer_hint_height: u16) -> u16 { @@ -189,6 +323,12 @@ impl ChatComposer { } } + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let [_, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -1538,99 +1678,101 @@ impl ChatComposer { } } -impl Renderable for ChatComposer { - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } +impl WidgetRef for ChatComposer { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let layout = self.render_layout(area); + let style = user_message_style(); - fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { - ActivePopup::None => footer_total_height, - ActivePopup::Command(c) => c.calculate_required_height(width), - ActivePopup::File(c) => c.calculate_required_height(), - } - } + // Top padding mirrors the bottom padding so the composer is framed evenly. + fill_rect_with_style(buf, layout.top_padding, style); + fill_rect_with_style(buf, layout.composer_rect, style); + + // Footer and margin rows start blank so each state can paint what it needs. + fill_rect_with_style(buf, layout.footer_area, Style::default()); + fill_rect_with_style(buf, layout.bottom_margin, Style::default()); - fn render(&self, area: Rect, buf: &mut Buffer) { - let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); match &self.active_popup { ActivePopup::Command(popup) => { - popup.render_ref(popup_rect, buf); + popup.render_ref(layout.footer_area, buf); } ActivePopup::File(popup) => { - popup.render_ref(popup_rect, buf); + popup.render_ref(layout.footer_area, buf); } ActivePopup::None => { - let footer_props = self.footer_props(); - let custom_height = self.custom_footer_height(); - let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); + if layout.footer_area.height > 0 { + let footer_props = self.footer_props(); + let footer_hint_height = + layout.footer_hint_height.min(layout.footer_area.height); + let spacing_height = layout + .footer_spacing + .min(layout.footer_area.height.saturating_sub(footer_hint_height)); + let available_for_hint = + layout.footer_area.height.saturating_sub(spacing_height); + if footer_hint_height > 0 && available_for_hint > 0 { + let hint_height = footer_hint_height.min(available_for_hint); + let hint_rect = Rect::new( + layout.footer_area.x, + layout.footer_area.y.saturating_add(spacing_height), + layout.footer_area.width, + hint_height, + ); + if let Some(items) = self.footer_hint_override.as_ref() { + if !items.is_empty() { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + Line::from(spans).render_ref(custom_rect, buf); } + } else { + render_footer(hint_rect, buf, footer_props); } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); } - } else { - render_footer(hint_rect, buf, footer_props); } } } - let style = user_message_style(); - Block::default().style(style).render_ref(composer_rect, buf); - if !textarea_rect.is_empty() { - buf.set_span( - textarea_rect.x - LIVE_PREFIX_COLS, - textarea_rect.y, - &"›".bold(), - textarea_rect.width, - ); + + if layout.composer_rect.height > 0 + && let Some(cell) = buf.cell_mut((layout.composer_rect.x, layout.composer_rect.y)) + { + cell.set_symbol("›"); + cell.set_style(style.add_modifier(Modifier::BOLD)); } let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + StatefulWidgetRef::render_ref(&(&self.textarea), layout.textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { let placeholder = Span::from(self.placeholder_text.as_str()).dim(); - Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); + Line::from(vec![placeholder]) + .render_ref(layout.textarea_rect.inner(Margin::new(0, 0)), buf); } } } +impl Renderable for ChatComposer { + fn render(&self, area: Rect, buf: &mut Buffer) { + WidgetRef::render_ref(self, area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + ChatComposer::desired_height(self, width) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + ChatComposer::cursor_pos(self, area) + } +} + fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, @@ -1699,57 +1841,218 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; #[test] - fn footer_hint_row_is_separated_from_composer() { + fn composer_cursor_aligns_with_text_row() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); - let composer = ChatComposer::new( + let mut composer = ChatComposer::new( true, sender, false, "Ask Codex to do anything".to_string(), false, ); + composer.insert_str("hello"); + + let height = composer.desired_height(40); + let area = Rect::new(0, 0, 40, height); + let cursor = composer + .cursor_pos(area) + .expect("cursor position should be available"); - let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); + composer.render_ref(area, &mut buf); - let row_to_string = |y: u16| { + let mut prompt_row = None; + for y in 0..area.height { let mut row = String::new(); for x in 0..area.width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } - row - }; - - let mut hint_row: Option<(u16, String)> = None; - for y in 0..area.height { - let row = row_to_string(y); - if row.contains("? for shortcuts") { - hint_row = Some((y, row)); + if row.contains('›') { + prompt_row = Some((y, row)); break; } } - let (hint_row_idx, hint_row_contents) = - hint_row.expect("expected footer hint row to be rendered"); + let (prompt_row_idx, prompt_row_contents) = + prompt_row.expect("expected prompt marker row to be rendered"); assert_eq!( - hint_row_idx, - area.height - 1, - "hint row should occupy the bottom line: {hint_row_contents:?}", + cursor.1, prompt_row_idx, + "cursor should align with composer row containing text; row contents: {prompt_row_contents:?}" ); + } + + #[test] + fn composer_renders_bottom_padding_row() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.insert_str("hello"); + + let height = composer.desired_height(40); + let area = Rect::new(0, 0, 40, height); + let mut buf = Buffer::empty(area); + composer.render_ref(area, &mut buf); + + let [composer_rect, _, _] = composer.layout_areas(area); + let top_padding_y = composer_rect.y.saturating_sub(1); + let bottom_padding_y = composer_rect + .y + .saturating_add(composer_rect.height) + .saturating_sub(1); + let input_row_y = composer_rect.y; assert!( - hint_row_idx > 0, - "expected a spacing row above the footer hints", + bottom_padding_y < area.y.saturating_add(area.height), + "expected area to include a row for bottom padding" ); - let spacing_row = row_to_string(hint_row_idx - 1); - assert_eq!( - spacing_row.trim(), - "", - "expected blank spacing row above hints but saw: {spacing_row:?}", + for x in composer_rect.x..composer_rect.x.saturating_add(composer_rect.width) { + let input_bg = buf[(x, input_row_y)].style().bg; + let top_cell = &buf[(x, top_padding_y)]; + assert_eq!( + top_cell.style().bg, + input_bg, + "expected top padding cell at ({x},{top_padding_y}) to share the input background" + ); + + if bottom_padding_y < area.y.saturating_add(area.height) { + let bottom_cell = &buf[(x, bottom_padding_y)]; + assert_eq!( + bottom_cell.style().bg, + input_bg, + "expected bottom padding cell at ({x},{bottom_padding_y}) to share the input background" + ); + } + } + } + + #[test] + fn composer_leaves_transparent_margin_below_padding() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.insert_str("hello"); + + let height = composer.desired_height(40); + let area = Rect::new(0, 0, 40, height); + let mut buf = Buffer::empty(area); + composer.render_ref(area, &mut buf); + + let [composer_rect, _textarea_rect, popup_rect] = composer.layout_areas(area); + let bottom_padding_y = composer_rect + .y + .saturating_add(composer_rect.height) + .saturating_sub(1); + let margin_y = popup_rect.y; + + assert!( + bottom_padding_y < area.y.saturating_add(area.height), + "expected area to include a row for bottom padding" + ); + if bottom_padding_y < area.y.saturating_add(area.height) { + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, bottom_padding_y)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.trim().is_empty(), + "expected bottom padding row to be blank spaces" + ); + } + + assert!( + margin_y < area.y.saturating_add(area.height), + "expected area to include a row for bottom margin" + ); + let mut margin_row = String::new(); + for x in 0..area.width { + margin_row.push(buf[(x, margin_y)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + margin_row.trim().is_empty(), + "expected margin row to contain only whitespace" + ); + } + + #[test] + fn footer_hint_row_is_separated_from_composer() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, ); + + let height = composer.desired_height(40); + let area = Rect::new(0, 0, 40, height); + let mut buf = Buffer::empty(area); + composer.render_ref(area, &mut buf); + + let [composer_rect, _textarea_rect, popup_rect] = composer.layout_areas(area); + assert!( + popup_rect.height > 0, + "expected popup rect to reserve at least one row for margin" + ); + + let mut margin_row = String::new(); + for x in 0..area.width { + margin_row.push( + buf[(x, popup_rect.y)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + margin_row.trim().is_empty(), + "expected margin row at {} to be blank but saw: {margin_row:?}", + popup_rect.y + ); + + // The row immediately above the margin should belong to the composer (bottom padding). + let bottom_padding_y = composer_rect + .y + .saturating_add(composer_rect.height) + .saturating_sub(1); + if bottom_padding_y < area.y.saturating_add(area.height) { + let mut bottom_row = String::new(); + for x in 0..area.width { + bottom_row.push( + buf[(x, bottom_padding_y)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + bottom_row.trim().is_empty(), + "expected bottom padding row at {bottom_padding_y} to be blank but saw: {bottom_row:?}" + ); + } } fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) @@ -1773,10 +2076,10 @@ mod tests { let footer_props = composer.footer_props(); let footer_lines = footer_height(footer_props); let footer_spacing = ChatComposer::footer_spacing(footer_lines); - let height = footer_lines + footer_spacing + 8; + let height = footer_lines + footer_spacing + BOTTOM_MARGIN_HEIGHT + 8; let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) + .draw(|f| f.render_widget_ref(composer, f.area())) .unwrap(); insta::assert_snapshot!(name, terminal.backend()); } @@ -2296,7 +2599,7 @@ mod tests { } terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) + .draw(|f| f.render_widget_ref(composer, f.area())) .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); insta::assert_snapshot!(name, terminal.backend()); @@ -2322,12 +2625,12 @@ mod tests { // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { + let mut terminal = match Terminal::new(TestBackend::new(60, 4)) { Ok(t) => t, Err(e) => panic!("Failed to create terminal: {e}"), }; terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) + .draw(|f| f.render_widget_ref(composer, f.area())) .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); // Visual snapshot should show the slash popup with /model as the first entry. diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 79d7c60fa708..df0e9a671189 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -72,29 +72,22 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { } fn footer_lines(props: FooterProps) -> Vec> { - // Show the context indicator on the left, appended after the primary hint - // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when - // the shortcut hint is hidden). Hide it only for the multi-line - // ShortcutOverlay. + // Show context indicators only when explicitly requested. The default + // summary mode intentionally renders nothing so the footer stays hidden + // during normal operation. match props.mode { FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { is_task_running: props.is_task_running, })], - FooterMode::ShortcutSummary => { - let mut line = context_window_line(props.context_window_percent); - line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); - vec![line] - } + FooterMode::ShortcutSummary => Vec::new(), FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, }), FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)], + FooterMode::ContextOnly => context_window_line(props.context_window_percent) + .map(|line| vec![line]) + .unwrap_or_default(), } } @@ -221,9 +214,9 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let percent = percent.unwrap_or(100).clamp(0, 100); - Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) +fn context_window_line(_percent: Option) -> Option> { + let _ = _percent; + None } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -469,4 +462,25 @@ mod tests { }, ); } + + #[test] + fn footer_hides_context_meter() { + let lines = super::footer_lines(FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: Some(88), + }); + for line in lines { + let mut text = String::new(); + for span in line.spans { + text.push_str(span.content.as_ref()); + } + assert!( + !text.contains("context left"), + "context meter should be hidden, saw: {text:?}" + ); + } + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 685c71c875e7..081699459fc8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -118,6 +118,10 @@ impl BottomPane { self.view_stack.last().map(std::convert::AsRef::as_ref) } + pub(crate) fn has_active_view(&self) -> bool { + self.active_view().is_some() + } + fn push_view(&mut self, view: Box) { self.view_stack.push(view); self.request_redraw(); @@ -257,6 +261,11 @@ impl BottomPane { self.ctrl_c_quit_hint } + #[cfg(test)] + pub(crate) fn composer_layout_for_tests(&self, area: Rect) -> [Rect; 3] { + self.composer.layout_areas_for_tests(area) + } + #[cfg(test)] pub(crate) fn status_indicator_visible(&self) -> bool { self.status.is_some() diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index e4cc9ffefd57..adb764b6c4b2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 53e0aee4cf90..2ae146291b4e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index 49ffb0d4c8fc..d99ac9ffcb55 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap index 7ecc5bba7196..f3384326c639 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap index 9cad17b86482..d0d56b33ab6a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap index 2fce42cc26b7..a00147562063 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap index 9cad17b86482..d0d56b33ab6a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap index 67e616e917fc..dfeb98d61952 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -10,4 +10,3 @@ expression: terminal.backend() " " " " " " -" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap index 2fce42cc26b7..a00147562063 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -10,4 +10,5 @@ expression: terminal.backend() " " " " " " +" " " esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b6782d06d62..13edf90e3a5c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,6 +10,7 @@ expression: terminal.backend() " " " " " " +" " " / for commands shift + enter for newline " " @ for file paths ctrl + v to paste images " " esc again to edit previous message ctrl + c to exit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 6b018021ecec..4237a17ae00b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 40098faee016..3edfc2ce226f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 661e82e3ad17..2c1d81f1f570 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -6,4 +6,3 @@ expression: terminal.backend() "› /mo " " " " /model choose what model and reasoning effort to use " -" /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 498ed7693660..402740b8fa07 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap index d05ac90a9113..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 72% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index c95a5dc0b3d6..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 100% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 123a5eb3a3e1..33cdd972b4e4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -6,6 +6,4 @@ expression: "render_snapshot(&pane, area)" ⌥ + ↑ edit -› Ask Codex to do anything - - 100% context left · ? for shortcuts +› Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index 86e3da45730f..95aaab153e92 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -1,10 +1,9 @@ --- source: tui/src/bottom_pane/mod.rs +assertion_line: 711 expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interru -› Ask Codex to do anything - - 100% context left · ? for sh +› Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_include_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_include_bottom_padding.snap new file mode 100644 index 000000000000..be331e68c3b1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_include_bottom_padding.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interru + + +› Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 27df671e4d3e..9136b020e47c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -7,6 +7,4 @@ expression: "render_snapshot(&pane, area)" ⌥ + ↑ edit -› Ask Codex to do anything - - 100% context left · ? for shortcuts +› Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap index 52f96e8557ab..0bef83b73d01 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area1)" --- -› Ask Codex to do a + diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap new file mode 100644 index 000000000000..a126f62418d2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area2)" +--- +• Working (0s • esc diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4b51a7bdbdd2..8e488ff6f639 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -58,7 +58,9 @@ use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -96,6 +98,10 @@ use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::statusline::StatusLineGitSnapshot; +use crate::statusline::StatusLineLayout; +use crate::statusline::StatusLineOverlay; +use crate::statusline::StatusLineRenderer; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -132,7 +138,7 @@ struct RunningCommand { } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; -const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; +const NUDGE_MODEL_SLUG: &str = "gpt-5-codex-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; #[derive(Default)] @@ -230,6 +236,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) status_renderer: Option>, } #[derive(Default)] @@ -244,6 +251,7 @@ pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, + status_overlay: Option, active_cell: Option>, config: Config, auth_manager: Arc, @@ -330,9 +338,40 @@ impl ChatWidget { } } + pub(crate) fn update_statusline_git(&mut self, git: Option) { + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.update_git(git); + } + } + + pub(crate) fn update_statusline_kube_context(&mut self, context: Option) { + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.update_kube_context(context); + } + } + + #[allow(dead_code)] + pub(crate) fn set_status_renderer(&mut self, renderer: Box) { + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_renderer(renderer); + } + } + + #[cfg(test)] + pub(crate) fn status_line_mut( + &mut self, + ) -> Option<&mut crate::statusline::state::StatusLineState> { + self.status_overlay + .as_mut() + .map(StatusLineOverlay::state_mut) + } + fn set_status_header(&mut self, header: String) { self.current_status_header = header.clone(); self.bottom_pane.update_status_header(header); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header(&self.current_status_header); + } } // --- Small event handlers --- @@ -341,6 +380,11 @@ impl ChatWidget { .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_session_id(Some(event.session_id.to_string())); + overlay.sync_model(&self.config); + overlay.spawn_background_tasks(); + } let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); @@ -454,9 +498,16 @@ impl ChatWidget { self.bottom_pane.set_task_running(true); self.retry_status_header = None; self.bottom_pane.set_interrupt_hint_visible(true); + if self.status_overlay.is_some() { + self.bottom_pane.hide_status_indicator(); + } self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_interrupt_hint_visible(true); + overlay.start_task("Working"); + } self.request_redraw(); } @@ -466,6 +517,13 @@ impl ChatWidget { // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_interrupt_hint_visible(false); + overlay.complete_task(); + } + if let Some(overlay) = self.status_overlay.as_ref() { + overlay.spawn_background_tasks(); + } self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -474,21 +532,26 @@ impl ChatWidget { self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), }); - self.maybe_show_pending_rate_limit_prompt(); } pub(crate) fn set_token_info(&mut self, info: Option) { - if let Some(info) = info { - let context_window = info + self.token_info = info.clone(); + if let Some(info_inner) = self.token_info.as_ref() { + let context_window = info_inner .model_context_window .or(self.config.model_context_window); let percent = context_window.map(|window| { - info.last_token_usage + info_inner + .last_token_usage .percent_of_context_window_remaining(window) }); self.bottom_pane.set_context_window_percent(percent); - self.token_info = Some(info); + } else { + self.bottom_pane.set_context_window_percent(None); + } + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.update_tokens(info); } } @@ -728,9 +791,15 @@ impl ChatWidget { fn on_undo_started(&mut self, event: UndoStartedEvent) { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); + if self.status_overlay.is_some() { + self.bottom_pane.hide_status_indicator(); + } let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_interrupt_hint_visible(false); + } self.set_status_header(message); } @@ -870,6 +939,14 @@ impl ChatWidget { self.flush_active_cell(); } } + if self.running_commands.is_empty() { + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header("Working"); + } + if let Some(overlay) = self.status_overlay.as_ref() { + overlay.refresh_git(); + } + } } pub(crate) fn handle_patch_apply_end_now( @@ -881,6 +958,12 @@ impl ChatWidget { if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header("Working"); + } + if let Some(overlay) = self.status_overlay.as_ref() { + overlay.refresh_git(); + } } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -895,6 +978,9 @@ impl ChatWidget { reason: ev.reason, risk: ev.risk, }; + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header(&StatusLineOverlay::approval_status_label("command")); + } self.bottom_pane.push_approval_request(request); self.request_redraw(); } @@ -912,6 +998,9 @@ impl ChatWidget { changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header(&StatusLineOverlay::approval_status_label("patch")); + } self.bottom_pane.push_approval_request(request); self.request_redraw(); self.notify(Notification::EditApprovalRequested { @@ -930,6 +1019,10 @@ impl ChatWidget { is_user_shell_command: ev.is_user_shell_command, }, ); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.resume_timer(); + overlay.set_run_header(&StatusLineOverlay::exec_status_label(&ev.command)); + } if let Some(cell) = self .active_cell .as_mut() @@ -958,6 +1051,10 @@ impl ChatWidget { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.resume_timer(); + overlay.set_run_header(&StatusLineOverlay::tool_status_label(&ev.invocation)); + } self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, @@ -994,6 +1091,9 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_run_header("Working"); + } } pub(crate) fn new( @@ -1009,14 +1109,22 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + status_renderer, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + let frame_requester_clone = frame_requester.clone(); + let status_overlay = StatusLineOverlay::new( + &config, + frame_requester_clone.clone(), + app_event_tx.clone(), + status_renderer, + ); - Self { + let mut widget = Self { app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + frame_requester: frame_requester_clone, codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, @@ -1026,6 +1134,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), + status_overlay, active_cell: None, config: config.clone(), auth_manager, @@ -1056,7 +1165,17 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + }; + if let Some(overlay) = widget.status_overlay.as_mut() { + let queued: Vec = widget + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect(); + overlay.bootstrap(&widget.config, widget.token_info.clone(), queued); } + widget.refresh_queued_user_messages(); + widget } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). @@ -1074,16 +1193,24 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + status_renderer, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let frame_requester_clone = frame_requester.clone(); + let status_overlay = StatusLineOverlay::new( + &config, + frame_requester_clone.clone(), + app_event_tx.clone(), + status_renderer, + ); - Self { + let mut widget = Self { app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + frame_requester: frame_requester_clone, codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, @@ -1093,6 +1220,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), + status_overlay, active_cell: None, config: config.clone(), auth_manager, @@ -1123,7 +1251,17 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + }; + if let Some(overlay) = widget.status_overlay.as_mut() { + let queued: Vec = widget + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect(); + overlay.bootstrap(&widget.config, widget.token_info.clone(), queued); } + widget.refresh_queued_user_messages(); + widget } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { @@ -1143,21 +1281,8 @@ impl ChatWidget { kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => { - match paste_image_to_temp_png() { - Ok((path, info)) => { - self.attach_image( - path, - info.width, - info.height, - info.encoded_format.label(), - ); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } + if let Ok((path, info)) = paste_image_to_temp_png() { + self.attach_image(path, info.width, info.height, info.encoded_format.label()); } return; } @@ -1166,6 +1291,13 @@ impl ChatWidget { } _ => {} } + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Esc + && self.bottom_pane.is_task_running() + { + self.halt_running_task(); + return; + } match key_event { KeyEvent { @@ -1689,7 +1821,10 @@ impl ChatWidget { .iter() .map(|m| m.text.clone()) .collect(); - self.bottom_pane.set_queued_user_messages(messages); + self.bottom_pane.set_queued_user_messages(messages.clone()); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_queued_messages(messages); + } } pub(crate) fn add_diff_in_progress(&mut self) { @@ -1938,7 +2073,7 @@ impl ChatWidget { let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits."; let show_warning = - preset.model.starts_with("gpt-5.1-codex") && effort == ReasoningEffortConfig::High; + preset.model.starts_with("gpt-5-codex") && effort == ReasoningEffortConfig::High; let selected_description = show_warning.then(|| { description .as_ref() @@ -2487,14 +2622,27 @@ impl ChatWidget { } if self.bottom_pane.is_task_running() { + self.halt_running_task(); self.bottom_pane.show_ctrl_c_quit_hint(); - self.submit_op(Op::Interrupt); return; } self.submit_op(Op::Shutdown); } + fn halt_running_task(&mut self) { + self.bottom_pane.clear_ctrl_c_quit_hint(); + self.bottom_pane.set_task_running(false); + self.bottom_pane.set_interrupt_hint_visible(false); + self.running_commands.clear(); + if let Some(overlay) = self.status_overlay.as_mut() { + overlay.set_interrupt_hint_visible(false); + overlay.complete_task(); + } + self.submit_op(Op::Interrupt); + self.request_redraw(); + } + pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.composer_is_empty() } @@ -2734,6 +2882,16 @@ impl ChatWidget { self.token_info = None; } + fn bottom_pane_renderable(&self) -> impl Renderable + '_ { + BottomPaneWithOverlay { + bottom_pane: &self.bottom_pane, + overlay: self + .status_overlay + .as_ref() + .filter(|_| !self.bottom_pane.has_active_view()), + } + } + fn as_renderable(&self) -> RenderableItem<'_> { let active_cell_renderable = match &self.active_cell { Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), @@ -2743,7 +2901,8 @@ impl ChatWidget { flex.push(1, active_cell_renderable); flex.push( 0, - RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), + RenderableItem::Owned(Box::new(self.bottom_pane_renderable())) + .inset(Insets::tlbr(1, 0, 0, 0)), ); RenderableItem::Owned(Box::new(flex)) } @@ -2764,6 +2923,66 @@ impl Renderable for ChatWidget { } } +struct BottomPaneWithOverlay<'a> { + bottom_pane: &'a BottomPane, + overlay: Option<&'a StatusLineOverlay>, +} + +impl BottomPaneWithOverlay<'_> { + fn overlay_layout(&self, area: Rect) -> Option { + let overlay = self.overlay?; + overlay.layout(area, self.bottom_pane.has_active_view()) + } +} + +impl Renderable for BottomPaneWithOverlay<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let overlay_layout = self.overlay_layout(area); + if let Some(layout) = overlay_layout { + if !layout.pane_area.is_empty() { + self.bottom_pane.render(layout.pane_area, buf); + } else { + self.bottom_pane.render(area, buf); + } + if !layout.run_pill_area.is_empty() { + Clear.render(layout.run_pill_area, buf); + if let Some(overlay) = self.overlay { + overlay.render_run_pill(layout.run_pill_area, buf); + } + } + if !layout.status_line_area.is_empty() { + Clear.render(layout.status_line_area, buf); + if let Some(overlay) = self.overlay { + overlay.render_status_line(layout.status_line_area, buf); + } + } + } else { + self.bottom_pane.render(area, buf); + } + } + + fn desired_height(&self, width: u16) -> u16 { + let mut height = self.bottom_pane.desired_height(width); + if self.overlay.is_some() && !self.bottom_pane.has_active_view() { + height = height.saturating_add(StatusLineOverlay::reserved_rows()); + } + height + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let overlay_layout = self.overlay_layout(area); + if let Some(layout) = overlay_layout { + if !layout.pane_area.is_empty() { + self.bottom_pane.cursor_pos(layout.pane_area) + } else { + self.bottom_pane.cursor_pos(area) + } + } else { + self.bottom_pane.cursor_pos(area) + } + } +} + enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index 4487d0652e88..7da38c956506 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,7 +1,8 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1998 expression: terminal.backend() --- " " " " -" " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 7a04b0ef1969..c601b03f8ed4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2028 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 4487d0652e88..cacf682ce57e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,7 +1,8 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2028 expression: terminal.backend() --- " " " " -" " +"› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index c3bdf60bd2cf..461494c6e179 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2977 expression: term.backend().vt100().screen().contents() --- • I’m going to search the repo for where “Change Approved” is rendered to update @@ -9,9 +10,11 @@ expression: term.backend().vt100().screen().contents() └ Search Change Approved Read diff_render.rs -• Investigating rendering code (0s • esc to interrupt) + + 󰔟 0s ◦ Investigating rendering code  › Summarize recent commits - 100% context left + + tui  󱚥 gpt-5-codex    vermissian   codex-aws-test  ☸ codex-dev  diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap.new new file mode 100644 index 000000000000..22e40a7d6300 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap.new @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 3076 +expression: term.backend().vt100().screen().contents() +--- +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + + + 󰔟 0s ◦ Investigating rendering code  + + +› Summarize recent commits + + + tui  󱚣 gpt-5.1-codex    vermissian   codex-aws-test  ☸ codex-dev  diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 6d9aa515b1a0..85cd2d878303 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 3175 expression: term.backend().vt100().screen().contents() --- • Working (0s • esc to interrupt) @@ -20,8 +21,7 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 14 ↳ Hello, world! 15 ↳ Hello, world! 16 + ↳ Hello, world! 17 › Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f986a927e596..260f7797f0ac 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -1,5 +1,9 @@ --- source: tui/src/chatwidget/tests.rs +<<<<<<< HEAD +======= +assertion_line: 562 +>>>>>>> main expression: "format!(\"{buf:?}\")" --- Buffer { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap.new new file mode 100644 index 000000000000..9b23873b0170 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap.new @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1587 +expression: popup +--- + Select Reasoning Level for gpt-5.1-codex + + 1. Low Fastest responses with limited reasoning + 2. Medium (default) Dynamically adjusts reasoning based on the task +› 3. High (current) Maximizes reasoning depth for complex or ambiguous + problems + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index e210d1f0a392..dc93fa024f2e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,7 +1,9 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 527 expression: popup --- +<<<<<<< HEAD Approaching rate limits Switch to gpt-5.1-codex-mini for lower credit usage? @@ -10,5 +12,12 @@ expression: popup 2. Keep current model 3. Keep current model (never show again) Hide future rate limit reminders about switching models. +======= + Approaching your credits limit + Switch to gpt-5-codex-mini to use fewer credits for upcoming turns. +>>>>>>> main + 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, and + less capable. +› 2. Keep current model (current) Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap.new new file mode 100644 index 000000000000..99e492111a9b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap.new @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 543 +expression: popup +--- +› Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_band_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_band_snapshot.snap new file mode 100644 index 000000000000..0f78011efd6e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_band_snapshot.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: "captured.join(\"\\n\")" +--- +› Summarize recent commits + + + tui  󱚥 gpt-5-codex    vermissian   feature/status-polish…   prod  diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index 9fbebfb500f9..de74d6e63223 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1577 +assertion_line: 2186 expression: terminal.backend() --- " " @@ -9,4 +9,3 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index f98c80787872..05af37e44850 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1548 +assertion_line: 2157 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 217e0fb307e1..b064ae6d67de 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,11 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::statusline::CustomStatusLineRenderer; +use crate::statusline::StatusLineOverlay; +use crate::statusline::StatusLineRenderer; +use crate::statusline::clear_devspace_override_for_tests; +use crate::statusline::set_devspace_override_for_tests; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -51,6 +56,8 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +use ratatui::style::Color; +use ratatui::style::Style; use std::fs::File; use std::io::BufRead; use std::io::BufReader; @@ -262,6 +269,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + status_renderer: None, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -269,7 +277,9 @@ async fn helpers_are_available_and_do_not_panic() { } // --- Helpers for tests that need direct construction and event draining --- -fn make_chatwidget_manual() -> ( +fn make_chatwidget_with_config( + cfg: Config, +) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, @@ -277,20 +287,29 @@ fn make_chatwidget_manual() -> ( let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); - let cfg = test_config(); + let frame_requester = FrameRequester::test_dummy(); + let frame_requester_clone = frame_requester.clone(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), + frame_requester, has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let widget = ChatWidget { + let status_overlay = StatusLineOverlay::new( + &cfg, + frame_requester_clone.clone(), + app_event_tx.clone(), + cfg.tui_custom_statusline + .then(|| Box::new(CustomStatusLineRenderer) as Box), + ); + let mut widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, bottom_pane: bottom, + status_overlay, active_cell: None, config: cfg.clone(), auth_manager, @@ -309,7 +328,7 @@ fn make_chatwidget_manual() -> ( current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, - frame_requester: FrameRequester::test_dummy(), + frame_requester: frame_requester_clone, show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, @@ -320,9 +339,33 @@ fn make_chatwidget_manual() -> ( feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, }; + if let Some(overlay) = widget.status_overlay.as_mut() { + overlay.bootstrap(&widget.config, widget.token_info.clone(), Vec::new()); + } + widget.refresh_queued_user_messages(); (widget, rx, op_rx) } +fn make_chatwidget_manual() -> ( + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let mut cfg = test_config(); + cfg.tui_custom_statusline = false; + make_chatwidget_with_config(cfg) +} + +fn make_chatwidget_manual_with_custom_statusline() -> ( + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let mut cfg = test_config(); + cfg.tui_custom_statusline = true; + make_chatwidget_with_config(cfg) +} + pub(crate) fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, @@ -802,6 +845,33 @@ fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); } +#[test] +fn esc_interrupt_resets_status_indicator_and_statusline() { + use std::time::Instant; + + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual_with_custom_statusline(); + chat.on_task_started(); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let op = op_rx.try_recv().expect("expected interrupt op"); + assert_matches!(op, Op::Interrupt); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + assert!(!chat.bottom_pane.is_task_running()); + assert!(!chat.bottom_pane.status_indicator_visible()); + assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible()); + + if let Some(overlay) = chat.status_overlay.as_mut() { + let snapshot = overlay.state_mut().snapshot_for_render(Instant::now()); + let label = snapshot.run_state.expect("run state").label; + assert_eq!(label, "Ready when you are"); + } + + let _ = drain_insert_history(&mut rx); +} + #[test] fn ctrl_c_shutdown_ignores_caps_lock() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); @@ -2755,12 +2825,182 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { assert_snapshot!(combined); } +#[test] +fn cursor_row_aligns_with_prompt_when_status_overlay_active() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_custom_statusline(); + chat.bottom_pane.insert_str("hello"); + + let width = 80; + let height = chat.desired_height(width); + let area = ratatui::layout::Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + + let cursor = chat + .cursor_pos(area) + .expect("cursor position should be available when overlay is active"); + let mut prompt_row = None; + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if row.contains('›') { + prompt_row = Some((y, row)); + break; + } + } + let (prompt_row_idx, prompt_row_contents) = + prompt_row.expect("expected to find composer prompt row"); + assert_eq!( + cursor.1, prompt_row_idx, + "cursor should align with composer prompt when status overlay is active: {prompt_row_contents:?}" + ); +} + +#[test] +fn typed_input_preserves_padding_and_margin_with_status_overlay() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_custom_statusline(); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + chat.bottom_pane.insert_str("h"); + + let width = 80; + assert!( + chat.bottom_pane.desired_height(width) >= 3, + "expected bottom pane to reserve at least three rows" + ); + let height = chat.desired_height(width); + let area = ratatui::layout::Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + chat.render(area, &mut buf); + + let bottom_pane_area = Rect::new( + area.x, + area.y.saturating_add( + area.height + .saturating_sub(chat.bottom_pane.desired_height(width)), + ), + area.width, + chat.bottom_pane.desired_height(width), + ); + let has_active_view = chat.bottom_pane.has_active_view(); + let pane_area = if let Some(overlay) = chat.status_overlay.as_ref() { + let layout = overlay + .layout(bottom_pane_area, has_active_view) + .unwrap_or_else(|| { + panic!("expected overlay layout to be available for custom status line") + }); + layout.pane_area + } else { + bottom_pane_area + }; + let [composer_rect, textarea_rect, popup_rect] = + chat.bottom_pane.composer_layout_for_tests(pane_area); + assert!( + popup_rect.height >= 1, + "expected popup area to reserve at least padding row: {popup_rect:?}" + ); + + let padding_y = popup_rect.y; + let margin_y = padding_y.saturating_add(1); + let expected_padding_bg = crate::style::user_message_style() + .bg + .unwrap_or(Color::Reset); + + let mut composer_row = String::new(); + for x in textarea_rect.x..textarea_rect.x.saturating_add(textarea_rect.width) { + composer_row.push( + buf[(x, textarea_rect.y)] + .symbol() + .chars() + .next() + .unwrap_or(' '), + ); + } + assert!( + composer_row.trim_end().starts_with('h'), + "expected composer row to render typed text but saw {composer_row:?}" + ); + assert_eq!( + buf[(composer_rect.x, composer_rect.y)].symbol(), + "›", + "expected prompt glyph to remain on composer row" + ); + for x in popup_rect.x..popup_rect.x.saturating_add(popup_rect.width) { + let padding_cell = &buf[(x, padding_y)]; + assert_eq!( + padding_cell.symbol().chars().next().unwrap_or(' '), + ' ', + "expected composer padding row to contain spaces at ({x},{padding_y})" + ); + let padding_style = padding_cell.style(); + assert_eq!( + padding_style.bg.unwrap_or(Color::Reset), + expected_padding_bg, + "expected composer padding row to match composer background color" + ); + assert!( + padding_style.add_modifier.is_empty(), + "expected composer padding row to avoid additional text modifiers" + ); + let margin_cell = &buf[(x, margin_y)]; + assert_eq!( + margin_cell.symbol().chars().next().unwrap_or(' '), + ' ', + "expected margin row to be blank at ({x},{margin_y})" + ); + assert_eq!( + margin_cell.style().bg.unwrap_or(Color::Reset), + Style::default().bg.unwrap_or(Color::Reset), + "expected margin row to use transparent background" + ); + assert!( + margin_cell.style().add_modifier.is_empty(), + "expected margin row to avoid additional text modifiers" + ); + } + + let bottom_padding_y = composer_rect + .y + .saturating_add(composer_rect.height) + .saturating_sub(1); + let margin_y = popup_rect.y; + let input_row_y = composer_rect.y; + let input_bg = buf[(composer_rect.x, input_row_y)].style().bg; + + for x in composer_rect.x..composer_rect.x.saturating_add(composer_rect.width) { + let bottom_cell = &buf[(x, bottom_padding_y)]; + assert_eq!( + bottom_cell.style().bg, + input_bg, + "expected bottom padding to retain composer background after typing at cell ({x},{bottom_padding_y})" + ); + } + + let mut margin_row = String::new(); + for x in popup_rect.x..popup_rect.x.saturating_add(popup_rect.width) { + margin_row.push(buf[(x, margin_y)].symbol().chars().next().unwrap_or(' ')); + } + assert!( + margin_row.trim().is_empty(), + "expected margin row at {margin_y} to remain whitespace after typing: {margin_row:?}" + ); +} + // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. // This renders the final visual as seen in a terminal: history above, then a blank line, // then the exec block, another blank line, the status line, a blank line, and the composer. #[test] fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + set_devspace_override_for_tests(Some("earth".to_string())); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_custom_statusline(); + clear_devspace_override_for_tests(); + if let Some(status_line) = chat.status_line_mut() { + status_line.set_devspace(Some("earth".to_string())); + status_line.set_hostname(Some("vermissian".to_string())); + status_line.set_aws_profile(Some("codex-aws-test".to_string())); + } + chat.update_statusline_kube_context(Some("codex-dev".to_string())); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d7b674a9df1e..6e2ba6acfbd9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -66,6 +66,7 @@ mod shimmer; mod slash_command; mod status; mod status_indicator_widget; +mod statusline; mod streaming; mod style; mod terminal_palette; diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap new file mode 100644 index 000000000000..d188b2fd6fd0 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Edited example.txt (+1 -1) " +" 1 line one " +" 2 -line two " +" 2 +line two changed " +" 3 line three " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index eccb6b72b5a7..2d836d5dae0b 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -5,6 +5,9 @@ mod helpers; mod rate_limits; pub(crate) use card::new_status_output; +pub(crate) use format::line_display_width; +pub(crate) use format::truncate_line_to_width; +pub(crate) use helpers::format_directory_display; pub(crate) use rate_limits::RateLimitSnapshotDisplay; pub(crate) use rate_limits::rate_limit_snapshot_display; diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs new file mode 100644 index 000000000000..6b997ba89be1 --- /dev/null +++ b/codex-rs/tui/src/statusline/mod.rs @@ -0,0 +1,1485 @@ +use std::borrow::Cow; +use std::time::Duration; +use std::time::Instant; + +use crate::exec_cell::spinner; +use crate::key_hint; +use crate::status::line_display_width; +use crate::status::truncate_line_to_width; +use crossterm::event::KeyCode; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +mod overlay; +mod palette; +pub(crate) mod skins; +pub(crate) mod state; + +pub(crate) use overlay::StatusLineLayout; +pub(crate) use overlay::StatusLineOverlay; +pub(crate) use skins::CustomStatusLineRenderer; + +#[cfg(test)] +pub(crate) use overlay::clear_devspace_override_for_tests; +#[cfg(test)] +pub(crate) use overlay::set_devspace_override_for_tests; + +use palette::BASE; +use palette::GREEN; +use palette::GREEN_LIGHT; +use palette::LAVENDER; +use palette::MAUVE; +use palette::PEACH; +use palette::PEACH_LIGHT; +use palette::RED; +use palette::RED_LIGHT; +use palette::ROSEWATER; +use palette::SKY; +use palette::SUBTEXT0; +use palette::TEAL; +use palette::YELLOW; +use palette::YELLOW_LIGHT; +use palette::queue_preview_style; + +const LEFT_CURVE: &str = ""; +const RIGHT_CURVE: &str = ""; +const LEFT_CHEVRON: &str = ""; +const RIGHT_CHEVRON: &str = ""; +const GIT_ICON: &str = " "; +const AWS_ICON: &str = " "; +const K8S_ICON: &str = "☸ "; +const HOSTNAME_ICON: &str = " "; +const CONTEXT_ICON: &str = " "; +const PROGRESS_LEFT_EMPTY: &str = ""; +const PROGRESS_MID_EMPTY: &str = ""; +const PROGRESS_RIGHT_EMPTY: &str = ""; +const PROGRESS_LEFT_FULL: &str = ""; +const PROGRESS_MID_FULL: &str = ""; +const PROGRESS_RIGHT_FULL: &str = ""; +const MODEL_ICONS: &[char] = &['󰚩', '󱚝', '󱚟', '󱚡', '󱚣', '󱚥']; +const DEVSPACE_ICONS: &[&str] = &["󰠖 ", "󰠶 ", "󰋩 ", "󰚌 "]; +const CONTEXT_PADDING: usize = 4; +const DEFAULT_STATUS_MESSAGE: &str = "Ready when you are"; + +pub(crate) trait StatusLineRenderer: std::fmt::Debug + Send + Sync { + fn render(&self, snapshot: &StatusLineSnapshot, width: u16, now: Instant) -> Line<'static>; + + fn render_run_pill( + &self, + snapshot: &StatusLineSnapshot, + width: u16, + now: Instant, + ) -> Line<'static>; +} + +fn span(text: S, style: Style) -> Span<'static> +where + S: Into>, +{ + Span::styled(text.into(), style) +} + +fn accent_fg(color: Color) -> Style { + Style::default().fg(color) +} + +fn segment_fill(color: Color) -> Style { + Style::default().fg(BASE).bg(color) +} + +fn status_spinner(start_time: Option) -> Span<'static> { + let mut span = spinner(start_time); + if span.content.as_ref() == "•" { + return "◦".dim(); + } + span.style = span.style.add_modifier(Modifier::DIM); + span +} + +fn bridge_left(prev: Color, next: Color) -> Style { + Style::default().fg(prev).bg(next) +} + +fn bridge_right(prev: Color, next: Color) -> Style { + Style::default().fg(next).bg(prev) +} + +fn dim_text() -> Style { + Style::default().fg(SUBTEXT0).add_modifier(Modifier::DIM) +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineSnapshot { + pub cwd_display: Option, + pub cwd_basename: Option, + pub cwd_fallback: Option, + pub model: Option, + pub tokens: Option, + pub context: Option, + pub run_state: Option, + pub git: Option, + pub environment: StatusLineEnvironmentSnapshot, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineEnvironmentSnapshot { + pub devspace: Option, + pub hostname: Option, + pub aws_profile: Option, + pub kubernetes_context: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineModelSnapshot { + pub label: String, + pub detail: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineTokenSnapshot { + pub total: TokenCountSnapshot, + #[allow(dead_code)] + pub last: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub(crate) struct TokenCountSnapshot { + pub total_tokens: i64, + pub input_tokens: i64, + pub cached_input_tokens: i64, + pub output_tokens: i64, + pub reasoning_output_tokens: i64, +} + +impl TokenCountSnapshot { + fn blended_total(&self) -> i64 { + self.input_without_cache() + self.output_tokens + } + + fn input_without_cache(&self) -> i64 { + self.input_tokens.saturating_sub(self.cached_input_tokens) + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineContextSnapshot { + pub percent_remaining: u8, + pub tokens_in_context: i64, + pub window: i64, +} + +impl StatusLineContextSnapshot { + #[allow(dead_code)] + fn percent_used(&self) -> u8 { + 100u8.saturating_sub(self.percent_remaining) + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineGitSnapshot { + pub branch: Option, + pub dirty: bool, + pub ahead: Option, + pub behind: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineDevspaceSnapshot { + pub name: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct StatusLineRunState { + pub label: String, + pub spinner_started_at: Option, + pub timer: Option, + pub queued_messages: Vec, + pub show_interrupt_hint: bool, + pub status_changed_at: Instant, +} + +impl Default for StatusLineRunState { + fn default() -> Self { + Self { + label: String::new(), + spinner_started_at: None, + timer: None, + queued_messages: Vec::new(), + show_interrupt_hint: false, + status_changed_at: Instant::now(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct RunTimerSnapshot { + pub elapsed_running: Duration, + pub last_resume_at: Option, + pub is_paused: bool, +} + +impl RunTimerSnapshot { + fn elapsed_at(&self, now: Instant) -> Duration { + if self.is_paused { + return self.elapsed_running; + } + let Some(last_resume) = self.last_resume_at else { + return self.elapsed_running; + }; + self.elapsed_running + .saturating_add(now.saturating_duration_since(last_resume)) + } +} + +pub(crate) fn format_elapsed_compact(elapsed_secs: u64) -> String { + if elapsed_secs < 60 { + return format!("{elapsed_secs}s"); + } + if elapsed_secs < 3600 { + let minutes = elapsed_secs / 60; + let seconds = elapsed_secs % 60; + return format!("{minutes}m {seconds:02}s"); + } + let hours = elapsed_secs / 3600; + let minutes = (elapsed_secs % 3600) / 60; + let seconds = elapsed_secs % 60; + format!("{hours}h {minutes:02}m {seconds:02}s") +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum PathVariant { + Full, + Basename, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum TokenVariant { + Full, + Compact, + Minimal, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum ContextVariant { + Bar, + Compact, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum GitVariant { + BranchWithStatus, + BranchOnly, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum RunLabelVariant { + Full, + Short, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum DegradeOp { + DropDevspace, + DropKubernetes, + DropAwsProfile, + DropHostname, + DropQueuePreview, + HideInterruptHint, + HideRunTimer, + ShortenRunLabel, + HideRunLabel, + SimplifyGit, + SimplifyTokens, + MinimalTokens, + HideTokens, + SimplifyContext, + HideContext, + BasenamePath, + HidePath, + HideGit, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct EnvironmentInclusion { + hostname: bool, + aws_profile: bool, + kubernetes: bool, + devspace: bool, +} + +impl EnvironmentInclusion { + fn new(snapshot: &StatusLineEnvironmentSnapshot) -> Self { + Self { + hostname: snapshot.hostname.is_some(), + aws_profile: snapshot.aws_profile.is_some(), + kubernetes: snapshot.kubernetes_context.is_some(), + devspace: snapshot.devspace.is_some(), + } + } + + fn empty() -> Self { + Self { + hostname: false, + aws_profile: false, + kubernetes: false, + devspace: false, + } + } +} + +#[derive(Debug, Default)] +pub(crate) struct DefaultStatusLineRenderer; + +impl StatusLineRenderer for DefaultStatusLineRenderer { + fn render(&self, snapshot: &StatusLineSnapshot, width: u16, now: Instant) -> Line<'static> { + render_status_line(snapshot, width, now) + } + + fn render_run_pill( + &self, + snapshot: &StatusLineSnapshot, + width: u16, + now: Instant, + ) -> Line<'static> { + render_status_run_pill(snapshot, width, now) + } +} + +pub(crate) fn render_status_line( + snapshot: &StatusLineSnapshot, + width: u16, + now: Instant, +) -> Line<'static> { + let mut model = RenderModel::new(snapshot, now); + let target_width = width as usize; + + loop { + if let Some(line) = model.try_render_line(target_width) { + return line; + } + if !model.apply_next_degrade() { + let fallback = model.fallback_line(); + return truncate_line_to_width(fallback, target_width); + } + } +} + +pub(crate) fn render_status_run_pill( + snapshot: &StatusLineSnapshot, + width: u16, + now: Instant, +) -> Line<'static> { + let target_width = width as usize; + if target_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut model = RenderModel::new(snapshot, now); + model.path_variant = PathVariant::Hidden; + model.token_variant = TokenVariant::Hidden; + model.context_variant = ContextVariant::Hidden; + model.git_variant = GitVariant::Hidden; + model.env = EnvironmentInclusion::empty(); + model.include_queue_preview = true; + model.show_interrupt_hint = false; + + let mut attempts = 0usize; + loop { + let segments = model.run_state_segments(snapshot.run_state.as_ref()); + let spans = capsule_spans(segments); + let mut line = Line::from(spans.clone()); + let display_width = line_display_width(&line); + if display_width <= target_width { + if display_width < target_width { + let padding = " ".repeat(target_width - display_width); + line.spans.push(Span::raw(padding)); + } + return line; + } + if !degrade_run_capsule(&mut model) { + return truncate_line_to_width(Line::from(spans), target_width); + } + attempts += 1; + if attempts > 8 { + return truncate_line_to_width(Line::from(spans), target_width); + } + } +} + +struct RenderModel<'a> { + snapshot: &'a StatusLineSnapshot, + now: Instant, + path_variant: PathVariant, + token_variant: TokenVariant, + context_variant: ContextVariant, + git_variant: GitVariant, + include_queue_preview: bool, + show_interrupt_hint: bool, + show_run_timer: bool, + show_run_label: bool, + run_label_variant: RunLabelVariant, + env: EnvironmentInclusion, + degrade_cursor: usize, +} + +impl<'a> RenderModel<'a> { + fn new(snapshot: &'a StatusLineSnapshot, now: Instant) -> Self { + let run_state = snapshot.run_state.as_ref(); + let has_timer = run_state.and_then(|state| state.timer.as_ref()).is_some(); + let show_hint = run_state + .map(|state| state.show_interrupt_hint) + .unwrap_or(false); + Self { + snapshot, + now, + path_variant: PathVariant::Full, + token_variant: TokenVariant::Hidden, + context_variant: ContextVariant::Bar, + git_variant: GitVariant::BranchWithStatus, + include_queue_preview: true, + show_interrupt_hint: show_hint, + show_run_timer: has_timer, + show_run_label: run_state.is_some(), + run_label_variant: RunLabelVariant::Full, + env: EnvironmentInclusion::new(&snapshot.environment), + degrade_cursor: 0, + } + } + + fn fallback_line(&self) -> Line<'static> { + let mut parts: Vec = Vec::new(); + if let Some(path) = self + .snapshot + .cwd_fallback + .as_ref() + .or(self.snapshot.cwd_display.as_ref()) + { + parts.push(path.clone()); + } + if let Some(model) = self.snapshot.model.as_ref() { + parts.push(model.label.clone()); + } + if let Some(git) = self.snapshot.git.as_ref() + && let Some(branch) = git.branch.as_ref() + { + let mut branch_text = branch.clone(); + if git.dirty { + branch_text.push('*'); + } + parts.push(branch_text); + } + if parts.is_empty() { + return Line::from("codex"); + } + Line::from(parts.join(" | ")) + } + + fn apply_next_degrade(&mut self) -> bool { + const DEGRADE_ORDER: &[DegradeOp] = &[ + DegradeOp::DropQueuePreview, + DegradeOp::HideInterruptHint, + DegradeOp::HideRunTimer, + DegradeOp::ShortenRunLabel, + DegradeOp::HideRunLabel, + DegradeOp::BasenamePath, + DegradeOp::SimplifyTokens, + DegradeOp::MinimalTokens, + DegradeOp::HideTokens, + DegradeOp::SimplifyContext, + DegradeOp::HideContext, + DegradeOp::SimplifyGit, + DegradeOp::HideGit, + DegradeOp::DropDevspace, + DegradeOp::DropKubernetes, + DegradeOp::DropAwsProfile, + DegradeOp::DropHostname, + DegradeOp::HidePath, + ]; + + while self.degrade_cursor < DEGRADE_ORDER.len() { + let op = DEGRADE_ORDER[self.degrade_cursor]; + self.degrade_cursor += 1; + if self.apply_degrade(op) { + return true; + } + } + false + } + + fn apply_degrade(&mut self, op: DegradeOp) -> bool { + match op { + DegradeOp::DropDevspace if self.env.devspace => { + self.env.devspace = false; + true + } + DegradeOp::DropKubernetes if self.env.kubernetes => { + self.env.kubernetes = false; + true + } + DegradeOp::DropAwsProfile if self.env.aws_profile => { + self.env.aws_profile = false; + true + } + DegradeOp::DropHostname if self.env.hostname => { + self.env.hostname = false; + true + } + DegradeOp::DropQueuePreview if self.include_queue_preview => { + self.include_queue_preview = false; + true + } + DegradeOp::HideInterruptHint if self.show_interrupt_hint => { + self.show_interrupt_hint = false; + true + } + DegradeOp::HideRunTimer if self.show_run_timer => { + self.show_run_timer = false; + true + } + DegradeOp::ShortenRunLabel + if self.show_run_label && self.run_label_variant == RunLabelVariant::Full => + { + self.run_label_variant = RunLabelVariant::Short; + true + } + DegradeOp::HideRunLabel if self.show_run_label => { + self.show_run_label = false; + true + } + DegradeOp::SimplifyGit if self.git_variant == GitVariant::BranchWithStatus => { + self.git_variant = GitVariant::BranchOnly; + true + } + DegradeOp::SimplifyTokens if self.token_variant == TokenVariant::Full => { + self.token_variant = TokenVariant::Compact; + true + } + DegradeOp::MinimalTokens if self.token_variant == TokenVariant::Compact => { + self.token_variant = TokenVariant::Minimal; + true + } + DegradeOp::HideTokens if self.token_variant != TokenVariant::Hidden => { + self.token_variant = TokenVariant::Hidden; + true + } + DegradeOp::SimplifyContext if self.context_variant == ContextVariant::Bar => { + self.context_variant = ContextVariant::Compact; + true + } + DegradeOp::HideContext if self.context_variant != ContextVariant::Hidden => { + self.context_variant = ContextVariant::Hidden; + true + } + DegradeOp::BasenamePath if self.path_variant == PathVariant::Full => { + self.path_variant = PathVariant::Basename; + true + } + DegradeOp::HidePath if self.path_variant != PathVariant::Hidden => { + self.path_variant = PathVariant::Hidden; + true + } + DegradeOp::HideGit if self.git_variant != GitVariant::Hidden => { + self.git_variant = GitVariant::Hidden; + true + } + _ => false, + } + } + + fn try_render_line(&self, target_width: usize) -> Option> { + let left_spans = self.render_left_segments()?; + let right_spans = self.render_right_segments()?; + + let left_line = Line::from(left_spans.clone()); + let right_line = Line::from(right_spans.clone()); + let left_width = line_display_width(&left_line); + let right_width = line_display_width(&right_line); + let available_for_middle = target_width.checked_sub(left_width + right_width)?; + let (middle_spans, _middle_width) = self.render_middle(available_for_middle)?; + + let mut spans: Vec> = Vec::new(); + spans.extend(left_spans); + spans.extend(middle_spans); + spans.extend(right_spans); + + let line = Line::from(spans); + debug_assert!(line_display_width(&line) <= target_width); + if line_display_width(&line) == target_width { + Some(line) + } else { + None + } + } + + fn render_left_segments(&self) -> Option>> { + let segments = self.collect_left_segments(); + if segments.is_empty() { + return Some(Vec::new()); + } + + let mut spans: Vec> = Vec::new(); + let mut previous: Option = None; + for segment in segments { + let accent = segment.accent; + if let Some(prev) = previous { + spans.push(span(LEFT_CHEVRON, bridge_left(prev, accent))); + } else { + spans.push(span(LEFT_CURVE, accent_fg(accent))); + } + spans.extend(segment.into_padded_spans()); + previous = Some(accent); + } + if let Some(last) = previous { + spans.push(span(LEFT_CHEVRON, accent_fg(last))); + } + Some(spans) + } + + fn collect_left_segments(&self) -> Vec { + let mut segments: Vec = Vec::new(); + segments.extend(self.run_state_segments(self.snapshot.run_state.as_ref())); + if let Some(segment) = self.path_segment() { + segments.push(segment); + } + if let Some(segment) = self.model_segment() { + segments.push(segment); + } + segments + } + + fn path_segment(&self) -> Option { + let text = self.path_text()?; + Some(PowerlineSegment::text(LAVENDER, text)) + } + + fn path_text(&self) -> Option { + match self.path_variant { + PathVariant::Hidden => None, + PathVariant::Full => self + .snapshot + .cwd_display + .as_ref() + .map(|path| truncate_graphemes(path, 40)), + PathVariant::Basename => self + .snapshot + .cwd_basename + .clone() + .or_else(|| self.snapshot.cwd_fallback.clone()) + .map(|path| truncate_graphemes(&path, 28)), + } + } + + fn model_segment(&self) -> Option { + let model = self.snapshot.model.as_ref()?; + let mut spans: Vec> = Vec::new(); + let icon = select_model_icon(&model.label).to_string(); + spans.push(icon.into()); + if !model.label.is_empty() { + spans.push(" ".into()); + spans.push(Span::styled( + model.label.clone(), + Style::default().add_modifier(Modifier::BOLD), + )); + } + if let Some(detail) = model.detail.as_ref() { + spans.push(" ".into()); + spans.push(Span::styled( + detail.clone(), + Style::default().fg(BASE).add_modifier(Modifier::ITALIC), + )); + } + if let Some(tokens) = self.format_token_summary() { + spans.push(" ".into()); + spans.push(Span::styled(tokens, dim_text())); + } + Some(PowerlineSegment::from_spans(SKY, spans)) + } + + fn format_token_summary(&self) -> Option { + let tokens = self.snapshot.tokens.as_ref()?; + match self.token_variant { + TokenVariant::Hidden => None, + TokenVariant::Minimal => Some(format!( + "Σ{}", + format_token_count(tokens.total.blended_total()) + )), + TokenVariant::Compact | TokenVariant::Full => { + let mut parts = Vec::new(); + parts.push(format!( + "Σ{}", + format_token_count(tokens.total.blended_total()) + )); + parts.push(format!( + "↑{}", + format_token_count(tokens.total.input_without_cache()) + )); + if tokens.total.cached_input_tokens > 0 { + parts.push(format!( + "↺{}", + format_token_count(tokens.total.cached_input_tokens) + )); + } + parts.push(format!( + "↓{}", + format_token_count(tokens.total.output_tokens) + )); + Some(parts.join(" ")) + } + } + } + + fn run_state_segments(&self, state: Option<&StatusLineRunState>) -> Vec { + let Some(state) = state else { + return Vec::new(); + }; + + let mut segments: Vec = Vec::new(); + + let mut capsule_spans: Vec> = Vec::new(); + if self.show_run_timer { + let elapsed_secs = state + .timer + .as_ref() + .map(|timer| timer.elapsed_at(self.now).as_secs()) + .unwrap_or(0); + capsule_spans.push(Span::raw(format!( + "󰔟 {}", + format_elapsed_compact(elapsed_secs) + ))); + } + + if self.show_run_label { + if !capsule_spans.is_empty() { + capsule_spans.push(" ".into()); + } + capsule_spans.push(status_spinner(state.spinner_started_at)); + let label = self.run_label_text(state); + if !label.trim().is_empty() { + capsule_spans.push(" ".into()); + capsule_spans.push(Span::raw(label.trim().to_string())); + } + } + + if capsule_spans.is_empty() { + let accent = self.status_capsule_accent(state); + segments.push(PowerlineSegment::from_spans( + accent, + vec![status_spinner(state.spinner_started_at)], + )); + } else { + let accent = self.status_capsule_accent(state); + segments.push(PowerlineSegment::from_spans(accent, capsule_spans)); + } + + if self.include_queue_preview && !state.queued_messages.is_empty() { + let (preview, extra) = queue_preview(&state.queued_messages); + let mut spans: Vec> = Vec::new(); + spans.push("next:".dim()); + spans.push(" ".into()); + spans.push(Span::styled(preview, queue_preview_style())); + if extra > 0 { + spans.push(" ".into()); + spans.push(Span::styled(format!("(+{extra})"), queue_preview_style())); + } + spans.push(" ".into()); + spans.push(key_hint::alt(KeyCode::Up).into()); + spans.push(" edit".dim()); + segments.push(PowerlineSegment::from_spans(MAUVE, spans)); + } + + segments + } + fn run_label_text(&self, state: &StatusLineRunState) -> String { + let mut label = match self.run_label_variant { + RunLabelVariant::Full => state.label.clone(), + RunLabelVariant::Short => state + .label + .split_whitespace() + .next() + .unwrap_or("") + .to_string(), + }; + if label.trim().is_empty() { + DEFAULT_STATUS_MESSAGE.to_string() + } else { + if label.starts_with(' ') || label.ends_with(' ') { + label = label.trim().to_string(); + } + label + } + } + + fn status_capsule_accent(&self, state: &StatusLineRunState) -> Color { + if state + .timer + .as_ref() + .map(|timer| !timer.is_paused) + .unwrap_or(false) + { + GREEN + } else { + MAUVE + } + } + + fn render_right_segments(&self) -> Option>> { + let segments = self.collect_right_segments(); + if segments.is_empty() { + return Some(Vec::new()); + } + let mut spans: Vec> = Vec::new(); + let mut previous_accent: Option = None; + for segment in segments { + let accent = segment.accent; + if let Some(prev) = previous_accent { + spans.push(span(RIGHT_CHEVRON, bridge_right(prev, accent))); + } else { + spans.push(span(RIGHT_CHEVRON, accent_fg(accent))); + } + spans.extend(segment.into_padded_spans()); + previous_accent = Some(accent); + } + if let Some(last) = previous_accent { + spans.push(span(RIGHT_CURVE, accent_fg(last))); + } + Some(spans) + } + + fn collect_right_segments(&self) -> Vec { + let mut segments: Vec = Vec::new(); + if self.env.devspace + && let Some(devspace) = self.snapshot.environment.devspace.as_ref() + { + let icon = devspace_icon(&devspace.name); + let text = format!("{icon}{}", truncate_graphemes(&devspace.name, 16)); + if !text.trim().is_empty() { + segments.push(PowerlineSegment::text(MAUVE, text)); + } + } + if self.env.hostname + && let Some(host) = self.snapshot.environment.hostname.as_ref() + { + let text = format!("{HOSTNAME_ICON}{}", truncate_graphemes(host, 20)); + segments.push(PowerlineSegment::text(ROSEWATER, text)); + } + if let Some(git) = self.build_git_segment() { + segments.push(git); + } + if self.env.aws_profile + && let Some(profile) = self.snapshot.environment.aws_profile.as_ref() + { + let trimmed = profile.trim_start_matches("export AWS_PROFILE="); + let text = format!("{AWS_ICON}{}", truncate_graphemes(trimmed, 16)); + segments.push(PowerlineSegment::text(PEACH, text)); + } + if self.env.kubernetes + && let Some(ctx) = self.snapshot.environment.kubernetes_context.as_ref() + { + let trimmed = ctx + .trim_start_matches("arn:aws:eks:") + .trim_start_matches("gke_"); + let text = format!("{K8S_ICON}{}", truncate_graphemes(trimmed, 18)); + segments.push(PowerlineSegment::text(TEAL, text)); + } + segments + } + + fn build_git_segment(&self) -> Option { + let git = self.snapshot.git.as_ref()?; + let branch = git.branch.as_ref()?; + let mut text = format!("{GIT_ICON}{branch}"); + if git.dirty { + text.push('*'); + } + if let Some(ahead) = git.ahead.filter(|value| *value > 0) { + text.push_str(&format!(" ↑{ahead}")); + } + if let Some(behind) = git.behind.filter(|value| *value > 0) { + text.push_str(&format!(" ↓{behind}")); + } + Some(PowerlineSegment::text(SKY, truncate_graphemes(&text, 24))) + } + + fn render_middle(&self, width: usize) -> Option<(Vec>, usize)> { + if width == 0 { + return Some((Vec::new(), 0)); + } + match self.context_variant { + ContextVariant::Hidden => { + Some((vec![span(" ".repeat(width), Style::default())], width)) + } + ContextVariant::Compact => self + .render_context_compact(width) + .map(|spans| (spans, width)), + ContextVariant::Bar => self.render_context_bar(width).map(|spans| (spans, width)), + } + } + + fn render_context_compact(&self, width: usize) -> Option>> { + let context = self.snapshot.context.as_ref()?; + let percentage = if context.window > 0 { + (context.tokens_in_context as f64 / context.window as f64 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let text = format!("{CONTEXT_ICON} {percentage:.1}%"); + let display_width = UnicodeWidthStr::width(text.as_str()); + if display_width > width { + return None; + } + let mut spans = vec![span(text, dim_text())]; + if width > display_width { + spans.push(span(" ".repeat(width - display_width), Style::default())); + } + Some(spans) + } + + fn render_context_bar(&self, width: usize) -> Option>> { + let context = self.snapshot.context.as_ref()?; + if width <= CONTEXT_PADDING * 2 + 2 { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let available = width.saturating_sub(CONTEXT_PADDING * 2); + let percent_remaining = f64::from(context.percent_remaining); + let percent_used = (100.0 - percent_remaining).clamp(0.0, 100.0); + + let label = format!("{CONTEXT_ICON}Context "); + let percent_text = format!(" {percent_remaining:.1}% left"); + let label_width = UnicodeWidthStr::width(label.as_str()); + let percent_width = UnicodeWidthStr::width(percent_text.as_str()); + let curves_width = 2usize; + let text_width = label_width + percent_width + curves_width; + if available <= text_width { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let fill_width = available - text_width; + if fill_width < 4 { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let filled = ((fill_width as f64) * (percent_used / 100.0)).round() as usize; + let (accent, light_bg) = context_bar_colors(percent_used); + + let mut spans: Vec> = Vec::new(); + spans.push(span(" ".repeat(CONTEXT_PADDING), Style::default())); + spans.push(span(LEFT_CURVE, accent_fg(accent))); + spans.push(span(label, segment_fill(accent))); + spans.extend(build_progress_bar(fill_width, filled, accent, light_bg)); + spans.push(span(percent_text, segment_fill(accent))); + spans.push(span(RIGHT_CURVE, accent_fg(accent))); + spans.push(span(" ".repeat(CONTEXT_PADDING), Style::default())); + Some(spans) + } +} + +fn degrade_run_capsule(model: &mut RenderModel<'_>) -> bool { + const OPS: &[DegradeOp] = &[DegradeOp::DropQueuePreview, DegradeOp::HideRunTimer]; + for op in OPS { + if model.apply_degrade(*op) { + return true; + } + } + false +} + +struct PowerlineSegment { + accent: Color, + spans: Vec>, +} + +impl PowerlineSegment { + fn text(accent: Color, text: String) -> Self { + Self { + accent, + spans: vec![Span::from(text)], + } + } + + fn from_spans(accent: Color, spans: Vec>) -> Self { + Self { accent, spans } + } + + fn into_padded_spans(self) -> Vec> { + let mut output = Vec::with_capacity(self.spans.len() + 2); + output.push(pad_segment_span(self.accent)); + for mut span in self.spans { + apply_segment_fill(&mut span, self.accent); + output.push(span); + } + output.push(pad_segment_span(self.accent)); + output + } +} + +fn capsule_spans(segments: Vec) -> Vec> { + let mut spans: Vec> = Vec::new(); + let mut iter = segments.into_iter(); + if let Some(first) = iter.next() { + let mut previous_accent = first.accent; + spans.push(span(LEFT_CURVE, accent_fg(previous_accent))); + spans.extend(first.into_padded_spans()); + for segment in iter { + let accent = segment.accent; + spans.push(span(LEFT_CHEVRON, bridge_left(previous_accent, accent))); + spans.extend(segment.into_padded_spans()); + previous_accent = accent; + } + spans.push(span(RIGHT_CURVE, accent_fg(previous_accent))); + } + spans +} + +fn pad_segment_span(accent: Color) -> Span<'static> { + let mut span: Span<'static> = " ".into(); + apply_segment_fill(&mut span, accent); + span +} + +fn apply_segment_fill(span: &mut Span<'static>, accent: Color) { + span.style = span.style.bg(accent); + if span.style.fg.is_none() { + span.style = span.style.fg(BASE); + } +} + +fn truncate_graphemes(text: &str, max_graphemes: usize) -> String { + if max_graphemes == 0 { + return String::new(); + } + let graphemes: Vec<&str> = text.graphemes(true).collect(); + if graphemes.len() <= max_graphemes { + return text.to_string(); + } + if max_graphemes == 1 { + return "…".to_string(); + } + let mut truncated = graphemes[..max_graphemes - 1].concat(); + truncated.push('…'); + truncated +} + +fn queue_preview(commands: &[String]) -> (String, usize) { + if commands.is_empty() { + return (String::new(), 0); + } + let raw = commands + .first() + .map(|value| value.lines().next().unwrap_or("")) + .unwrap_or(""); + let normalized = raw.split_whitespace().collect::>().join(" "); + let mut preview = if normalized.is_empty() { + String::new() + } else { + normalized + }; + + const MAX_WIDTH: usize = 32; + let width = UnicodeWidthStr::width(preview.as_str()); + if width > MAX_WIDTH { + let mut truncated = String::new(); + let mut used = 0usize; + for grapheme in preview.graphemes(true) { + let g_width = UnicodeWidthStr::width(grapheme); + if used + g_width > MAX_WIDTH.saturating_sub(1) { + break; + } + truncated.push_str(grapheme); + used += g_width; + } + truncated.push('…'); + preview = truncated; + } + + (preview, commands.len().saturating_sub(1)) +} + +fn build_progress_bar( + fill_width: usize, + filled_width: usize, + accent: Color, + light_bg: Color, +) -> Vec> { + let mut spans = Vec::with_capacity(fill_width); + for position in 0..fill_width { + let glyph = select_progress_char(position, fill_width, filled_width); + spans.push(span(glyph, Style::default().fg(accent).bg(light_bg))); + } + spans +} + +fn select_progress_char(position: usize, fill_width: usize, filled_width: usize) -> &'static str { + if position == 0 { + if filled_width > 0 { + PROGRESS_LEFT_FULL + } else { + PROGRESS_LEFT_EMPTY + } + } else if position == fill_width.saturating_sub(1) { + if position < filled_width { + PROGRESS_RIGHT_FULL + } else { + PROGRESS_RIGHT_EMPTY + } + } else if position < filled_width { + PROGRESS_MID_FULL + } else { + PROGRESS_MID_EMPTY + } +} + +fn format_token_count(value: i64) -> String { + const MILLION: f64 = 1_000_000.0; + const THOUSAND: f64 = 1_000.0; + let clamped = value.max(0); + let value_f64 = clamped as f64; + if value_f64 >= MILLION { + let mut formatted = format!("{:.1}M", value_f64 / MILLION); + if formatted.ends_with(".0M") { + formatted.truncate(formatted.len() - 3); + formatted.push('M'); + } + formatted + } else if value_f64 >= THOUSAND { + let mut formatted = format!("{:.1}k", value_f64 / THOUSAND); + if formatted.ends_with(".0k") { + formatted.truncate(formatted.len() - 3); + formatted.push('k'); + } + formatted + } else { + clamped.to_string() + } +} + +fn select_model_icon(model: &str) -> char { + match MODEL_ICONS { + [] => '󰚩', + icons => { + if model.is_empty() { + return icons[0]; + } + let mut hash: u64 = 0; + for byte in model.as_bytes() { + hash = hash.wrapping_mul(131).wrapping_add(*byte as u64); + } + icons[(hash as usize) % icons.len()] + } + } +} + +fn devspace_icon(name: &str) -> &'static str { + match DEVSPACE_ICONS { + [] => "󰠖 ", + icons => { + let mut hash: u64 = 0; + for byte in name.as_bytes() { + hash = hash.wrapping_mul(167).wrapping_add(*byte as u64); + } + icons[(hash as usize) % icons.len()] + } + } +} + +fn context_bar_colors(percent_used: f64) -> (Color, Color) { + match percent_used { + value if value <= 60.0 => (GREEN, GREEN_LIGHT), + value if value <= 80.0 => (YELLOW, YELLOW_LIGHT), + value if value <= 92.0 => (PEACH, PEACH_LIGHT), + _ => (RED, RED_LIGHT), + } +} + +#[cfg(test)] +mod tests { + use super::skins::CustomStatusLineRenderer; + use super::*; + use insta::assert_snapshot; + use ratatui::style::Modifier; + use ratatui::style::Style; + use std::time::Duration; + use std::time::Instant; + use unicode_width::UnicodeWidthStr; + + #[test] + fn elapsed_formatting_matches_indicator() { + assert_eq!(format_elapsed_compact(0), "0s"); + assert_eq!(format_elapsed_compact(59), "59s"); + assert_eq!(format_elapsed_compact(60), "1m 00s"); + assert_eq!(format_elapsed_compact(3_661), "1h 01m 01s"); + } + + #[test] + fn queue_preview_handles_extra_count() { + let long = "x".repeat(80); + let (preview, extra) = queue_preview(&[long, "second".to_string(), "third".to_string()]); + assert!(preview.ends_with('…')); + assert_eq!(extra, 2); + assert!(UnicodeWidthStr::width(preview.as_str()) <= 32); + } + + #[test] + fn context_bar_colors_follow_thresholds() { + let (green, _) = context_bar_colors(10.0); + assert_eq!(green, GREEN); + let (yellow, _) = context_bar_colors(70.0); + assert_eq!(yellow, YELLOW); + let (peach, _) = context_bar_colors(85.0); + assert_eq!(peach, PEACH); + let (red, _) = context_bar_colors(98.0); + assert_eq!(red, RED); + } + + #[test] + fn renderer_renders_core_segments() { + let snapshot = StatusLineSnapshot { + cwd_display: Some("codex".to_string()), + model: Some(StatusLineModelSnapshot { + label: "codex-model".to_string(), + detail: Some("high".to_string()), + }), + tokens: Some(StatusLineTokenSnapshot { + total: TokenCountSnapshot { + input_tokens: 600, + cached_input_tokens: 0, + output_tokens: 424, + ..TokenCountSnapshot::default() + }, + last: None, + }), + context: Some(StatusLineContextSnapshot { + percent_remaining: 80, + ..StatusLineContextSnapshot::default() + }), + git: Some(StatusLineGitSnapshot { + branch: Some("main".to_string()), + dirty: true, + ahead: Some(1), + behind: None, + }), + environment: StatusLineEnvironmentSnapshot { + hostname: Some("vermissian".to_string()), + aws_profile: Some("prod".to_string()), + ..StatusLineEnvironmentSnapshot::default() + }, + ..StatusLineSnapshot::default() + }; + let renderer = DefaultStatusLineRenderer; + let line = renderer.render(&snapshot, 80, Instant::now()); + let rendered: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(rendered.contains("codex-model")); + assert!(rendered.contains("high")); + assert!(!rendered.contains('Σ')); + assert!(rendered.contains("main*")); + assert!(rendered.contains(" codex") || rendered.contains(" tui")); + assert!(rendered.contains("vermissian")); + } + + #[test] + fn renderer_snapshot_wide_width() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let renderer = DefaultStatusLineRenderer; + let line = renderer.render(&snapshot, 80, now); + assert_snapshot!("statusline_wide_80", snapshot_line_repr(&line)); + } + + #[test] + fn renderer_snapshot_narrow_width_degrades() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let renderer = DefaultStatusLineRenderer; + let line = renderer.render(&snapshot, 40, now); + assert_snapshot!("statusline_narrow_40", snapshot_line_repr(&line)); + } + + #[test] + fn renderer_run_pill_includes_timer_queue_and_hint() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let renderer = DefaultStatusLineRenderer; + let repr = snapshot_line_repr(&renderer.render_run_pill(&snapshot, 80, now)); + assert!(repr.contains("2m 05s"), "timer text missing: {repr}"); + assert!( + repr.contains("Applying patch"), + "run label missing from pill: {repr}" + ); + assert!(repr.contains("next:"), "queue prefix missing: {repr}"); + assert!(repr.contains("git status"), "queue preview missing: {repr}"); + assert!(repr.contains("(+1)"), "queue extra count missing: {repr}"); + assert!(repr.contains("⌥ + ↑"), "hint missing: {repr}"); + } + + #[test] + fn renderer_run_pill_idle_is_blank_capsule() { + let mut snapshot = sample_snapshot(); + snapshot.run_state = None; + let now = Instant::now(); + let renderer = DefaultStatusLineRenderer; + let repr = snapshot_line_repr(&renderer.render_run_pill(&snapshot, 60, now)); + assert!( + repr.lines().all(|line| line.contains("plain \"")), + "idle pill should collapse to plain padding: {repr}" + ); + } + + #[test] + fn custom_renderer_matches_default_statusline() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let default_line = DefaultStatusLineRenderer.render(&snapshot, 80, now); + let custom_line = CustomStatusLineRenderer.render(&snapshot, 80, now); + assert_eq!( + snapshot_line_repr(&custom_line), + snapshot_line_repr(&default_line) + ); + } + + #[test] + fn custom_renderer_matches_default_run_pill() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let default_line = DefaultStatusLineRenderer.render_run_pill(&snapshot, 60, now); + let custom_line = CustomStatusLineRenderer.render_run_pill(&snapshot, 60, now); + assert_eq!( + snapshot_line_repr(&custom_line), + snapshot_line_repr(&default_line) + ); + } + + #[test] + fn run_label_defaults_to_waiting_message() { + let now = Instant::now(); + let snapshot = StatusLineSnapshot { + context: Some(StatusLineContextSnapshot { + percent_remaining: 100, + tokens_in_context: 0, + window: 1, + }), + run_state: Some(StatusLineRunState { + status_changed_at: now, + ..StatusLineRunState::default() + }), + ..StatusLineSnapshot::default() + }; + let renderer = DefaultStatusLineRenderer; + let line = renderer.render(&snapshot, 120, now); + let has_default = line + .spans + .iter() + .any(|span| span.content.contains(DEFAULT_STATUS_MESSAGE)); + assert!( + has_default, + "status capsule should show default message when label empty" + ); + } + + fn sample_snapshot() -> StatusLineSnapshot { + StatusLineSnapshot { + cwd_display: Some("~/workspace/codex".to_string()), + cwd_basename: Some("codex".to_string()), + cwd_fallback: Some("codex".to_string()), + model: Some(StatusLineModelSnapshot { + label: "gpt-5-codex".to_string(), + detail: Some("high".to_string()), + }), + tokens: Some(StatusLineTokenSnapshot { + total: TokenCountSnapshot { + total_tokens: 48_234, + input_tokens: 30_000, + cached_input_tokens: 8_000, + output_tokens: 18_234, + reasoning_output_tokens: 234, + }, + last: Some(TokenCountSnapshot { + total_tokens: 2_345, + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 45, + }), + }), + context: Some(StatusLineContextSnapshot { + percent_remaining: 68, + tokens_in_context: 52_000, + window: 160_000, + }), + run_state: Some(StatusLineRunState { + label: "Applying patch".to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::from_secs(125), + last_resume_at: None, + is_paused: true, + }), + queued_messages: vec!["git status".to_string(), "cargo test --all".to_string()], + show_interrupt_hint: true, + status_changed_at: Instant::now(), + }), + git: Some(StatusLineGitSnapshot { + branch: Some("feature/fix-tests".to_string()), + dirty: true, + ahead: Some(1), + behind: Some(0), + }), + environment: StatusLineEnvironmentSnapshot { + devspace: Some(StatusLineDevspaceSnapshot { + name: "earth".to_string(), + }), + hostname: Some("vermissian".to_string()), + aws_profile: Some("prod".to_string()), + kubernetes_context: Some("codex-dev".to_string()), + }, + } + } + + fn snapshot_line_repr(line: &Line<'_>) -> String { + line.spans + .iter() + .enumerate() + .map(|(idx, span)| { + format!( + "{idx:02}: {} {:?}", + describe_style(span.style), + span.content.as_ref() + ) + }) + .collect::>() + .join("\n") + } + + fn describe_style(style: Style) -> String { + let mut parts: Vec = Vec::new(); + if let Some(fg) = style.fg { + parts.push(format!("fg={fg:?}")); + } + if let Some(bg) = style.bg { + parts.push(format!("bg={bg:?}")); + } + if style.add_modifier != Modifier::empty() { + parts.push(format!("mod={:?}", style.add_modifier)); + } + if parts.is_empty() { + "plain".to_string() + } else { + parts.join("|") + } + } +} diff --git a/codex-rs/tui/src/statusline/overlay.rs b/codex-rs/tui/src/statusline/overlay.rs new file mode 100644 index 000000000000..b12130473018 --- /dev/null +++ b/codex-rs/tui/src/statusline/overlay.rs @@ -0,0 +1,491 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; +#[cfg(test)] +use std::sync::Mutex; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::statusline::StatusLineGitSnapshot; +use crate::statusline::StatusLineRenderer; +use crate::statusline::state::StatusLineState; +use crate::text_formatting::truncate_text; +use codex_core::config::Config; +use codex_core::git_info::collect_git_info; +use codex_core::protocol::McpInvocation; +use codex_core::protocol::TokenUsageInfo; +use hostname::get as get_hostname; +#[cfg(test)] +use lazy_static::lazy_static; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget as _; +use tokio::process::Command; +use tokio::runtime::Handle; +use tokio::task::spawn_blocking; + +use super::CustomStatusLineRenderer; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StatusLineLayout { + pub pane_area: Rect, + pub run_pill_area: Rect, + pub status_line_area: Rect, +} + +#[derive(Debug)] +pub(crate) struct StatusLineOverlay { + state: StatusLineState, + app_event_tx: AppEventSender, + cwd: PathBuf, +} + +impl StatusLineOverlay { + const MARGIN_ABOVE_PILL: u16 = 1; + const MARGIN_ABOVE_PANE: u16 = 1; + const MARGIN_BELOW_PANE: u16 = 1; + const RUN_PILL_HEIGHT: u16 = 1; + const STATUS_LINE_HEIGHT: u16 = 1; + // Minimum pane content reduced by 1 since BottomPane no longer adds TOP_MARGIN + const MIN_PANE_CONTENT_HEIGHT: u16 = 3; + const RESERVED_ROWS: u16 = Self::MARGIN_ABOVE_PILL + + Self::RUN_PILL_HEIGHT + + Self::MARGIN_ABOVE_PANE + + Self::MARGIN_BELOW_PANE + + Self::STATUS_LINE_HEIGHT; + pub(crate) fn new( + config: &Config, + frame_requester: crate::tui::FrameRequester, + app_event_tx: AppEventSender, + renderer: Option>, + ) -> Option { + if !config.tui_custom_statusline { + return None; + } + let renderer = renderer.unwrap_or_else(|| Box::new(CustomStatusLineRenderer)); + let state = StatusLineState::with_renderer(config, frame_requester, renderer); + Some(Self { + state, + app_event_tx, + cwd: config.cwd.clone(), + }) + } + + pub(crate) fn bootstrap( + &mut self, + config: &Config, + initial_tokens: Option, + queued_messages: Vec, + ) { + self.sync_model(config); + self.state.update_tokens(initial_tokens); + self.refresh_environment(); + self.state.set_queued_messages(queued_messages); + self.spawn_git_refresh(); + self.spawn_kube_refresh(); + } + + pub(crate) fn sync_model(&mut self, config: &Config) { + self.state + .update_model(config.model.clone(), config.model_reasoning_effort); + } + + pub(crate) fn refresh_environment(&mut self) { + self.state.set_devspace(detect_devspace()); + self.state.set_hostname(detect_hostname()); + self.state.set_aws_profile(detect_aws_profile()); + } + + pub(crate) fn spawn_background_tasks(&self) { + self.spawn_git_refresh(); + self.spawn_kube_refresh(); + } + + pub(crate) fn refresh_git(&self) { + self.spawn_git_refresh(); + } + + fn spawn_git_refresh(&self) { + let Ok(handle) = Handle::try_current() else { + return; + }; + let cwd = self.cwd.clone(); + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let snapshot = collect_status_line_git_snapshot(cwd).await; + tx.send(AppEvent::StatusLineGit(snapshot)); + }); + } + + fn spawn_kube_refresh(&self) { + let Ok(handle) = Handle::try_current() else { + return; + }; + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let context = detect_kube_context_async().await; + tx.send(AppEvent::StatusLineKubeContext(context)); + }); + } + + pub(crate) fn update_git(&mut self, git: Option) { + self.state.set_git_info(git); + } + + pub(crate) fn update_kube_context(&mut self, context: Option) { + self.state.set_kubernetes_context(context); + } + + pub(crate) fn set_renderer(&mut self, renderer: Box) { + self.state.set_renderer(renderer); + } + + #[cfg(test)] + pub(crate) fn state_mut(&mut self) -> &mut StatusLineState { + &mut self.state + } + + pub(crate) fn set_session_id(&mut self, session_id: Option) { + self.state.set_session_id(session_id); + } + + pub(crate) fn set_run_header(&mut self, header: &str) { + self.state.update_run_header(header); + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + self.state.set_interrupt_hint_visible(visible); + } + + pub(crate) fn start_task(&mut self, label: &str) { + self.state.start_task(label); + } + + pub(crate) fn complete_task(&mut self) { + self.state.complete_task(); + } + + pub(crate) fn resume_timer(&mut self) { + self.state.resume_timer(); + } + + pub(crate) fn update_tokens(&mut self, info: Option) { + self.state.update_tokens(info); + } + + pub(crate) fn set_queued_messages(&mut self, messages: Vec) { + self.state.set_queued_messages(messages); + } + + pub(crate) const fn reserved_rows() -> u16 { + Self::RESERVED_ROWS + } + + pub(crate) fn layout( + &self, + bottom_pane_area: Rect, + has_active_view: bool, + ) -> Option { + let reserved_height = Self::RESERVED_ROWS; + let minimum_height = reserved_height + Self::MIN_PANE_CONTENT_HEIGHT; + if has_active_view || bottom_pane_area.height < minimum_height { + return None; + } + + let mut y_cursor = bottom_pane_area.y.saturating_add(Self::MARGIN_ABOVE_PILL); + let run_pill_area = Rect { + x: bottom_pane_area.x, + y: y_cursor, + width: bottom_pane_area.width, + height: Self::RUN_PILL_HEIGHT, + }; + + y_cursor = y_cursor + .saturating_add(Self::RUN_PILL_HEIGHT) + .saturating_add(Self::MARGIN_ABOVE_PANE); + let pane_height = bottom_pane_area.height.saturating_sub(reserved_height); + let pane_area = Rect { + x: bottom_pane_area.x, + y: y_cursor, + width: bottom_pane_area.width, + height: pane_height, + }; + + let status_line_area = Rect { + x: bottom_pane_area.x, + y: bottom_pane_area + .y + .saturating_add(bottom_pane_area.height) + .saturating_sub(Self::STATUS_LINE_HEIGHT), + width: bottom_pane_area.width, + height: Self::STATUS_LINE_HEIGHT, + }; + + Some(StatusLineLayout { + pane_area, + run_pill_area, + status_line_area, + }) + } + + pub(crate) fn render_run_pill(&self, area: Rect, buf: &mut Buffer) { + let line = self.state.render_run_pill(area.width); + line.render(area, buf); + } + + pub(crate) fn render_status_line(&self, area: Rect, buf: &mut Buffer) { + let line = self.state.render_line(area.width); + line.render(area, buf); + } + + pub(crate) fn exec_status_label(command: &[String]) -> String { + if command.is_empty() { + return "Running command".to_string(); + } + let joined = command.join(" "); + let summary = truncate_text(&joined, 40); + format!("Running {summary}") + } + + pub(crate) fn tool_status_label(invocation: &McpInvocation) -> String { + let label = if invocation.server.is_empty() { + invocation.tool.clone() + } else { + format!("{}:{}", invocation.server, invocation.tool) + }; + format!("Running tool {}", truncate_text(&label, 40)) + } + + pub(crate) fn approval_status_label(subject: &str) -> String { + format!("Awaiting approval for {subject}") + } +} + +fn detect_devspace() -> Option { + #[cfg(test)] + if let Some(override_value) = DEVSPACE_OVERRIDE.lock().unwrap().clone() { + return override_value; + } + + env::var("TMUX_DEVSPACE") + .ok() + .filter(|s| !s.trim().is_empty()) +} + +fn detect_aws_profile() -> Option { + env::var("AWS_PROFILE") + .or_else(|_| env::var("AWS_VAULT")) + .ok() + .map(|profile| { + profile + .trim() + .trim_start_matches("export AWS_PROFILE=") + .to_string() + }) + .filter(|s| !s.is_empty()) +} + +fn detect_hostname() -> Option { + if let Ok(host) = env::var("HOSTNAME") + && !host.trim().is_empty() + { + return Some(host); + } + get_hostname().ok().and_then(|os| os.into_string().ok()) +} + +async fn collect_status_line_git_snapshot(cwd: PathBuf) -> Option { + let info = collect_git_info(&cwd).await?; + let (dirty, ahead, behind) = git_status_porcelain(&cwd) + .await + .unwrap_or((false, None, None)); + Some(StatusLineGitSnapshot { + branch: info.branch, + dirty, + ahead, + behind, + }) +} + +async fn git_status_porcelain(cwd: &Path) -> Option<(bool, Option, Option)> { + let output = Command::new("git") + .args(["status", "--porcelain=2", "--branch"]) + .current_dir(cwd) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let mut dirty = false; + let mut ahead = None; + let mut behind = None; + for line in text.lines() { + if !line.starts_with('#') { + dirty = true; + continue; + } + if let Some(rest) = line.strip_prefix("# branch.ab ") { + let mut parts = rest.split_whitespace(); + if let Some(ahead_part) = parts.next() { + ahead = ahead_part + .strip_prefix('+') + .and_then(|s| s.parse::().ok()); + } + if let Some(behind_part) = parts.next() { + behind = behind_part + .strip_prefix('-') + .and_then(|s| s.parse::().ok()); + } + } + } + Some((dirty, ahead, behind)) +} + +async fn detect_kube_context_async() -> Option { + spawn_blocking(detect_kube_context_sync) + .await + .ok() + .flatten() +} + +fn detect_kube_context_sync() -> Option { + for path in kube_config_paths() { + if let Ok(contents) = std::fs::read_to_string(&path) { + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + continue; + } + if let Some(value) = trimmed.strip_prefix("current-context:") { + let context = value.trim(); + if !context.is_empty() { + return Some(trim_kube_context(context)); + } + } + } + } + } + None +} + +fn kube_config_paths() -> Vec { + if let Some(paths) = env::var_os("KUBECONFIG") { + env::split_paths(&paths).collect() + } else if let Some(home) = env::var_os("HOME") { + vec![PathBuf::from(home).join(".kube/config")] + } else { + Vec::new() + } +} + +fn trim_kube_context(context: &str) -> String { + context.rsplit('/').next().unwrap_or(context).to_string() +} + +#[cfg(test)] +lazy_static! { + static ref DEVSPACE_OVERRIDE: Mutex>> = Mutex::new(None); +} + +#[cfg(test)] +pub(crate) fn set_devspace_override_for_tests(value: Option) { + *DEVSPACE_OVERRIDE.lock().unwrap() = Some(value); +} + +#[cfg(test)] +pub(crate) fn clear_devspace_override_for_tests() { + *DEVSPACE_OVERRIDE.lock().unwrap() = None; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::statusline::CustomStatusLineRenderer; + use crate::tui::FrameRequester; + use codex_core::config::ConfigOverrides; + use codex_core::config::ConfigToml; + use ratatui::buffer::Buffer; + use tokio::sync::mpsc::unbounded_channel; + + fn overlay_for_tests() -> StatusLineOverlay { + let mut cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("config"); + cfg.tui_custom_statusline = true; + let (tx, _rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx); + StatusLineOverlay::new( + &cfg, + FrameRequester::test_dummy(), + app_event_tx, + Some(Box::new(CustomStatusLineRenderer) as Box), + ) + .expect("overlay") + } + + #[test] + fn layout_includes_margin_above_run_pill() { + let overlay = overlay_for_tests(); + let area = Rect::new(0, 0, 80, 10); + let layout = overlay.layout(area, false).expect("layout available"); + assert_eq!( + layout.run_pill_area.y, + area.y + StatusLineOverlay::MARGIN_ABOVE_PILL, + "run pill should sit one row below the top margin" + ); + assert_eq!( + layout.pane_area.y, + layout.run_pill_area.y + + layout.run_pill_area.height + + StatusLineOverlay::MARGIN_ABOVE_PANE, + "pane area should start after the pill-to-pane margin" + ); + assert_eq!( + layout.status_line_area.y, + area.y + area.height - 1, + "status line stays anchored to bottom row" + ); + } + + #[test] + fn render_leaves_blank_margin_row() { + let overlay = overlay_for_tests(); + let area = Rect::new(0, 0, 40, 10); + let layout = overlay.layout(area, false).expect("layout available"); + let mut buf = Buffer::empty(area); + overlay.render_run_pill(layout.run_pill_area, &mut buf); + let margin_y = area.y; + for x in area.x..area.x + area.width { + let cell = &buf[(x, margin_y)]; + assert_eq!( + cell.symbol(), + " ", + "expected transparent margin symbol at ({x},{margin_y})" + ); + let style = cell.style(); + assert!( + style.bg.is_none() || style.bg == Some(ratatui::style::Color::Reset), + "expected default background at margin cell ({x},{margin_y}) but saw {:?}", + style.bg + ); + assert!( + style.fg.is_none() || style.fg == Some(ratatui::style::Color::Reset), + "expected default foreground at margin cell ({x},{margin_y}) but saw {:?}", + style.fg + ); + assert!( + style.underline_color.is_none() + || style.underline_color == Some(ratatui::style::Color::Reset), + "expected default underline color at margin cell ({x},{margin_y}) but saw {:?}", + style.underline_color + ); + } + } +} diff --git a/codex-rs/tui/src/statusline/palette.rs b/codex-rs/tui/src/statusline/palette.rs new file mode 100644 index 000000000000..023e3095a32f --- /dev/null +++ b/codex-rs/tui/src/statusline/palette.rs @@ -0,0 +1,43 @@ +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; + +#[allow(clippy::disallowed_methods)] +pub(crate) const BASE: Color = Color::Rgb(30, 30, 46); +#[allow(clippy::disallowed_methods)] +pub(crate) const LAVENDER: Color = Color::Rgb(180, 190, 254); +#[allow(clippy::disallowed_methods)] +pub(crate) const SKY: Color = Color::Rgb(137, 220, 235); +#[allow(clippy::disallowed_methods)] +pub(crate) const MAUVE: Color = Color::Rgb(203, 166, 247); +#[allow(clippy::disallowed_methods)] +pub(crate) const PEACH: Color = Color::Rgb(250, 179, 135); +#[allow(clippy::disallowed_methods)] +pub(crate) const GREEN: Color = Color::Rgb(166, 227, 161); +#[allow(clippy::disallowed_methods)] +pub(crate) const YELLOW: Color = Color::Rgb(249, 226, 175); +#[allow(clippy::disallowed_methods)] +pub(crate) const RED: Color = Color::Rgb(243, 139, 168); +#[allow(clippy::disallowed_methods)] +pub(crate) const ROSEWATER: Color = Color::Rgb(245, 224, 220); +#[allow(clippy::disallowed_methods)] +pub(crate) const TEAL: Color = Color::Rgb(148, 226, 213); +#[allow(clippy::disallowed_methods)] +#[allow(dead_code)] +pub(crate) const SURFACE0: Color = Color::Rgb(49, 50, 68); +#[allow(clippy::disallowed_methods)] +pub(crate) const SUBTEXT0: Color = Color::Rgb(166, 173, 200); +#[allow(clippy::disallowed_methods)] +pub(crate) const GREEN_LIGHT: Color = Color::Rgb(86, 127, 81); +#[allow(clippy::disallowed_methods)] +pub(crate) const YELLOW_LIGHT: Color = Color::Rgb(149, 136, 95); +#[allow(clippy::disallowed_methods)] +pub(crate) const PEACH_LIGHT: Color = Color::Rgb(150, 107, 81); +#[allow(clippy::disallowed_methods)] +pub(crate) const RED_LIGHT: Color = Color::Rgb(146, 83, 100); + +pub(crate) fn queue_preview_style() -> Style { + Style::default() + .fg(SUBTEXT0) + .add_modifier(Modifier::ITALIC | Modifier::DIM) +} diff --git a/codex-rs/tui/src/statusline/skins/mod.rs b/codex-rs/tui/src/statusline/skins/mod.rs new file mode 100644 index 000000000000..d21c72a85a5e --- /dev/null +++ b/codex-rs/tui/src/statusline/skins/mod.rs @@ -0,0 +1,26 @@ +use std::time::Instant; + +use ratatui::text::Line; + +use super::StatusLineRenderer; +use super::StatusLineSnapshot; +use super::render_status_line; +use super::render_status_run_pill; + +#[derive(Debug, Default)] +pub(crate) struct CustomStatusLineRenderer; + +impl StatusLineRenderer for CustomStatusLineRenderer { + fn render(&self, snapshot: &StatusLineSnapshot, width: u16, now: Instant) -> Line<'static> { + render_status_line(snapshot, width, now) + } + + fn render_run_pill( + &self, + snapshot: &StatusLineSnapshot, + width: u16, + now: Instant, + ) -> Line<'static> { + render_status_run_pill(snapshot, width, now) + } +} diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap new file mode 100644 index 000000000000..d3028ef98228 --- /dev/null +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/statusline/mod.rs +expression: snapshot_line_repr(&line) +--- +00: plain "codex | gpt-5-codex | feature/fix-tests*" diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap new file mode 100644 index 000000000000..255d7aa59711 --- /dev/null +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -0,0 +1,32 @@ +--- +source: tui/src/statusline/mod.rs +assertion_line: 1292 +expression: snapshot_line_repr(&line) +--- +00: fg=Rgb(203, 166, 247) "\u{e0b6}" +01: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247) " " +02: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247)|mod=DIM "◦" +03: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247) " " +04: fg=Rgb(203, 166, 247)|bg=Rgb(180, 190, 254) "\u{e0b0}" +05: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " +06: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) "codex" +07: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " +08: fg=Rgb(180, 190, 254)|bg=Rgb(137, 220, 235) "\u{e0b0}" +09: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +10: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{f16a5}" +11: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +12: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=BOLD "gpt-5-codex" +13: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +14: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=ITALIC "high" +15: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +16: fg=Rgb(137, 220, 235) "\u{e0b0}" +17: plain " " +18: fg=Rgb(245, 224, 220) "\u{e0b2}" +19: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) " " +20: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) "\u{f233} vermissian" +21: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) " " +22: fg=Rgb(137, 220, 235)|bg=Rgb(245, 224, 220) "\u{e0b2}" +23: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +24: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{e0a0} feature/fix-tests* ↑1" +25: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +26: fg=Rgb(137, 220, 235) "\u{e0b4}" diff --git a/codex-rs/tui/src/statusline/state.rs b/codex-rs/tui/src/statusline/state.rs new file mode 100644 index 000000000000..801c2e1b6e37 --- /dev/null +++ b/codex-rs/tui/src/statusline/state.rs @@ -0,0 +1,447 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use crate::status::format_directory_display; +use crate::tui::FrameRequester; +use codex_core::config::Config; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol_config_types::ReasoningEffort; +use ratatui::text::Line; + +use super::DEFAULT_STATUS_MESSAGE; +use super::DefaultStatusLineRenderer; +use super::RunTimerSnapshot; +use super::StatusLineContextSnapshot; +use super::StatusLineDevspaceSnapshot; +use super::StatusLineGitSnapshot; +use super::StatusLineModelSnapshot; +use super::StatusLineRenderer; +use super::StatusLineRunState; +use super::StatusLineSnapshot; +use super::StatusLineTokenSnapshot; +use super::TokenCountSnapshot; + +#[derive(Debug)] +pub(crate) struct StatusLineState { + cwd: PathBuf, + frame_requester: FrameRequester, + renderer: Box, + snapshot: StatusLineSnapshot, + run_timer: Option, + queued_messages: Vec, + esc_hint: bool, + context_window_hint: Option, +} + +impl StatusLineState { + pub(crate) fn new(config: &Config, frame_requester: FrameRequester) -> Self { + Self::with_renderer( + config, + frame_requester, + Box::::default(), + ) + } + + pub(crate) fn with_renderer( + config: &Config, + frame_requester: FrameRequester, + renderer: Box, + ) -> Self { + let cwd = config.cwd.clone(); + let mut state = Self { + cwd: cwd.clone(), + frame_requester, + renderer, + snapshot: StatusLineSnapshot::default(), + run_timer: None, + queued_messages: Vec::new(), + esc_hint: true, + context_window_hint: config.model_context_window, + }; + state.set_working_directory(&cwd); + state.set_idle_run_state(Instant::now()); + state + } + + pub(crate) fn set_renderer(&mut self, renderer: Box) { + self.renderer = renderer; + self.request_redraw(); + } + + pub(crate) fn set_working_directory(&mut self, cwd: &Path) { + self.cwd = cwd.to_path_buf(); + let display = format_directory_display(cwd, None); + let basename = cwd + .file_name() + .map(|os| os.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()); + self.snapshot.cwd_display = Some(display.clone()); + self.snapshot.cwd_basename = basename.clone(); + self.snapshot.cwd_fallback = basename.or(Some(display)); + self.request_redraw(); + } + + pub(crate) fn update_model( + &mut self, + label: impl Into, + effort: Option, + ) { + let detail = reasoning_detail(effort); + self.snapshot.model = Some(StatusLineModelSnapshot { + label: label.into(), + detail, + }); + self.request_redraw(); + } + + pub(crate) fn update_tokens(&mut self, info: Option) { + if let Some(info) = info { + let context_window = info.model_context_window.or(self.context_window_hint); + let (token_snapshot, context_snapshot) = + token_snapshot_from_info(&info, context_window); + self.snapshot.tokens = Some(token_snapshot); + self.snapshot.context = context_snapshot; + } else { + self.snapshot.tokens = None; + self.snapshot.context = None; + } + self.request_redraw(); + } + + pub(crate) fn set_git_info(&mut self, git: Option) { + self.snapshot.git = git; + self.request_redraw(); + } + + pub(crate) fn set_devspace(&mut self, devspace: Option) { + self.snapshot.environment.devspace = + devspace.map(|name| StatusLineDevspaceSnapshot { name }); + self.request_redraw(); + } + + pub(crate) fn set_hostname(&mut self, hostname: Option) { + self.snapshot.environment.hostname = hostname; + self.request_redraw(); + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + if self.esc_hint == visible { + return; + } + self.esc_hint = visible; + if let Some(run_state) = self.snapshot.run_state.as_mut() { + run_state.show_interrupt_hint = visible; + } + self.request_redraw(); + } + + pub(crate) fn set_aws_profile(&mut self, profile: Option) { + self.snapshot.environment.aws_profile = profile; + self.request_redraw(); + } + + pub(crate) fn set_kubernetes_context(&mut self, context: Option) { + self.snapshot.environment.kubernetes_context = context; + self.request_redraw(); + } + + pub(crate) fn set_session_id(&mut self, session_id: Option) { + let _ = session_id; + } + + pub(crate) fn set_queued_messages(&mut self, messages: Vec) { + self.queued_messages = messages; + if let Some(run_state) = self.snapshot.run_state.as_mut() { + run_state.queued_messages = self.queued_messages.clone(); + } + self.request_redraw(); + } + + pub(crate) fn update_run_header(&mut self, header: &str) { + if let Some(run_state) = self.snapshot.run_state.as_mut() { + if run_state.label != header { + run_state.label = header.to_string(); + run_state.status_changed_at = Instant::now(); + self.request_redraw(); + } + } else { + self.snapshot.run_state = Some(StatusLineRunState { + label: header.to_string(), + show_interrupt_hint: self.esc_hint, + queued_messages: self.queued_messages.clone(), + status_changed_at: Instant::now(), + ..StatusLineRunState::default() + }); + self.request_redraw(); + } + } + fn set_idle_run_state(&mut self, now: Instant) { + let run_state = StatusLineRunState { + label: DEFAULT_STATUS_MESSAGE.to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::ZERO, + last_resume_at: None, + is_paused: true, + }), + queued_messages: self.queued_messages.clone(), + show_interrupt_hint: false, + status_changed_at: now, + }; + self.snapshot.run_state = Some(run_state); + self.request_redraw(); + } + + pub(crate) fn start_task(&mut self, header: impl Into) { + let header = header.into(); + let now = Instant::now(); + match self.run_timer.as_mut() { + Some(timer) => timer.resume(now), + None => self.run_timer = Some(RunTimer::new(now)), + } + let mut run_state = self.snapshot.run_state.clone().unwrap_or_default(); + run_state.label = header; + run_state.show_interrupt_hint = self.esc_hint; + run_state.queued_messages = self.queued_messages.clone(); + run_state.status_changed_at = now; + self.snapshot.run_state = Some(run_state); + self.request_redraw(); + } + + pub(crate) fn complete_task(&mut self) { + let now = Instant::now(); + if let Some(timer) = self.run_timer.as_mut() { + timer.pause(now); + } + self.run_timer = None; + self.set_idle_run_state(now); + self.request_redraw(); + } + + pub(crate) fn resume_timer(&mut self) { + if let Some(timer) = self.run_timer.as_mut() { + timer.resume(Instant::now()); + self.request_redraw(); + } + } + + pub(crate) fn elapsed_seconds(&self) -> Option { + let timer = self.run_timer.as_ref()?; + Some(timer.snapshot(Instant::now()).elapsed_running.as_secs()) + } + + pub(crate) fn snapshot_for_render(&self, now: Instant) -> StatusLineSnapshot { + let mut snapshot = self.snapshot.clone(); + if let (Some(run_state), Some(timer)) = + (snapshot.run_state.as_mut(), self.run_timer.as_ref()) + { + run_state.timer = Some(timer.snapshot(now)); + run_state.spinner_started_at = Some(timer.spinner_started_at); + run_state.queued_messages = self.queued_messages.clone(); + run_state.show_interrupt_hint = self.esc_hint; + } + let timer_active = self + .run_timer + .as_ref() + .map(|timer| !timer.is_paused) + .unwrap_or(false); + if timer_active { + self.frame_requester + .schedule_frame_in(Duration::from_millis(48)); + } + snapshot + } + + pub(crate) fn render_line(&self, width: u16) -> Line<'static> { + let now = Instant::now(); + let mut snapshot = self.snapshot_for_render(now); + snapshot.run_state = None; + self.renderer.render(&snapshot, width, now) + } + + pub(crate) fn render_run_pill(&self, width: u16) -> Line<'static> { + let now = Instant::now(); + let mut snapshot = self.snapshot_for_render(now); + if snapshot.run_state.is_none() { + snapshot.run_state = Some(StatusLineRunState { + label: DEFAULT_STATUS_MESSAGE.to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::ZERO, + last_resume_at: None, + is_paused: true, + }), + queued_messages: Vec::new(), + show_interrupt_hint: false, + status_changed_at: now, + }); + } + self.renderer.render_run_pill(&snapshot, width, now) + } + + fn request_redraw(&self) { + self.frame_requester.schedule_frame(); + } +} + +#[derive(Debug)] +struct RunTimer { + elapsed_running: Duration, + last_resume_at: Option, + is_paused: bool, + spinner_started_at: Instant, +} + +impl RunTimer { + fn new(now: Instant) -> Self { + Self { + elapsed_running: Duration::ZERO, + last_resume_at: Some(now), + is_paused: false, + spinner_started_at: now, + } + } + + fn resume(&mut self, now: Instant) { + if self.is_paused { + self.last_resume_at = Some(now); + self.is_paused = false; + } + } + + fn pause(&mut self, now: Instant) { + if self.is_paused { + return; + } + if let Some(last) = self.last_resume_at { + self.elapsed_running += now.saturating_duration_since(last); + } + self.is_paused = true; + } + + fn snapshot(&self, now: Instant) -> RunTimerSnapshot { + let mut elapsed = self.elapsed_running; + let mut last_resume = self.last_resume_at; + if !self.is_paused { + if let Some(last) = self.last_resume_at { + elapsed += now.saturating_duration_since(last); + } + last_resume = Some(now); + } + RunTimerSnapshot { + elapsed_running: elapsed, + last_resume_at: last_resume, + is_paused: self.is_paused, + } + } +} + +fn reasoning_detail(effort: Option) -> Option { + match effort { + Some(ReasoningEffort::High) => Some("high".to_string()), + Some(ReasoningEffort::Low) => Some("low".to_string()), + _ => None, + } +} + +fn token_snapshot_from_info( + info: &TokenUsageInfo, + context_window: Option, +) -> (StatusLineTokenSnapshot, Option) { + let total = info.total_token_usage.clone(); + let last = info.last_token_usage.clone(); + + let token_snapshot = StatusLineTokenSnapshot { + total: TokenCountSnapshot { + total_tokens: total.total_tokens, + input_tokens: total.input_tokens, + cached_input_tokens: total.cached_input_tokens, + output_tokens: total.output_tokens, + reasoning_output_tokens: total.reasoning_output_tokens, + }, + last: Some(TokenCountSnapshot { + total_tokens: last.total_tokens, + input_tokens: last.input_tokens, + cached_input_tokens: last.cached_input_tokens, + output_tokens: last.output_tokens, + reasoning_output_tokens: last.reasoning_output_tokens, + }), + }; + + let context_snapshot = context_window.map(|window| { + let percent = context_percent_remaining(&last, window); + StatusLineContextSnapshot { + percent_remaining: percent, + tokens_in_context: last.tokens_in_context_window(), + window, + } + }); + + (token_snapshot, context_snapshot) +} + +fn context_percent_remaining(last: &TokenUsage, context_window: i64) -> u8 { + const BASELINE_TOKENS: i64 = 12_000; + if context_window <= BASELINE_TOKENS { + return 0; + } + let effective_window = context_window - BASELINE_TOKENS; + if effective_window <= 0 { + return 0; + } + let used = (last.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + let percent = (remaining * 100) / effective_window; + percent.clamp(0, 100) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::TokenUsage; + + #[test] + fn context_snapshot_matches_status_values() { + let window = 272_000; + let info = TokenUsageInfo { + total_token_usage: TokenUsage { + total_tokens: 540_000, + input_tokens: 420_000, + cached_input_tokens: 160_000, + output_tokens: 120_000, + reasoning_output_tokens: 60_000, + }, + last_token_usage: TokenUsage { + total_tokens: 110_300, + input_tokens: 74_000, + cached_input_tokens: 18_000, + output_tokens: 36_300, + reasoning_output_tokens: 12_000, + }, + model_context_window: Some(window), + }; + + let (_, context_snapshot) = token_snapshot_from_info(&info, info.model_context_window); + let context = context_snapshot.expect("context snapshot"); + + assert_eq!(context.window, window); + assert_eq!(context.tokens_in_context, 98_300); + assert_eq!(context.percent_remaining, 66); + } + + #[test] + fn run_timer_snapshot_advances_in_real_seconds() { + let start = Instant::now(); + let timer = RunTimer::new(start); + let first_tick = start + Duration::from_millis(1_200); + let snapshot = timer.snapshot(first_tick); + assert_eq!(snapshot.elapsed_running.as_millis(), 1_200); + assert_eq!(snapshot.elapsed_at(first_tick).as_secs(), 1); + + let later = first_tick + Duration::from_millis(1_000); + assert_eq!(snapshot.elapsed_at(later).as_secs(), 2); + } +} diff --git a/customization-plan.md b/customization-plan.md new file mode 100644 index 000000000000..3f04c2060435 --- /dev/null +++ b/customization-plan.md @@ -0,0 +1,36 @@ +## Codex TUI Customization Playbook + +This repository keeps the upstream OpenAI TUI as the "engine" and applies the bespoke Codex skin as a thin layer on top. The goals are: + +1. Keep upstream merges straightforward. +2. Make the custom statusline opt-in. +3. Guard behaviour with targeted tests and snapshots. + +### 1. Layer, Don’t Fork + +- **Renderer seam only.** Leave upstream widgets intact. The custom look lives in `codex-rs/tui/src/statusline/skins`, implementing `StatusLineRenderer`. Upstream logic keeps producing `StatusLineSnapshot`; the renderer turns that snapshot into our palette and capsule. +- **Config flag.** The renderer activates when `Config.tui_custom_statusline` is `true` (default). If the flag is disabled the upstream bar renders unchanged. Avoid direct feature gates inside upstream modules—keep the hook isolated to `ChatWidget`. +- **Custom assets in one module.** Colors, icons, and layout helpers stay in the statusline skin module. Nothing outside the module should rely on our palette to minimize conflict surface during merges. +- **Controller shim.** `tui/src/statusline/overlay.rs` owns background refresh, queued message mirroring, and rendering glue. `ChatWidget` forwards high-level events to this shim so upstream merges only touch the thin hook layer. + +### 2. Automate Reapplication + +- **Patch stack.** Maintain the customization as a small patch series (e.g. `git format-patch` or `git rerere`). Each release: pull upstream, replay the patch stack, resolve any new trait mismatches. +- **Scripted apply.** Optional helper `scripts/apply-customizations.sh` can replay the patches against a fresh checkout to guarantee deterministic diffs. +- **Reuse conflict knowledge.** Keep `git rerere` enabled. Once a merge conflict in the renderer hook is resolved, rerere remembers it for the next upstream sync. + +### 3. Strengthen Tests + +- **Statusline snapshots.** `statusline/tests.rs` and `chatwidget/tests.rs` cover both the upstream and custom capsules (queued messages, hints, timers, git/kube metadata). When the renderer changes, update or extend these snapshots instead of loosening assertions. +- **Run pill parity.** Tests such as `custom_renderer_matches_default_run_pill` ensure that our skin mirrors upstream semantics unless the flag is set. +- **VT100 coverage.** Snapshot tests that render the entire layout (history + custom statusline) act as regression guards for spacing and hint placement. + +### Workflow Checklist + +1. `git pull upstream main`. +2. Reapply customization patches (`scripts/apply-customizations.sh`). +3. Resolve new compiler or trait changes in the renderer. +4. `just fmt`, `just fix -p codex-tui`, `cargo test -p codex-tui`. +5. Update snapshots only when behaviour intentionally changes. + +With this structure, upstream refreshes become “pull → reapply → fix hooks” instead of wrestling with widespread conflicts. diff --git a/flake.lock b/flake.lock index 21550893f4d7..c0cef3a48f65 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,30 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1758427187, - "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", + "lastModified": 1761373498, + "narHash": "sha256-Q/uhWNvd7V7k1H1ZPMy/vkx3F8C13ZcdrKjO7Jv7v0c=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", + "rev": "6a08e6bb4e46ff7fcbb53d409b253f6bad8a28ce", "type": "github" }, "original": { @@ -16,9 +34,60 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1761758177, + "narHash": "sha256-MsVJG2gQTm6n2jIGu2KDT87AMeMx1GExOaEQqNkQKVE=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "37f8f092415b444c3bed6eda6bcbee51cee22e5d", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index b331c443bbf0..02e2f3179cd7 100644 --- a/flake.nix +++ b/flake.nix @@ -1,28 +1,109 @@ { - description = "Development Nix flake for OpenAI Codex CLI"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + outputs = { self, nixpkgs, flake-utils, rust-overlay }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + gitRevision = + if self ? rev && self.rev != null then self.rev else "unknown"; + rustToolchain = pkgs.rust-bin.nightly.latest.default; + rustPlatform = pkgs.makeRustPlatform { + inherit (pkgs) stdenv; + cargo = rustToolchain; + rustc = rustToolchain; + }; + rmcpSrc = pkgs.fetchgit { + url = "https://github.com/modelcontextprotocol/rust-sdk"; + rev = "c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10"; + hash = "sha256-uAEBai6Uzmpi5fcIn9v4MPE9DbzPvemkaaZ+alwM4PQ="; + }; + ratatuiSrc = pkgs.fetchgit { + url = "https://github.com/nornagon/ratatui"; + rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2"; + hash = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + }; + cargoLock = { + lockFile = ./codex-rs/Cargo.lock; + outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + "crossterm-0.28.1" = "0vzgpvbri4m4qydkj50ch468az7myy04qh5z2n500p1f4dysv87a"; + }; + }; + cargoVendorSha = "sha256-NP94EW+XS1PrbFfMnGOCnwoNoT1S7txJ8bDD6xRb5hw="; + cargoPatchConfig = pkgs.writeText "cargo-config.toml" '' + [patch."https://github.com/modelcontextprotocol/rust-sdk"] + rmcp = { path = "${rmcpSrc}/crates/rmcp" } + rmcp-macros = { path = "${rmcpSrc}/crates/rmcp-macros" } - outputs = { nixpkgs, ... }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forAllSystems = f: nixpkgs.lib.genAttrs systems f; - in - { - packages = forAllSystems (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - codex-rs = pkgs.callPackage ./codex-rs { }; - in - { - codex-rs = codex-rs; - default = codex-rs; - } - ); - }; + [patch.crates-io] + ratatui = { path = "${ratatuiSrc}" } + ''; + commonRustPackageArgs = { + version = "unstable"; + src = ./codex-rs; + inherit cargoLock; + cargoSha256 = cargoVendorSha; + CODEX_BUILD_GIT_SHA = gitRevision; + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; + [ openssl libgit2 curl zlib ] + ++ lib.optionals stdenv.isDarwin [ + libiconv + apple-sdk_11 + ]; + preBuild = '' + export CARGO_HOME="$TMPDIR/cargo-home" + mkdir -p "$CARGO_HOME" + cp ${cargoPatchConfig} "$CARGO_HOME/config.toml" + ''; + doCheck = false; + }; + codex-tui = rustPlatform.buildRustPackage (commonRustPackageArgs // { + pname = "codex-tui"; + cargoBuildFlags = [ "--package" "codex-tui" "--bin" "codex-tui" ]; + meta = with pkgs.lib; { + description = "Codex TUI built from codex-rs"; + homepage = "https://github.com/sourcegraph/codex"; + license = licenses.asl20; + mainProgram = "codex-tui"; + platforms = platforms.unix; + }; + }); + codex-cli = rustPlatform.buildRustPackage (commonRustPackageArgs // { + pname = "codex-cli"; + cargoBuildFlags = [ "--package" "codex-cli" "--bin" "codex" ]; + meta = with pkgs.lib; { + description = "Codex CLI built from codex-rs"; + homepage = "https://github.com/sourcegraph/codex"; + license = licenses.asl20; + mainProgram = "codex"; + platforms = platforms.unix; + }; + }); + in { + packages = { + codex-cli = codex-cli; + codex-tui = codex-tui; + default = codex-cli; + }; + apps = + let + codexCliApp = flake-utils.lib.mkApp { drv = codex-cli; }; + codexApp = flake-utils.lib.mkApp { drv = codex-tui; }; + in { + codex = codexCliApp; + codex-cli = codexCliApp; + codex-tui = codexApp; + default = codexCliApp; + }; + } + ); }