Compare commits

..

11 Commits

Author SHA1 Message Date
Yaroslav Volovich
800268f558 fix(tui): refresh git branch for title-only terminal title 2026-02-21 10:27:18 +00:00
Yaroslav Volovich
e4d135fbbc fix(tui): clear title when sanitization strips content 2026-02-21 10:27:18 +00:00
Yaroslav Volovich
0dcdc04526 fix(tui): retry terminal title writes after io errors 2026-02-21 10:27:18 +00:00
Yaroslav Volovich
a2669ae447 fix(tui): use neutral /title preview examples 2026-02-21 08:48:53 +00:00
Yaroslav Volovich
22dedfa7f5 fix(tui): add ellipses to transient title states 2026-02-21 08:48:53 +00:00
Yaroslav Volovich
7d2e191f88 fix(tui): live preview /title and revert on cancel 2026-02-21 08:48:53 +00:00
Yaroslav Volovich
e391b8ca16 fix(app-server): stabilize running thread resume status 2026-02-21 08:48:53 +00:00
Yaroslav Volovich
895ddb9b20 tui: add secure /title terminal title config 2026-02-21 08:47:55 +00:00
Michael Bolin
f5d7a74568 chore: delete empty codex-rs/code file (#12440)
This file was added in https://github.com/openai/codex/pull/4195, but I
think it may have been a mistake?
2026-02-21 08:44:55 +00:00
Michael Bolin
85ce91a5b3 refactor(core): move embedded system skills into codex-skills crate (#12435)
## Why

`codex-core` was carrying the embedded system-skill sample assets (and a
`build.rs` that walks those files to register rerun triggers). Those
assets change infrequently, but any change under `codex-core` still ties
them to `codex-core`'s build/cache lifecycle.

This change moves the embedded system-skills packaging into a dedicated
`codex-skills` crate so it can be cached independently. That reduces
unnecessary invalidation/rebuild pressure on `codex-core` when the
skills bundle is the only thing that changes.

## What Changed

- Added a new `codex-rs/skills` crate (`codex-skills`) with:
  - `Cargo.toml`
  - `BUILD.bazel`
  - `build.rs` to track skill asset file changes for Cargo rebuilds
- `src/lib.rs` containing the embedded system-skills install/cache logic
previously in `codex-core`
- Moved the embedded sample skill assets from
`codex-rs/core/src/skills/assets/samples` to
`codex-rs/skills/src/assets/samples`.
- Updated `codex-rs/core/Cargo.toml` to depend on `codex-skills` and
removed `codex-core`'s direct `include_dir` dependency.
- Removed `codex-core`'s `build.rs`.
- Replaced `codex-rs/core/src/skills/system.rs` implementation with a
thin re-export wrapper to keep existing `codex-core` call sites
unchanged.
- Updated workspace manifests/lockfile (`codex-rs/Cargo.toml`,
`codex-rs/Cargo.lock`) for the new crate.
2026-02-21 08:34:08 +00:00
Michael Bolin
2fe4be1aa9 fix: codex-arg0 no longer depends on codex-core (#12434)
## Why

`codex-rs/arg0` only needed two things from `codex-core`:

- the `find_codex_home()` wrapper
- the special argv flag used for the internal `apply_patch`
self-invocation path

That made `codex-arg0` depend on `codex-core` for a very small surface
area. This change removes that dependency edge and moves the shared
`apply_patch` invocation flag to a more natural boundary
(`codex-apply-patch`) while keeping the contract explicitly documented.

## What Changed

- Moved the internal `apply_patch` argv[1] flag constant out of
`codex-core` and into `codex-apply-patch`.
- Renamed the constant to `CODEX_CORE_APPLY_PATCH_ARG1` and documented
that it is part of the Codex core process-invocation contract (even
though it now lives in `codex-apply-patch`).
- Updated `arg0`, the core apply-patch runtime, and the `codex-exec`
apply-patch test to import the constant from `codex-apply-patch`.
- Updated `codex-rs/arg0` to call
`codex_utils_home_dir::find_codex_home()` directly instead of
`codex_core::config::find_codex_home()`.
- Removed the `codex-core` dependency from `codex-rs/arg0` and added the
needed direct dependency on `codex-utils-home-dir`.
- Added `codex-apply-patch` as a dev-dependency for `codex-rs/exec`
tests (the apply-patch test now imports the moved constant directly).

## Verification

- `cargo test -p codex-apply-patch`
- `cargo test -p codex-arg0`
- `cargo test -p codex-core --lib apply_patch`
- `cargo test -p codex-exec
test_standalone_exec_cli_can_use_apply_patch`
- `cargo shear`
2026-02-21 00:20:42 -08:00
53 changed files with 1187 additions and 502 deletions

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Required for bubblewrap to work on Linux CI runners.
sudo sysctl -w kernel.unprivileged_userns_clone=1
# Ubuntu 24.04+ can additionally gate unprivileged user namespaces behind AppArmor.
if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
archive_file="$1"
target="${2:-x86_64-unknown-linux-gnu}"
cargo_profile="${3:-ci-test}"
cargo nextest archive \
--all-features \
--target "$target" \
--cargo-profile "$cargo_profile" \
--timings \
--archive-file "$archive_file"

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
archive_file="$1"
workspace_remap="$2"
partition_spec="$3"
cargo nextest run \
--archive-file "$archive_file" \
--workspace-remap "$workspace_remap" \
--partition "$partition_spec" \
--no-fail-fast

View File

@@ -472,6 +472,12 @@ jobs:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
@@ -573,11 +579,18 @@ jobs:
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: nextest
version: 0.9.127
version: 0.9.103
- name: Enable unprivileged user namespaces (Linux)
if: runner.os == 'Linux'
run: bash "${GITHUB_WORKSPACE}/.github/scripts/enable-unprivileged-userns.sh"
run: |
# Required for bubblewrap to work on Linux CI runners.
sudo sysctl -w kernel.unprivileged_userns_clone=1
# Ubuntu 24.04+ can additionally gate unprivileged user namespaces
# behind AppArmor.
if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
- name: tests
id: test
@@ -637,248 +650,10 @@ jobs:
echo "Tests failed. See logs for details."
exit 1
# --- Build-once + sharded tests for the primary Linux GNU target ----------
tests_linux_gnu_build:
name: Tests build archive — ubuntu-24.04 - x86_64-unknown-linux-gnu
runs-on:
group: codex-runners
labels: codex-linux-x64
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:
USE_SCCACHE: "true"
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
steps:
- uses: actions/checkout@v6
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
fi
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: x86_64-unknown-linux-gnu
- name: Compute lockfile hash
id: lockhash
working-directory: codex-rs
shell: bash
run: |
set -euo pipefail
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Restore cargo home cache
id: cache_cargo_home_restore
uses: actions/cache/restore@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
restore-keys: |
cargo-home-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-
- 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: Enable sccache wrapper
shell: bash
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Restore sccache cache (fallback)
if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }}
id: cache_sccache_restore
uses: actions/cache/restore@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
restore-keys: |
sccache-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ steps.lockhash.outputs.hash }}-
sccache-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: nextest
version: 0.9.127
- name: Enable unprivileged user namespaces (Linux)
if: runner.os == 'Linux'
run: bash "${GITHUB_WORKSPACE}/.github/scripts/enable-unprivileged-userns.sh"
- name: Build and archive tests
shell: bash
run: |
bash "${GITHUB_WORKSPACE}/.github/scripts/nextest-archive-build.sh" \
"${RUNNER_TEMP}/nextest-x86_64-unknown-linux-gnu.tar.zst" \
x86_64-unknown-linux-gnu \
ci-test
env:
RUST_BACKTRACE: 1
NEXTEST_STATUS_LEVEL: leak
- name: Upload nextest archive
uses: actions/upload-artifact@v6
with:
name: nextest-archive-rust-ci-x86_64-unknown-linux-gnu-dev
path: ${{ runner.temp }}/nextest-x86_64-unknown-linux-gnu.tar.zst
if-no-files-found: error
- name: Upload Cargo timings (nextest archive build)
if: always()
uses: actions/upload-artifact@v6
with:
name: cargo-timings-rust-ci-nextest-x86_64-unknown-linux-gnu-dev
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- 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@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: cargo-home-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
- name: Save sccache cache (fallback)
if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ github.workspace }}/.sccache/
key: sccache-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ steps.lockhash.outputs.hash }}-${{ 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 — x86_64-unknown-linux-gnu (archive build)";
echo;
echo '```';
sccache --show-stats || true;
echo '```';
} >> "$GITHUB_STEP_SUMMARY"
tests_linux_gnu_shards:
name: Tests — ubuntu-24.04 - x86_64-unknown-linux-gnu (shard ${{ matrix.shard_index }}/${{ matrix.shard_count }})
runs-on:
group: codex-runners
labels: codex-linux-x64
timeout-minutes: 30
needs: [changed, tests_linux_gnu_build]
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
defaults:
run:
working-directory: codex-rs
strategy:
fail-fast: false
matrix:
include:
- shard_index: 1
shard_count: 2
- shard_index: 2
shard_count: 2
steps:
- uses: actions/checkout@v6
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: x86_64-unknown-linux-gnu
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: nextest
version: 0.9.127
- name: Enable unprivileged user namespaces (Linux)
if: runner.os == 'Linux'
run: bash "${GITHUB_WORKSPACE}/.github/scripts/enable-unprivileged-userns.sh"
- name: Download nextest archive
uses: actions/download-artifact@v5
with:
name: nextest-archive-rust-ci-x86_64-unknown-linux-gnu-dev
path: ${{ runner.temp }}/nextest-archive
- name: tests
id: test
shell: bash
env:
RUST_BACKTRACE: 1
NEXTEST_STATUS_LEVEL: leak
run: |
bash "${GITHUB_WORKSPACE}/.github/scripts/nextest-archive-run-shard.sh" \
"${RUNNER_TEMP}/nextest-archive/nextest-x86_64-unknown-linux-gnu.tar.zst" \
"${GITHUB_WORKSPACE}/codex-rs" \
"slice:${{ matrix.shard_index }}/${{ matrix.shard_count }}"
- 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,
tests_linux_gnu_build,
tests_linux_gnu_shards,
]
needs: [changed, general, cargo_shear, lint_build, tests]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -889,8 +664,6 @@ jobs:
echo "shear : ${{ needs.cargo_shear.result }}"
echo "lint : ${{ needs.lint_build.result }}"
echo "tests : ${{ needs.tests.result }}"
echo "linux archive build: ${{ needs.tests_linux_gnu_build.result }}"
echo "linux shard tests : ${{ needs.tests_linux_gnu_shards.result }}"
# If nothing relevant changed (PR touching only root README, etc.),
# declare success regardless of other jobs.
@@ -904,8 +677,6 @@ jobs:
[[ '${{ 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; }
[[ '${{ needs.tests_linux_gnu_build.result }}' == 'success' ]] || { echo 'tests_linux_gnu_build failed'; exit 1; }
[[ '${{ needs.tests_linux_gnu_shards.result }}' == 'success' ]] || { echo 'tests_linux_gnu_shards failed'; exit 1; }
- name: sccache summary note
if: always()

14
codex-rs/Cargo.lock generated
View File

@@ -1398,8 +1398,8 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-apply-patch",
"codex-core",
"codex-linux-sandbox",
"codex-utils-home-dir",
"dotenvy",
"tempfile",
"tokio",
@@ -1644,6 +1644,7 @@ dependencies = [
"codex-rmcp-client",
"codex-secrets",
"codex-shell-command",
"codex-skills",
"codex-state",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
@@ -1663,7 +1664,6 @@ dependencies = [
"futures",
"http 1.4.0",
"image",
"include_dir",
"indexmap 2.13.0",
"indoc",
"insta",
@@ -1735,6 +1735,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"clap",
"codex-apply-patch",
"codex-arg0",
"codex-cloud-requirements",
"codex-core",
@@ -2188,6 +2189,15 @@ dependencies = [
"which",
]
[[package]]
name = "codex-skills"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"include_dir",
"thiserror 2.0.18",
]
[[package]]
name = "codex-state"
version = "0.0.0"

View File

@@ -17,6 +17,7 @@ members = [
"cli",
"config",
"shell-command",
"skills",
"core",
"hooks",
"secrets",
@@ -112,6 +113,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-rmcp-client = { path = "rmcp-client" }
codex-secrets = { path = "secrets" }
codex-shell-command = { path = "shell-command" }
codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-tui = { path = "tui" }

View File

@@ -6325,7 +6325,6 @@ async fn handle_pending_thread_resume_request(
return;
}
};
has_in_progress_turn = has_in_progress_turn
|| thread
.turns

View File

@@ -25,6 +25,15 @@ use crate::invocation::ExtractHeredocError;
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
/// Special argv[1] flag used when the Codex executable self-invokes to run the
/// internal `apply_patch` path.
///
/// Although this constant lives in `codex-apply-patch` (to avoid forcing
/// `codex-arg0` to depend on `codex-core`), it is part of the "codex core"
/// process-invocation contract between the apply-patch runtime and the arg0
/// dispatcher.
pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
#[derive(Debug, Error, PartialEq)]
pub enum ApplyPatchError {
#[error(transparent)]

View File

@@ -14,8 +14,8 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
codex-apply-patch = { workspace = true }
codex-core = { workspace = true }
codex-linux-sandbox = { workspace = true }
codex-utils-home-dir = { workspace = true }
dotenvy = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }

View File

@@ -3,7 +3,8 @@ use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use codex_core::CODEX_APPLY_PATCH_ARG1;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::TempDir;
@@ -46,7 +47,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_APPLY_PATCH_ARG1 {
if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned));
let exit_code = match patch_arg {
Some(patch_arg) => {
@@ -58,7 +59,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
}
None => {
eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument.");
eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument.");
1
}
};
@@ -139,7 +140,7 @@ const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
fn load_dotenv() {
if let Ok(codex_home) = codex_core::config::find_codex_home()
if let Ok(codex_home) = find_codex_home()
&& let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env"))
{
set_filtered(iter);
@@ -175,7 +176,7 @@ where
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
/// be called before multiple threads are spawned.
pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGuard> {
let codex_home = codex_core::config::find_codex_home()?;
let codex_home = find_codex_home()?;
#[cfg(not(debug_assertions))]
{
// Guard against placing helpers in system temp directories outside debug builds.
@@ -242,7 +243,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
&batch_script,
format!(
r#"@echo off
"{}" {CODEX_APPLY_PATCH_ARG1} %*
"{}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
"#,
exe.display()
),

View File

View File

@@ -3,7 +3,6 @@ edition.workspace = true
license.workspace = true
name = "codex-core"
version.workspace = true
build = "build.rs"
[lib]
doctest = false
@@ -34,6 +33,7 @@ codex-async-utils = { workspace = true }
codex-client = { workspace = true }
codex-config = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
@@ -58,7 +58,6 @@ env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
include_dir = { workspace = true }
indexmap = { workspace = true }
indoc = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust"] }

View File

@@ -1392,6 +1392,14 @@
"type": "string"
},
"type": "array"
},
"terminal_title": {
"default": null,
"description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `project` and `status`.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"

View File

@@ -9,8 +9,6 @@ use codex_apply_patch::ApplyPatchFileChange;
use std::collections::HashMap;
use std::path::PathBuf;
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was handled programmatically, without any sort
/// of sandbox, because the user explicitly approved it. This is the
@@ -20,7 +18,8 @@ pub(crate) enum InternalApplyPatchInvocation {
/// The `apply_patch` call was approved, either automatically because it
/// appears that it should be allowed based on the user's sandbox policy
/// *or* because the user explicitly approved it. In either case, we use
/// exec with [`CODEX_APPLY_PATCH_ARG1`] to realize the `apply_patch` call,
/// exec with [`codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1`] to realize
/// the `apply_patch` call,
/// but [`ApplyPatchExec::auto_approved`] is used to determine the sandbox
/// used with the `exec()`.
DelegateToExec(ApplyPatchExec),

View File

@@ -67,6 +67,18 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
}
}
pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {
let mut array = toml_edit::Array::new();
for item in items {
array.push(item.clone());
}
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "terminal_title".to_string()],
value: TomlItem::Value(array.into()),
}
}
// TODO(jif) move to a dedicated file
mod document_helpers {
use crate::config::types::McpServerConfig;

View File

@@ -278,6 +278,11 @@ pub struct Config {
/// `current-dir`.
pub tui_status_line: Option<Vec<String>>,
/// Ordered list of terminal title item identifiers for the TUI.
///
/// When unset, the TUI defaults to: `project` and `status`.
pub tui_terminal_title: Option<Vec<String>>,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -2115,6 +2120,7 @@ impl Config {
.map(|t| t.alternate_screen)
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -2532,6 +2538,7 @@ allowed_domains = ["openai.com"]
show_tooltips: true,
alternate_screen: AltScreenMode::Auto,
status_line: None,
terminal_title: None,
}
);
}
@@ -4641,6 +4648,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -4763,6 +4771,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
otel: OtelConfig::default(),
};
@@ -4883,6 +4892,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
otel: OtelConfig::default(),
};
@@ -4989,6 +4999,7 @@ model_verbosity = "high"
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
otel: OtelConfig::default(),
};

