mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Merge branch 'main' into rhan/surface-updates
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }}/*
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
@@ -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, ¶ms.expected_turn_id)
|
||||
.await;
|
||||
if let Err(error) = Self::validate_v2_input_limit(¶ms.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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, ¶ms).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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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(
|
||||
¶ms.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(¶ms.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(¶ms.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(
|
||||
¶ms.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(
|
||||
¶ms.source_path,
|
||||
¶ms.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())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
¶ms.env,
|
||||
¶ms.arg0,
|
||||
TerminalSize::default(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
codex_utils_pty::spawn_pipe_process_no_stdin(
|
||||
program,
|
||||
args,
|
||||
params.cwd.as_path(),
|
||||
¶ms.env,
|
||||
¶ms.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(¶ms.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(¶ms.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(¶ms.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, ¬ification)
|
||||
.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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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\\");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user