Merge branch 'main' into rhan/surface-updates

This commit is contained in:
rhan-oai
2026-03-19 13:42:37 -07:00
committed by GitHub
48 changed files with 4240 additions and 445 deletions
@@ -0,0 +1,24 @@
{
"outputs": {
"argument-comment-lint": {
"platforms": {
"macos-aarch64": {
"regex": "^argument-comment-lint-aarch64-apple-darwin\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"linux-x86_64": {
"regex": "^argument-comment-lint-x86_64-unknown-linux-gnu\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"linux-aarch64": {
"regex": "^argument-comment-lint-aarch64-unknown-linux-gnu\\.tar\\.gz$",
"path": "argument-comment-lint/bin/argument-comment-lint"
},
"windows-x86_64": {
"regex": "^argument-comment-lint-x86_64-pc-windows-msvc\\.zip$",
"path": "argument-comment-lint/bin/argument-comment-lint.exe"
}
}
}
}
}
+8 -4
View File
@@ -141,8 +141,10 @@ jobs:
run:
working-directory: codex-rs
env:
# Speed up repeated builds across CI runs by caching compiled objects (non-Windows).
USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }}
# Speed up repeated builds across CI runs by caching compiled objects, except on
# arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce
# mixed-architecture archives under sccache.
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
# In rust-ci, representative release-profile checks use thin LTO for faster feedback.
@@ -506,8 +508,10 @@ jobs:
run:
working-directory: codex-rs
env:
# Speed up repeated builds across CI runs by caching compiled objects (non-Windows).
USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }}
# Speed up repeated builds across CI runs by caching compiled objects, except on
# arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce
# mixed-architecture archives under sccache.
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
@@ -0,0 +1,103 @@
name: rust-release-argument-comment-lint
on:
workflow_call:
inputs:
publish:
required: true
type: boolean
jobs:
skip:
if: ${{ !inputs.publish }}
runs-on: ubuntu-latest
steps:
- run: echo "Skipping argument-comment-lint release assets for prerelease tag"
build:
if: ${{ inputs.publish }}
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
archive_name: argument-comment-lint-aarch64-apple-darwin.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-apple-darwin.dylib
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
archive_name: argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-x86_64-unknown-linux-gnu.so
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
archive_name: argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz
lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-unknown-linux-gnu.so
runner_binary: argument-comment-lint
cargo_dylint_binary: cargo-dylint
- runner: windows-x64
target: x86_64-pc-windows-msvc
archive_name: argument-comment-lint-x86_64-pc-windows-msvc.zip
lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll
runner_binary: argument-comment-lint.exe
cargo_dylint_binary: cargo-dylint.exe
runs_on:
group: codex-runners
labels: codex-windows-x64
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
with:
toolchain: nightly-2025-09-18
targets: ${{ matrix.target }}
components: llvm-tools-preview, rustc-dev, rust-src
- name: Install tooling
shell: bash
run: |
install_root="${RUNNER_TEMP}/argument-comment-lint-tools"
cargo install --locked cargo-dylint --root "$install_root"
cargo install --locked dylint-link
echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV"
- name: Cargo build
working-directory: tools/argument-comment-lint
shell: bash
run: cargo build --release --target ${{ matrix.target }}
- name: Stage artifact
shell: bash
run: |
dest="dist/argument-comment-lint/${{ matrix.target }}"
mkdir -p "$dest"
package_root="${RUNNER_TEMP}/argument-comment-lint"
rm -rf "$package_root"
mkdir -p "$package_root/bin" "$package_root/lib"
cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.runner_binary }}" \
"$package_root/bin/${{ matrix.runner_binary }}"
cp "${INSTALL_ROOT}/bin/${{ matrix.cargo_dylint_binary }}" \
"$package_root/bin/${{ matrix.cargo_dylint_binary }}"
cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.lib_name }}" \
"$package_root/lib/${{ matrix.lib_name }}"
archive_path="$dest/${{ matrix.archive_name }}"
if [[ "${{ runner.os }}" == "Windows" ]]; then
(cd "${RUNNER_TEMP}" && 7z a "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint >/dev/null)
else
(cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint)
fi
- uses: actions/upload-artifact@v7
with:
name: argument-comment-lint-${{ matrix.target }}
path: dist/argument-comment-lint/${{ matrix.target }}/*
+15
View File
@@ -380,11 +380,19 @@ jobs:
publish: true
secrets: inherit
argument-comment-lint-release-assets:
name: argument-comment-lint release assets
needs: tag-check
uses: ./.github/workflows/rust-release-argument-comment-lint.yml
with:
publish: true
release:
needs:
- build
- build-windows
- shell-tool-mcp
- argument-comment-lint-release-assets
name: release
runs-on: ubuntu-latest
permissions:
@@ -521,6 +529,13 @@ jobs:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-argument-comment-lint-config.json
- name: Trigger developers.openai.com deploy
# Only trigger the deploy if the release is not a pre-release.
# The deploy is used to update the developers.openai.com website with the new config schema json file.
+2
View File
@@ -1998,10 +1998,12 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-pty",
"futures",
"pretty_assertions",
"serde",
@@ -107,6 +107,7 @@ fn app_server_request_span_template(
app_server.api_version = "v2",
app_server.client_name = field::Empty,
app_server.client_version = field::Empty,
turn.id = field::Empty,
)
}
@@ -6024,6 +6024,9 @@ impl CodexMessageProcessor {
match turn_id {
Ok(turn_id) => {
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
let turn = Turn {
id: turn_id.clone(),
items: vec![],
@@ -6076,6 +6079,9 @@ impl CodexMessageProcessor {
.await;
return;
}
self.outgoing
.record_request_turn_id(&request_id, &params.expected_turn_id)
.await;
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
self.outgoing.send_error(request_id, error).await;
return;
@@ -6556,7 +6562,10 @@ impl CodexMessageProcessor {
request_id: ConnectionRequestId,
params: TurnInterruptParams,
) {
let TurnInterruptParams { thread_id, .. } = params;
let TurnInterruptParams { thread_id, turn_id } = params;
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
let (thread_uuid, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
+1 -1
View File
@@ -34,7 +34,7 @@ pub(crate) struct FsApi {
impl Default for FsApi {
fn default() -> Self {
Self {
file_system: Arc::new(Environment.get_filesystem()),
file_system: Arc::new(Environment::default().get_filesystem()),
}
}
}
@@ -580,7 +580,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
parent_span_id: remote_parent_span_id,
context: remote_trace,
} = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088");
let _: TurnStartResponse = harness
let turn_start_response: TurnStartResponse = harness
.request(
ClientRequest::TurnStart {
request_id: RequestId::Integer(3),
@@ -628,6 +628,10 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_eq!(
span_attr(server_request_span, "turn.id"),
Some(turn_start_response.turn.id.as_str())
);
assert_span_descends_from(&spans, core_turn_span, server_request_span);
harness.shutdown().await;
@@ -75,6 +75,10 @@ impl RequestContext {
pub(crate) fn span(&self) -> Span {
self.span.clone()
}
fn record_turn_id(&self, turn_id: &str) {
self.span.record("turn.id", turn_id);
}
}
#[derive(Debug, Clone)]
@@ -217,6 +221,17 @@ impl OutgoingMessageSender {
.and_then(RequestContext::request_trace)
}
pub(crate) async fn record_request_turn_id(
&self,
request_id: &ConnectionRequestId,
turn_id: &str,
) {
let request_contexts = self.request_contexts.lock().await;
if let Some(request_context) = request_contexts.get(request_id) {
request_context.record_turn_id(turn_id);
}
}
async fn take_request_context(
&self,
request_id: &ConnectionRequestId,
+12
View File
@@ -1670,6 +1670,14 @@
},
"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: `spinner` and `project`.",
"items": {
"type": "string"
},
"type": "array"
},
"theme": {
"default": null,
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
@@ -1895,6 +1903,10 @@
"experimental_compact_prompt_file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"experimental_exec_server_url": {
"description": "Experimental / do not use. Overrides the URL used when connecting to a remote exec server.",
"type": "string"
},
"experimental_realtime_start_instructions": {
"description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.",
"type": "string"
+3 -1
View File
@@ -1826,7 +1826,9 @@ impl Session {
code_mode_service: crate::tools::code_mode::CodeModeService::new(
config.js_repl_node_path.clone(),
),
environment: Arc::new(Environment),
environment: Arc::new(
Environment::create(config.experimental_exec_server_url.clone()).await?,
),
};
let js_repl = Arc::new(JsReplHandle::with_node_path(
config.js_repl_node_path.clone(),
+10 -2
View File
@@ -2455,7 +2455,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
true,
));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(codex_exec_server::Environment);
let environment = Arc::new(
codex_exec_server::Environment::create(None)
.await
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let services = SessionServices {
@@ -3249,7 +3253,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
true,
));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(codex_exec_server::Environment);
let environment = Arc::new(
codex_exec_server::Environment::create(None)
.await
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let services = SessionServices {
+38
View File
@@ -237,6 +237,7 @@ fn config_toml_deserializes_model_availability_nux() {
show_tooltips: true,
alternate_screen: AltScreenMode::default(),
status_line: None,
terminal_title: None,
theme: None,
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
@@ -921,6 +922,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
show_tooltips: true,
alternate_screen: AltScreenMode::Auto,
status_line: None,
terminal_title: None,
theme: None,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
}
@@ -4310,6 +4312,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_exec_server_url: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4348,6 +4351,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
},
@@ -4451,6 +4455,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_exec_server_url: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4489,6 +4494,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};
@@ -4590,6 +4596,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_exec_server_url: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4628,6 +4635,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};
@@ -4715,6 +4723,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_exec_server_url: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -4753,6 +4762,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
otel: OtelConfig::default(),
};
@@ -5974,6 +5984,34 @@ experimental_realtime_start_instructions = "start instructions from config"
Ok(())
}
#[test]
fn experimental_exec_server_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_exec_server_url = "http://127.0.0.1:8080"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_exec_server_url.as_deref(),
Some("http://127.0.0.1:8080")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.experimental_exec_server_url.as_deref(),
Some("http://127.0.0.1:8080")
);
Ok(())
}
#[test]
fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
+19 -5
View File
@@ -60,7 +60,7 @@ pub enum ConfigEdit {
ClearPath { segments: Vec<String> },
}
/// Produces a config edit that sets `[tui] theme = "<name>"`.
/// Produces a config edit that sets `[tui].theme = "<name>"`.
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "theme".to_string()],
@@ -68,11 +68,12 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
///
/// The array is written even when it is empty so "hide the status line" stays
/// distinct from "unset, so use defaults".
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
let mut array = toml_edit::Array::new();
for item in items {
array.push(item.clone());
}
let array = items.iter().cloned().collect::<toml_edit::Array>();
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "status_line".to_string()],
@@ -80,6 +81,19 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
///
/// The array is written even when it is empty so "disabled title updates" stays
/// distinct from "unset, so use defaults".
pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {
let array = items.iter().cloned().collect::<toml_edit::Array>();
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "terminal_title".to_string()],
value: TomlItem::Value(array.into()),
}
}
pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
let mut shown_count_entries: Vec<_> = shown_count.iter().collect();
shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
+15
View File
@@ -359,6 +359,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 `spinner`.
pub tui_terminal_title: Option<Vec<String>>,
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
@@ -493,6 +498,10 @@ pub struct Config {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: String,
/// Experimental / do not use. Overrides the URL used when connecting to
/// a remote exec server.
pub experimental_exec_server_url: Option<String>,
/// Machine-local realtime audio device preferences used by realtime voice.
pub realtime_audio: RealtimeAudioConfig,
@@ -1393,6 +1402,10 @@ pub struct ConfigToml {
/// Base URL override for the built-in `openai` model provider.
pub openai_base_url: Option<String>,
/// Experimental / do not use. Overrides the URL used when connecting to
/// a remote exec server.
pub experimental_exec_server_url: Option<String>,
/// Machine-local realtime audio device preferences used by realtime voice.
#[serde(default)]
pub audio: Option<RealtimeAudioToml>,
@@ -2745,6 +2758,7 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
experimental_exec_server_url: cfg.experimental_exec_server_url,
realtime_audio: cfg
.audio
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
@@ -2814,6 +2828,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()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
+7
View File
@@ -752,6 +752,13 @@ pub struct Tui {
#[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: `spinner` and `project`.
#[serde(default)]
pub terminal_title: Option<Vec<String>>,
/// Syntax highlighting theme name (kebab-case).
///
/// When set, overrides automatic light/dark theme detection.
+13 -2
View File
@@ -112,6 +112,7 @@ impl ToolOrchestrator {
let otel_tn = &tool_ctx.tool_name;
let otel_ci = &tool_ctx.call_id;
let otel_user = ToolDecisionSource::User;
let otel_automated_reviewer = ToolDecisionSource::AutomatedReviewer;
let otel_cfg = ToolDecisionSource::Config;
// 1) Approval
@@ -136,8 +137,13 @@ impl ToolOrchestrator {
network_approval_context: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if routes_approval_to_guardian(turn_ctx) {
otel_automated_reviewer.clone()
} else {
otel_user.clone()
};
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone());
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -286,7 +292,12 @@ impl ToolOrchestrator {
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user);
let otel_source = if routes_approval_to_guardian(turn_ctx) {
otel_automated_reviewer
} else {
otel_user
};
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
+2
View File
@@ -16,9 +16,11 @@ workspace = true
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+328 -23
View File
@@ -1,20 +1,65 @@
use std::sync::Arc;
use std::time::Duration;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCNotification;
use serde_json::Value;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::connect_async;
use tracing::debug;
use tracing::warn;
use crate::client_api::ExecServerClientConnectOptions;
use crate::client_api::ExecServerEvent;
use crate::client_api::RemoteExecServerConnectArgs;
use crate::connection::JsonRpcConnection;
use crate::protocol::EXEC_EXITED_METHOD;
use crate::protocol::EXEC_METHOD;
use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_READ_DIRECTORY_METHOD;
use crate::protocol::FS_READ_FILE_METHOD;
use crate::protocol::FS_REMOVE_METHOD;
use crate::protocol::FS_WRITE_FILE_METHOD;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
use crate::protocol::WriteResponse;
use crate::rpc::RpcCallError;
use crate::rpc::RpcClient;
use crate::rpc::RpcClientEvent;
use crate::rpc::RpcNotificationSender;
use crate::rpc::RpcServerOutboundMessage;
mod local_backend;
use local_backend::LocalBackend;
@@ -74,6 +119,7 @@ impl ClientBackend {
struct Inner {
backend: ClientBackend,
events_tx: broadcast::Sender<ExecServerEvent>,
reader_task: tokio::task::JoinHandle<()>,
}
@@ -124,11 +170,32 @@ impl ExecServerClient {
pub async fn connect_in_process(
options: ExecServerClientConnectOptions,
) -> Result<Self, ExecServerError> {
let backend = LocalBackend::new(crate::server::ExecServerHandler::new());
let inner = Arc::new(Inner {
backend: ClientBackend::InProcess(backend),
reader_task: tokio::spawn(async {}),
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<RpcServerOutboundMessage>(256);
let backend = LocalBackend::new(crate::server::ExecServerHandler::new(
RpcNotificationSender::new(outgoing_tx),
));
let inner = Arc::new_cyclic(|weak| {
let weak = weak.clone();
let reader_task = tokio::spawn(async move {
while let Some(message) = outgoing_rx.recv().await {
if let Some(inner) = weak.upgrade()
&& let Err(err) = handle_in_process_outbound_message(&inner, message).await
{
warn!(
"in-process exec-server client closing after unexpected response: {err}"
);
return;
}
}
});
Inner {
backend: ClientBackend::InProcess(backend),
events_tx: broadcast::channel(256).0,
reader_task,
}
});
let client = Self { inner };
client.initialize(options).await?;
Ok(client)
@@ -160,6 +227,10 @@ impl ExecServerClient {
.await
}
pub fn event_receiver(&self) -> broadcast::Receiver<ExecServerEvent> {
self.inner.events_tx.subscribe()
}
pub async fn initialize(
&self,
options: ExecServerClientConnectOptions,
@@ -190,36 +261,234 @@ impl ExecServerClient {
})?
}
pub async fn exec(&self, params: ExecParams) -> Result<ExecResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.exec(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during exec".to_string(),
));
};
remote.call(EXEC_METHOD, &params).await.map_err(Into::into)
}
pub async fn read(&self, params: ReadParams) -> Result<ReadResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.exec_read(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during read".to_string(),
));
};
remote
.call(EXEC_READ_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn write(
&self,
process_id: &str,
chunk: Vec<u8>,
) -> Result<WriteResponse, ExecServerError> {
let params = WriteParams {
process_id: process_id.to_string(),
chunk: chunk.into(),
};
if let Some(backend) = self.inner.backend.as_local() {
return backend.exec_write(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during write".to_string(),
));
};
remote
.call(EXEC_WRITE_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn terminate(&self, process_id: &str) -> Result<TerminateResponse, ExecServerError> {
let params = TerminateParams {
process_id: process_id.to_string(),
};
if let Some(backend) = self.inner.backend.as_local() {
return backend.terminate(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during terminate".to_string(),
));
};
remote
.call(EXEC_TERMINATE_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_read_file(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/readFile".to_string(),
));
};
remote
.call(FS_READ_FILE_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_write_file(
&self,
params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_write_file(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/writeFile".to_string(),
));
};
remote
.call(FS_WRITE_FILE_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_create_directory(
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_create_directory(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/createDirectory".to_string(),
));
};
remote
.call(FS_CREATE_DIRECTORY_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_get_metadata(
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_get_metadata(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/getMetadata".to_string(),
));
};
remote
.call(FS_GET_METADATA_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_read_directory(
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_read_directory(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/readDirectory".to_string(),
));
};
remote
.call(FS_READ_DIRECTORY_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_remove(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/remove".to_string(),
));
};
remote
.call(FS_REMOVE_METHOD, &params)
.await
.map_err(Into::into)
}
pub async fn fs_copy(&self, params: FsCopyParams) -> Result<FsCopyResponse, ExecServerError> {
if let Some(backend) = self.inner.backend.as_local() {
return backend.fs_copy(params).await;
}
let Some(remote) = self.inner.backend.as_remote() else {
return Err(ExecServerError::Protocol(
"remote backend missing during fs/copy".to_string(),
));
};
remote
.call(FS_COPY_METHOD, &params)
.await
.map_err(Into::into)
}
async fn connect(
connection: JsonRpcConnection,
options: ExecServerClientConnectOptions,
) -> Result<Self, ExecServerError> {
let (rpc_client, mut events_rx) = RpcClient::new(connection);
let reader_task = tokio::spawn(async move {
while let Some(event) = events_rx.recv().await {
match event {
RpcClientEvent::Notification(notification) => {
warn!(
"ignoring unexpected exec-server notification during stub phase: {}",
notification.method
);
}
RpcClientEvent::Disconnected { reason } => {
if let Some(reason) = reason {
warn!("exec-server client transport disconnected: {reason}");
let inner = Arc::new_cyclic(|weak| {
let weak = weak.clone();
let reader_task = tokio::spawn(async move {
while let Some(event) = events_rx.recv().await {
match event {
RpcClientEvent::Notification(notification) => {
if let Some(inner) = weak.upgrade()
&& let Err(err) =
handle_server_notification(&inner, notification).await
{
warn!("exec-server client closing after protocol error: {err}");
return;
}
}
RpcClientEvent::Disconnected { reason } => {
if let Some(reason) = reason {
warn!("exec-server client transport disconnected: {reason}");
}
return;
}
return;
}
}
});
Inner {
backend: ClientBackend::Remote(rpc_client),
events_tx: broadcast::channel(256).0,
reader_task,
}
});
let client = Self {
inner: Arc::new(Inner {
backend: ClientBackend::Remote(rpc_client),
reader_task,
}),
};
let client = Self { inner };
client.initialize(options).await?;
Ok(client)
}
@@ -247,3 +516,39 @@ impl From<RpcCallError> for ExecServerError {
}
}
}
async fn handle_in_process_outbound_message(
inner: &Arc<Inner>,
message: RpcServerOutboundMessage,
) -> Result<(), ExecServerError> {
match message {
RpcServerOutboundMessage::Response { .. } | RpcServerOutboundMessage::Error { .. } => Err(
ExecServerError::Protocol("unexpected in-process RPC response".to_string()),
),
RpcServerOutboundMessage::Notification(notification) => {
handle_server_notification(inner, notification).await
}
}
}
async fn handle_server_notification(
inner: &Arc<Inner>,
notification: JSONRPCNotification,
) -> Result<(), ExecServerError> {
match notification.method.as_str() {
EXEC_OUTPUT_DELTA_METHOD => {
let params: ExecOutputDeltaNotification =
serde_json::from_value(notification.params.unwrap_or(Value::Null))?;
let _ = inner.events_tx.send(ExecServerEvent::OutputDelta(params));
}
EXEC_EXITED_METHOD => {
let params: ExecExitedNotification =
serde_json::from_value(notification.params.unwrap_or(Value::Null))?;
let _ = inner.events_tx.send(ExecServerEvent::Exited(params));
}
other => {
debug!("ignoring unknown exec-server notification: {other}");
}
}
Ok(())
}
@@ -1,7 +1,29 @@
use std::sync::Arc;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::InitializeResponse;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
use crate::protocol::WriteResponse;
use crate::server::ExecServerHandler;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use super::ExecServerError;
@@ -35,4 +57,144 @@ impl LocalBackend {
.initialized()
.map_err(ExecServerError::Protocol)
}
pub(super) async fn exec(&self, params: ExecParams) -> Result<ExecResponse, ExecServerError> {
self.handler
.exec(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn exec_read(
&self,
params: ReadParams,
) -> Result<ReadResponse, ExecServerError> {
self.handler
.exec_read(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn exec_write(
&self,
params: WriteParams,
) -> Result<WriteResponse, ExecServerError> {
self.handler
.exec_write(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn terminate(
&self,
params: TerminateParams,
) -> Result<TerminateResponse, ExecServerError> {
self.handler
.terminate(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, ExecServerError> {
self.handler
.fs_read_file(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_write_file(
&self,
params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, ExecServerError> {
self.handler
.fs_write_file(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_create_directory(
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, ExecServerError> {
self.handler
.fs_create_directory(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_get_metadata(
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, ExecServerError> {
self.handler
.fs_get_metadata(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_read_directory(
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, ExecServerError> {
self.handler
.fs_read_directory(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, ExecServerError> {
self.handler
.fs_remove(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
pub(super) async fn fs_copy(
&self,
params: FsCopyParams,
) -> Result<FsCopyResponse, ExecServerError> {
self.handler
.fs_copy(params)
.await
.map_err(|error| ExecServerError::Server {
code: error.code,
message: error.message,
})
}
}
+10
View File
@@ -1,5 +1,8 @@
use std::time::Duration;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
/// Connection options for any exec-server client transport.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecServerClientConnectOptions {
@@ -15,3 +18,10 @@ pub struct RemoteExecServerConnectArgs {
pub connect_timeout: Duration,
pub initialize_timeout: Duration,
}
/// Connection-level server events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecServerEvent {
OutputDelta(ExecOutputDeltaNotification),
Exited(ExecExitedNotification),
}
+67 -2
View File
@@ -1,11 +1,76 @@
use crate::ExecServerClient;
use crate::ExecServerError;
use crate::RemoteExecServerConnectArgs;
use crate::fs;
use crate::fs::ExecutorFileSystem;
#[derive(Clone, Debug, Default)]
pub struct Environment;
#[derive(Clone, Default)]
pub struct Environment {
experimental_exec_server_url: Option<String>,
remote_exec_server_client: Option<ExecServerClient>,
}
impl std::fmt::Debug for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Environment")
.field(
"experimental_exec_server_url",
&self.experimental_exec_server_url,
)
.field(
"has_remote_exec_server_client",
&self.remote_exec_server_client.is_some(),
)
.finish()
}
}
impl Environment {
pub async fn create(
experimental_exec_server_url: Option<String>,
) -> Result<Self, ExecServerError> {
let remote_exec_server_client =
if let Some(websocket_url) = experimental_exec_server_url.as_deref() {
Some(
ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new(
websocket_url.to_string(),
"codex-core".to_string(),
))
.await?,
)
} else {
None
};
Ok(Self {
experimental_exec_server_url,
remote_exec_server_client,
})
}
pub fn experimental_exec_server_url(&self) -> Option<&str> {
self.experimental_exec_server_url.as_deref()
}
pub fn remote_exec_server_client(&self) -> Option<&ExecServerClient> {
self.remote_exec_server_client.as_ref()
}
pub fn get_filesystem(&self) -> impl ExecutorFileSystem + use<> {
fs::LocalFileSystem
}
}
#[cfg(test)]
mod tests {
use super::Environment;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn create_without_remote_exec_server_url_does_not_connect() {
let environment = Environment::create(None).await.expect("create environment");
assert_eq!(environment.experimental_exec_server_url(), None);
assert!(environment.remote_exec_server_client().is_none());
}
}
+27
View File
@@ -10,7 +10,23 @@ mod server;
pub use client::ExecServerClient;
pub use client::ExecServerError;
pub use client_api::ExecServerClientConnectOptions;
pub use client_api::ExecServerEvent;
pub use client_api::RemoteExecServerConnectArgs;
pub use codex_app_server_protocol::FsCopyParams;
pub use codex_app_server_protocol::FsCopyResponse;
pub use codex_app_server_protocol::FsCreateDirectoryParams;
pub use codex_app_server_protocol::FsCreateDirectoryResponse;
pub use codex_app_server_protocol::FsGetMetadataParams;
pub use codex_app_server_protocol::FsGetMetadataResponse;
pub use codex_app_server_protocol::FsReadDirectoryEntry;
pub use codex_app_server_protocol::FsReadDirectoryParams;
pub use codex_app_server_protocol::FsReadDirectoryResponse;
pub use codex_app_server_protocol::FsReadFileParams;
pub use codex_app_server_protocol::FsReadFileResponse;
pub use codex_app_server_protocol::FsRemoveParams;
pub use codex_app_server_protocol::FsRemoveResponse;
pub use codex_app_server_protocol::FsWriteFileParams;
pub use codex_app_server_protocol::FsWriteFileResponse;
pub use environment::Environment;
pub use fs::CopyOptions;
pub use fs::CreateDirectoryOptions;
@@ -19,8 +35,19 @@ pub use fs::FileMetadata;
pub use fs::FileSystemResult;
pub use fs::ReadDirectoryEntry;
pub use fs::RemoveOptions;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;
pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
pub use protocol::WriteResponse;
pub use server::DEFAULT_LISTEN_URL;
pub use server::ExecServerListenUrlParseError;
pub use server::run_main;
+151
View File
@@ -1,8 +1,41 @@
use std::collections::HashMap;
use std::path::PathBuf;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use serde::Deserialize;
use serde::Serialize;
pub const INITIALIZE_METHOD: &str = "initialize";
pub const INITIALIZED_METHOD: &str = "initialized";
pub const EXEC_METHOD: &str = "process/start";
pub const EXEC_READ_METHOD: &str = "process/read";
pub const EXEC_WRITE_METHOD: &str = "process/write";
pub const EXEC_TERMINATE_METHOD: &str = "process/terminate";
pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output";
pub const EXEC_EXITED_METHOD: &str = "process/exited";
pub const FS_READ_FILE_METHOD: &str = "fs/readFile";
pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile";
pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory";
pub const FS_GET_METADATA_METHOD: &str = "fs/getMetadata";
pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory";
pub const FS_REMOVE_METHOD: &str = "fs/remove";
pub const FS_COPY_METHOD: &str = "fs/copy";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ByteChunk(#[serde(with = "base64_bytes")] pub Vec<u8>);
impl ByteChunk {
pub fn into_inner(self) -> Vec<u8> {
self.0
}
}
impl From<Vec<u8>> for ByteChunk {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -13,3 +46,121 @@ pub struct InitializeParams {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecParams {
/// Client-chosen logical process handle scoped to this connection/session.
/// This is a protocol key, not an OS pid.
pub process_id: String,
pub argv: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {
pub process_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadParams {
pub process_id: String,
pub after_seq: Option<u64>,
pub max_bytes: Option<usize>,
pub wait_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessOutputChunk {
pub seq: u64,
pub stream: ExecOutputStream,
pub chunk: ByteChunk,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadResponse {
pub chunks: Vec<ProcessOutputChunk>,
pub next_seq: u64,
pub exited: bool,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteParams {
pub process_id: String,
pub chunk: ByteChunk,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteResponse {
pub accepted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminateParams {
pub process_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminateResponse {
pub running: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExecOutputStream {
Stdout,
Stderr,
Pty,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecOutputDeltaNotification {
pub process_id: String,
pub stream: ExecOutputStream,
pub chunk: ByteChunk,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecExitedNotification {
pub process_id: String,
pub exit_code: i32,
}
mod base64_bytes {
use super::BASE64_STANDARD;
use base64::Engine as _;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serializer;
pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;
BASE64_STANDARD
.decode(encoded)
.map_err(serde::de::Error::custom)
}
}
+227 -8
View File
@@ -1,4 +1,6 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -23,6 +25,11 @@ use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
type PendingRequest = oneshot::Sender<Result<Value, JSONRPCErrorError>>;
type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
type RequestRoute<S> =
Box<dyn Fn(Arc<S>, JSONRPCRequest) -> BoxFuture<RpcServerOutboundMessage> + Send + Sync>;
type NotificationRoute<S> =
Box<dyn Fn(Arc<S>, JSONRPCNotification) -> BoxFuture<Result<(), String>> + Send + Sync>;
#[derive(Debug)]
pub(crate) enum RpcClientEvent {
@@ -30,6 +37,139 @@ pub(crate) enum RpcClientEvent {
Disconnected { reason: Option<String> },
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RpcServerOutboundMessage {
Response {
request_id: RequestId,
result: Value,
},
Error {
request_id: RequestId,
error: JSONRPCErrorError,
},
#[allow(dead_code)]
Notification(JSONRPCNotification),
}
#[allow(dead_code)]
#[derive(Clone)]
pub(crate) struct RpcNotificationSender {
outgoing_tx: mpsc::Sender<RpcServerOutboundMessage>,
}
impl RpcNotificationSender {
pub(crate) fn new(outgoing_tx: mpsc::Sender<RpcServerOutboundMessage>) -> Self {
Self { outgoing_tx }
}
#[allow(dead_code)]
pub(crate) async fn notify<P: Serialize>(
&self,
method: &str,
params: &P,
) -> Result<(), JSONRPCErrorError> {
let params = serde_json::to_value(params).map_err(|err| internal_error(err.to_string()))?;
self.outgoing_tx
.send(RpcServerOutboundMessage::Notification(
JSONRPCNotification {
method: method.to_string(),
params: Some(params),
},
))
.await
.map_err(|_| internal_error("RPC connection closed while sending notification".into()))
}
}
pub(crate) struct RpcRouter<S> {
request_routes: HashMap<&'static str, RequestRoute<S>>,
notification_routes: HashMap<&'static str, NotificationRoute<S>>,
}
impl<S> Default for RpcRouter<S> {
fn default() -> Self {
Self {
request_routes: HashMap::new(),
notification_routes: HashMap::new(),
}
}
}
impl<S> RpcRouter<S>
where
S: Send + Sync + 'static,
{
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn request<P, R, F, Fut>(&mut self, method: &'static str, handler: F)
where
P: DeserializeOwned + Send + 'static,
R: Serialize + Send + 'static,
F: Fn(Arc<S>, P) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<R, JSONRPCErrorError>> + Send + 'static,
{
self.request_routes.insert(
method,
Box::new(move |state, request| {
let request_id = request.id;
let params = request.params;
let response =
decode_request_params::<P>(params).map(|params| handler(state, params));
Box::pin(async move {
let response = match response {
Ok(response) => response.await,
Err(error) => {
return RpcServerOutboundMessage::Error { request_id, error };
}
};
match response {
Ok(result) => match serde_json::to_value(result) {
Ok(result) => RpcServerOutboundMessage::Response { request_id, result },
Err(err) => RpcServerOutboundMessage::Error {
request_id,
error: internal_error(err.to_string()),
},
},
Err(error) => RpcServerOutboundMessage::Error { request_id, error },
}
})
}),
);
}
pub(crate) fn notification<P, F, Fut>(&mut self, method: &'static str, handler: F)
where
P: DeserializeOwned + Send + 'static,
F: Fn(Arc<S>, P) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), String>> + Send + 'static,
{
self.notification_routes.insert(
method,
Box::new(move |state, notification| {
let params = decode_notification_params::<P>(notification.params)
.map(|params| handler(state, params));
Box::pin(async move {
let handler = match params {
Ok(handler) => handler,
Err(err) => return Err(err),
};
handler.await
})
}),
);
}
pub(crate) fn request_route(&self, method: &str) -> Option<&RequestRoute<S>> {
self.request_routes.get(method)
}
pub(crate) fn notification_route(&self, method: &str) -> Option<&NotificationRoute<S>> {
self.notification_routes.get(method)
}
}
pub(crate) struct RpcClient {
write_tx: mpsc::Sender<JSONRPCMessage>,
pending: Arc<Mutex<HashMap<RequestId, PendingRequest>>>,
@@ -57,14 +197,8 @@ impl RpcClient {
}
}
JsonRpcConnectionEvent::MalformedMessage { reason } => {
warn!("JSON-RPC client closing after malformed server message: {reason}");
let _ = event_tx
.send(RpcClientEvent::Disconnected {
reason: Some(reason),
})
.await;
drain_pending(&pending_for_reader).await;
return;
warn!("JSON-RPC client closing after malformed message: {reason}");
break;
}
JsonRpcConnectionEvent::Disconnected { reason } => {
let _ = event_tx.send(RpcClientEvent::Disconnected { reason }).await;
@@ -177,6 +311,91 @@ pub(crate) enum RpcCallError {
Server(JSONRPCErrorError),
}
pub(crate) fn encode_server_message(
message: RpcServerOutboundMessage,
) -> Result<JSONRPCMessage, serde_json::Error> {
match message {
RpcServerOutboundMessage::Response { request_id, result } => {
Ok(JSONRPCMessage::Response(JSONRPCResponse {
id: request_id,
result,
}))
}
RpcServerOutboundMessage::Error { request_id, error } => {
Ok(JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error,
}))
}
RpcServerOutboundMessage::Notification(notification) => {
Ok(JSONRPCMessage::Notification(notification))
}
}
}
pub(crate) fn invalid_request(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32600,
data: None,
message,
}
}
pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32601,
data: None,
message,
}
}
pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32602,
data: None,
message,
}
}
pub(crate) fn internal_error(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32603,
data: None,
message,
}
}
fn decode_request_params<P>(params: Option<Value>) -> Result<P, JSONRPCErrorError>
where
P: DeserializeOwned,
{
decode_params(params).map_err(|err| invalid_params(err.to_string()))
}
fn decode_notification_params<P>(params: Option<Value>) -> Result<P, String>
where
P: DeserializeOwned,
{
decode_params(params).map_err(|err| err.to_string())
}
fn decode_params<P>(params: Option<Value>) -> Result<P, serde_json::Error>
where
P: DeserializeOwned,
{
let params = params.unwrap_or(Value::Null);
match serde_json::from_value(params.clone()) {
Ok(params) => Ok(params),
Err(err) => {
if matches!(params, Value::Object(ref map) if map.is_empty()) {
serde_json::from_value(Value::Null).map_err(|_| err)
} else {
Err(err)
}
}
}
}
async fn handle_server_message(
pending: &Mutex<HashMap<RequestId, PendingRequest>>,
event_tx: &mpsc::Sender<RpcClientEvent>,
+2 -1
View File
@@ -1,6 +1,7 @@
mod filesystem;
mod handler;
mod jsonrpc;
mod processor;
mod registry;
mod transport;
pub(crate) use handler::ExecServerHandler;
@@ -0,0 +1,170 @@
use std::io;
use std::sync::Arc;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::Environment;
use crate::ExecutorFileSystem;
use crate::RemoveOptions;
use crate::rpc::internal_error;
use crate::rpc::invalid_request;
#[derive(Clone)]
pub(crate) struct ExecServerFileSystem {
file_system: Arc<dyn ExecutorFileSystem>,
}
impl Default for ExecServerFileSystem {
fn default() -> Self {
Self {
file_system: Arc::new(Environment.get_filesystem()),
}
}
}
impl ExecServerFileSystem {
pub(crate) async fn read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
data_base64: STANDARD.encode(bytes),
})
}
pub(crate) async fn write_file(
&self,
params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
invalid_request(format!(
"fs/writeFile requires valid base64 dataBase64: {err}"
))
})?;
self.file_system
.write_file(&params.path, bytes)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
}
pub(crate) async fn create_directory(
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
self.file_system
.create_directory(
&params.path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
)
.await
.map_err(map_fs_error)?;
Ok(FsCreateDirectoryResponse {})
}
pub(crate) async fn get_metadata(
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata(&params.path)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
is_directory: metadata.is_directory,
is_file: metadata.is_file,
created_at_ms: metadata.created_at_ms,
modified_at_ms: metadata.modified_at_ms,
})
}
pub(crate) async fn read_directory(
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory(&params.path)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
entries: entries
.into_iter()
.map(|entry| FsReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect(),
})
}
pub(crate) async fn remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
self.file_system
.remove(
&params.path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
)
.await
.map_err(map_fs_error)?;
Ok(FsRemoveResponse {})
}
pub(crate) async fn copy(
&self,
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
self.file_system
.copy(
&params.source_path,
&params.destination_path,
CopyOptions {
recursive: params.recursive,
},
)
.await
.map_err(map_fs_error)?;
Ok(FsCopyResponse {})
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
internal_error(err.to_string())
}
}
+480 -3
View File
@@ -1,25 +1,112 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tracing::warn;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
use crate::protocol::ExecOutputStream;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::InitializeResponse;
use crate::server::jsonrpc::invalid_request;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::protocol::WriteParams;
use crate::protocol::WriteResponse;
use crate::rpc::RpcNotificationSender;
use crate::rpc::internal_error;
use crate::rpc::invalid_params;
use crate::rpc::invalid_request;
use crate::server::filesystem::ExecServerFileSystem;
const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024;
#[cfg(test)]
const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25);
#[cfg(not(test))]
const EXITED_PROCESS_RETENTION: Duration = Duration::from_secs(30);
#[derive(Clone)]
struct RetainedOutputChunk {
seq: u64,
stream: ExecOutputStream,
chunk: Vec<u8>,
}
struct RunningProcess {
session: ExecCommandSession,
tty: bool,
output: VecDeque<RetainedOutputChunk>,
retained_bytes: usize,
next_seq: u64,
exit_code: Option<i32>,
output_notify: Arc<Notify>,
}
enum ProcessEntry {
Starting,
Running(Box<RunningProcess>),
}
pub(crate) struct ExecServerHandler {
notifications: RpcNotificationSender,
file_system: ExecServerFileSystem,
processes: Arc<Mutex<HashMap<String, ProcessEntry>>>,
initialize_requested: AtomicBool,
initialized: AtomicBool,
}
impl ExecServerHandler {
pub(crate) fn new() -> Self {
pub(crate) fn new(notifications: RpcNotificationSender) -> Self {
Self {
notifications,
file_system: ExecServerFileSystem::default(),
processes: Arc::new(Mutex::new(HashMap::new())),
initialize_requested: AtomicBool::new(false),
initialized: AtomicBool::new(false),
}
}
pub(crate) async fn shutdown(&self) {}
pub(crate) async fn shutdown(&self) {
let remaining = {
let mut processes = self.processes.lock().await;
processes
.drain()
.filter_map(|(_, process)| match process {
ProcessEntry::Starting => None,
ProcessEntry::Running(process) => Some(process),
})
.collect::<Vec<_>>()
};
for process in remaining {
process.session.terminate();
}
}
pub(crate) fn initialize(&self) -> Result<InitializeResponse, JSONRPCErrorError> {
if self.initialize_requested.swap(true, Ordering::SeqCst) {
@@ -37,4 +124,394 @@ impl ExecServerHandler {
self.initialized.store(true, Ordering::SeqCst);
Ok(())
}
fn require_initialized_for(&self, method_family: &str) -> Result<(), JSONRPCErrorError> {
if !self.initialize_requested.load(Ordering::SeqCst) {
return Err(invalid_request(format!(
"client must call initialize before using {method_family} methods"
)));
}
if !self.initialized.load(Ordering::SeqCst) {
return Err(invalid_request(format!(
"client must send initialized before using {method_family} methods"
)));
}
Ok(())
}
pub(crate) async fn exec(&self, params: ExecParams) -> Result<ExecResponse, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let process_id = params.process_id.clone();
let (program, args) = params
.argv
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
{
let mut process_map = self.processes.lock().await;
if process_map.contains_key(&process_id) {
return Err(invalid_request(format!(
"process {process_id} already exists"
)));
}
process_map.insert(process_id.clone(), ProcessEntry::Starting);
}
let spawned_result = if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
TerminalSize::default(),
)
.await
} else {
codex_utils_pty::spawn_pipe_process_no_stdin(
program,
args,
params.cwd.as_path(),
&params.env,
&params.arg0,
)
.await
};
let spawned = match spawned_result {
Ok(spawned) => spawned,
Err(err) => {
let mut process_map = self.processes.lock().await;
if matches!(process_map.get(&process_id), Some(ProcessEntry::Starting)) {
process_map.remove(&process_id);
}
return Err(internal_error(err.to_string()));
}
};
let output_notify = Arc::new(Notify::new());
{
let mut process_map = self.processes.lock().await;
process_map.insert(
process_id.clone(),
ProcessEntry::Running(Box::new(RunningProcess {
session: spawned.session,
tty: params.tty,
output: VecDeque::new(),
retained_bytes: 0,
next_seq: 1,
exit_code: None,
output_notify: Arc::clone(&output_notify),
})),
);
}
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stdout
},
spawned.stdout_rx,
self.notifications.clone(),
Arc::clone(&self.processes),
Arc::clone(&output_notify),
));
tokio::spawn(stream_output(
process_id.clone(),
if params.tty {
ExecOutputStream::Pty
} else {
ExecOutputStream::Stderr
},
spawned.stderr_rx,
self.notifications.clone(),
Arc::clone(&self.processes),
Arc::clone(&output_notify),
));
tokio::spawn(watch_exit(
process_id.clone(),
spawned.exit_rx,
self.notifications.clone(),
Arc::clone(&self.processes),
output_notify,
));
Ok(ExecResponse { process_id })
}
pub(crate) async fn exec_read(
&self,
params: ReadParams,
) -> Result<ReadResponse, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let after_seq = params.after_seq.unwrap_or(0);
let max_bytes = params.max_bytes.unwrap_or(usize::MAX);
let wait = Duration::from_millis(params.wait_ms.unwrap_or(0));
let deadline = tokio::time::Instant::now() + wait;
loop {
let (response, output_notify) = {
let process_map = self.processes.lock().await;
let process = process_map.get(&params.process_id).ok_or_else(|| {
invalid_request(format!("unknown process id {}", params.process_id))
})?;
let ProcessEntry::Running(process) = process else {
return Err(invalid_request(format!(
"process id {} is starting",
params.process_id
)));
};
let mut chunks = Vec::new();
let mut total_bytes = 0;
let mut next_seq = process.next_seq;
for retained in process.output.iter().filter(|chunk| chunk.seq > after_seq) {
let chunk_len = retained.chunk.len();
if !chunks.is_empty() && total_bytes + chunk_len > max_bytes {
break;
}
total_bytes += chunk_len;
chunks.push(ProcessOutputChunk {
seq: retained.seq,
stream: retained.stream,
chunk: retained.chunk.clone().into(),
});
next_seq = retained.seq + 1;
if total_bytes >= max_bytes {
break;
}
}
(
ReadResponse {
chunks,
next_seq,
exited: process.exit_code.is_some(),
exit_code: process.exit_code,
},
Arc::clone(&process.output_notify),
)
};
if !response.chunks.is_empty()
|| response.exited
|| tokio::time::Instant::now() >= deadline
{
return Ok(response);
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(response);
}
let _ = tokio::time::timeout(remaining, output_notify.notified()).await;
}
}
pub(crate) async fn exec_write(
&self,
params: WriteParams,
) -> Result<WriteResponse, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let writer_tx = {
let process_map = self.processes.lock().await;
let process = process_map.get(&params.process_id).ok_or_else(|| {
invalid_request(format!("unknown process id {}", params.process_id))
})?;
let ProcessEntry::Running(process) = process else {
return Err(invalid_request(format!(
"process id {} is starting",
params.process_id
)));
};
if !process.tty {
return Err(invalid_request(format!(
"stdin is closed for process {}",
params.process_id
)));
}
process.session.writer_sender()
};
writer_tx
.send(params.chunk.into_inner())
.await
.map_err(|_| internal_error("failed to write to process stdin".to_string()))?;
Ok(WriteResponse { accepted: true })
}
pub(crate) async fn terminate(
&self,
params: TerminateParams,
) -> Result<TerminateResponse, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let running = {
let process_map = self.processes.lock().await;
match process_map.get(&params.process_id) {
Some(ProcessEntry::Running(process)) => {
if process.exit_code.is_some() {
return Ok(TerminateResponse { running: false });
}
process.session.terminate();
true
}
Some(ProcessEntry::Starting) | None => false,
}
};
Ok(TerminateResponse { running })
}
pub(crate) async fn fs_read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.read_file(params).await
}
pub(crate) async fn fs_write_file(
&self,
params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.write_file(params).await
}
pub(crate) async fn fs_create_directory(
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.create_directory(params).await
}
pub(crate) async fn fs_get_metadata(
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.get_metadata(params).await
}
pub(crate) async fn fs_read_directory(
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.read_directory(params).await
}
pub(crate) async fn fs_remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.remove(params).await
}
pub(crate) async fn fs_copy(
&self,
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
self.require_initialized_for("filesystem")?;
self.file_system.copy(params).await
}
}
async fn stream_output(
process_id: String,
stream: ExecOutputStream,
mut receiver: tokio::sync::mpsc::Receiver<Vec<u8>>,
notifications: RpcNotificationSender,
processes: Arc<Mutex<HashMap<String, ProcessEntry>>>,
output_notify: Arc<Notify>,
) {
while let Some(chunk) = receiver.recv().await {
let notification = {
let mut processes = processes.lock().await;
let Some(entry) = processes.get_mut(&process_id) else {
break;
};
let ProcessEntry::Running(process) = entry else {
break;
};
let seq = process.next_seq;
process.next_seq += 1;
process.retained_bytes += chunk.len();
process.output.push_back(RetainedOutputChunk {
seq,
stream,
chunk: chunk.clone(),
});
while process.retained_bytes > RETAINED_OUTPUT_BYTES_PER_PROCESS {
let Some(evicted) = process.output.pop_front() else {
break;
};
process.retained_bytes = process.retained_bytes.saturating_sub(evicted.chunk.len());
warn!(
"retained output cap exceeded for process {process_id}; dropping oldest output"
);
}
ExecOutputDeltaNotification {
process_id: process_id.clone(),
stream,
chunk: chunk.into(),
}
};
output_notify.notify_waiters();
if notifications
.notify(crate::protocol::EXEC_OUTPUT_DELTA_METHOD, &notification)
.await
.is_err()
{
break;
}
}
}
async fn watch_exit(
process_id: String,
exit_rx: tokio::sync::oneshot::Receiver<i32>,
notifications: RpcNotificationSender,
processes: Arc<Mutex<HashMap<String, ProcessEntry>>>,
output_notify: Arc<Notify>,
) {
let exit_code = exit_rx.await.unwrap_or(-1);
{
let mut processes = processes.lock().await;
if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) {
process.exit_code = Some(exit_code);
}
}
output_notify.notify_waiters();
if notifications
.notify(
crate::protocol::EXEC_EXITED_METHOD,
&ExecExitedNotification {
process_id: process_id.clone(),
exit_code,
},
)
.await
.is_err()
{
return;
}
tokio::time::sleep(EXITED_PROCESS_RETENTION).await;
let mut processes = processes.lock().await;
if matches!(
processes.get(&process_id),
Some(ProcessEntry::Running(process)) if process.exit_code == Some(exit_code)
) {
processes.remove(&process_id);
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,102 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc;
use super::ExecServerHandler;
use crate::protocol::ExecParams;
use crate::protocol::InitializeResponse;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::rpc::RpcNotificationSender;
fn exec_params(process_id: &str) -> ExecParams {
let mut env = HashMap::new();
if let Some(path) = std::env::var_os("PATH") {
env.insert("PATH".to_string(), path.to_string_lossy().into_owned());
}
ExecParams {
process_id: process_id.to_string(),
argv: vec![
"bash".to_string(),
"-lc".to_string(),
"sleep 0.1".to_string(),
],
cwd: std::env::current_dir().expect("cwd"),
env,
tty: false,
arg0: None,
}
}
async fn initialized_handler() -> Arc<ExecServerHandler> {
let (outgoing_tx, _outgoing_rx) = mpsc::channel(16);
let handler = Arc::new(ExecServerHandler::new(RpcNotificationSender::new(
outgoing_tx,
)));
assert_eq!(
handler.initialize().expect("initialize"),
InitializeResponse {}
);
handler.initialized().expect("initialized");
handler
}
#[tokio::test]
async fn duplicate_process_ids_allow_only_one_successful_start() {
let handler = initialized_handler().await;
let first_handler = Arc::clone(&handler);
let second_handler = Arc::clone(&handler);
let (first, second) = tokio::join!(
first_handler.exec(exec_params("proc-1")),
second_handler.exec(exec_params("proc-1")),
);
let (successes, failures): (Vec<_>, Vec<_>) =
[first, second].into_iter().partition(Result::is_ok);
assert_eq!(successes.len(), 1);
assert_eq!(failures.len(), 1);
let error = failures
.into_iter()
.next()
.expect("one failed request")
.expect_err("expected duplicate process error");
assert_eq!(error.code, -32600);
assert_eq!(error.message, "process proc-1 already exists");
tokio::time::sleep(Duration::from_millis(150)).await;
handler.shutdown().await;
}
#[tokio::test]
async fn terminate_reports_false_after_process_exit() {
let handler = initialized_handler().await;
handler
.exec(exec_params("proc-1"))
.await
.expect("start process");
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
loop {
let response = handler
.terminate(TerminateParams {
process_id: "proc-1".to_string(),
})
.await
.expect("terminate response");
if response == (TerminateResponse { running: false }) {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"process should have exited within 1s"
);
tokio::time::sleep(Duration::from_millis(25)).await;
}
handler.shutdown().await;
}
+96 -94
View File
@@ -1,53 +1,109 @@
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use tracing::debug;
use std::sync::Arc;
use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::server::ExecServerHandler;
use crate::server::jsonrpc::invalid_params;
use crate::server::jsonrpc::invalid_request_message;
use crate::server::jsonrpc::method_not_found;
use crate::server::jsonrpc::response_message;
use tokio::sync::mpsc;
use tracing::debug;
use tracing::warn;
pub(crate) async fn run_connection(connection: JsonRpcConnection) {
let (json_outgoing_tx, mut incoming_rx, _connection_tasks) = connection.into_parts();
let handler = ExecServerHandler::new();
use crate::connection::CHANNEL_CAPACITY;
use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
use crate::rpc::RpcNotificationSender;
use crate::rpc::RpcServerOutboundMessage;
use crate::rpc::encode_server_message;
use crate::rpc::invalid_request;
use crate::rpc::method_not_found;
use crate::server::ExecServerHandler;
use crate::server::registry::build_router;
while let Some(event) = incoming_rx.recv().await {
match event {
JsonRpcConnectionEvent::Message(message) => {
let response = match handle_connection_message(&handler, message).await {
Ok(response) => response,
Err(err) => {
tracing::warn!(
"closing exec-server connection after protocol error: {err}"
);
break;
}
};
let Some(response) = response else {
continue;
};
if json_outgoing_tx.send(response).await.is_err() {
pub(crate) async fn run_connection(connection: JsonRpcConnection) {
let router = Arc::new(build_router());
let (json_outgoing_tx, mut incoming_rx, connection_tasks) = connection.into_parts();
let (outgoing_tx, mut outgoing_rx) =
mpsc::channel::<RpcServerOutboundMessage>(CHANNEL_CAPACITY);
let notifications = RpcNotificationSender::new(outgoing_tx.clone());
let handler = Arc::new(ExecServerHandler::new(notifications));
let outbound_task = tokio::spawn(async move {
while let Some(message) = outgoing_rx.recv().await {
let json_message = match encode_server_message(message) {
Ok(json_message) => json_message,
Err(err) => {
warn!("failed to serialize exec-server outbound message: {err}");
break;
}
};
if json_outgoing_tx.send(json_message).await.is_err() {
break;
}
}
});
// Process inbound events sequentially to preserve initialize/initialized ordering.
while let Some(event) = incoming_rx.recv().await {
match event {
JsonRpcConnectionEvent::MalformedMessage { reason } => {
warn!("ignoring malformed exec-server message: {reason}");
if json_outgoing_tx
.send(invalid_request_message(reason))
if outgoing_tx
.send(RpcServerOutboundMessage::Error {
request_id: codex_app_server_protocol::RequestId::Integer(-1),
error: invalid_request(reason),
})
.await
.is_err()
{
break;
}
}
JsonRpcConnectionEvent::Message(message) => match message {
codex_app_server_protocol::JSONRPCMessage::Request(request) => {
if let Some(route) = router.request_route(request.method.as_str()) {
let message = route(handler.clone(), request).await;
if outgoing_tx.send(message).await.is_err() {
break;
}
} else if outgoing_tx
.send(RpcServerOutboundMessage::Error {
request_id: request.id,
error: method_not_found(format!(
"exec-server stub does not implement `{}` yet",
request.method
)),
})
.await
.is_err()
{
break;
}
}
codex_app_server_protocol::JSONRPCMessage::Notification(notification) => {
let Some(route) = router.notification_route(notification.method.as_str())
else {
warn!(
"closing exec-server connection after unexpected notification: {}",
notification.method
);
break;
};
if let Err(err) = route(handler.clone(), notification).await {
warn!("closing exec-server connection after protocol error: {err}");
break;
}
}
codex_app_server_protocol::JSONRPCMessage::Response(response) => {
warn!(
"closing exec-server connection after unexpected client response: {:?}",
response.id
);
break;
}
codex_app_server_protocol::JSONRPCMessage::Error(error) => {
warn!(
"closing exec-server connection after unexpected client error: {:?}",
error.id
);
break;
}
},
JsonRpcConnectionEvent::Disconnected { reason } => {
if let Some(reason) = reason {
debug!("exec-server connection disconnected: {reason}");
@@ -58,64 +114,10 @@ pub(crate) async fn run_connection(connection: JsonRpcConnection) {
}
handler.shutdown().await;
}
pub(crate) async fn handle_connection_message(
handler: &ExecServerHandler,
message: JSONRPCMessage,
) -> Result<Option<JSONRPCMessage>, String> {
match message {
JSONRPCMessage::Request(request) => Ok(Some(dispatch_request(handler, request))),
JSONRPCMessage::Notification(notification) => {
handle_notification(handler, notification)?;
Ok(None)
}
JSONRPCMessage::Response(response) => Err(format!(
"unexpected client response for request id {:?}",
response.id
)),
JSONRPCMessage::Error(error) => Err(format!(
"unexpected client error for request id {:?}",
error.id
)),
}
}
fn dispatch_request(handler: &ExecServerHandler, request: JSONRPCRequest) -> JSONRPCMessage {
let JSONRPCRequest {
id,
method,
params,
trace: _,
} = request;
match method.as_str() {
INITIALIZE_METHOD => {
let result = serde_json::from_value::<InitializeParams>(
params.unwrap_or(serde_json::Value::Null),
)
.map_err(|err| invalid_params(err.to_string()))
.and_then(|_params| handler.initialize())
.and_then(|response| {
serde_json::to_value(response).map_err(|err| invalid_params(err.to_string()))
});
response_message(id, result)
}
other => response_message(
id,
Err(method_not_found(format!(
"exec-server stub does not implement `{other}` yet"
))),
),
}
}
fn handle_notification(
handler: &ExecServerHandler,
notification: JSONRPCNotification,
) -> Result<(), String> {
match notification.method.as_str() {
INITIALIZED_METHOD => handler.initialized(),
other => Err(format!("unexpected notification method: {other}")),
drop(outgoing_tx);
for task in connection_tasks {
task.abort();
let _ = task.await;
}
let _ = outbound_task.await;
}
+110
View File
@@ -0,0 +1,110 @@
use std::sync::Arc;
use crate::protocol::EXEC_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::ExecParams;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_READ_DIRECTORY_METHOD;
use crate::protocol::FS_READ_FILE_METHOD;
use crate::protocol::FS_REMOVE_METHOD;
use crate::protocol::FS_WRITE_FILE_METHOD;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::ReadParams;
use crate::protocol::TerminateParams;
use crate::protocol::WriteParams;
use crate::rpc::RpcRouter;
use crate::server::ExecServerHandler;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsWriteFileParams;
pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
let mut router = RpcRouter::new();
router.request(
INITIALIZE_METHOD,
|handler: Arc<ExecServerHandler>, _params: InitializeParams| async move {
handler.initialize()
},
);
router.notification(
INITIALIZED_METHOD,
|handler: Arc<ExecServerHandler>, _params: serde_json::Value| async move {
handler.initialized()
},
);
router.request(
EXEC_METHOD,
|handler: Arc<ExecServerHandler>, params: ExecParams| async move { handler.exec(params).await },
);
router.request(
EXEC_READ_METHOD,
|handler: Arc<ExecServerHandler>, params: ReadParams| async move {
handler.exec_read(params).await
},
);
router.request(
EXEC_WRITE_METHOD,
|handler: Arc<ExecServerHandler>, params: WriteParams| async move {
handler.exec_write(params).await
},
);
router.request(
EXEC_TERMINATE_METHOD,
|handler: Arc<ExecServerHandler>, params: TerminateParams| async move {
handler.terminate(params).await
},
);
router.request(
FS_READ_FILE_METHOD,
|handler: Arc<ExecServerHandler>, params: FsReadFileParams| async move {
handler.fs_read_file(params).await
},
);
router.request(
FS_WRITE_FILE_METHOD,
|handler: Arc<ExecServerHandler>, params: FsWriteFileParams| async move {
handler.fs_write_file(params).await
},
);
router.request(
FS_CREATE_DIRECTORY_METHOD,
|handler: Arc<ExecServerHandler>, params: FsCreateDirectoryParams| async move {
handler.fs_create_directory(params).await
},
);
router.request(
FS_GET_METADATA_METHOD,
|handler: Arc<ExecServerHandler>, params: FsGetMetadataParams| async move {
handler.fs_get_metadata(params).await
},
);
router.request(
FS_READ_DIRECTORY_METHOD,
|handler: Arc<ExecServerHandler>, params: FsReadDirectoryParams| async move {
handler.fs_read_directory(params).await
},
);
router.request(
FS_REMOVE_METHOD,
|handler: Arc<ExecServerHandler>, params: FsRemoveParams| async move {
handler.fs_remove(params).await
},
);
router.request(
FS_COPY_METHOD,
|handler: Arc<ExecServerHandler>, params: FsCopyParams| async move {
handler.fs_copy(params).await
},
);
router
}
+14 -8
View File
@@ -2,15 +2,15 @@
mod common;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_exec_server::ExecResponse;
use codex_exec_server::InitializeParams;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()> {
async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let initialize_id = server
.send_request(
@@ -29,6 +29,10 @@ async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()>
})
.await?;
server
.send_notification("initialized", serde_json::json!({}))
.await?;
let process_start_id = server
.send_request(
"process/start",
@@ -46,18 +50,20 @@ async fn exec_server_stubs_process_start_over_websocket() -> anyhow::Result<()>
.wait_for_event(|event| {
matches!(
event,
JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &process_start_id
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id
)
})
.await?;
let JSONRPCMessage::Error(JSONRPCError { id, error }) = response else {
panic!("expected process/start stub error");
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected process/start response");
};
assert_eq!(id, process_start_id);
assert_eq!(error.code, -32601);
let process_start_response: ExecResponse = serde_json::from_value(result)?;
assert_eq!(
error.message,
"exec-server stub does not implement `process/start` yet"
process_start_response,
ExecResponse {
process_id: "proc-1".to_string()
}
);
server.shutdown().await?;
+1
View File
@@ -31,6 +31,7 @@ pub use codex_utils_string::sanitize_metric_tag_value;
#[derive(Debug, Clone, Serialize, Display)]
#[serde(rename_all = "snake_case")]
pub enum ToolDecisionSource {
AutomatedReviewer,
Config,
User,
}
+109 -18
View File
@@ -724,6 +724,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,
@@ -811,6 +813,7 @@ impl App {
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
session_telemetry: self.session_telemetry.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
}
}
@@ -1783,8 +1786,7 @@ impl App {
let (tx, _rx) = unbounded_channel();
tx
};
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
self.sync_active_agent_label();
self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx));
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot, !is_replay_only);
@@ -1824,6 +1826,16 @@ impl App {
self.sync_active_agent_label();
}
fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
if chat_widget.last_terminal_title.is_none() {
chat_widget.last_terminal_title = previous_terminal_title;
}
self.chat_widget = chat_widget;
self.sync_active_agent_label();
self.refresh_status_surfaces();
}
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
// Start a fresh in-memory session while preserving resumability via persisted rollout
// history.
@@ -1864,8 +1876,9 @@ impl App {
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
session_telemetry: self.session_telemetry.clone(),
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.replace_chat_widget(ChatWidget::new(init, self.server.clone()));
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
@@ -1956,7 +1969,7 @@ impl App {
if resume_restored_queue {
self.chat_widget.maybe_send_next_queued_input();
}
self.refresh_status_line();
self.refresh_status_surfaces();
}
fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
@@ -2078,6 +2091,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 =
@@ -2107,6 +2121,8 @@ impl App {
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
session_telemetry: session_telemetry.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
};
ChatWidget::new(init, thread_manager.clone())
}
@@ -2143,6 +2159,8 @@ impl App {
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
session_telemetry: session_telemetry.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
}
@@ -2185,6 +2203,8 @@ impl App {
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
session_telemetry: session_telemetry.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
}
@@ -2217,6 +2237,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(),
@@ -2386,7 +2407,7 @@ impl App {
if matches!(event, TuiEvent::Draw) {
let size = tui.terminal.size()?;
if size != tui.terminal.last_known_screen_size {
self.refresh_status_line();
self.refresh_status_surfaces();
}
}
@@ -2514,11 +2535,11 @@ impl App {
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
self.replace_chat_widget(ChatWidget::new_from_existing(
init,
resumed.thread,
resumed.session_configured,
);
));
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
@@ -2585,11 +2606,11 @@ impl App {
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
self.replace_chat_widget(ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
));
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
@@ -2762,15 +2783,15 @@ impl App {
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
self.refresh_status_line();
self.refresh_status_surfaces();
}
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(&model);
self.refresh_status_line();
self.refresh_status_surfaces();
}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
self.refresh_status_line();
self.refresh_status_surfaces();
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
@@ -3211,7 +3232,7 @@ impl App {
}
}
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
self.refresh_status_surfaces();
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
@@ -3416,7 +3437,7 @@ impl App {
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.config.plan_mode_reasoning_effort = effort;
self.chat_widget.set_plan_mode_reasoning_effort(effort);
self.refresh_status_line();
self.refresh_status_surfaces();
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
@@ -3730,11 +3751,38 @@ impl App {
}
AppEvent::StatusLineBranchUpdated { cwd, branch } => {
self.chat_widget.set_status_line_branch(cwd, branch);
self.refresh_status_line();
self.refresh_status_surfaces();
}
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();
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = codex_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
@@ -3809,7 +3857,7 @@ impl App {
self.chat_widget.handle_codex_event(event);
if needs_refresh {
self.refresh_status_line();
self.refresh_status_surfaces();
}
}
@@ -4190,8 +4238,8 @@ impl App {
};
}
fn refresh_status_line(&mut self) {
self.chat_widget.refresh_status_line();
fn refresh_status_surfaces(&mut self) {
self.chat_widget.refresh_status_surfaces();
}
#[cfg(target_os = "windows")]
@@ -4223,12 +4271,21 @@ impl App {
}
}
impl Drop for App {
fn drop(&mut self) {
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::file_search::FileSearchManager;
@@ -5051,6 +5108,38 @@ mod tests {
}
}
#[tokio::test]
async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string());
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
make_chatwidget_manual_with_sender().await;
replacement.setup_terminal_title(Vec::new());
app.replace_chat_widget(replacement);
assert_eq!(app.chat_widget.last_terminal_title, None);
}
#[tokio::test]
async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string());
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
make_chatwidget_manual_with_sender().await;
replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]);
replacement.last_terminal_title = Some("codex".to_string());
app.replace_chat_widget(replacement);
assert_eq!(
app.chat_widget.last_terminal_title,
Some("codex".to_string())
);
}
#[tokio::test]
async fn replay_thread_snapshot_restores_pending_pastes_for_submit() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -6472,6 +6561,7 @@ guardian_approval = true
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(),
@@ -6532,6 +6622,7 @@ guardian_approval = true
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(),
+11
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::config::types::ApprovalsReviewer;
@@ -451,6 +452,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,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected {
+3
View File
@@ -49,6 +49,7 @@ mod request_user_input;
mod status_line_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
mod title_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLinePreviewData;
pub(crate) use status_line_setup::StatusLineSetupView;
pub(crate) use title_setup::TerminalTitleItem;
pub(crate) use title_setup::TerminalTitleSetupView;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
@@ -0,0 +1,21 @@
---
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] spinner Animated task spinner (omitted while idle or when animations…
[x] status Compact session status text (Ready, Working, Thinking)
[x] thread Current thread title (omitted until available)
[ ] app-name Codex app name
[ ] 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.
+298
View File
@@ -0,0 +1,298 @@
//! 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 itertools::Itertools;
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, Copy, Eq, PartialEq, Hash)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum TerminalTitleItem {
/// Codex app name.
AppName,
/// Project root name, or a compact cwd fallback.
Project,
/// Animated task spinner while active.
Spinner,
/// Compact runtime status text.
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).
TaskProgress,
}
impl TerminalTitleItem {
pub(crate) fn description(self) -> &'static str {
match self {
TerminalTitleItem::AppName => "Codex app name",
TerminalTitleItem::Project => "Project name (falls back to current directory name)",
TerminalTitleItem::Spinner => {
"Animated task spinner (omitted while idle or when animations are off)"
}
TerminalTitleItem::Status => "Compact session status text (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)"
}
}
}
/// Example text used when previewing the title picker.
///
/// These are illustrative sample values, not live data from the current
/// session.
pub(crate) fn preview_example(self) -> &'static str {
match self {
TerminalTitleItem::AppName => "codex",
TerminalTitleItem::Project => "my-project",
TerminalTitleItem::Spinner => "⠋",
TerminalTitleItem::Status => "Working",
TerminalTitleItem::Thread => "Investigate flaky test",
TerminalTitleItem::GitBranch => "feat/awesome-feature",
TerminalTitleItem::Model => "gpt-5.2-codex",
TerminalTitleItem::TaskProgress => "Tasks 2/5",
}
}
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
match previous {
None => "",
Some(previous)
if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner =>
{
" "
}
Some(_) => " | ",
}
}
}
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
where
T: AsRef<str>,
{
// Treat parsing as all-or-nothing so preview/confirm callbacks never emit
// a partially interpreted ordering. Invalid ids are ignored when building
// the picker, but once the user is interacting with the picker we only want
// to persist or preview a fully valid selection.
ids.map(|id| id.as_ref().parse::<TerminalTitleItem>())
.collect::<Result<Vec<_>, _>>()
.ok()
}
/// Interactive view for configuring terminal-title items.
pub(crate) struct TerminalTitleSetupView {
picker: MultiSelectPicker,
}
impl TerminalTitleSetupView {
/// Creates the terminal-title picker, preserving the configured item order first.
///
/// Unknown configured ids are skipped here instead of surfaced inline. The
/// main TUI still warns about them when rendering the actual title, but the
/// picker itself only exposes the selectable items it can meaningfully
/// preview and persist.
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
let selected_items = title_items
.into_iter()
.flatten()
.filter_map(|id| id.parse::<TerminalTitleItem>().ok())
.unique()
.collect_vec();
let selected_set = selected_items
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
let items = selected_items
.into_iter()
.map(|item| Self::title_select_item(item, /*enabled*/ true))
.chain(
TerminalTitleItem::iter()
.filter(|item| !selected_set.contains(item))
.map(|item| Self::title_select_item(item, /*enabled*/ false)),
)
.collect();
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 items = parse_terminal_title_items(
items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.as_str()),
)?;
let mut preview = String::new();
let mut previous = None;
for item in items.iter().copied() {
preview.push_str(item.separator_from_previous(previous));
preview.push_str(item.preview_example());
previous = Some(item);
}
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
})
.on_change(|items, app_event| {
let Some(items) = parse_terminal_title_items(
items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.as_str()),
) else {
return;
};
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
})
.on_confirm(|ids, app_event| {
let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else {
return;
};
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 pretty_assertions::assert_eq;
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(),
"spinner".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));
}
#[test]
fn parse_terminal_title_items_preserves_order() {
let items =
parse_terminal_title_items(["project", "spinner", "status", "thread"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::Project,
TerminalTitleItem::Spinner,
TerminalTitleItem::Status,
TerminalTitleItem::Thread,
])
);
}
#[test]
fn parse_terminal_title_items_rejects_invalid_ids() {
let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());
assert_eq!(items, None);
}
}
+161 -261
View File
@@ -44,10 +44,15 @@ use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
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::SetTerminalTitleResult;
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;
@@ -169,6 +174,7 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
use tracing::warn;
use unicode_segmentation::UnicodeSegmentation;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
@@ -284,6 +290,11 @@ use self::skills::find_skill_mentions_with_tool_mentions;
mod realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
mod status_surfaces;
use self::status_surfaces::CachedProjectRootName;
#[cfg(test)]
use self::status_surfaces::TERMINAL_TITLE_SPINNER_INTERVAL;
use self::status_surfaces::TerminalTitleStatusKind;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::streaming::chunking::AdaptiveChunkingPolicy;
@@ -300,6 +311,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;
@@ -484,6 +496,8 @@ pub(crate) struct ChatWidgetInit {
pub(crate) startup_tooltip_override: 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) session_telemetry: SessionTelemetry,
}
@@ -709,6 +723,8 @@ pub(crate) struct ChatWidget {
// Guardian review keeps its own pending set so it can derive a single
// footer summary from one or more in-flight review events.
pending_guardian_review_status: PendingGuardianReviewStatus,
// Semantic status used for terminal-title status rendering (avoid string matching on headers).
terminal_title_status_kind: TerminalTitleStatusKind,
// Previous status header to restore after a transient stream retry.
retry_status_header: Option<String>,
// Set when commentary output completes; once stream queues go idle we restore the status row.
@@ -771,6 +787,8 @@ pub(crate) struct ChatWidget {
// later steer. This is cleared when the user submits a steer so the plan popup only appears
// if a newer proposed plan arrives afterward.
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.
@@ -794,6 +812,21 @@ 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.
//
// App carries this cache across ChatWidget replacement so the next widget can
// clear a stale title when its own configuration renders no title content.
pub(crate) 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>>>,
// Baseline instant used to animate spinner-prefixed title statuses.
terminal_title_animation_origin: Instant,
// Cached project root display name for the current cwd; avoids walking parent directories on
// frequent title/status refreshes.
status_line_project_root_name_cache: Option<CachedProjectRootName>,
// 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.
@@ -1089,12 +1122,15 @@ 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) {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(header);
} else if self.bottom_pane.is_task_running() {
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.set_status_header(String::from("Working"));
}
}
@@ -1187,6 +1223,22 @@ impl ChatWidget {
StatusDetailsCapitalization::Preserve,
details_max_lines,
);
let title_uses_status = self
.config
.tui_terminal_title
.as_ref()
.is_some_and(|items| items.iter().any(|item| item == "status"));
let title_uses_spinner = self
.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "spinner"));
if title_uses_status
|| (title_uses_spinner
&& self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing)
{
self.refresh_terminal_title();
}
}
/// Convenience wrapper around [`Self::set_status`];
@@ -1213,70 +1265,6 @@ impl ChatWidget {
self.bottom_pane.set_active_agent_label(active_agent_label);
}
/// Recomputes footer status-line content from config and current runtime state.
///
/// This method is the status-line orchestrator: it parses configured item identifiers,
/// warns once per session about invalid items, updates whether status-line mode is enabled,
/// schedules async git-branch lookup when needed, and renders only values that are currently
/// available.
///
/// The omission behavior is intentional. If selected items are unavailable (for example before
/// a session id exists or before branch lookup completes), those items are skipped without
/// 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();
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.status_line_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 status line {label}: {}.",
proper_join(invalid_items.as_slice())
);
self.on_warning(message);
}
if !items.contains(&StatusLineItem::GitBranch) {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
let enabled = !items.is_empty();
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(/*status_line*/ None);
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 {
self.request_status_line_branch(cwd);
}
let mut parts = Vec::new();
for item in items {
if let Some(value) = self.status_line_value_for_item(&item) {
parts.push(value);
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
}
/// Records that status-line setup was canceled.
///
/// Cancellation is intentionally side-effect free for config state; the existing configuration
@@ -1292,7 +1280,45 @@ impl ChatWidget {
tracing::info!("status line setup confirmed with items: {items:#?}");
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.config.tui_status_line = Some(ids);
self.refresh_status_line();
self.refresh_status_surfaces();
}
/// 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.
@@ -1309,17 +1335,6 @@ impl ChatWidget {
self.status_line_branch_lookup_complete = true;
}
/// Forces a new git-branch lookup when `GitBranch` is part of the configured status line.
fn request_status_line_branch_refresh(&mut self) {
let (items, _) = self.status_line_items_with_invalids();
if items.is_empty() || !items.contains(&StatusLineItem::GitBranch) {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
}
fn collect_runtime_metrics_delta(&mut self) {
if let Some(delta) = self.session_telemetry.runtime_metrics_summary() {
self.apply_runtime_metrics_delta(delta);
@@ -1385,6 +1400,7 @@ impl ChatWidget {
Constrained::allow_only(event.sandbox_policy.clone());
}
self.config.approvals_reviewer = event.approvals_reviewer;
self.status_line_project_root_name_cache = None;
let initial_messages = event.initial_messages.clone();
self.last_copyable_output = None;
let forked_from_id = event.forked_from_id;
@@ -1488,6 +1504,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();
}
}
@@ -1659,6 +1676,7 @@ impl ChatWidget {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
@@ -1696,6 +1714,7 @@ impl ChatWidget {
.set_turn_running(/*turn_running*/ true);
self.saw_plan_update_this_turn = false;
self.saw_plan_item_this_turn = false;
self.last_plan_progress = None;
self.plan_delta_buffer.clear();
self.plan_item_active = false;
self.adaptive_chunking.reset();
@@ -1710,6 +1729,7 @@ impl ChatWidget {
self.pending_status_indicator_restore = false;
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
@@ -2048,7 +2068,7 @@ impl ChatWidget {
} else {
self.rate_limit_snapshots_by_limit_id.clear();
}
self.refresh_status_line();
self.refresh_status_surfaces();
}
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
///
@@ -2353,6 +2373,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));
}
@@ -2671,6 +2702,7 @@ impl ChatWidget {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal;
self.set_status(
"Waiting for background terminal".to_string(),
command_display.clone(),
@@ -2913,7 +2945,7 @@ impl ChatWidget {
fn on_turn_diff(&mut self, unified_diff: String) {
debug!("TurnDiffEvent: {unified_diff}");
self.refresh_status_line();
self.refresh_status_surfaces();
}
fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) {
@@ -2927,6 +2959,7 @@ impl ChatWidget {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(message);
}
@@ -2968,12 +3001,15 @@ impl ChatWidget {
let message = event
.message
.unwrap_or_else(|| "Undo in progress...".to_string());
self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing;
self.set_status_header(message);
}
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
let UndoCompletedEvent { success, message } = event;
self.bottom_pane.hide_status_indicator();
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.refresh_terminal_title();
let message = message.unwrap_or_else(|| {
if success {
"Undo completed successfully.".to_string()
@@ -2993,6 +3029,7 @@ impl ChatWidget {
self.retry_status_header = Some(self.current_status.header.clone());
}
self.bottom_pane.ensure_status_indicator();
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status(
message,
additional_details,
@@ -3003,6 +3040,9 @@ impl ChatWidget {
pub(crate) fn pre_draw_tick(&mut self) {
self.bottom_pane.pre_draw_tick();
if self.should_animate_terminal_title_spinner() {
self.refresh_terminal_title();
}
}
/// Handle completion of an `AgentMessage` turn item.
@@ -3525,6 +3565,7 @@ impl ChatWidget {
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3616,6 +3657,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
@@ -3638,6 +3680,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,
@@ -3649,6 +3692,11 @@ 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,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3693,6 +3741,8 @@ impl ChatWidget {
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_terminal_title();
widget
}
@@ -3714,6 +3764,7 @@ impl ChatWidget {
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3804,6 +3855,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
@@ -3812,6 +3864,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(),
@@ -3837,6 +3890,11 @@ 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,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -3870,6 +3928,8 @@ impl ChatWidget {
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_terminal_title();
widget.refresh_terminal_title();
widget
}
@@ -3894,6 +3954,7 @@ impl ChatWidget {
model,
startup_tooltip_override: _,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
@@ -3984,6 +4045,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
@@ -4006,6 +4068,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,
@@ -4017,6 +4080,11 @@ 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,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -4059,6 +4127,8 @@ impl ChatWidget {
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_terminal_title();
widget.refresh_terminal_title();
widget
}
@@ -4556,6 +4626,9 @@ impl ChatWidget {
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
@@ -5748,188 +5821,14 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(params);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
fn configured_status_line_items(&self) -> Vec<String> {
self.config.tui_status_line.clone().unwrap_or_else(|| {
DEFAULT_STATUS_LINE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
fn status_line_cwd(&self) -> &Path {
self.current_cwd.as_ref().unwrap_or(&self.config.cwd)
}
fn status_line_project_root(&self) -> Option<PathBuf> {
let cwd = self.status_line_cwd();
if let Some(repo_root) = get_git_repo_root(cwd) {
return Some(repo_root);
}
self.config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
}
_ => None,
})
}
fn status_line_project_root_name(&self) -> Option<String> {
self.status_line_project_root().map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None))
})
}
/// 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.
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
if self
.status_line_branch_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_branch_cwd = Some(cwd.to_path_buf());
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
/// Starts an async git-branch lookup unless one is already running.
///
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
/// stale completions after directory changes.
fn request_status_line_branch(&mut self, cwd: PathBuf) {
if self.status_line_branch_pending {
return;
}
self.status_line_branch_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let branch = current_branch_name(&cwd).await;
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
fn status_line_value_for_item(&self, item: &StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => {
let label =
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label = if self
.should_show_fast_status(self.current_model(), self.config.service_tier)
{
" fast"
} else {
""
};
Some(format!("{} {label}{fast_label}", self.model_display_name()))
}
StatusLineItem::CurrentDir => {
Some(format_directory_display(
self.status_line_cwd(),
/*max_width*/ None,
))
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
if total <= 0 {
None
} else {
Some(format!("{} used", format_tokens_compact(total)))
}
}
StatusLineItem::ContextRemaining => self
.status_line_context_remaining_percent()
.map(|remaining| format!("{remaining}% left")),
StatusLineItem::ContextUsed => self
.status_line_context_used_percent()
.map(|used| format!("{used}% used")),
StatusLineItem::FiveHourLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.primary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::WeeklyLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.secondary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
StatusLineItem::ContextWindowSize => self
.status_line_context_window_size()
.map(|cws| format!("{} window", format_tokens_compact(cws))),
StatusLineItem::TotalInputTokens => Some(format!(
"{} in",
format_tokens_compact(self.status_line_total_usage().input_tokens)
)),
StatusLineItem::TotalOutputTokens => Some(format!(
"{} out",
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
}
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));
}
fn status_line_context_window_size(&self) -> Option<i64> {
@@ -8187,6 +8086,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 {
@@ -9288,8 +9188,8 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
impl Drop for ChatWidget {
fn drop(&mut self) {
self.reset_realtime_conversation_state();
self.stop_rate_limit_poller();
self.reset_realtime_conversation_state();
}
}
@@ -0,0 +1,660 @@
//! Status-line and terminal-title rendering helpers for `ChatWidget`.
//!
//! Keeping this logic in a focused submodule makes the additive title/status
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
use super::*;
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
Undoing,
#[default]
Thinking,
}
#[derive(Debug)]
/// Parsed status-surface configuration for one refresh pass.
///
/// The status line and terminal title share some expensive or stateful inputs
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
/// refresh pass compute those shared concerns once, then render both surfaces
/// from the same selection set.
struct StatusSurfaceSelections {
status_line_items: Vec<StatusLineItem>,
invalid_status_line_items: Vec<String>,
terminal_title_items: Vec<TerminalTitleItem>,
invalid_terminal_title_items: Vec<String>,
}
impl StatusSurfaceSelections {
fn uses_git_branch(&self) -> bool {
self.status_line_items.contains(&StatusLineItem::GitBranch)
|| self
.terminal_title_items
.contains(&TerminalTitleItem::GitBranch)
}
}
#[derive(Clone, Debug)]
/// Cached project-root display name keyed by the cwd used for the last lookup.
///
/// Terminal-title refreshes can happen very frequently, so the title path avoids
/// repeatedly walking up the filesystem to rediscover the same project root name
/// while the working directory is unchanged.
pub(super) struct CachedProjectRootName {
pub(super) cwd: PathBuf,
pub(super) root_name: Option<String>,
}
impl ChatWidget {
fn status_surface_selections(&self) -> StatusSurfaceSelections {
let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
let (terminal_title_items, invalid_terminal_title_items) =
self.terminal_title_items_with_invalids();
StatusSurfaceSelections {
status_line_items,
invalid_status_line_items,
terminal_title_items,
invalid_terminal_title_items,
}
}
fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) {
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.status_line_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 status line {label}: {}.",
proper_join(invalid_items)
);
self.on_warning(message);
}
}
fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) {
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)
);
self.on_warning(message);
}
}
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
if !selections.uses_git_branch() {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
return;
}
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);
}
}
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
let enabled = !selections.status_line_items.is_empty();
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(/*status_line*/ None);
return;
}
let mut parts = Vec::new();
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
}
/// Clears the terminal title Codex most recently wrote, if any.
///
/// This does not attempt to restore the shell or terminal's previous title;
/// it only clears the managed title and updates the cache after a successful
/// OSC write.
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
if self.last_terminal_title.is_some() {
clear_terminal_title()?;
self.last_terminal_title = None;
}
Ok(())
}
/// Renders and applies the terminal title for one parsed selection snapshot.
///
/// Empty selections clear the managed title. Non-empty selections render the
/// current values in configured order, skip unavailable segments, and cache
/// the last successfully written title so redundant OSC writes are avoided.
/// When the `spinner` item is present in an animated running state, this also
/// schedules the next frame so the spinner keeps advancing.
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
if selections.terminal_title_items.is_empty() {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
return;
}
let now = Instant::now();
let mut previous = None;
let title = selections
.terminal_title_items
.iter()
.copied()
.filter_map(|item| {
self.terminal_title_value_for_item(item, now)
.map(|value| (item, value))
})
.fold(String::new(), |mut title, (item, value)| {
title.push_str(item.separator_from_previous(previous));
title.push_str(&value);
previous = Some(item);
title
});
let title = (!title.is_empty()).then_some(title);
let should_animate_spinner =
self.should_animate_terminal_title_spinner_with_selections(selections);
if self.last_terminal_title == title {
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
return;
}
match title {
Some(title) => match set_terminal_title(&title) {
Ok(SetTerminalTitleResult::Applied) => {
self.last_terminal_title = Some(title);
}
Ok(SetTerminalTitleResult::NoVisibleContent) => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}
},
None => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
}
/// Recomputes both status surfaces from one shared config snapshot.
///
/// This is the common refresh entrypoint for the footer status line and the
/// terminal title. It parses both configurations once, emits invalid-item
/// warnings once, synchronizes shared cached state (such as git-branch
/// lookup), then renders each surface from that shared snapshot.
pub(crate) fn refresh_status_surfaces(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_status_line_from_selections(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
/// Recomputes and emits the terminal title from config and runtime state.
pub(crate) fn refresh_terminal_title(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
pub(super) fn request_status_line_branch_refresh(&mut self) {
let selections = self.status_surface_selections();
if !selections.uses_git_branch() {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
self.config.tui_status_line.clone().unwrap_or_else(|| {
DEFAULT_STATUS_LINE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
/// 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)
}
/// Returns the configured terminal-title ids, or the default ordering when unset.
pub(super) 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)
}
/// Resolves the project root associated with `cwd`.
///
/// Git repository root wins when available. Otherwise we fall back to the
/// nearest project config layer so non-git projects can still surface a
/// stable project label.
fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option<PathBuf> {
if let Some(repo_root) = get_git_repo_root(cwd) {
return Some(repo_root);
}
self.config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
}
_ => None,
})
}
fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option<String> {
self.status_line_project_root_for_cwd(cwd).map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None))
})
}
/// Returns a cached project-root display name for the active cwd.
fn status_line_project_root_name(&mut self) -> Option<String> {
let cwd = self.status_line_cwd().to_path_buf();
if let Some(cache) = &self.status_line_project_root_name_cache
&& cache.cwd == cwd
{
return cache.root_name.clone();
}
let root_name = self.status_line_project_root_name_for_cwd(&cwd);
self.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd,
root_name: root_name.clone(),
});
root_name
}
/// Produces the terminal-title `project` value.
///
/// This prefers the cached project-root name and falls back to the current
/// directory name when no project root can be inferred.
fn terminal_title_project_name(&mut 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, /*max_width*/ None)),
)
})?;
Some(Self::truncate_terminal_title_part(
project, /*max_chars*/ 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.
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
if self
.status_line_branch_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_branch_cwd = Some(cwd.to_path_buf());
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
/// Starts an async git-branch lookup unless one is already running.
///
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
/// stale completions after directory changes.
fn request_status_line_branch(&mut self, cwd: PathBuf) {
if self.status_line_branch_pending {
return;
}
self.status_line_branch_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let branch = current_branch_name(&cwd).await;
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => {
let label =
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label = if self
.should_show_fast_status(self.current_model(), self.config.service_tier)
{
" fast"
} else {
""
};
Some(format!("{} {label}{fast_label}", self.model_display_name()))
}
StatusLineItem::CurrentDir => {
Some(format_directory_display(
self.status_line_cwd(),
/*max_width*/ None,
))
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
if total <= 0 {
None
} else {
Some(format!("{} used", format_tokens_compact(total)))
}
}
StatusLineItem::ContextRemaining => self
.status_line_context_remaining_percent()
.map(|remaining| format!("{remaining}% left")),
StatusLineItem::ContextUsed => self
.status_line_context_used_percent()
.map(|used| format!("{used}% used")),
StatusLineItem::FiveHourLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.primary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::WeeklyLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.secondary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
StatusLineItem::ContextWindowSize => self
.status_line_context_window_size()
.map(|cws| format!("{} window", format_tokens_compact(cws))),
StatusLineItem::TotalInputTokens => Some(format!(
"{} in",
format_tokens_compact(self.status_line_total_usage().input_tokens)
)),
StatusLineItem::TotalOutputTokens => Some(format!(
"{} out",
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
}
}
/// Resolves one configured terminal-title item into a displayable segment.
///
/// Returning `None` means "omit this segment for now" so callers can keep
/// the configured order while hiding values that are not yet available.
fn terminal_title_value_for_item(
&mut self,
item: TerminalTitleItem,
now: Instant,
) -> Option<String> {
match item {
TerminalTitleItem::AppName => Some("codex".to_string()),
TerminalTitleItem::Project => self.terminal_title_project_name(),
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
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(),
/*max_chars*/ 48,
))
}
}),
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
/*max_chars*/ 32,
)),
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
}
}
/// Computes the compact runtime status label used by the terminal title.
///
/// Startup takes precedence over normal task states, and idle state renders
/// as `Ready` regardless of the last active status bucket.
pub(super) fn terminal_title_status_text(&self) -> String {
if self.mcp_startup_status.is_some() {
return "Starting".to_string();
}
match self.terminal_title_status_kind {
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::WaitingForBackgroundTerminal
if !self.bottom_pane.is_task_running() =>
{
"Ready".to_string()
}
TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::Working => "Working".to_string(),
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
}
}
pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option<String> {
if !self.config.animations {
return None;
}
if !self.terminal_title_has_active_progress() {
return None;
}
Some(self.terminal_title_spinner_frame_at(now).to_string())
}
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
let frame_index =
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
}
fn terminal_title_uses_spinner(&self) -> bool {
self.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
}
fn terminal_title_has_active_progress(&self) -> bool {
self.mcp_startup_status.is_some()
|| self.bottom_pane.is_task_running()
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
}
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
self.config.animations
&& self.terminal_title_uses_spinner()
&& self.terminal_title_has_active_progress()
}
fn should_animate_terminal_title_spinner_with_selections(
&self,
selections: &StatusSurfaceSelections,
) -> bool {
self.config.animations
&& selections
.terminal_title_items
.contains(&TerminalTitleItem::Spinner)
&& self.terminal_title_has_active_progress()
}
/// Formats the last `update_plan` progress snapshot for terminal-title display.
pub(super) 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}"))
}
/// Truncates a title segment by grapheme cluster and appends `...` when needed.
pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut graphemes = value.graphemes(true);
let head: String = graphemes.by_ref().take(max_chars).collect();
if graphemes.next().is_none() || max_chars <= 3 {
return head;
}
let mut truncated = head.graphemes(true).take(max_chars - 3).collect::<String>();
truncated.push_str("...");
truncated
}
}
+338 -10
View File
@@ -1777,6 +1777,7 @@ async fn helpers_are_available_and_do_not_panic() {
model: Some(resolved_model),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
let mut w = ChatWidget::new(init, thread_manager);
@@ -1896,6 +1897,7 @@ async fn make_chatwidget_manual(
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
@@ -1919,6 +1921,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,
@@ -1930,6 +1933,11 @@ 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,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
@@ -5716,6 +5724,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
@@ -5766,6 +5775,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
@@ -6322,6 +6332,50 @@ async fn undo_started_hides_interrupt_hint() {
);
}
#[tokio::test]
async fn undo_completed_clears_terminal_title_undo_state() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = true;
chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]);
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
chat.handle_codex_event(Event {
id: "turn-undo".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
});
assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string()));
chat.handle_codex_event(Event {
id: "turn-undo".to_string(),
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
success: true,
message: None,
}),
});
assert_eq!(chat.last_terminal_title, Some("Ready".to_string()));
}
#[tokio::test]
async fn undo_started_refreshes_default_spinner_project_title() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = true;
chat.refresh_terminal_title();
let project = chat
.last_terminal_title
.clone()
.expect("default title should include a project name");
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
chat.handle_codex_event(Event {
id: "turn-undo".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
});
assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}")));
}
/// The commit picker shows only commit subjects (no timestamps).
#[tokio::test]
async fn review_commit_picker_shows_subjects_without_timestamps() {
@@ -10505,16 +10559,20 @@ async fn status_line_invalid_items_warn_once() {
]);
chat.thread_id = Some(ThreadId::new());
chat.refresh_status_line();
chat.refresh_status_surfaces();
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("bogus_item"),
rendered.contains(r#""bogus_item""#),
"warning cell missing invalid item content: {rendered}"
);
assert!(
!rendered.contains(r#"\"bogus_item\""#),
"warning cell should render plain quotes, not escaped quotes: {rendered}"
);
chat.refresh_status_line();
chat.refresh_status_surfaces();
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
@@ -10522,6 +10580,257 @@ async fn status_line_invalid_items_warn_once() {
);
}
#[tokio::test]
async fn terminal_title_invalid_items_warn_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.tui_terminal_title = Some(vec![
"status".to_string(),
"bogus_item".to_string(),
"bogus_item".to_string(),
]);
chat.thread_id = Some(ThreadId::new());
chat.refresh_status_surfaces();
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(r#""bogus_item""#),
"warning cell missing invalid item content: {rendered}"
);
assert!(
!rendered.contains(r#"\"bogus_item\""#),
"warning cell should render plain quotes, not escaped quotes: {rendered}"
);
chat.refresh_status_surfaces();
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected invalid terminal title warning to emit only 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_label_for_background_terminal_when_animations_disabled()
{
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = false;
chat.on_task_started();
terminal_interaction(&mut chat, "call-1", "proc-1", "");
assert_eq!(chat.terminal_title_status_text(), "Waiting");
}
#[tokio::test]
async fn terminal_title_status_uses_plain_labels_for_transient_states_when_animations_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = false;
chat.mcp_startup_status = Some(std::collections::HashMap::new());
assert_eq!(chat.terminal_title_status_text(), "Starting");
chat.mcp_startup_status = None;
chat.on_task_started();
assert_eq!(chat.terminal_title_status_text(), "Working");
chat.handle_codex_event(Event {
id: "undo-1".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent {
message: Some("Undoing changes".to_string()),
}),
});
assert_eq!(chat.terminal_title_status_text(), "Undoing");
chat.on_agent_reasoning_delta("**Planning**\nmore".to_string());
assert_eq!(chat.terminal_title_status_text(), "Thinking");
}
#[tokio::test]
async fn default_terminal_title_items_are_spinner_then_project() {
let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
assert_eq!(
chat.configured_terminal_title_items(),
vec!["spinner".to_string(), "project".to_string()]
);
}
#[tokio::test]
async fn terminal_title_can_render_app_name_item() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.tui_terminal_title = Some(vec!["app-name".to_string()]);
chat.refresh_terminal_title();
assert_eq!(chat.last_terminal_title, Some("codex".to_string()));
}
#[tokio::test]
async fn default_terminal_title_refreshes_when_spinner_state_changes() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = true;
chat.config.tui_terminal_title = None;
let cwd = chat
.current_cwd
.clone()
.unwrap_or_else(|| chat.config.cwd.clone());
let project = get_git_repo_root(&cwd)
.map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, None))
})
.or_else(|| {
chat.config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(|path| {
path.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(path, None))
})
}
_ => None,
})
})
.unwrap_or_else(|| {
cwd.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&cwd, None))
});
chat.last_terminal_title = Some(project.clone());
chat.bottom_pane.set_task_running(true);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
chat.refresh_terminal_title();
assert_eq!(chat.last_terminal_title, Some(format!("⠋ {project}")));
}
#[tokio::test]
async fn terminal_title_spinner_item_renders_when_animations_enabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
chat.terminal_title_animation_origin = Instant::now();
assert_eq!(
chat.terminal_title_spinner_text_at(chat.terminal_title_animation_origin),
Some("⠋".to_string())
);
assert_eq!(
chat.terminal_title_spinner_text_at(
chat.terminal_title_animation_origin + TERMINAL_TITLE_SPINNER_INTERVAL,
),
Some("⠙".to_string())
);
}
#[tokio::test]
async fn terminal_title_uses_spaces_around_spinner_item() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = true;
chat.config.tui_terminal_title = Some(vec![
"project".to_string(),
"spinner".to_string(),
"status".to_string(),
"thread".to_string(),
]);
chat.thread_name = Some("Investigate flaky test".to_string());
chat.bottom_pane.set_task_running(true);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
chat.refresh_terminal_title();
let title = chat
.last_terminal_title
.clone()
.expect("expected terminal title");
assert!(title.contains(" ⠋ Working | "));
assert!(!title.contains("| ⠋"));
assert!(!title.contains("⠋ |"));
}
#[tokio::test]
async fn terminal_title_shows_spinner_and_undoing_without_task_running() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.animations = true;
chat.config.tui_terminal_title = Some(vec!["spinner".to_string(), "status".to_string()]);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Undoing;
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
assert!(!chat.bottom_pane.is_task_running());
chat.refresh_terminal_title();
assert_eq!(chat.last_terminal_title, Some("⠋ Undoing".to_string()));
}
#[tokio::test]
async fn terminal_title_reschedules_spinner_when_title_text_is_unchanged() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let (frame_requester, mut frame_schedule_rx) = FrameRequester::test_observable();
chat.frame_requester = frame_requester;
chat.config.animations = true;
chat.config.tui_terminal_title = Some(vec!["spinner".to_string()]);
chat.bottom_pane.set_task_running(true);
chat.terminal_title_status_kind = TerminalTitleStatusKind::Working;
chat.terminal_title_animation_origin = Instant::now() + Duration::from_secs(1);
chat.last_terminal_title = Some("⠋".to_string());
chat.refresh_terminal_title();
assert!(frame_schedule_rx.try_recv().is_ok());
}
#[tokio::test]
async fn on_task_started_resets_terminal_title_task_progress() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.last_plan_progress = Some((2, 5));
chat.on_task_started();
assert_eq!(chat.last_plan_progress, None);
assert_eq!(chat.terminal_title_task_progress(), None);
}
#[test]
fn terminal_title_part_truncation_preserves_grapheme_clusters() {
let value = "ab👩‍💻cdefg".to_string();
let truncated = ChatWidget::truncate_terminal_title_part(value, 7);
assert_eq!(truncated, "ab👩‍💻c...");
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -10530,7 +10839,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() {
chat.status_line_branch_lookup_complete = true;
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
chat.refresh_status_line();
chat.refresh_status_surfaces();
assert_eq!(chat.status_line_branch, None);
assert!(!chat.status_line_branch_pending);
@@ -10555,6 +10864,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;
@@ -10578,11 +10906,11 @@ async fn status_line_fast_mode_renders_on_and_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.refresh_status_line();
chat.refresh_status_surfaces();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.refresh_status_line();
chat.refresh_status_surfaces();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
}
@@ -10595,7 +10923,7 @@ async fn status_line_fast_mode_footer_snapshot() {
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.set_service_tier(Some(ServiceTier::Fast));
chat.refresh_status_line();
chat.refresh_status_surfaces();
let width = 80;
let height = chat.desired_height(width);
@@ -10618,7 +10946,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() {
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
set_chatgpt_auth(&mut chat);
chat.refresh_status_line();
chat.refresh_status_surfaces();
assert_eq!(
status_line_text(&chat),
@@ -10626,7 +10954,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() {
);
chat.set_model("gpt-5.3-codex");
chat.refresh_status_line();
chat.refresh_status_surfaces();
assert_eq!(
status_line_text(&chat),
@@ -10650,7 +10978,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() {
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
set_chatgpt_auth(&mut chat);
chat.refresh_status_line();
chat.refresh_status_surfaces();
let width = 80;
let height = chat.desired_height(width);
+1
View File
@@ -125,6 +125,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod text_formatting;
mod theme_picker;
mod tooltips;
+3
View File
@@ -38,6 +38,7 @@ pub enum SlashCommand {
Mention,
Status,
DebugConfig,
Title,
Statusline,
Theme,
Mcp,
@@ -85,6 +86,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::Theme => "choose a syntax highlighting theme",
SlashCommand::Ps => "list background terminals",
@@ -177,6 +179,7 @@ impl SlashCommand {
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
SlashCommand::Title => false,
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Terminal-title output helpers for the TUI.
//!
//! This module owns the low-level OSC title write path and the sanitization
//! that happens immediately before we emit it. It is intentionally narrow:
//! callers decide when the title should change and whether an empty title means
//! "leave the old title alone" or "clear the title Codex last wrote".
//! This module does not attempt to read or restore the terminal's previous
//! title because that is not portable across terminals.
//!
//! Sanitization is necessary because title content is assembled from untrusted
//! text sources such as model output, thread names, project paths, and config.
//! Before we place that text inside an OSC sequence, we strip:
//! - control characters that could terminate or reshape the escape sequence
//! - bidi/invisible formatting codepoints that can visually reorder or hide
//! text (the same family of issues discussed in Trojan Source writeups)
//! - redundant whitespace that would make titles noisy or hard to scan
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;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum SetTerminalTitleResult {
/// A sanitized title was written, or stdout is not a terminal so no write was needed.
Applied,
/// Sanitization removed every visible character, so no title was emitted.
///
/// This is distinct from clearing the title. Callers decide whether an
/// empty post-sanitization value should result in no-op behavior, clearing
/// the title Codex manages, or some other fallback.
NoVisibleContent,
}
/// Writes a sanitized OSC window-title sequence to stdout.
///
/// The input is treated as untrusted display text: control characters,
/// invisible formatting characters, and redundant whitespace are removed before
/// the title is emitted. If sanitization removes all visible content, the
/// function returns [`SetTerminalTitleResult::NoVisibleContent`] instead of
/// clearing the title because clearing and restoring are policy decisions for
/// higher-level callers. Mechanically, sanitization collapses whitespace runs
/// to single spaces, drops disallowed codepoints, and bounds the result to
/// [`MAX_TERMINAL_TITLE_CHARS`] visible characters before writing OSC 0.
pub(crate) fn set_terminal_title(title: &str) -> io::Result<SetTerminalTitleResult> {
if !stdout().is_terminal() {
return Ok(SetTerminalTitleResult::Applied);
}
let title = sanitize_terminal_title(title);
if title.is_empty() {
return Ok(SetTerminalTitleResult::NoVisibleContent);
}
execute!(stdout(), SetWindowTitle(title))?;
Ok(SetTerminalTitleResult::Applied)
}
/// Clears the current terminal title by writing an empty OSC title payload.
///
/// This clears the visible title; it does not restore whatever title the shell
/// or a previous program may have set before Codex started managing the 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
}
}
/// Normalizes untrusted title text into a single bounded display line.
///
/// This removes terminal control characters, strips invisible/bidi formatting
/// characters, collapses any whitespace run into a single ASCII space, and
/// truncates after [`MAX_TERMINAL_TITLE_CHARS`] emitted characters.
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
}
/// Returns whether `ch` should be dropped from terminal-title output.
///
/// This includes both plain control characters and a curated set of invisible
/// formatting codepoints. The bidi entries here cover the Trojan-Source-style
/// text-reordering controls that can make a title render misleadingly relative
/// to its underlying byte sequence.
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\\");
}
}
+11
View File
@@ -65,6 +65,17 @@ impl FrameRequester {
frame_schedule_tx: tx,
}
}
/// Create a requester and expose its raw schedule queue for assertions.
pub(crate) fn test_observable() -> (Self, mpsc::UnboundedReceiver<Instant>) {
let (tx, rx) = mpsc::unbounded_channel();
(
FrameRequester {
frame_schedule_tx: tx,
},
rx,
)
}
}
/// A scheduler for coalescing frame draw requests and notifying the TUI event loop.
+5
View File
@@ -68,6 +68,11 @@ cd tools/argument-comment-lint
cargo test
```
GitHub releases also publish a DotSlash file named
`argument-comment-lint` for macOS arm64, Linux arm64, Linux x64, and Windows
x64. The published package contains a small runner executable, a bundled
`cargo-dylint`, and the prebuilt lint library.
Run the lint against `codex-rs` from the repo root:
```bash
@@ -0,0 +1,164 @@
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err}");
ExitCode::from(1)
}
}
}
fn run() -> Result<ExitCode, String> {
let exe_path =
env::current_exe().map_err(|err| format!("failed to locate current executable: {err}"))?;
let bin_dir = exe_path.parent().ok_or_else(|| {
format!(
"failed to locate parent directory for executable {}",
exe_path.display()
)
})?;
let package_root = bin_dir.parent().ok_or_else(|| {
format!(
"failed to locate package root for executable {}",
exe_path.display()
)
})?;
let cargo_dylint = bin_dir.join(cargo_dylint_binary_name());
let library_dir = package_root.join("lib");
let library_path = find_bundled_library(&library_dir)?;
ensure_exists(&cargo_dylint, "bundled cargo-dylint executable")?;
ensure_exists(
&library_dir,
"bundled argument-comment lint library directory",
)?;
let args: Vec<OsString> = env::args_os().skip(1).collect();
let mut command = Command::new(&cargo_dylint);
command.arg("dylint");
command.arg("--lib-path").arg(&library_path);
if !has_library_selection(&args) {
command.arg("--all");
}
command.args(&args);
set_default_env(&mut command);
let status = command
.status()
.map_err(|err| format!("failed to execute {}: {err}", cargo_dylint.display()))?;
Ok(exit_code_from_status(status.code()))
}
fn has_library_selection(args: &[OsString]) -> bool {
let mut expect_value = false;
for arg in args {
if expect_value {
return true;
}
match arg.to_string_lossy().as_ref() {
"--" => break,
"--lib" | "--lib-path" => {
expect_value = true;
}
"--lib=" | "--lib-path=" => return true,
value if value.starts_with("--lib=") || value.starts_with("--lib-path=") => {
return true;
}
_ => {}
}
}
false
}
fn set_default_env(command: &mut Command) {
if let Some(flags) = env::var_os("DYLINT_RUSTFLAGS") {
let mut flags = flags.to_string_lossy().to_string();
append_flag_if_missing(&mut flags, "-D uncommented-anonymous-literal-argument");
append_flag_if_missing(&mut flags, "-A unknown_lints");
command.env("DYLINT_RUSTFLAGS", flags);
} else {
command.env(
"DYLINT_RUSTFLAGS",
"-D uncommented-anonymous-literal-argument -A unknown_lints",
);
}
if env::var_os("CARGO_INCREMENTAL").is_none() {
command.env("CARGO_INCREMENTAL", "0");
}
}
fn append_flag_if_missing(flags: &mut String, flag: &str) {
if flags.contains(flag) {
return;
}
if !flags.is_empty() {
flags.push(' ');
}
flags.push_str(flag);
}
fn cargo_dylint_binary_name() -> &'static str {
if cfg!(windows) {
"cargo-dylint.exe"
} else {
"cargo-dylint"
}
}
fn ensure_exists(path: &Path, label: &str) -> Result<(), String> {
if path.exists() {
Ok(())
} else {
Err(format!("{label} not found at {}", path.display()))
}
}
fn find_bundled_library(library_dir: &Path) -> Result<PathBuf, String> {
let entries = fs::read_dir(library_dir).map_err(|err| {
format!(
"failed to read bundled library directory {}: {err}",
library_dir.display()
)
})?;
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file())
.filter(|path| {
path.file_name()
.map(|name| name.to_string_lossy().contains('@'))
.unwrap_or(false)
});
let Some(first) = candidates.next() else {
return Err(format!(
"no packaged Dylint library found in {}",
library_dir.display()
));
};
if candidates.next().is_some() {
return Err(format!(
"expected exactly one packaged Dylint library in {}",
library_dir.display()
));
}
Ok(first)
}
fn exit_code_from_status(code: Option<i32>) -> ExitCode {
code.and_then(|value| u8::try_from(value).ok())
.map_or_else(|| ExitCode::from(1), ExitCode::from)
}