View File

@@ -681,6 +681,13 @@ pub struct Tui {
/// `current-dir`.
#[serde(default)]
pub status_line: Option<Vec<String>>,
/// Ordered list of terminal title item identifiers.
///
/// When set, the TUI renders the selected items into the terminal window/tab title.
/// When unset, the TUI defaults to: `project` and `status`.
#[serde(default)]
pub terminal_title: Option<Vec<String>>,
}
const fn default_true() -> bool {

View File

@@ -144,7 +144,6 @@ pub(crate) use codex_shell_command::is_safe_command;
pub(crate) use codex_shell_command::parse_command;
pub(crate) use codex_shell_command::powershell;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use exec_policy::ExecPolicyError;
pub use exec_policy::check_execpolicy_for_warnings;

View File

@@ -1,196 +1,2 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use include_dir::Dir;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::Hash;
use std::hash::Hasher;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
const SYSTEM_SKILLS_DIR: Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/skills/assets/samples");
const SYSTEM_SKILLS_DIR_NAME: &str = ".system";
const SKILLS_DIR_NAME: &str = "skills";
const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker";
const SYSTEM_SKILLS_MARKER_SALT: &str = "v1";
/// Returns the on-disk cache location for embedded system skills.
///
/// This is typically located at `CODEX_HOME/skills/.system`.
pub(crate) fn system_cache_root_dir(codex_home: &Path) -> PathBuf {
AbsolutePathBuf::try_from(codex_home)
.and_then(|codex_home| system_cache_root_dir_abs(&codex_home))
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|_| {
codex_home
.join(SKILLS_DIR_NAME)
.join(SYSTEM_SKILLS_DIR_NAME)
})
}
fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result<AbsolutePathBuf> {
codex_home
.join(SKILLS_DIR_NAME)?
.join(SYSTEM_SKILLS_DIR_NAME)
}
/// Installs embedded system skills into `CODEX_HOME/skills/.system`.
///
/// Clears any existing system skills directory first and then writes the embedded
/// skills directory into place.
///
/// To avoid doing unnecessary work on every startup, a marker file is written
/// with a fingerprint of the embedded directory. When the marker matches, the
/// install is skipped.
pub(crate) fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> {
let codex_home = AbsolutePathBuf::try_from(codex_home)
.map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?;
let skills_root_dir = codex_home
.join(SKILLS_DIR_NAME)
.map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?;
fs::create_dir_all(skills_root_dir.as_path())
.map_err(|source| SystemSkillsError::io("create skills root dir", source))?;
let dest_system = system_cache_root_dir_abs(&codex_home)
.map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?;
let marker_path = dest_system
.join(SYSTEM_SKILLS_MARKER_FILENAME)
.map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?;
let expected_fingerprint = embedded_system_skills_fingerprint();
if dest_system.as_path().is_dir()
&& read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint)
{
return Ok(());
}
if dest_system.as_path().exists() {
fs::remove_dir_all(dest_system.as_path())
.map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?;
}
write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?;
fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n"))
.map_err(|source| SystemSkillsError::io("write system skills marker", source))?;
Ok(())
}
fn read_marker(path: &AbsolutePathBuf) -> Result<String, SystemSkillsError> {
Ok(fs::read_to_string(path.as_path())
.map_err(|source| SystemSkillsError::io("read system skills marker", source))?
.trim()
.to_string())
}
fn embedded_system_skills_fingerprint() -> String {
let mut items = Vec::new();
collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items);
items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut hasher = DefaultHasher::new();
SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher);
for (path, contents_hash) in items {
path.hash(&mut hasher);
contents_hash.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option<u64>)>) {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(subdir) => {
items.push((subdir.path().to_string_lossy().to_string(), None));
collect_fingerprint_items(subdir, items);
}
include_dir::DirEntry::File(file) => {
let mut file_hasher = DefaultHasher::new();
file.contents().hash(&mut file_hasher);
items.push((
file.path().to_string_lossy().to_string(),
Some(file_hasher.finish()),
));
}
}
}
}
/// Writes the embedded `include_dir::Dir` to disk under `dest`.
///
/// Preserves the embedded directory structure.
fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> {
fs::create_dir_all(dest.as_path())
.map_err(|source| SystemSkillsError::io("create system skills dir", source))?;
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(subdir) => {
let subdir_dest = dest.join(subdir.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills subdir", source)
})?;
fs::create_dir_all(subdir_dest.as_path()).map_err(|source| {
SystemSkillsError::io("create system skills subdir", source)
})?;
write_embedded_dir(subdir, dest)?;
}
include_dir::DirEntry::File(file) => {
let path = dest.join(file.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills file", source)
})?;
if let Some(parent) = path.as_path().parent() {
fs::create_dir_all(parent).map_err(|source| {
SystemSkillsError::io("create system skills file parent", source)
})?;
}
fs::write(path.as_path(), file.contents())
.map_err(|source| SystemSkillsError::io("write system skill file", source))?;
}
}
}
Ok(())
}
#[derive(Debug, Error)]
pub(crate) enum SystemSkillsError {
#[error("io error while {action}: {source}")]
Io {
action: &'static str,
#[source]
source: std::io::Error,
},
}
impl SystemSkillsError {
fn io(action: &'static str, source: std::io::Error) -> Self {
Self::Io { action, source }
}
}
#[cfg(test)]
mod tests {
use super::SYSTEM_SKILLS_DIR;
use super::collect_fingerprint_items;
#[test]
fn fingerprint_traverses_nested_entries() {
let mut items = Vec::new();
collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items);
let mut paths: Vec<String> = items.into_iter().map(|(path, _)| path).collect();
paths.sort_unstable();
assert!(
paths
.binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md"))
.is_ok()
);
assert!(
paths
.binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py"))
.is_ok()
);
}
}
pub(crate) use codex_skills::install_system_skills;
pub(crate) use codex_skills::system_cache_root_dir;

View File

@@ -4,7 +4,6 @@
//! decision to avoid re-prompting, builds the self-invocation command for
//! `codex --codex-run-as-apply-patch`, and runs under the current
//! `SandboxAttempt` with a minimal environment.
use crate::CODEX_APPLY_PATCH_ARG1;
use crate::exec::ExecToolCallOutput;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxPermissions;
@@ -20,6 +19,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
@@ -57,7 +57,10 @@ impl ApplyPatchRuntime {
let program = exe.to_string_lossy().to_string();
Ok(CommandSpec {
program,
args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.action.patch.clone()],
args: vec![
CODEX_CORE_APPLY_PATCH_ARG1.to_string(),
req.action.patch.clone(),
],
cwd: req.action.cwd.clone(),
expiration: req.timeout_ms.into(),
// Run apply_patch with a minimal environment for determinism and to avoid leaks.

View File

@@ -51,6 +51,7 @@ uuid = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
codex-apply-patch = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }

View File

@@ -2,7 +2,7 @@
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_core::CODEX_APPLY_PATCH_ARG1;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_apply_patch_function_call;
use core_test_support::responses::ev_completed;
@@ -24,7 +24,7 @@ fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> {
fs::write(&absolute_path, "original content\n")?;
Command::new(codex_utils_cargo_bin::cargo_bin("codex-exec")?)
.arg(CODEX_APPLY_PATCH_ARG1)
.arg(CODEX_CORE_APPLY_PATCH_ARG1)
.arg(
r#"*** Begin Patch
*** Update File: source.txt

View File

@@ -0,0 +1,15 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "skills",
crate_name = "codex_skills",
compile_data = glob(
include = ["**"],
exclude = [
"**/* *",
"BUILD.bazel",
"Cargo.toml",
],
allow_empty = True,
),
)

View File

@@ -0,0 +1,19 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-skills"
version.workspace = true
build = "build.rs"
[lib]
doctest = false
name = "codex_skills"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-utils-absolute-path = { workspace = true }
include_dir = { workspace = true }
thiserror = { workspace = true }

View File

@@ -2,7 +2,7 @@ use std::fs;
use std::path::Path;
fn main() {
let samples_dir = Path::new("src/skills/assets/samples");
let samples_dir = Path::new("src/assets/samples");
if !samples_dir.exists() {
return;
}

195
codex-rs/skills/src/lib.rs Normal file
View File

@@ -0,0 +1,195 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use include_dir::Dir;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::Hash;
use std::hash::Hasher;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
const SYSTEM_SKILLS_DIR: Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/assets/samples");
const SYSTEM_SKILLS_DIR_NAME: &str = ".system";
const SKILLS_DIR_NAME: &str = "skills";
const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker";
const SYSTEM_SKILLS_MARKER_SALT: &str = "v1";
/// Returns the on-disk cache location for embedded system skills.
///
/// This is typically located at `CODEX_HOME/skills/.system`.
pub fn system_cache_root_dir(codex_home: &Path) -> PathBuf {
AbsolutePathBuf::try_from(codex_home)
.and_then(|codex_home| system_cache_root_dir_abs(&codex_home))
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|_| {
codex_home
.join(SKILLS_DIR_NAME)
.join(SYSTEM_SKILLS_DIR_NAME)
})
}
fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result<AbsolutePathBuf> {
codex_home
.join(SKILLS_DIR_NAME)?
.join(SYSTEM_SKILLS_DIR_NAME)
}
/// Installs embedded system skills into `CODEX_HOME/skills/.system`.
///
/// Clears any existing system skills directory first and then writes the embedded
/// skills directory into place.
///
/// To avoid doing unnecessary work on every startup, a marker file is written
/// with a fingerprint of the embedded directory. When the marker matches, the
/// install is skipped.
pub fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> {
let codex_home = AbsolutePathBuf::try_from(codex_home)
.map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?;
let skills_root_dir = codex_home
.join(SKILLS_DIR_NAME)
.map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?;
fs::create_dir_all(skills_root_dir.as_path())
.map_err(|source| SystemSkillsError::io("create skills root dir", source))?;
let dest_system = system_cache_root_dir_abs(&codex_home)
.map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?;
let marker_path = dest_system
.join(SYSTEM_SKILLS_MARKER_FILENAME)
.map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?;
let expected_fingerprint = embedded_system_skills_fingerprint();
if dest_system.as_path().is_dir()
&& read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint)
{
return Ok(());
}
if dest_system.as_path().exists() {
fs::remove_dir_all(dest_system.as_path())
.map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?;
}
write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?;
fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n"))
.map_err(|source| SystemSkillsError::io("write system skills marker", source))?;
Ok(())
}
fn read_marker(path: &AbsolutePathBuf) -> Result<String, SystemSkillsError> {
Ok(fs::read_to_string(path.as_path())
.map_err(|source| SystemSkillsError::io("read system skills marker", source))?
.trim()
.to_string())
}
fn embedded_system_skills_fingerprint() -> String {
let mut items = Vec::new();
collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items);
items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut hasher = DefaultHasher::new();
SYSTEM_SKILLS_MARKER_SALT.hash(&mut hasher);
for (path, contents_hash) in items {
path.hash(&mut hasher);
contents_hash.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
fn collect_fingerprint_items(dir: &Dir<'_>, items: &mut Vec<(String, Option<u64>)>) {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(subdir) => {
items.push((subdir.path().to_string_lossy().to_string(), None));
collect_fingerprint_items(subdir, items);
}
include_dir::DirEntry::File(file) => {
let mut file_hasher = DefaultHasher::new();
file.contents().hash(&mut file_hasher);
items.push((
file.path().to_string_lossy().to_string(),
Some(file_hasher.finish()),
));
}
}
}
}
/// Writes the embedded `include_dir::Dir` to disk under `dest`.
///
/// Preserves the embedded directory structure.
fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> {
fs::create_dir_all(dest.as_path())
.map_err(|source| SystemSkillsError::io("create system skills dir", source))?;
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(subdir) => {
let subdir_dest = dest.join(subdir.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills subdir", source)
})?;
fs::create_dir_all(subdir_dest.as_path()).map_err(|source| {
SystemSkillsError::io("create system skills subdir", source)
})?;
write_embedded_dir(subdir, dest)?;
}
include_dir::DirEntry::File(file) => {
let path = dest.join(file.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills file", source)
})?;
if let Some(parent) = path.as_path().parent() {
fs::create_dir_all(parent).map_err(|source| {
SystemSkillsError::io("create system skills file parent", source)
})?;
}
fs::write(path.as_path(), file.contents())
.map_err(|source| SystemSkillsError::io("write system skill file", source))?;
}
}
}
Ok(())
}
#[derive(Debug, Error)]
pub enum SystemSkillsError {
#[error("io error while {action}: {source}")]
Io {
action: &'static str,
#[source]
source: std::io::Error,
},
}
impl SystemSkillsError {
fn io(action: &'static str, source: std::io::Error) -> Self {
Self::Io { action, source }
}
}
#[cfg(test)]
mod tests {
use super::SYSTEM_SKILLS_DIR;
use super::collect_fingerprint_items;
#[test]
fn fingerprint_traverses_nested_entries() {
let mut items = Vec::new();
collect_fingerprint_items(&SYSTEM_SKILLS_DIR, &mut items);
let mut paths: Vec<String> = items.into_iter().map(|(path, _)| path).collect();
paths.sort_unstable();
assert!(
paths
.binary_search_by(|probe| probe.as_str().cmp("skill-creator/SKILL.md"))
.is_ok()
);
assert!(
paths
.binary_search_by(|probe| probe.as_str().cmp("skill-creator/scripts/init_skill.py"))
.is_ok()
);
}
}

View File

@@ -543,6 +543,8 @@ pub(crate) struct App {
pub(crate) commit_anim_running: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid status-line config warnings only emit once.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid terminal-title config warnings only emit once.
terminal_title_invalid_items_warned: Arc<AtomicBool>,
// Esc-backtracking state grouped
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
@@ -626,6 +628,7 @@ impl App {
feedback_audience: self.feedback_audience,
model: Some(self.chat_widget.current_model().to_string()),
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
otel_manager: self.otel_manager.clone(),
}
}
@@ -1107,6 +1110,7 @@ impl App {
}
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
@@ -1131,6 +1135,8 @@ impl App {
feedback_audience,
model: Some(model.clone()),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new(init, thread_manager.clone())
@@ -1161,6 +1167,8 @@ impl App {
feedback_audience,
model: config.model.clone(),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
@@ -1192,6 +1200,8 @@ impl App {
feedback_audience,
model: config.model.clone(),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
@@ -1225,6 +1235,7 @@ impl App {
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
@@ -1445,6 +1456,9 @@ impl App {
feedback_audience: self.feedback_audience,
model: Some(model),
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: self
.terminal_title_invalid_items_warned
.clone(),
otel_manager: self.otel_manager.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
@@ -2605,6 +2619,33 @@ impl App {
AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup();
}
AppEvent::TerminalTitleSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = codex_core::config::edit::terminal_title_items_edit(&ids);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_terminal_title = Some(ids.clone());
self.chat_widget.setup_terminal_title(items);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
self.chat_widget.revert_terminal_title_setup_preview();
self.chat_widget.add_error_message(format!(
"Failed to save terminal title items: {err}"
));
}
}
}
AppEvent::TerminalTitleSetupPreview { items } => {
self.chat_widget.preview_terminal_title(items);
}
AppEvent::TerminalTitleSetupCancelled => {
self.chat_widget.cancel_terminal_title_setup();
}
}
Ok(AppRunControl::Continue)
}
@@ -3315,6 +3356,7 @@ mod tests {
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
@@ -3373,6 +3415,7 @@ mod tests {
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),

View File

@@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::history_cell::HistoryCell;
use codex_core::features::Feature;
@@ -360,6 +361,16 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed terminal-title item ordering/selection.
TerminalTitleSetup {
items: Vec<TerminalTitleItem>,
},
/// Apply a temporary terminal-title preview while the setup UI is open.
TerminalTitleSetupPreview {
items: Vec<TerminalTitleItem>,
},
/// Dismiss the terminal-title setup UI without changing config.
TerminalTitleSetupCancelled,
}
/// The exit strategy requested by the UI layer.

View File

@@ -43,6 +43,7 @@ mod approval_overlay;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod title_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -87,6 +88,8 @@ pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLineSetupView;
pub(crate) use title_setup::TerminalTitleItem;
pub(crate) use title_setup::TerminalTitleSetupView;
mod paste_burst;
pub mod popup_consts;
mod queued_user_messages;

View File

@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/title_setup.rs
expression: "render_lines(&view, 84)"
---
Configure Terminal Title
Select which items to display in the terminal title.
Type to search
>
[x] project Project name (falls back to current directory name)
[x] status Compact session status (Ready, Working, Thinking, ...)
[x] thread Current thread title (omitted until available)
[ ] git-branch Current Git branch (omitted when unavailable)
[ ] model Current model name
[ ] task-progress Latest task progress from update_plan (omitted until availab…
my-project | Working... | Investigate flaky test
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.

View File

@@ -0,0 +1,229 @@
//! Terminal title configuration view for customizing the terminal window/tab title.
//!
//! This module provides an interactive picker for selecting which items appear
//! in the terminal title. Users can:
//!
//! - Select items
//! - Reorder items
//! - Preview the rendered title
use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
use strum_macros::EnumString;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
#[derive(EnumIter, EnumString, Display, Debug, Clone, Eq, PartialEq)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum TerminalTitleItem {
/// Project root name, or a compact cwd fallback.
Project,
/// Compact runtime status (Ready, Working, Thinking, ...).
Status,
/// Current thread title (if available).
Thread,
/// Current git branch (if available).
GitBranch,
/// Current model name.
Model,
/// Latest checklist task progress from `update_plan` (if available).
#[strum(to_string = "task-progress")]
TaskProgress,
}
impl TerminalTitleItem {
pub(crate) fn description(&self) -> &'static str {
match self {
TerminalTitleItem::Project => "Project name (falls back to current directory name)",
TerminalTitleItem::Status => "Compact session status (Ready, Working, Thinking, ...)",
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
TerminalTitleItem::Model => "Current model name",
TerminalTitleItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
}
}
}
pub(crate) fn render(&self) -> &'static str {
match self {
TerminalTitleItem::Project => "my-project",
TerminalTitleItem::Status => "Working...",
TerminalTitleItem::Thread => "Investigate flaky test",
TerminalTitleItem::GitBranch => "feat/awesome-feature",
TerminalTitleItem::Model => "gpt-5.2-codex",
TerminalTitleItem::TaskProgress => "Tasks 2/5",
}
}
}
/// Interactive view for configuring terminal-title items.
pub(crate) struct TerminalTitleSetupView {
picker: MultiSelectPicker,
}
impl TerminalTitleSetupView {
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
let mut used_ids = HashSet::new();
let mut items = Vec::new();
if let Some(selected_items) = title_items.as_ref() {
for id in *selected_items {
let Ok(item) = id.parse::<TerminalTitleItem>() else {
continue;
};
let item_id = item.to_string();
if !used_ids.insert(item_id.clone()) {
continue;
}
items.push(Self::title_select_item(item, true));
}
}
for item in TerminalTitleItem::iter() {
let item_id = item.to_string();
if used_ids.contains(&item_id) {
continue;
}
items.push(Self::title_select_item(item, false));
}
Self {
picker: MultiSelectPicker::builder(
"Configure Terminal Title".to_string(),
Some("Select which items to display in the terminal title.".to_string()),
app_event_tx,
)
.instructions(vec![
"Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel."
.into(),
])
.items(items)
.enable_ordering()
.on_preview(|items| {
let preview = items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<TerminalTitleItem>().ok())
.map(|item| item.render())
.collect::<Vec<_>>()
.join(" | ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
})
.on_change(|items, app_event| {
let items = items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<TerminalTitleItem>().ok())
.collect::<Vec<_>>();
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
})
.on_confirm(|ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<TerminalTitleItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
app_event.send(AppEvent::TerminalTitleSetup { items });
})
.on_cancel(|app_event| {
app_event.send(AppEvent::TerminalTitleSetupCancelled);
})
.build(),
}
}
fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem {
MultiSelectItem {
id: item.to_string(),
name: item.to_string(),
description: Some(item.description().to_string()),
enabled,
}
}
}
impl BottomPaneView for TerminalTitleSetupView {
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}
fn is_complete(&self) -> bool {
self.picker.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.picker.close();
CancellationEvent::Handled
}
}
impl Renderable for TerminalTitleSetupView {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.picker.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.picker.desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect();
lines.join("\n")
}
#[test]
fn renders_title_setup_popup() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let selected = [
"project".to_string(),
"status".to_string(),
"thread".to_string(),
];
let view = TerminalTitleSetupView::new(Some(&selected), tx);
assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84));
}
}

View File

@@ -39,10 +39,14 @@ use std::time::Instant;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::status::RateLimitWindowDisplay;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
use crate::status::rate_limit_snapshot_display_for_limit;
use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::ConfigLayerSource;
@@ -272,6 +276,7 @@ use codex_file_search::FileMatch;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
@@ -284,6 +289,7 @@ const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] =
["model-with-reasoning", "context-remaining", "current-dir"];
const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["project", "status"];
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
@@ -454,6 +460,8 @@ pub(crate) struct ChatWidgetInit {
pub(crate) model: Option<String>,
// Shared latch so we only warn once about invalid status-line item IDs.
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared latch so we only warn once about invalid terminal-title item IDs.
pub(crate) terminal_title_invalid_items_warned: Arc<AtomicBool>,
pub(crate) otel_manager: OtelManager,
}
@@ -630,6 +638,8 @@ pub(crate) struct ChatWidget {
saw_plan_update_this_turn: bool,
// Whether the current turn emitted a proposed plan item.
saw_plan_item_this_turn: bool,
// Latest `update_plan` checklist task counts for terminal-title rendering.
last_plan_progress: Option<(usize, usize)>,
// Incremental buffer for streamed plan content.
plan_delta_buffer: String,
// True while a plan item is streaming.
@@ -653,6 +663,13 @@ pub(crate) struct ChatWidget {
session_network_proxy: Option<codex_protocol::protocol::SessionNetworkProxyRuntime>,
// Shared latch so we only warn once about invalid status-line item IDs.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared latch so we only warn once about invalid terminal-title item IDs.
terminal_title_invalid_items_warned: Arc<AtomicBool>,
// Last terminal title emitted, to avoid writing duplicate OSC updates.
last_terminal_title: Option<String>,
// Original terminal-title config captured when opening the setup UI so live preview can be
// rolled back on cancel.
terminal_title_setup_original_items: Option<Option<Vec<String>>>,
// Cached git branch name for the status line (None if unknown).
status_line_branch: Option<String>,
// CWD used to resolve the cached branch; change resets branch state.
@@ -840,6 +857,7 @@ impl ChatWidget {
fn update_task_running_state(&mut self) {
self.bottom_pane
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
self.refresh_terminal_title();
}
fn restore_reasoning_status_header(&mut self) {
@@ -907,6 +925,7 @@ impl ChatWidget {
fn set_status(&mut self, header: String, details: Option<String>) {
self.current_status_header = header.clone();
self.bottom_pane.update_status(header, details);
self.refresh_terminal_title();
}
/// Convenience wrapper around [`Self::set_status`];
@@ -932,6 +951,7 @@ impl ChatWidget {
/// placeholders so the line remains compact and stable.
pub(crate) fn refresh_status_line(&mut self) {
let (items, invalid_items) = self.status_line_items_with_invalids();
let (title_items, _) = self.terminal_title_items_with_invalids();
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
@@ -950,7 +970,9 @@ impl ChatWidget {
);
self.on_warning(message);
}
if !items.contains(&StatusLineItem::GitBranch) {
if !items.contains(&StatusLineItem::GitBranch)
&& !title_items.contains(&TerminalTitleItem::GitBranch)
{
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
@@ -959,13 +981,17 @@ impl ChatWidget {
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(None);
self.refresh_terminal_title();
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
if items.contains(&StatusLineItem::GitBranch) && !self.status_line_branch_lookup_complete {
if (items.contains(&StatusLineItem::GitBranch)
|| title_items.contains(&TerminalTitleItem::GitBranch))
&& !self.status_line_branch_lookup_complete
{
self.request_status_line_branch(cwd);
}
@@ -982,6 +1008,7 @@ impl ChatWidget {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
self.refresh_terminal_title();
}
/// Records that status-line setup was canceled.
@@ -1002,6 +1029,131 @@ impl ChatWidget {
self.refresh_status_line();
}
/// Recomputes and emits the terminal title from config and runtime state.
pub(crate) fn refresh_terminal_title(&mut self) {
let (items, invalid_items) = self.terminal_title_items_with_invalids();
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.terminal_title_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid terminal title {label}: {}.",
proper_join(invalid_items.as_slice())
);
self.on_warning(message);
}
if items.is_empty() {
if self.last_terminal_title.is_some() {
match clear_terminal_title() {
Ok(()) => {
self.last_terminal_title = None;
}
Err(err) => {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
return;
}
let status_line_uses_git_branch = self
.status_line_items_with_invalids()
.0
.contains(&StatusLineItem::GitBranch);
if !items.contains(&TerminalTitleItem::GitBranch) && !status_line_uses_git_branch {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
if items.contains(&TerminalTitleItem::GitBranch) {
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
if !self.status_line_branch_lookup_complete {
self.request_status_line_branch(cwd);
}
}
let title = items
.iter()
.filter_map(|item| self.terminal_title_value_for_item(item))
.collect::<Vec<_>>()
.join(" | ");
let title = (!title.is_empty()).then_some(title);
if self.last_terminal_title == title {
return;
}
match title {
Some(title) => match set_terminal_title(&title) {
Ok(()) => {
self.last_terminal_title = Some(title);
}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}
},
None => {
if self.last_terminal_title.is_some() {
match clear_terminal_title() {
Ok(()) => {
self.last_terminal_title = None;
}
Err(err) => {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
}
}
}
/// Applies a temporary terminal-title selection while the setup UI is open.
pub(crate) fn preview_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
if self.terminal_title_setup_original_items.is_none() {
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
}
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.config.tui_terminal_title = Some(ids);
self.refresh_terminal_title();
}
/// Restores the terminal title selection captured before opening the setup UI.
pub(crate) fn revert_terminal_title_setup_preview(&mut self) {
let Some(original_items) = self.terminal_title_setup_original_items.take() else {
return;
};
self.config.tui_terminal_title = original_items;
self.refresh_terminal_title();
}
/// Records that terminal-title setup was canceled and rolls back live preview changes.
pub(crate) fn cancel_terminal_title_setup(&mut self) {
tracing::info!("Terminal title setup canceled by user");
self.revert_terminal_title_setup_preview();
}
/// Applies terminal-title item selection from the setup view to in-memory config.
///
/// An empty selection persists as an explicit empty list (disables title updates).
pub(crate) fn setup_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
tracing::info!("terminal title setup confirmed with items: {items:#?}");
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.terminal_title_setup_original_items = None;
self.config.tui_terminal_title = Some(ids);
self.refresh_terminal_title();
}
/// Stores async git-branch lookup results for the current status-line cwd.
///
/// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch
@@ -1016,10 +1168,14 @@ impl ChatWidget {
self.status_line_branch_lookup_complete = true;
}
/// Forces a new git-branch lookup when `GitBranch` is part of the configured status line.
/// Forces a new git-branch lookup when `GitBranch` is used by the status line or terminal
/// title.
fn request_status_line_branch_refresh(&mut self) {
let (items, _) = self.status_line_items_with_invalids();
if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) {
let (status_line_items, _) = self.status_line_items_with_invalids();
let (title_items, _) = self.terminal_title_items_with_invalids();
if !status_line_items.contains(&StatusLineItem::GitBranch)
&& !title_items.contains(&TerminalTitleItem::GitBranch)
{
return;
}
let cwd = self.status_line_cwd().to_path_buf();
@@ -1162,6 +1318,7 @@ impl ChatWidget {
fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) {
if self.thread_id == Some(event.thread_id) {
self.thread_name = event.thread_name;
self.refresh_terminal_title();
self.request_redraw();
}
}
@@ -1843,6 +2000,17 @@ impl ChatWidget {
fn on_plan_update(&mut self, update: UpdatePlanArgs) {
self.saw_plan_update_this_turn = true;
let total = update.plan.len();
let completed = update
.plan
.iter()
.filter(|item| match &item.status {
StepStatus::Completed => true,
StepStatus::Pending | StepStatus::InProgress => false,
})
.count();
self.last_plan_progress = (total > 0).then_some((completed, total));
self.refresh_terminal_title();
self.add_to_history(history_cell::new_plan_update(update));
}
@@ -2608,6 +2776,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -2713,6 +2882,7 @@ impl ChatWidget {
had_work_activity: false,
saw_plan_update_this_turn: false,
saw_plan_item_this_turn: false,
last_plan_progress: None,
plan_delta_buffer: String::new(),
plan_item_active: false,
last_separator_elapsed_secs: None,
@@ -2724,6 +2894,9 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -2759,6 +2932,8 @@ impl ChatWidget {
.bottom_pane
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
widget.refresh_terminal_title();
widget
}
@@ -2779,6 +2954,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -2872,6 +3048,7 @@ impl ChatWidget {
forked_from: None,
saw_plan_update_this_turn: false,
saw_plan_item_this_turn: false,
last_plan_progress: None,
plan_delta_buffer: String::new(),
plan_item_active: false,
queued_user_messages: VecDeque::new(),
@@ -2894,6 +3071,9 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -2915,6 +3095,7 @@ impl ChatWidget {
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
widget.refresh_terminal_title();
widget
}
@@ -2938,6 +3119,7 @@ impl ChatWidget {
feedback_audience,
model,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3042,6 +3224,7 @@ impl ChatWidget {
had_work_activity: false,
saw_plan_update_this_turn: false,
saw_plan_item_this_turn: false,
last_plan_progress: None,
plan_delta_buffer: String::new(),
plan_item_active: false,
last_separator_elapsed_secs: None,
@@ -3053,6 +3236,9 @@ impl ChatWidget {
current_cwd,
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3083,6 +3269,7 @@ impl ChatWidget {
),
);
widget.update_collaboration_mode_indicator();
widget.refresh_terminal_title();
widget
}
@@ -3473,6 +3660,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
@@ -4409,6 +4599,16 @@ impl ChatWidget {
self.bottom_pane.show_view(Box::new(view));
}
fn open_terminal_title_setup(&mut self) {
let configured_terminal_title_items = self.configured_terminal_title_items();
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
let view = TerminalTitleSetupView::new(
Some(configured_terminal_title_items.as_slice()),
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
@@ -4438,6 +4638,35 @@ impl ChatWidget {
})
}
/// Parses configured terminal-title ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_terminal_title_items() {
match id.parse::<TerminalTitleItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
fn configured_terminal_title_items(&self) -> Vec<String> {
self.config.tui_terminal_title.clone().unwrap_or_else(|| {
DEFAULT_TERMINAL_TITLE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
fn status_line_cwd(&self) -> &Path {
self.current_cwd.as_ref().unwrap_or(&self.config.cwd)
}
@@ -4468,6 +4697,18 @@ impl ChatWidget {
})
}
fn terminal_title_project_name(&self) -> Option<String> {
let project = self.status_line_project_root_name().or_else(|| {
let cwd = self.status_line_cwd();
Some(
cwd.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(cwd, None)),
)
})?;
Some(Self::truncate_terminal_title_part(project, 24))
}
/// Resets git-branch cache state when the status-line cwd changes.
///
/// The branch cache is keyed by cwd because branch lookup is performed relative to that path.
@@ -4573,6 +4814,81 @@ impl ChatWidget {
}
}
fn terminal_title_value_for_item(&self, item: &TerminalTitleItem) -> Option<String> {
match item {
TerminalTitleItem::Project => self.terminal_title_project_name(),
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(Self::truncate_terminal_title_part(trimmed.to_string(), 48))
}
}),
TerminalTitleItem::GitBranch => self
.status_line_branch
.as_ref()
.map(|branch| Self::truncate_terminal_title_part(branch.clone(), 32)),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
32,
)),
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
}
}
fn terminal_title_status_text(&self) -> String {
if self.mcp_startup_status.is_some() {
return "Starting...".to_string();
}
if !self.bottom_pane.is_task_running() {
return "Ready".to_string();
}
if self.current_status_header == "Working" {
return "Working...".to_string();
}
if self
.current_status_header
.starts_with("Waiting for background terminal")
{
return "Waiting...".to_string();
}
if self.current_status_header.starts_with("Undo") {
return "Undoing...".to_string();
}
"Thinking...".to_string()
}
fn terminal_title_task_progress(&self) -> Option<String> {
let (completed, total) = self.last_plan_progress?;
if total == 0 {
return None;
}
Some(format!("Tasks {completed}/{total}"))
}
fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut chars = value.chars();
let head: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_none() || max_chars <= 3 {
return head;
}
let mut truncated = head.chars().take(max_chars - 3).collect::<String>();
truncated.push_str("...");
truncated
}
fn status_line_context_window_size(&self) -> Option<i64> {
self.token_info
.as_ref()
@@ -6459,6 +6775,7 @@ impl ChatWidget {
self.session_header.set_model(effective.model());
// Keep composer paste affordances aligned with the currently effective model.
self.sync_image_paste_enabled();
self.refresh_terminal_title();
}
fn model_display_name(&self) -> &str {

View File

@@ -1523,6 +1523,7 @@ async fn helpers_are_available_and_do_not_panic() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
otel_manager,
};
let mut w = ChatWidget::new(init, thread_manager);
@@ -1652,6 +1653,7 @@ async fn make_chatwidget_manual(
had_work_activity: false,
saw_plan_update_this_turn: false,
saw_plan_item_this_turn: false,
last_plan_progress: None,
plan_delta_buffer: String::new(),
plan_item_active: false,
last_separator_elapsed_secs: None,
@@ -1663,6 +1665,9 @@ async fn make_chatwidget_manual(
current_cwd: None,
session_network_proxy: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
last_terminal_title: None,
terminal_title_setup_original_items: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -4113,6 +4118,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model.clone()),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
otel_manager,
};
@@ -4162,6 +4168,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
feedback_audience: FeedbackAudience::External,
model: Some(resolved_model.clone()),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
otel_manager,
};
@@ -7303,6 +7310,58 @@ async fn status_line_invalid_items_warn_once() {
);
}
#[tokio::test]
async fn terminal_title_setup_cancel_reverts_live_preview() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let original = chat.config.tui_terminal_title.clone();
chat.open_terminal_title_setup();
chat.preview_terminal_title(vec![TerminalTitleItem::Thread, TerminalTitleItem::Status]);
assert_eq!(
chat.config.tui_terminal_title,
Some(vec!["thread".to_string(), "status".to_string()])
);
assert_eq!(
chat.terminal_title_setup_original_items,
Some(original.clone())
);
chat.cancel_terminal_title_setup();
assert_eq!(chat.config.tui_terminal_title, original);
assert_eq!(chat.terminal_title_setup_original_items, None);
}
#[tokio::test]
async fn terminal_title_status_uses_waiting_ellipsis_for_background_terminal() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
chat.current_status_header = "Waiting for background terminal · just fix".to_string();
assert_eq!(chat.terminal_title_status_text(), "Waiting...");
}
#[tokio::test]
async fn terminal_title_status_uses_ellipses_for_other_transient_states() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.mcp_startup_status = Some(std::collections::HashMap::new());
assert_eq!(chat.terminal_title_status_text(), "Starting...");
chat.mcp_startup_status = None;
chat.bottom_pane.set_task_running(true);
chat.current_status_header = "Working".to_string();
assert_eq!(chat.terminal_title_status_text(), "Working...");
chat.current_status_header = "Undoing changes".to_string();
assert_eq!(chat.terminal_title_status_text(), "Undoing...");
chat.current_status_header = "Some other active header".to_string();
assert_eq!(chat.terminal_title_status_text(), "Thinking...");
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -7336,6 +7395,25 @@ async fn status_line_branch_refreshes_after_turn_complete() {
assert!(chat.status_line_branch_pending);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_turn_complete_when_terminal_title_uses_git_branch() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.tui_status_line = Some(Vec::new());
chat.config.tui_terminal_title = Some(vec!["git-branch".to_string()]);
chat.status_line_branch_lookup_complete = true;
chat.status_line_branch_pending = false;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
assert!(chat.status_line_branch_pending);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_interrupt() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;

View File

@@ -104,6 +104,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod text_formatting;
mod tooltips;
mod tui;

View File

@@ -36,6 +36,7 @@ pub enum SlashCommand {
Mention,
Status,
DebugConfig,
Title,
Statusline,
Mcp,
Apps,
@@ -74,6 +75,7 @@ impl SlashCommand {
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
SlashCommand::Statusline => "configure which items appear in the status line",
SlashCommand::Ps => "list background terminals",
SlashCommand::Clean => "stop all background terminals",
@@ -155,6 +157,7 @@ impl SlashCommand {
SlashCommand::Collab => true,
SlashCommand::Agent => true,
SlashCommand::Statusline => false,
SlashCommand::Title => false,
}
}

View File

@@ -0,0 +1,150 @@
use std::fmt;
use std::io;
use std::io::IsTerminal;
use std::io::stdout;
use crossterm::Command;
use ratatui::crossterm::execute;
const MAX_TERMINAL_TITLE_CHARS: usize = 240;
pub(crate) fn set_terminal_title(title: &str) -> io::Result<()> {
if !stdout().is_terminal() {
return Ok(());
}
let title = sanitize_terminal_title(title);
if title.is_empty() {
return clear_terminal_title();
}
execute!(stdout(), SetWindowTitle(title))
}
pub(crate) fn clear_terminal_title() -> io::Result<()> {
if !stdout().is_terminal() {
return Ok(());
}
execute!(stdout(), SetWindowTitle(String::new()))
}
#[derive(Debug, Clone)]
struct SetWindowTitle(String);
impl Command for SetWindowTitle {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
// xterm/ctlseqs documents OSC 0/2 title sequences with ST (ESC \) termination.
// Most terminals also accept BEL for compatibility, but ST is the canonical form.
write!(f, "\x1b]0;{}\x1b\\", self.0)
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Err(std::io::Error::other(
"tried to execute SetWindowTitle using WinAPI; use ANSI instead",
))
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
true
}
}
fn sanitize_terminal_title(title: &str) -> String {
let mut sanitized = String::new();
let mut chars_written = 0;
let mut pending_space = false;
for ch in title.chars() {
if ch.is_whitespace() {
pending_space = !sanitized.is_empty();
continue;
}
if is_disallowed_terminal_title_char(ch) {
continue;
}
if pending_space && chars_written < MAX_TERMINAL_TITLE_CHARS {
sanitized.push(' ');
chars_written += 1;
pending_space = false;
}
if chars_written >= MAX_TERMINAL_TITLE_CHARS {
break;
}
sanitized.push(ch);
chars_written += 1;
}
sanitized
}
fn is_disallowed_terminal_title_char(ch: char) -> bool {
if ch.is_control() {
return true;
}
// Strip Trojan-Source-related bidi controls plus common non-rendering
// formatting characters so title text cannot smuggle terminal control
// semantics or visually misleading content.
matches!(
ch,
'\u{00AD}'
| '\u{034F}'
| '\u{061C}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FE00}'..='\u{FE0F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{E0100}'..='\u{E01EF}'
)
}
#[cfg(test)]
mod tests {
use super::MAX_TERMINAL_TITLE_CHARS;
use super::SetWindowTitle;
use super::sanitize_terminal_title;
use crossterm::Command;
use pretty_assertions::assert_eq;
#[test]
fn sanitizes_terminal_title() {
let sanitized =
sanitize_terminal_title(" Project\t|\nWorking\x1b\x07\u{009D}\u{009C} | Thread ");
assert_eq!(sanitized, "Project | Working | Thread");
}
#[test]
fn strips_invisible_format_chars_from_terminal_title() {
let sanitized = sanitize_terminal_title(
"Pro\u{202E}j\u{2066}e\u{200F}c\u{061C}t\u{200B} \u{FEFF}T\u{2060}itle",
);
assert_eq!(sanitized, "Project Title");
}
#[test]
fn truncates_terminal_title() {
let input = "a".repeat(MAX_TERMINAL_TITLE_CHARS + 10);
let sanitized = sanitize_terminal_title(&input);
assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS);
}
#[test]
fn writes_osc_title_with_string_terminator() {
let mut out = String::new();
SetWindowTitle("hello".to_string())
.write_ansi(&mut out)
.expect("encode terminal title");
assert_eq!(out, "\x1b]0;hello\x1b\\");
}
}