Compare commits

...

9 Commits

Author SHA1 Message Date
Ahmed Ibrahim
fb5f4fa17f shortcuts 2025-09-26 12:42:54 -07:00
Ahmed Ibrahim
b692603e72 shortcuts 2025-09-26 12:29:42 -07:00
Ahmed Ibrahim
665e22d69d shortcuts 2025-09-26 12:29:27 -07:00
Michael Bolin
c549481513 feat: introduce responses-api-proxy (#4246)
Details are in `responses-api-proxy/README.md`, but the key contribution
of this PR is a new subcommand, `codex responses-api-proxy`, which reads
the auth token for use with the OpenAI Responses API from `stdin` at
startup and then proxies `POST` requests to `/v1/responses` over to
`https://api.openai.com/v1/responses`, injecting the auth token as part
of the `Authorization` header.

The expectation is that `codex responses-api-proxy` is launched by a
privileged user who has access to the auth token so that it can be used
by unprivileged users of the Codex CLI on the same host.

If the client only has one user account with `sudo`, one option is to:

- run `sudo codex responses-api-proxy --http-shutdown --server-info
/tmp/server-info.json` to start the server
- record the port written to `/tmp/server-info.json`
- relinquish their `sudo` privileges (which is irreversible!) like so:

```
sudo deluser $USER sudo || sudo gpasswd -d $USER sudo || true
```

- use `codex` with the proxy (see `README.md`)
- when done, make a `GET` request to the server using the `PORT` from
`server-info.json` to shut it down:

```shell
curl --fail --silent --show-error "http://127.0.0.1:$PORT/shutdown"
```

To protect the auth token, we:

- allocate a 1024 byte buffer on the stack and write `"Bearer "` into it
to start
- we then read from `stdin`, copying to the contents into the buffer
after the prefix
- after verifying the input looks good, we create a `String` from that
buffer (so the data is now on the heap)
- we zero out the stack-allocated buffer using
https://crates.io/crates/zeroize so it is not optimized away by the
compiler
- we invoke `.leak()` on the `String` so we can treat its contents as a
`&'static str`, as it will live for the rest of the processs
- on UNIX, we `mlock(2)` the memory backing the `&'static str`
- when using the `&'static str` when building an HTTP request, we use
`HeaderValue::from_static()` to avoid copying the `&str`
- we also invoke `.set_sensitive(true)` on the `HeaderValue`, which in
theory indicates to other parts of the HTTP stack that the header should
be treated with "special care" to avoid leakage:


439d1c50d7/src/header/value.rs (L346-L376)
2025-09-26 08:19:00 -07:00
jif-oai
8797145678 fix: token usage for compaction (#4281)
Emit token usage update when draining compaction
2025-09-26 16:24:27 +02:00
Ahmed Ibrahim
a53720e278 Show exec output on success with trimmed display (#4113)
- Refactor Exec Cell into its own module
- update exec command rendering to inline the first command line
- limit continuation lines
- always show trimmed output
2025-09-26 07:13:44 -07:00
Ahmed Ibrahim
41f5d61f24 Move approvals to use ListSelectionView (#4275)
Unify selection menus:
- Move approvals to the vertical menu `ListSelectionView`
- Add header section to `ListSelectionView`

<img width="502" height="214" alt="image"
src="https://github.com/user-attachments/assets/f4b43ddf-3549-403c-ad9e-a523688714e4"
/>

<img width="748" height="214" alt="image"
src="https://github.com/user-attachments/assets/f94ac7b5-dc94-4dc0-a1df-7a8e3ba2453b"
/>

---------

Co-authored-by: pakrym-oai <pakrym@openai.com>
2025-09-26 07:13:29 -07:00
Ahmed Ibrahim
02609184be Refactor the footer logic to a new file (#4259)
This will help us have more control over the footer

---------

Co-authored-by: pakrym-oai <pakrym@openai.com>
2025-09-26 07:13:13 -07:00
jif-oai
1fc3413a46 ref: state - 2 (#4229)
Extracting tasks in a module and start abstraction behind a Trait (more
to come on this but each task will be tackled in a dedicated PR)
The goal was to drop the ActiveTask and to have a (potentially) set of
tasks during each turn
2025-09-26 13:49:08 +00:00
69 changed files with 3282 additions and 1469 deletions

139
codex-rs/Cargo.lock generated
View File

@@ -488,6 +488,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
@@ -640,6 +646,7 @@ dependencies = [
"codex-mcp-server",
"codex-protocol",
"codex-protocol-ts",
"codex-responses-api-proxy",
"codex-tui",
"ctor 0.5.0",
"libc",
@@ -673,6 +680,7 @@ dependencies = [
"askama",
"assert_cmd",
"async-channel",
"async-trait",
"base64",
"bytes",
"chrono",
@@ -685,6 +693,7 @@ dependencies = [
"env-flags",
"eventsource-stream",
"futures",
"indexmap 2.10.0",
"landlock",
"libc",
"maplit",
@@ -924,6 +933,22 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "codex-responses-api-proxy"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-arg0",
"libc",
"reqwest",
"serde",
"serde_json",
"tiny_http",
"tokio",
"zeroize",
]
[[package]]
name = "codex-tui"
version = "0.0.0"
@@ -1947,8 +1972,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1958,9 +1985,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -2157,6 +2186,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2757,6 +2787,12 @@ dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lsp-types"
version = "0.94.1"
@@ -2936,7 +2972,7 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.1.1",
"libc",
]
@@ -3493,6 +3529,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.16",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.16",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.40"
@@ -3685,6 +3776,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3692,6 +3785,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3701,6 +3795,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3723,6 +3818,12 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "0.38.44"
@@ -3756,6 +3857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3768,6 +3870,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
@@ -4682,6 +4785,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.47.1"
@@ -5294,6 +5412,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webbrowser"
version = "1.0.5"
@@ -5310,6 +5438,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.10"

View File

@@ -18,6 +18,7 @@ members = [
"ollama",
"protocol",
"protocol-ts",
"responses-api-proxy",
"tui",
"utils/readiness",
]
@@ -49,6 +50,7 @@ codex-mcp-server = { path = "mcp-server" }
codex-ollama = { path = "ollama" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-tui = { path = "tui" }
codex-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
@@ -85,6 +87,7 @@ icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
ignore = "0.4.23"
image = { version = "^0.25.8", default-features = false }
indexmap = "2.6.0"
insta = "1.43.2"
itertools = "0.14.0"
landlock = "0.4.1"
@@ -151,6 +154,7 @@ webbrowser = "1.0"
which = "6"
wildmatch = "2.5.0"
wiremock = "0.6"
zeroize = "1.8.1"
[workspace.lints]
rust = {}

View File

@@ -27,6 +27,7 @@ codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-tui = { workspace = true }
ctor = { workspace = true }
owo-colors = { workspace = true }

View File

@@ -1,3 +1,4 @@
use anyhow::Context;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
@@ -14,6 +15,7 @@ use codex_cli::login::run_logout;
use codex_cli::proto;
use codex_common::CliConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use owo_colors::OwoColorize;
@@ -86,6 +88,10 @@ enum Subcommand {
/// Internal: generate TypeScript protocol bindings.
#[clap(hide = true)]
GenerateTs(GenerateTsCommand),
/// Internal: run the responses API proxy.
#[clap(hide = true)]
ResponsesApiProxy(ResponsesApiProxyArgs),
}
#[derive(Debug, Parser)]
@@ -341,6 +347,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::GenerateTs(gen_cli)) => {
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
}
Some(Subcommand::ResponsesApiProxy(args)) => {
tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args))
.await
.context("responses-api-proxy blocking task panicked")??;
}
}
Ok(())

View File

@@ -15,6 +15,7 @@ workspace = true
anyhow = { workspace = true }
askama = { workspace = true }
async-channel = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
@@ -26,6 +27,7 @@ dirs = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
indexmap = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
os_info = { workspace = true }

View File

@@ -24,7 +24,6 @@ use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::TaskStartedEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnContextItem;
use futures::prelude::*;
use mcp_types::CallToolResult;
@@ -34,7 +33,6 @@ use serde_json;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tokio::task::AbortHandle;
use tracing::debug;
use tracing::error;
use tracing::info;
@@ -107,7 +105,6 @@ use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
use crate::protocol::StreamErrorEvent;
use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
@@ -120,6 +117,9 @@ use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::tasks::CompactTask;
use crate::tasks::RegularTask;
use crate::tasks::ReviewTask;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::user_instructions::UserInstructions;
@@ -262,7 +262,7 @@ pub(crate) struct Session {
conversation_id: ConversationId,
tx_event: Sender<Event>,
state: Mutex<SessionState>,
active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
services: SessionServices,
next_internal_sub_id: AtomicU64,
}
@@ -495,38 +495,6 @@ impl Session {
Ok((sess, turn_context))
}
pub async fn set_task(&self, task: AgentTask) {
let mut state = self.state.lock().await;
if let Some(current_task) = state.current_task.take() {
current_task.abort(TurnAbortReason::Replaced);
}
state.current_task = Some(task);
if let Some(current_task) = &state.current_task {
let mut active = self.active_turn.lock().await;
*active = Some(ActiveTurn {
sub_id: current_task.sub_id.clone(),
turn_state: std::sync::Arc::new(tokio::sync::Mutex::new(
crate::state::TurnState::default(),
)),
});
}
}
pub async fn remove_task(&self, sub_id: &str) {
let mut state = self.state.lock().await;
if let Some(task) = &state.current_task
&& task.sub_id == sub_id
{
state.current_task.take();
}
let mut active = self.active_turn.lock().await;
if let Some(at) = &*active
&& at.sub_id == sub_id
{
*active = None;
}
}
fn next_internal_sub_id(&self) -> String {
let id = self
.next_internal_sub_id
@@ -1015,26 +983,25 @@ impl Session {
/// Returns the input if there was no task running to inject into
pub async fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
let state = self.state.lock().await;
if state.current_task.is_some() {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.push_pending_input(input.into());
Ok(())
}
Ok(())
} else {
Err(input)
None => Err(input),
}
}
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.take_pending_input()
} else {
Vec::with_capacity(0)
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.take_pending_input()
}
None => Vec::with_capacity(0),
}
}
@@ -1050,29 +1017,20 @@ impl Session {
.await
}
pub async fn interrupt_task(&self) {
pub async fn interrupt_task(self: &Arc<Self>) {
info!("interrupt received: abort current task, if any");
let mut state = self.state.lock().await;
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.clear_pending();
}
if let Some(task) = state.current_task.take() {
task.abort(TurnAbortReason::Interrupted);
}
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
fn interrupt_task_sync(&self) {
if let Ok(mut state) = self.state.try_lock() {
if let Ok(mut active) = self.active_turn.try_lock()
&& let Some(at) = active.as_mut()
&& let Ok(mut ts) = at.turn_state.try_lock()
{
ts.clear_pending();
}
if let Some(task) = state.current_task.take() {
task.abort(TurnAbortReason::Interrupted);
if let Ok(mut active) = self.active_turn.try_lock()
&& let Some(at) = active.as_mut()
{
at.try_clear_pending_sync();
let tasks = at.drain_tasks();
*active = None;
for (_sub_id, task) in tasks {
task.handle.abort();
}
}
}
@@ -1111,106 +1069,6 @@ pub(crate) struct ApplyPatchCommandContext {
pub(crate) changes: HashMap<PathBuf, FileChange>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum AgentTaskKind {
Regular,
Review,
Compact,
}
/// A series of Turns in response to user input.
pub(crate) struct AgentTask {
sess: Arc<Session>,
sub_id: String,
handle: AbortHandle,
kind: AgentTaskKind,
}
impl AgentTask {
fn spawn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Self {
let handle = {
let sess = sess.clone();
let sub_id = sub_id.clone();
let tc = Arc::clone(&turn_context);
tokio::spawn(async move { run_task(sess, tc, sub_id, input).await }).abort_handle()
};
Self {
sess,
sub_id,
handle,
kind: AgentTaskKind::Regular,
}
}
fn review(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Self {
let handle = {
let sess = sess.clone();
let sub_id = sub_id.clone();
let tc = Arc::clone(&turn_context);
tokio::spawn(async move { run_task(sess, tc, sub_id, input).await }).abort_handle()
};
Self {
sess,
sub_id,
handle,
kind: AgentTaskKind::Review,
}
}
fn compact(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Self {
let handle = {
let sess = sess.clone();
let sub_id = sub_id.clone();
let tc = Arc::clone(&turn_context);
tokio::spawn(async move { compact::run_compact_task(sess, tc, sub_id, input).await })
.abort_handle()
};
Self {
sess,
sub_id,
handle,
kind: AgentTaskKind::Compact,
}
}
fn abort(self, reason: TurnAbortReason) {
// TOCTOU?
if !self.handle.is_finished() {
self.handle.abort();
let sub_id = self.sub_id.clone();
let is_review = self.kind == AgentTaskKind::Review;
let sess = self.sess;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }),
};
tokio::spawn(async move {
if is_review {
exit_review_mode(sess.clone(), sub_id.clone(), None).await;
}
// Ensure active turn state is cleared when a task is aborted.
sess.remove_task(&sub_id).await;
sess.send_event(event).await;
});
}
}
}
async fn submission_loop(
sess: Arc<Session>,
turn_context: TurnContext,
@@ -1318,9 +1176,8 @@ async fn submission_loop(
// attempt to inject input into current task
if let Err(items) = sess.inject_input(items).await {
// no current task, spawn a new one
let task =
AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items);
sess.set_task(task).await;
sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask)
.await;
}
}
Op::UserTurn {
@@ -1396,10 +1253,9 @@ async fn submission_loop(
// Install the new persistent context for subsequent tasks/turns.
turn_context = Arc::new(fresh_turn_context);
// no current task, spawn a new one with the perturn context
let task =
AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items);
sess.set_task(task).await;
// no current task, spawn a new one with the per-turn context
sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask)
.await;
}
}
Op::ExecApproval { id, decision } => match decision {
@@ -1497,16 +1353,12 @@ async fn submission_loop(
}])
.await
{
compact::spawn_compact_task(
sess.clone(),
Arc::clone(&turn_context),
sub.id,
items,
)
.await;
sess.spawn_task(Arc::clone(&turn_context), sub.id, items, CompactTask)
.await;
}
}
Op::Shutdown => {
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
info!("Shutting down Codex instance");
// Gracefully flush and shutdown rollout recorder on session end so tests
@@ -1648,8 +1500,7 @@ async fn spawn_review_thread(
// Clone sub_id for the upcoming announcement before moving it into the task.
let sub_id_for_event = sub_id.clone();
let task = AgentTask::review(sess.clone(), tc.clone(), sub_id, input);
sess.set_task(task).await;
sess.spawn_task(tc.clone(), sub_id, input, ReviewTask).await;
// Announce entering review mode so UIs can switch modes.
sess.send_event(Event {
@@ -1676,14 +1527,14 @@ async fn spawn_review_thread(
/// Review mode: when `turn_context.is_review_mode` is true, the turn runs in an
/// isolated in-memory thread without the parent session's prior history or
/// user_instructions. Emits ExitedReviewMode upon final review message.
async fn run_task(
pub(crate) async fn run_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) {
) -> Option<String> {
if input.is_empty() {
return;
return None;
}
let event = Event {
id: sub_id.clone(),
@@ -1955,12 +1806,7 @@ async fn run_task(
.await;
}
sess.remove_task(&sub_id).await;
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }),
};
sess.send_event(event).await;
last_agent_message
}
/// Parse the review output; when not valid JSON, build a structured
@@ -3219,7 +3065,7 @@ fn convert_call_tool_result_to_function_call_output_payload(
/// Emits an ExitedReviewMode Event with optional ReviewOutput,
/// and records a developer message with the review output.
async fn exit_review_mode(
pub(crate) async fn exit_review_mode(
session: Arc<Session>,
task_sub_id: String,
review_output: Option<ReviewOutputEvent>,
@@ -3283,6 +3129,9 @@ mod tests {
use crate::protocol::CompactedItem;
use crate::protocol::InitialHistory;
use crate::protocol::ResumedHistory;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use codex_protocol::models::ContentItem;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
@@ -3292,6 +3141,8 @@ mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration as StdDuration;
use tokio::time::Duration;
use tokio::time::sleep;
#[test]
fn reconstruct_history_matches_live_compactions() {
@@ -3577,6 +3428,174 @@ mod tests {
(session, turn_context)
}
// Like make_session_and_context, but returns Arc<Session> and the event receiver
// so tests can assert on emitted events.
fn make_session_and_context_with_rx() -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
) {
let (tx_event, rx_event) = async_channel::unbounded();
let codex_home = tempfile::tempdir().expect("create temp dir");
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)
.expect("load default test config");
let config = Arc::new(config);
let conversation_id = ConversationId::default();
let client = ModelClient::new(
config.clone(),
None,
config.model_provider.clone(),
config.model_reasoning_effort,
config.model_reasoning_summary,
conversation_id,
);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &config.model_family,
include_plan_tool: config.include_plan_tool,
include_apply_patch_tool: config.include_apply_patch_tool,
include_web_search_request: config.tools_web_search_request,
use_streamable_shell_tool: config.use_experimental_streamable_shell_tool,
include_view_image_tool: config.include_view_image_tool,
experimental_unified_exec_tool: config.use_experimental_unified_exec_tool,
});
let turn_context = Arc::new(TurnContext {
client,
cwd: config.cwd.clone(),
base_instructions: config.base_instructions.clone(),
user_instructions: config.user_instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
shell_environment_policy: config.shell_environment_policy.clone(),
tools_config,
is_review_mode: false,
final_output_json_schema: None,
});
let services = SessionServices {
mcp_connection_manager: McpConnectionManager::default(),
session_manager: ExecSessionManager::default(),
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::default(),
rollout: Mutex::new(None),
codex_linux_sandbox_exe: None,
user_shell: shell::Shell::Unknown,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
};
let session = Arc::new(Session {
conversation_id,
tx_event,
state: Mutex::new(SessionState::new()),
active_turn: Mutex::new(None),
services,
next_internal_sub_id: AtomicU64::new(0),
});
(session, turn_context, rx_event)
}
#[derive(Clone, Copy)]
struct NeverEndingTask(TaskKind);
#[async_trait::async_trait]
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
self.0
}
async fn run(
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_sub_id: String,
_input: Vec<InputItem>,
) -> Option<String> {
loop {
sleep(Duration::from_secs(60)).await;
}
}
async fn abort(&self, session: Arc<SessionTaskContext>, sub_id: &str) {
if let TaskKind::Review = self.0 {
exit_review_mode(session.clone_session(), sub_id.to_string(), None).await;
}
}
}
#[tokio::test]
async fn abort_regular_task_emits_turn_aborted_only() {
let (sess, tc, rx) = make_session_and_context_with_rx();
let sub_id = "sub-regular".to_string();
let input = vec![InputItem::Text {
text: "hello".to_string(),
}];
sess.spawn_task(
Arc::clone(&tc),
sub_id.clone(),
input,
NeverEndingTask(TaskKind::Regular),
)
.await;
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
let evt = rx.recv().await.expect("event");
match evt.msg {
EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason),
other => panic!("unexpected event: {other:?}"),
}
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx();
let sub_id = "sub-review".to_string();
let input = vec![InputItem::Text {
text: "start review".to_string(),
}];
sess.spawn_task(
Arc::clone(&tc),
sub_id.clone(),
input,
NeverEndingTask(TaskKind::Review),
)
.await;
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
let first = rx.recv().await.expect("first event");
match first.msg {
EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()),
other => panic!("unexpected first event: {other:?}"),
}
let second = rx.recv().await.expect("second event");
match second.msg {
EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason),
other => panic!("unexpected second event: {other:?}"),
}
let history = sess.history_snapshot().await;
let found = history.iter().any(|item| match item {
ResponseItem::Message { role, content, .. } if role == "user" => {
content.iter().any(|ci| match ci {
ContentItem::InputText { text } => {
text.contains("<user_action>")
&& text.contains("review")
&& text.contains("interrupted")
}
_ => false,
})
}
_ => false,
});
assert!(
found,
"synthetic review interruption not recorded in history"
);
}
fn sample_rollout(
session: &Session,
turn_context: &TurnContext,

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use super::AgentTask;
use super::Session;
use super::TurnContext;
use super::get_last_assistant_message_from_turn;
@@ -15,7 +14,6 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::InputItem;
use crate::protocol::InputMessageKind;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TaskStartedEvent;
use crate::protocol::TurnContextItem;
use crate::truncate::truncate_middle;
@@ -37,17 +35,7 @@ struct HistoryBridgeTemplate<'a> {
summary_text: &'a str,
}
pub(super) async fn spawn_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) {
let task = AgentTask::compact(sess.clone(), turn_context, sub_id, input);
sess.set_task(task).await;
}
pub(super) async fn run_inline_auto_compact_task(
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
) {
@@ -55,15 +43,15 @@ pub(super) async fn run_inline_auto_compact_task(
let input = vec![InputItem::Text {
text: SUMMARIZATION_PROMPT.to_string(),
}];
run_compact_task_inner(sess, turn_context, sub_id, input, false).await;
run_compact_task_inner(sess, turn_context, sub_id, input).await;
}
pub(super) async fn run_compact_task(
pub(crate) async fn run_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) {
) -> Option<String> {
let start_event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskStarted(TaskStartedEvent {
@@ -71,14 +59,8 @@ pub(super) async fn run_compact_task(
}),
};
sess.send_event(start_event).await;
run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input, true).await;
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
sess.send_event(event).await;
run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input).await;
None
}
async fn run_compact_task_inner(
@@ -86,7 +68,6 @@ async fn run_compact_task_inner(
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
remove_task_on_completion: bool,
) {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let turn_input = sess
@@ -112,7 +93,8 @@ async fn run_compact_task_inner(
sess.persist_rollout_items(&[rollout_item]).await;
loop {
let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await;
let attempt_result =
drain_to_completed(&sess, turn_context.as_ref(), &sub_id, &prompt).await;
match attempt_result {
Ok(()) => {
@@ -148,9 +130,6 @@ async fn run_compact_task_inner(
}
}
if remove_task_on_completion {
sess.remove_task(&sub_id).await;
}
let history_snapshot = sess.history_snapshot().await;
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
let user_messages = collect_user_messages(&history_snapshot);
@@ -251,6 +230,7 @@ pub(crate) fn build_compacted_history(
async fn drain_to_completed(
sess: &Session,
turn_context: &TurnContext,
sub_id: &str,
prompt: &Prompt,
) -> CodexResult<()> {
let mut stream = turn_context.client.clone().stream(prompt).await?;
@@ -266,7 +246,12 @@ async fn drain_to_completed(
Ok(ResponseEvent::OutputItemDone(item)) => {
sess.record_into_history(std::slice::from_ref(&item)).await;
}
Ok(ResponseEvent::Completed { .. }) => {
Ok(ResponseEvent::RateLimits(snapshot)) => {
sess.update_rate_limits(sub_id, snapshot).await;
}
Ok(ResponseEvent::Completed { token_usage, .. }) => {
sess.update_token_usage_info(sub_id, turn_context, token_usage.as_ref())
.await;
return Ok(());
}
Ok(_) => continue,

View File

@@ -77,6 +77,7 @@ pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
mod function_tool;
mod state;
mod tasks;
mod user_notification;
pub mod util;

View File

@@ -5,4 +5,5 @@ mod turn;
pub(crate) use service::SessionServices;
pub(crate) use session::SessionState;
pub(crate) use turn::ActiveTurn;
pub(crate) use turn::TurnState;
pub(crate) use turn::RunningTask;
pub(crate) use turn::TaskKind;

View File

@@ -4,7 +4,6 @@ use std::collections::HashSet;
use codex_protocol::models::ResponseItem;
use crate::codex::AgentTask;
use crate::conversation_history::ConversationHistory;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
@@ -14,7 +13,6 @@ use crate::protocol::TokenUsageInfo;
#[derive(Default)]
pub(crate) struct SessionState {
pub(crate) approved_commands: HashSet<Vec<String>>,
pub(crate) current_task: Option<AgentTask>,
pub(crate) history: ConversationHistory,
pub(crate) token_info: Option<TokenUsageInfo>,
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,

View File

@@ -1,21 +1,61 @@
//! Turn-scoped state and active turn metadata scaffolding.
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::AbortHandle;
use codex_protocol::models::ResponseInputItem;
use tokio::sync::oneshot;
use crate::protocol::ReviewDecision;
use crate::tasks::SessionTask;
/// Metadata about the currently running turn.
#[derive(Default)]
pub(crate) struct ActiveTurn {
pub(crate) sub_id: String,
pub(crate) tasks: IndexMap<String, RunningTask>,
pub(crate) turn_state: Arc<Mutex<TurnState>>,
}
impl Default for ActiveTurn {
fn default() -> Self {
Self {
tasks: IndexMap::new(),
turn_state: Arc::new(Mutex::new(TurnState::default())),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TaskKind {
Regular,
Review,
Compact,
}
#[derive(Clone)]
pub(crate) struct RunningTask {
pub(crate) handle: AbortHandle,
pub(crate) kind: TaskKind,
pub(crate) task: Arc<dyn SessionTask>,
}
impl ActiveTurn {
pub(crate) fn add_task(&mut self, sub_id: String, task: RunningTask) {
self.tasks.insert(sub_id, task);
}
pub(crate) fn remove_task(&mut self, sub_id: &str) -> bool {
self.tasks.swap_remove(sub_id);
self.tasks.is_empty()
}
pub(crate) fn drain_tasks(&mut self) -> IndexMap<String, RunningTask> {
std::mem::take(&mut self.tasks)
}
}
/// Mutable state for a single turn.
#[derive(Default)]
pub(crate) struct TurnState {
@@ -58,3 +98,18 @@ impl TurnState {
}
}
}
impl ActiveTurn {
/// Clear any pending approvals and input buffered for the current turn.
pub(crate) async fn clear_pending(&self) {
let mut ts = self.turn_state.lock().await;
ts.clear_pending();
}
/// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt).
pub(crate) fn try_clear_pending_sync(&self) {
if let Ok(mut ts) = self.turn_state.try_lock() {
ts.clear_pending();
}
}
}

View File

@@ -0,0 +1,31 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::compact;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct CompactTask;
#[async_trait]
impl SessionTask for CompactTask {
fn kind(&self) -> TaskKind {
TaskKind::Compact
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await
}
}

View File

@@ -0,0 +1,166 @@
mod compact;
mod regular;
mod review;
use std::sync::Arc;
use async_trait::async_trait;
use tracing::trace;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::InputItem;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
pub(crate) use compact::CompactTask;
pub(crate) use regular::RegularTask;
pub(crate) use review::ReviewTask;
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
pub(crate) struct SessionTaskContext {
session: Arc<Session>,
}
impl SessionTaskContext {
pub(crate) fn new(session: Arc<Session>) -> Self {
Self { session }
}
pub(crate) fn clone_session(&self) -> Arc<Session> {
Arc::clone(&self.session)
}
}
#[async_trait]
pub(crate) trait SessionTask: Send + Sync + 'static {
fn kind(&self) -> TaskKind;
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String>;
async fn abort(&self, session: Arc<SessionTaskContext>, sub_id: &str) {
let _ = (session, sub_id);
}
}
impl Session {
pub async fn spawn_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
task: T,
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
let task: Arc<dyn SessionTask> = Arc::new(task);
let task_kind = task.kind();
let handle = {
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let sub_clone = sub_id.clone();
tokio::spawn(async move {
let last_agent_message = task_for_run
.run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input)
.await;
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
let sess = session_ctx.clone_session();
sess.on_task_finished(sub_clone, last_agent_message).await;
})
.abort_handle()
};
let running_task = RunningTask {
handle,
kind: task_kind,
task,
};
self.register_new_active_task(sub_id, running_task).await;
}
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
for (sub_id, task) in self.take_all_running_tasks().await {
self.handle_task_abort(sub_id, task, reason.clone()).await;
}
}
pub async fn on_task_finished(
self: &Arc<Self>,
sub_id: String,
last_agent_message: Option<String>,
) {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut()
&& at.remove_task(&sub_id)
{
*active = None;
}
drop(active);
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }),
};
self.send_event(event).await;
}
async fn register_new_active_task(&self, sub_id: String, task: RunningTask) {
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
turn.add_task(sub_id, task);
*active = Some(turn);
}
async fn take_all_running_tasks(&self) -> Vec<(String, RunningTask)> {
let mut active = self.active_turn.lock().await;
match active.take() {
Some(mut at) => {
at.clear_pending().await;
let tasks = at.drain_tasks();
tasks.into_iter().collect()
}
None => Vec::new(),
}
}
async fn handle_task_abort(
self: &Arc<Self>,
sub_id: String,
task: RunningTask,
reason: TurnAbortReason,
) {
if task.handle.is_finished() {
return;
}
trace!(task_kind = ?task.kind, sub_id, "aborting running task");
let session_task = task.task;
let handle = task.handle;
handle.abort();
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
session_task.abort(session_ctx, &sub_id).await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }),
};
self.send_event(event).await;
}
}
#[cfg(test)]
mod tests {}

View File

@@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::run_task;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct RegularTask;
#[async_trait]
impl SessionTask for RegularTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
let sess = session.clone_session();
run_task(sess, ctx, sub_id, input).await
}
}

View File

@@ -0,0 +1,37 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::codex::TurnContext;
use crate::codex::exit_review_mode;
use crate::codex::run_task;
use crate::protocol::InputItem;
use crate::state::TaskKind;
use super::SessionTask;
use super::SessionTaskContext;
#[derive(Clone, Copy, Default)]
pub(crate) struct ReviewTask;
#[async_trait]
impl SessionTask for ReviewTask {
fn kind(&self) -> TaskKind {
TaskKind::Review
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
) -> Option<String> {
let sess = session.clone_session();
run_task(sess, ctx, sub_id, input).await
}
async fn abort(&self, session: Arc<SessionTaskContext>, sub_id: &str) {
exit_review_mode(session.clone_session(), sub_id.to_string(), None).await;
}
}

View File

@@ -0,0 +1,66 @@
use std::time::Duration;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_with_timeout;
use serde_json::json;
use wiremock::matchers::body_string_contains;
/// Integration test: spawn a longrunning shell tool via a mocked Responses SSE
/// function call, then interrupt the session and expect TurnAborted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interrupt_long_running_tool_emits_turn_aborted() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"sleep 60".to_string(),
];
let args = json!({
"command": command,
"timeout_ms": 60_000
})
.to_string();
let body = sse(vec![ev_function_call("call_sleep", "shell", &args)]);
let server = start_mock_server().await;
mount_sse_once(&server, body_string_contains("start sleep"), body).await;
let codex = test_codex().build(&server).await.unwrap().codex;
let wait_timeout = Duration::from_secs(5);
// Kick off a turn that triggers the function call.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "start sleep".into(),
}],
})
.await
.unwrap();
// Wait until the exec begins to avoid a race, then interrupt.
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecCommandBegin(_)),
wait_timeout,
)
.await;
codex.submit(Op::Interrupt).await.unwrap();
// Expect TurnAborted soon after.
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TurnAborted(_)),
wait_timeout,
)
.await;
}

View File

@@ -1,5 +1,7 @@
// Aggregates all former standalone integration tests as modules.
#[cfg(not(target_os = "windows"))]
mod abort_tasks;
mod cli_stream;
mod client;
mod compact;

View File

@@ -0,0 +1,27 @@
[package]
edition = "2024"
name = "codex-responses-api-proxy"
version = { workspace = true }
[lib]
name = "codex_responses_api_proxy"
path = "src/lib.rs"
[[bin]]
name = "responses-api-proxy"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-arg0 = { workspace = true }
libc = { workspace = true }
reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tiny_http = { workspace = true }
tokio = { workspace = true }
zeroize = { workspace = true }

View File

@@ -0,0 +1,53 @@
# codex-responses-api-proxy
A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the OpenAI API (`https://api.openai.com`), injecting the `Authorization: Bearer $OPENAI_API_KEY` header. Everything else is rejected with `403 Forbidden`.
## Expected Usage
**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server.
A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server:
```shell
printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json
```
A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically:
```shell
PROXY_PORT=$(jq .port /tmp/server-info.json)
PROXY_BASE_URL="http://127.0.0.1:${PROXY_PORT}"
codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url = '${PROXY_BASE_URL}/v1', wire_api='responses' }" \
-c model_provider="openai-proxy" \
'Your prompt here'
```
When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option):
```shell
curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
```
## Behavior
- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`).
- Formats the header value as `Bearer <key>` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk.
- Listens on the provided port or an ephemeral port if `--port` is not specified.
- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer <key>` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`.
- Optionally writes a single-line JSON file with server info, currently `{ "port": <u16> }`.
- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down.
## CLI
```
responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
```
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT> }` once listening.
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
## Notes
- Only `POST /v1/responses` is permitted. No query strings are allowed.
- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream.

View File

@@ -0,0 +1,202 @@
use std::fs::File;
use std::fs::{self};
use std::io::Write;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use clap::Parser;
use reqwest::blocking::Client;
use reqwest::header::AUTHORIZATION;
use reqwest::header::HOST;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use serde::Serialize;
use tiny_http::Header;
use tiny_http::Method;
use tiny_http::Request;
use tiny_http::Response;
use tiny_http::Server;
use tiny_http::StatusCode;
mod read_api_key;
use read_api_key::read_auth_header_from_stdin;
/// CLI arguments for the proxy.
#[derive(Debug, Clone, Parser)]
#[command(name = "responses-api-proxy", about = "Minimal OpenAI responses proxy")]
pub struct Args {
/// Port to listen on. If not set, an ephemeral port is used.
#[arg(long)]
pub port: Option<u16>,
/// Path to a JSON file to write startup info (single line). Includes {"port": <u16>}.
#[arg(long, value_name = "FILE")]
pub server_info: Option<PathBuf>,
/// Enable HTTP shutdown endpoint at GET /shutdown
#[arg(long)]
pub http_shutdown: bool,
}
#[derive(Serialize)]
struct ServerInfo {
port: u16,
}
/// Entry point for the library main, for parity with other crates.
pub fn run_main(args: Args) -> Result<()> {
let auth_header = read_auth_header_from_stdin()?;
let (listener, bound_addr) = bind_listener(args.port)?;
if let Some(path) = args.server_info.as_ref() {
write_server_info(path, bound_addr.port())?;
}
let server = Server::from_listener(listener, None)
.map_err(|err| anyhow!("creating HTTP server: {err}"))?;
let client = Arc::new(
Client::builder()
.build()
.context("building reqwest client")?,
);
eprintln!("responses-api-proxy listening on {bound_addr}");
let http_shutdown = args.http_shutdown;
for request in server.incoming_requests() {
let client = client.clone();
std::thread::spawn(move || {
if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" {
let _ = request.respond(Response::new_empty(StatusCode(200)));
std::process::exit(0);
}
if let Err(e) = forward_request(&client, auth_header, request) {
eprintln!("forwarding error: {e}");
}
});
}
Err(anyhow!("server stopped unexpectedly"))
}
fn bind_listener(port: Option<u16>) -> Result<(TcpListener, SocketAddr)> {
let addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0)));
let listener = TcpListener::bind(addr).with_context(|| format!("failed to bind {addr}"))?;
let bound = listener.local_addr().context("failed to read local_addr")?;
Ok((listener, bound))
}
fn write_server_info(path: &Path, port: u16) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
let parent_display = parent.display();
fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?;
}
let info = ServerInfo { port };
let data = serde_json::to_vec(&info).context("serialize startup info")?;
let p = path.display();
let mut f = File::create(path).with_context(|| format!("create {p}"))?;
f.write_all(&data).with_context(|| format!("write {p}"))?;
f.write_all(b"\n").with_context(|| format!("newline {p}"))?;
Ok(())
}
fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> {
// Only allow POST /v1/responses exactly, no query string.
let method = req.method().clone();
let url_path = req.url().to_string();
let allow = method == Method::Post && url_path == "/v1/responses";
if !allow {
let resp = Response::new_empty(StatusCode(403));
let _ = req.respond(resp);
return Ok(());
}
// Read request body
let mut body = Vec::new();
let mut reader = req.as_reader();
std::io::Read::read_to_end(&mut reader, &mut body)?;
// Build headers for upstream, forwarding everything from the incoming
// request except Authorization (we replace it below).
let mut headers = HeaderMap::new();
for header in req.headers() {
let name_ascii = header.field.as_str();
let lower = name_ascii.to_ascii_lowercase();
if lower.as_str() == "authorization" || lower.as_str() == "host" {
continue;
}
let header_name = match HeaderName::from_bytes(lower.as_bytes()) {
Ok(name) => name,
Err(_) => continue,
};
if let Ok(value) = HeaderValue::from_bytes(header.value.as_bytes()) {
headers.append(header_name, value);
}
}
// As part of our effort to to keep `auth_header` secret, we use a
// combination of `from_static()` and `set_sensitive(true)`.
let mut auth_header_value = HeaderValue::from_static(auth_header);
auth_header_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_header_value);
headers.insert(HOST, HeaderValue::from_static("api.openai.com"));
let upstream = "https://api.openai.com/v1/responses";
let upstream_resp = client
.post(upstream)
.headers(headers)
.body(body)
.send()
.context("forwarding request to upstream")?;
// We have to create an adapter between a `reqwest::blocking::Response`
// and a `tiny_http::Response`. Fortunately, `reqwest::blocking::Response`
// implements `Read`, so we can use it directly as the body of the
// `tiny_http::Response`.
let status = upstream_resp.status();
let mut response_headers = Vec::new();
for (name, value) in upstream_resp.headers().iter() {
// Skip headers that tiny_http manages itself.
if matches!(
name.as_str(),
"content-length" | "transfer-encoding" | "connection" | "trailer" | "upgrade"
) {
continue;
}
if let Ok(header) = Header::from_bytes(name.as_str().as_bytes(), value.as_bytes()) {
response_headers.push(header);
}
}
let content_length = upstream_resp.content_length().and_then(|len| {
if len <= usize::MAX as u64 {
Some(len as usize)
} else {
None
}
});
let response = Response::new(
StatusCode(status.as_u16()),
response_headers,
upstream_resp,
content_length,
None,
);
let _ = req.respond(response);
Ok(())
}

View File

@@ -0,0 +1,14 @@
use anyhow::Context;
use clap::Parser;
use codex_arg0::arg0_dispatch_or_else;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
pub fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move {
let args = ResponsesApiProxyArgs::parse();
tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args))
.await
.context("responses-api-proxy blocking task panicked")??;
Ok(())
})
}

View File

@@ -0,0 +1,185 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use std::io::Read;
use zeroize::Zeroize;
/// Use a generous buffer size to avoid truncation and to allow for longer API
/// keys in the future.
const BUFFER_SIZE: usize = 1024;
const AUTH_HEADER_PREFIX: &[u8] = b"Bearer ";
/// Reads the auth token from stdin and returns a static `Authorization` header
/// value with the auth token used with `Bearer`. The header value is returned
/// as a `&'static str` whose bytes are locked in memory to avoid accidental
/// exposure.
pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> {
read_auth_header_with(|buffer| std::io::stdin().read(buffer))
}
fn read_auth_header_with<F>(read_fn: F) -> Result<&'static str>
where
F: FnOnce(&mut [u8]) -> std::io::Result<usize>,
{
// TAKE CARE WHEN MODIFYING THIS CODE!!!
//
// This function goes to great lengths to avoid leaving the API key in
// memory longer than necessary and to avoid copying it around. We read
// directly into a stack buffer so the only heap allocation should be the
// one to create the String (with the exact size) for the header value,
// which we then immediately protect with mlock(2).
let mut buf = [0u8; BUFFER_SIZE];
buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX);
let read = read_fn(&mut buf[AUTH_HEADER_PREFIX.len()..]).inspect_err(|_err| {
buf.zeroize();
})?;
if read == buf.len() - AUTH_HEADER_PREFIX.len() {
buf.zeroize();
return Err(anyhow!(
"OPENAI_API_KEY is too large to fit in the 512-byte buffer"
));
}
let mut total = AUTH_HEADER_PREFIX.len() + read;
while total > AUTH_HEADER_PREFIX.len() && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') {
total -= 1;
}
if total == AUTH_HEADER_PREFIX.len() {
buf.zeroize();
return Err(anyhow!(
"OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
));
}
let header_str = match std::str::from_utf8(&buf[..total]) {
Ok(value) => value,
Err(err) => {
buf.zeroize();
return Err(err).context("reading Authorization header from stdin as UTF-8");
}
};
let header_value = String::from(header_str);
buf.zeroize();
let leaked: &'static mut str = header_value.leak();
mlock_str(leaked);
Ok(leaked)
}
#[cfg(unix)]
fn mlock_str(value: &str) {
use libc::_SC_PAGESIZE;
use libc::c_void;
use libc::mlock;
use libc::sysconf;
if value.is_empty() {
return;
}
let page_size = unsafe { sysconf(_SC_PAGESIZE) };
if page_size <= 0 {
return;
}
let page_size = page_size as usize;
if page_size == 0 {
return;
}
let addr = value.as_ptr() as usize;
let len = value.len();
let start = addr & !(page_size - 1);
let addr_end = match addr.checked_add(len) {
Some(v) => match v.checked_add(page_size - 1) {
Some(total) => total,
None => return,
},
None => return,
};
let end = addr_end & !(page_size - 1);
let size = end.saturating_sub(start);
if size == 0 {
return;
}
let _ = unsafe { mlock(start as *const c_void, size) };
}
#[cfg(not(unix))]
fn mlock_str(_value: &str) {}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn reads_key_with_no_newlines() {
let result = read_auth_header_with(|buf| {
let data = b"sk-abc123";
buf[..data.len()].copy_from_slice(data);
Ok(data.len())
})
.unwrap();
assert_eq!(result, "Bearer sk-abc123");
}
#[test]
fn reads_key_and_trims_newlines() {
let result = read_auth_header_with(|buf| {
let data = b"sk-abc123\r\n";
buf[..data.len()].copy_from_slice(data);
Ok(data.len())
})
.unwrap();
assert_eq!(result, "Bearer sk-abc123");
}
#[test]
fn errors_when_no_input_provided() {
let err = read_auth_header_with(|_| Ok(0)).unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("must be provided"));
}
#[test]
fn errors_when_buffer_filled() {
let err = read_auth_header_with(|buf| {
let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()];
buf[..data.len()].copy_from_slice(&data);
Ok(data.len())
})
.unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("too large"));
}
#[test]
fn propagates_io_error() {
let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err();
let io_error = err.downcast_ref::<io::Error>().unwrap();
assert_eq!(io_error.kind(), io::ErrorKind::Other);
assert_eq!(io_error.to_string(), "boom");
}
#[test]
fn errors_on_invalid_utf8() {
let err = read_auth_header_with(|buf| {
let data = b"sk-abc\xff";
buf[..data.len()].copy_from_slice(data);
Ok(data.len())
})
.unwrap_err();
let message = format!("{err:#}");
assert!(message.contains("UTF-8"));
}
}

View File

@@ -1,111 +0,0 @@
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest;
use crate::user_approval_widget::UserApprovalWidget;
use super::BottomPaneView;
use super::CancellationEvent;
/// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView {
current: UserApprovalWidget,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
}
impl ApprovalModalView {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
Self {
current: UserApprovalWidget::new(request, app_event_tx.clone()),
queue: Vec::new(),
app_event_tx,
}
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
}
/// Advance to next request if the current one is finished.
fn maybe_advance(&mut self) {
if self.current.is_complete()
&& let Some(req) = self.queue.pop()
{
self.current = UserApprovalWidget::new(req, self.app_event_tx.clone());
}
}
}
impl BottomPaneView for ApprovalModalView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
self.current.handle_key_event(key_event);
self.maybe_advance();
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.current.on_ctrl_c();
self.queue.clear();
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.current.is_complete() && self.queue.is_empty()
}
fn desired_height(&self, width: u16) -> u16 {
self.current.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
(&self.current).render_ref(area, buf);
}
fn try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option<ApprovalRequest> {
self.enqueue_request(req);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::BottomPane;
use tokio::sync::mpsc::unbounded_channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let first = make_exec_request();
let mut view = ApprovalModalView::new(first, tx);
view.enqueue_request(make_exec_request());
let (tx2, _rx2) = unbounded_channel::<AppEvent>();
// Why do we have this?
let _pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx2),
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
});
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
assert!(view.current.is_complete());
assert!(view.is_complete());
}
}

View File

@@ -0,0 +1,559 @@
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::HeaderLine;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
reason: Option<String>,
},
ApplyPatch {
id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
},
}
/// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay {
current: Option<ApprovalRequestState>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
options: Vec<ApprovalOption>,
current_complete: bool,
done: bool,
}
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let mut view = Self {
current: Some(ApprovalRequestState::from(request)),
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(
SelectionViewParams {
title: String::new(),
..Default::default()
},
app_event_tx,
),
options: Vec::new(),
current_complete: false,
done: false,
};
let (options, params) = view.build_options();
view.options = options;
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
view
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
}
fn set_current(&mut self, request: ApprovalRequest) {
self.current = Some(ApprovalRequestState::from(request));
self.current_complete = false;
let (options, params) = self.build_options();
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
}
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
let Some(state) = self.current.as_ref() else {
return (
Vec::new(),
SelectionViewParams {
title: String::new(),
..Default::default()
},
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
};
let items = options
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
description: Some(opt.description.clone()),
is_current: false,
actions: Vec::new(),
dismiss_on_select: false,
search_value: None,
})
.collect();
let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items,
header: state.header.clone(),
..Default::default()
};
(options, params)
}
fn apply_selection(&mut self, actual_idx: usize) {
if self.current_complete {
return;
}
let Some(option) = self.options.get(actual_idx) else {
return;
};
if let Some(state) = self.current.as_ref() {
match (&state.variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
}
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
self.handle_patch_decision(id, decision);
}
}
}
self.current_complete = true;
self.advance_queue();
}
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_user_approval_decision(lines),
)));
}
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
decision,
}));
}
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval {
id: id.to_string(),
decision,
}));
}
fn advance_queue(&mut self) {
if let Some(next) = self.queue.pop() {
self.set_current(next);
} else {
self.done = true;
}
}
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = key_event
else {
return false;
};
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
return false;
}
let lower = c.to_ascii_lowercase();
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
impl BottomPaneView for ApprovalOverlay {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.try_handle_shortcut(&key_event) {
return;
}
self.list.handle_key_event(key_event);
if let Some(idx) = self.list.take_last_selected_index() {
self.apply_selection(idx);
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.done {
return CancellationEvent::Handled;
}
if !self.current_complete
&& let Some(state) = self.current.as_ref()
{
match &state.variant {
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
ApprovalVariant::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
}
}
}
self.queue.clear();
self.done = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
) -> Option<ApprovalRequest> {
self.enqueue_request(request);
None
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.list.cursor_pos(area)
}
}
struct ApprovalRequestState {
variant: ApprovalVariant,
header: Vec<HeaderLine>,
}
impl From<ApprovalRequest> for ApprovalRequestState {
fn from(value: ApprovalRequest) -> Self {
match value {
ApprovalRequest::Exec {
id,
command,
reason,
} => {
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
let command_snippet = exec_snippet(&command);
if !command_snippet.is_empty() {
header.push(HeaderLine::Text {
text: format!("Command: {command_snippet}"),
italic: false,
});
header.push(HeaderLine::Spacer);
}
Self {
variant: ApprovalVariant::Exec { id, command },
header,
}
}
ApprovalRequest::ApplyPatch {
id,
reason,
grant_root,
} => {
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
if let Some(root) = grant_root {
header.push(HeaderLine::Text {
text: format!(
"Grant write access to {} for the remainder of this session.",
root.display()
),
italic: false,
});
header.push(HeaderLine::Spacer);
}
Self {
variant: ApprovalVariant::ApplyPatch { id },
header,
}
}
}
}
}
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
}
#[derive(Clone)]
struct ApprovalOption {
label: String,
description: String,
decision: ReviewDecision,
shortcut: Option<char>,
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve and run now".to_string(),
description: "(Y) Run this command one time".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Always approve this session".to_string(),
description: "(A) Automatically approve this command for the rest of the session"
.to_string(),
decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not run the command".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
]
}
fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve".to_string(),
description: "(Y) Apply the proposed changes".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not apply the changes".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
]
}
fn build_exec_history_lines(
command: Vec<String>,
decision: ReviewDecision,
) -> Option<Vec<Line<'static>>> {
use ReviewDecision::*;
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
Approved => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" this time".bold(),
],
)
}
ApprovedForSession => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" every time this session".bold(),
],
)
}
Denied => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet,
],
)
}
Abort => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet,
],
)
}
};
let mut lines = Vec::new();
let mut spans = Vec::new();
spans.push(symbol);
spans.extend(summary);
lines.push(Line::from(spans));
Some(lines)
}
fn truncate_exec_snippet(full_cmd: &str) -> String {
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.to_string(),
};
snippet = truncate_text(&snippet, 80);
snippet
}
fn exec_snippet(command: &[String]) -> String {
let full_cmd = strip_bash_lc_and_escape(command);
truncate_exec_snippet(&full_cmd)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use tokio::sync::mpsc::unbounded_channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
view.enqueue_request(make_exec_request());
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
assert!(view.is_complete());
}
#[test]
fn shortcut_triggers_selection() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
// We expect at least one CodexOp message in the queue.
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::CodexOp(_)) {
saw_op = true;
break;
}
}
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let command = vec!["echo".into(), "hello".into(), "world".into()];
let exec_request = ApprovalRequest::Exec {
id: "test".into(),
command,
reason: None,
};
let view = ApprovalOverlay::new(exec_request, tx);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
view.render(Rect::new(0, 0, 80, 6), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("Command: echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}
#[test]
fn enter_sets_last_selected_index_without_dismissing() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
view.is_complete(),
"exec approval should complete without queued requests"
);
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev {
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(ReviewDecision::ApprovedForSession));
}
}

View File

@@ -1,4 +1,4 @@
use crate::user_approval_widget::ApprovalRequest;
use crate::bottom_pane::ApprovalRequest;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;

View File

@@ -1,5 +1,3 @@
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::num_format::format_si_suffix;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -25,6 +23,14 @@ use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandItem;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::esc_hint_mode;
use super::footer::footer_height;
use super::footer::prompt_mode;
use super::footer::render_footer;
use super::footer::reset_mode_after_activity;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
@@ -37,7 +43,6 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::key_hint;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@@ -77,7 +82,6 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
@@ -87,6 +91,7 @@ pub(crate) struct ChatComposer {
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
custom_prompts: Vec<CustomPrompt>,
footer_mode: FooterMode,
}
/// Popup state at most one can be visible at any time.
@@ -96,9 +101,7 @@ enum ActivePopup {
File(FileSearchPopup),
}
const FOOTER_HINT_HEIGHT: u16 = 1;
const FOOTER_SPACING_HEIGHT: u16 = 1;
const FOOTER_HEIGHT_WITH_HINT: u16 = FOOTER_HINT_HEIGHT + FOOTER_SPACING_HEIGHT;
impl ChatComposer {
pub fn new(
@@ -122,7 +125,6 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
@@ -130,6 +132,7 @@ impl ChatComposer {
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
footer_mode: FooterMode::ShortcutPrompt,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -137,23 +140,34 @@ impl ChatComposer {
}
pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(&footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let footer_total_height = footer_hint_height + footer_spacing;
// Leave 1 column for the left border and 1 column for left padding
self.textarea
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
+ match &self.active_popup {
ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
ActivePopup::None => footer_total_height,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(&footer_props);
let footer_total_height = footer_hint_height + FOOTER_SPACING_HEIGHT;
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
ActivePopup::None => Constraint::Max(footer_total_height),
};
let [textarea_rect, _] =
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
@@ -170,13 +184,6 @@ impl ChatComposer {
self.textarea.is_empty()
}
/// Update the cached *context-left* percentage and refresh the placeholder
/// text. The UI relies on the placeholder to convey the remaining
/// context when the composer is empty.
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.token_usage_info = token_info;
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
@@ -314,6 +321,11 @@ impl ChatComposer {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = prompt_mode();
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
self.set_has_focus(has_focus);
}
@@ -349,6 +361,18 @@ impl ChatComposer {
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if matches!(key_event.code, KeyCode::Esc) {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -473,6 +497,18 @@ impl ChatComposer {
/// Handle key events when file search popup is visible.
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if matches!(key_event.code, KeyCode::Esc) {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -724,6 +760,18 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if matches!(key_event.code, KeyCode::Esc) {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -867,6 +915,10 @@ impl ChatComposer {
let now = Instant::now();
self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& self.paste_burst.is_active()
@@ -1140,6 +1192,42 @@ impl ChatComposer {
false
}
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if let KeyEvent {
code: KeyCode::Char('?'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} = *key_event
{
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
} else {
false
}
}
fn footer_props(&self) -> FooterProps {
FooterProps {
mode: self.footer_mode(),
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
}
}
fn footer_mode(&self) -> FooterMode {
if matches!(self.footer_mode, FooterMode::EscHint) {
FooterMode::EscHint
} else if self.ctrl_c_quit_hint {
FooterMode::CtrlCReminder
} else {
self.footer_mode
}
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -1223,15 +1311,31 @@ impl ChatComposer {
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
if running {
self.footer_mode = prompt_mode();
}
}
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.esc_backtrack_hint = show;
if show {
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
}
}
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(&footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let (popup_constraint, hint_spacing) = match &self.active_popup {
ActivePopup::Command(popup) => (
Constraint::Max(popup.calculate_required_height(area.width)),
@@ -1239,8 +1343,8 @@ impl WidgetRef for ChatComposer {
),
ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
ActivePopup::None => (
Constraint::Length(FOOTER_HEIGHT_WITH_HINT),
FOOTER_SPACING_HEIGHT,
Constraint::Length(footer_hint_height + footer_spacing),
footer_spacing,
),
};
let [textarea_rect, popup_rect] =
@@ -1253,88 +1357,17 @@ impl WidgetRef for ChatComposer {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let hint_rect = if hint_spacing > 0 {
let hint_rect = if hint_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(hint_spacing),
Constraint::Length(FOOTER_HINT_HEIGHT),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
let ctrl_c_followup = if self.is_task_running {
" to interrupt"
} else {
" to quit"
};
vec![
" ".into(),
key_hint::ctrl('C'),
" again".into(),
ctrl_c_followup.into(),
]
} else {
let newline_hint_key = if self.use_shift_enter_hint {
key_hint::shift('⏎')
} else {
key_hint::ctrl('J')
};
vec![
key_hint::plain('⏎'),
" send ".into(),
newline_hint_key,
" newline ".into(),
key_hint::ctrl('T'),
" transcript ".into(),
key_hint::ctrl('C'),
" quit".into(),
]
};
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
hint.push(" ".into());
hint.push(key_hint::plain("Esc"));
hint.push(" edit prev".into());
}
// Append token/context usage info to the footer hints when available.
if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.total_token_usage;
hint.push(" ".into());
hint.push(
Span::from(format!(
"{} tokens used",
format_si_suffix(token_usage.blended_total())
))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
last_token_usage.percent_of_context_window_remaining(context_window)
} else {
100
};
let context_style = if percent_remaining < 20 {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::DIM)
};
hint.push(" ".into());
hint.push(Span::styled(
format!("{percent_remaining}% context left"),
context_style,
));
}
}
let hint = hint
.into_iter()
.map(|span| span.patch_style(Style::default().dim()))
.collect::<Vec<_>>();
Line::from(hint).render_ref(hint_rect, buf);
render_footer(hint_rect, buf, footer_props);
}
}
let border_style = if self.has_focus {
@@ -1379,9 +1412,37 @@ mod tests {
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::footer::footer_height;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let width = 100;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
false,
);
setup(&mut composer);
let footer_lines = footer_height(&composer.footer_props());
let height = footer_lines + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_hint_row_is_separated_from_composer() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -1409,7 +1470,7 @@ mod tests {
let mut hint_row: Option<(u16, String)> = None;
for y in 0..area.height {
let row = row_to_string(y);
if row.contains(" send") {
if row.contains("? for shortcuts") {
hint_row = Some((y, row));
break;
}
@@ -1436,6 +1497,54 @@ mod tests {
);
}
#[test]
fn footer_mode_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint(true);
},
);
}
#[test]
fn test_current_at_token_basic_cases() {
let test_cases = vec![

View File

@@ -0,0 +1,310 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
pub(crate) esc_backtrack_hint: bool,
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
ShortcutPrompt,
ShortcutOverlay,
EscHint,
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint {
return current;
}
match current {
FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt,
FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt,
_ => FooterMode::ShortcutOverlay,
}
}
pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
if is_task_running {
return current;
}
FooterMode::EscHint
}
pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt,
other => other,
}
}
pub(crate) fn prompt_mode() -> FooterMode {
FooterMode::ShortcutPrompt
}
#[derive(Clone, Copy, Debug)]
struct CtrlCReminderState {
is_task_running: bool,
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
esc_backtrack_hint: bool,
is_task_running: bool,
}
struct ShortcutEntry {
render: fn(ShortcutsState) -> Option<String>,
}
const SHORTCUT_ENTRIES: &[ShortcutEntry] = &[
ShortcutEntry {
render: |_: ShortcutsState| Some("/ for commands".to_string()),
},
ShortcutEntry {
render: |_: ShortcutsState| Some("@ for file paths".to_string()),
},
ShortcutEntry {
render: |state: ShortcutsState| {
let binding = if state.use_shift_enter_hint {
"shift + enter"
} else {
"ctrl + j"
};
Some(format!("{binding} for newline"))
},
},
ShortcutEntry {
render: |_: ShortcutsState| Some("ctrl + v to paste images".to_string()),
},
ShortcutEntry {
render: |state: ShortcutsState| {
let action = if state.is_task_running {
"interrupt"
} else {
"exit"
};
Some(format!("ctrl + c to {action}"))
},
},
ShortcutEntry {
render: |_: ShortcutsState| Some("ctrl + t to view transcript".to_string()),
},
ShortcutEntry {
render: |_: ShortcutsState| Some("? to hide shortcuts".to_string()),
},
ShortcutEntry {
render: |state: ShortcutsState| {
let label = if state.esc_backtrack_hint {
"esc again to edit previous message"
} else {
"esc esc to edit previous message"
};
Some(label.to_string())
},
},
];
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
footer_lines(props).len() as u16
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
let lines = footer_lines(&props);
for (idx, line) in lines.into_iter().enumerate() {
let y = area.y + idx as u16;
if y >= area.y + area.height {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
}
fn footer_lines(props: &FooterProps) -> Vec<Line<'static>> {
match props.mode {
FooterMode::CtrlCReminder => {
vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})]
}
FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])],
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
}),
FooterMode::EscHint => {
vec![esc_hint_line(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
})]
}
}
}
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
let action = if state.is_task_running {
"interrupt"
} else {
"quit"
};
Line::from(vec![
Span::from(format!(" ctrl + c again to {action}")).dim(),
])
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut rendered = Vec::new();
for entry in SHORTCUT_ENTRIES {
if let Some(text) = (entry.render)(state) {
rendered.push(text);
}
}
build_columns(rendered)
}
fn esc_hint_line(state: ShortcutsState) -> Line<'static> {
let text = if state.esc_backtrack_hint {
" esc again to edit previous message"
} else {
" esc esc to edit previous message"
};
Line::from(vec![Span::from(text).dim()])
}
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
const COLUMNS: usize = 3;
const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28];
let rows = (entries.len() + COLUMNS - 1) / COLUMNS;
let mut column_widths = vec![0usize; COLUMNS];
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.len());
}
let mut lines = Vec::new();
for row in 0..rows {
let mut line = String::from(" ");
for col in 0..COLUMNS {
let idx = row * COLUMNS + col;
if idx >= entries.len() {
continue;
}
let entry = &entries[idx];
if col < COLUMNS - 1 {
let max_width = MAX_PADDED_WIDTHS[col];
let target_width = column_widths[col].min(max_width);
let pad_width = target_width + 2;
line.push_str(&format!("{entry:<pad_width$}", pad_width = pad_width));
} else {
if col != 0 {
line.push_str(" ");
}
line.push_str(entry);
}
}
lines.push(Line::from(vec![Span::from(line).dim()]));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
let height = footer_height(&props).max(1);
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, f.area().width, height);
render_footer(area, f.buffer_mut(), props);
})
.unwrap();
assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_snapshots() {
snapshot_footer(
"footer_shortcuts_default",
FooterProps {
mode: FooterMode::ShortcutPrompt,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
},
);
snapshot_footer(
"footer_shortcuts_shift_and_esc",
FooterProps {
mode: FooterMode::ShortcutOverlay,
esc_backtrack_hint: true,
use_shift_enter_hint: true,
is_task_running: false,
},
);
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
mode: FooterMode::CtrlCReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
},
);
snapshot_footer(
"footer_ctrl_c_quit_running",
FooterProps {
mode: FooterMode::CtrlCReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
},
);
snapshot_footer(
"footer_esc_hint_idle",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
},
);
snapshot_footer(
"footer_esc_hint_primed",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: true,
use_shift_enter_hint: false,
is_task_running: false,
},
);
}
}

View File

@@ -8,6 +8,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender;
@@ -22,6 +23,12 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Spacer,
}
pub(crate) struct SelectionItem {
pub name: String,
pub description: Option<String>,
@@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams {
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub header: Vec<HeaderLine>,
}
pub(crate) struct ListSelectionView {
@@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView {
search_query: String,
search_placeholder: Option<String>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
header: Vec<HeaderLine>,
}
impl ListSelectionView {
@@ -82,6 +92,8 @@ impl ListSelectionView {
None
},
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
header: params.header,
};
s.apply_filter();
s
@@ -198,6 +210,7 @@ impl ListSelectionView {
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& let Some(item) = self.items.get(*actual_idx)
{
self.last_selected_actual_idx = Some(*actual_idx);
for act in &item.actions {
act(&self.app_event_tx);
}
@@ -214,6 +227,43 @@ impl ListSelectionView {
self.search_query = query;
self.apply_filter();
}
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
self.last_selected_actual_idx.take()
}
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
continue;
}
for part in wrap(text, available) {
let span = if *italic {
Span::from(part.into_owned()).italic()
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
}
}
}
}
lines
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
}
impl BottomPaneView for ListSelectionView {
@@ -276,7 +326,8 @@ impl BottomPaneView for ListSelectionView {
// +1 for the title row, +1 for a spacer line beneath the header,
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
let mut height = rows_height + 2;
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
if self.is_searchable {
height = height.saturating_add(1);
}
@@ -295,20 +346,46 @@ impl BottomPaneView for ListSelectionView {
return;
}
let mut next_y = area.y;
let header_spans = self.header_spans_for_width(area.width);
for spans in header_spans.into_iter() {
if next_y >= area.y + area.height {
return;
}
let row = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
if spans.is_empty() {
prefixed.push(String::new().into());
} else {
prefixed.extend(spans);
}
Paragraph::new(Line::from(prefixed)).render(row, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let title_area = Rect {
x: area.x,
y: area.y,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
let title_spans: Vec<Span<'static>> =
vec![Self::dim_prefix_span(), self.title.clone().bold()];
let title_para = Paragraph::new(Line::from(title_spans));
title_para.render(title_area, buf);
let mut next_y = area.y.saturating_add(1);
if self.is_searchable {
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
@@ -327,20 +404,25 @@ impl BottomPaneView for ListSelectionView {
.render(search_area, buf);
next_y = next_y.saturating_add(1);
}
if let Some(sub) = &self.subtitle {
if next_y >= area.y + area.height {
return;
}
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let subtitle_spans: Vec<Span<'static>> =
vec![Self::dim_prefix_span(), sub.clone().dim()];
let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
subtitle_para.render(subtitle_area, buf);
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
@@ -351,6 +433,9 @@ impl BottomPaneView for ListSelectionView {
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
@@ -381,8 +466,7 @@ impl BottomPaneView for ListSelectionView {
width: area.width,
height: 1,
};
let footer_para = Paragraph::new(hint.clone().dim());
footer_para.render(footer_area, buf);
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
}
}
}

View File

@@ -3,9 +3,7 @@ use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -16,13 +14,16 @@ use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::time::Duration;
mod approval_modal_view;
mod approval_overlay;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
mod command_popup;
pub mod custom_prompt_view;
mod file_search_popup;
mod footer;
mod list_selection_view;
pub(crate) use list_selection_view::SelectionViewParams;
mod paste_burst;
@@ -42,7 +43,6 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
@@ -370,13 +370,6 @@ impl BottomPane {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Update the *context-window remaining* indicator in the composer. This
/// is forwarded directly to the underlying `ChatComposer`.
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.composer.set_token_usage(token_info);
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
@@ -396,7 +389,7 @@ impl BottomPane {
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1760
expression: terminal.backend()
---
"▌ [Pasted Content 1002 chars][Pasted Content 1004 chars] "
@@ -11,4 +12,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" ctrl + c again to interrupt "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" ctrl + c again to quit "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" esc esc to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" esc again to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" esc esc to edit previous message "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" esc again to edit previous message "

View File

@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
"▌ "
" "
" / for commands @ for file paths shift + enter for newline "
" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript "
" ? to hide shortcuts esc again to edit previous message "

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1760
expression: terminal.backend()
---
"▌ [Pasted Content 1005 chars] "
@@ -11,4 +12,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1760
expression: terminal.backend()
---
"▌ [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste "
@@ -11,4 +12,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to quit "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to interrupt "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc esc to edit previous message "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc again to edit previous message "

View File

@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
"? for shortcuts "

View File

@@ -0,0 +1,7 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands @ for file paths shift + enter for ne"
" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra"
" ? to hide shortcuts esc again to edit previous message "

View File

@@ -70,11 +70,12 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
use crate::exec_cell::new_active_exec_command;
use crate::get_git_diff::get_git_diff;
use crate::history_cell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PatchEventType;
@@ -84,7 +85,7 @@ use crate::slash_command::SlashCommand;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
// streaming internals are provided by crate::streaming and crate::markdown_stream
use crate::user_approval_widget::ApprovalRequest;
use crate::bottom_pane::ApprovalRequest;
mod interrupts;
use self::interrupts::InterruptManager;
mod agent;
@@ -393,7 +394,6 @@ impl ChatWidget {
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
if info.is_some() {
self.bottom_pane.set_token_usage(info.clone());
self.token_info = info;
}
}
@@ -673,7 +673,7 @@ impl ChatWidget {
.unwrap_or(true);
if needs_new {
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_active_exec_command(
self.active_cell = Some(Box::new(new_active_exec_command(
ev.call_id.clone(),
command,
parsed,
@@ -777,7 +777,7 @@ impl ChatWidget {
} else {
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_active_exec_command(
self.active_cell = Some(Box::new(new_active_exec_command(
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd,
@@ -1960,7 +1960,6 @@ impl ChatWidget {
pub(crate) fn clear_token_usage(&mut self) {
self.token_info = None;
self.bottom_pane.set_token_usage(None);
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {

View File

@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
"this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,7 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,11 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"The model wants to apply changes "
"The model wants to apply changes "
"▌ "
"▌ Grant write access to /tmp for the remainder of this session. "
"▌ "
"▌ Apply changes? "
"▌ "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the changes "
" "
"This will grant write access to /tmp for the remainder of this session. "
" "
"▌Apply changes? "
"▌ Yes No, provide feedback "
"▌ Approve and apply the changes "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -17,11 +17,15 @@ expression: visible_after
through crates for heavy dependencies in Cargo.toml, including cli, core,
exec, linux-sandbox, tui, login, ollama, and mcp.
• Ran
for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
done
• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
│ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
│ … +1 lines
└ --- ansi-escape/Cargo.toml
[package]
… +7 lines
] }
tracing = { version
• Explored
└ Read Cargo.toml

View File

@@ -13,4 +13,4 @@ expression: visual
▌ Summarize recent commits
⏎ send ⌃J newline ⌃T transcript ⌃C quit
? for shortcuts

View File

@@ -7,5 +7,5 @@ expression: terminal.backend()
" "
"▌ Ask Codex to do anything "
" "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
"? for shortcuts "
" "

View File

@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
"this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo 'hello world' "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -0,0 +1,12 @@
mod model;
mod render;
pub(crate) use model::CommandOutput;
#[cfg(test)]
pub(crate) use model::ExecCall;
pub(crate) use model::ExecCell;
pub(crate) use render::OutputLinesParams;
pub(crate) use render::TOOL_CALL_MAX_LINES;
pub(crate) use render::new_active_exec_command;
pub(crate) use render::output_lines;
pub(crate) use render::spinner;

View File

@@ -0,0 +1,123 @@
use std::time::Duration;
use std::time::Instant;
use codex_protocol::parse_command::ParsedCommand;
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) formatted_output: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ExecCall {
pub(crate) call_id: String,
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
pub(crate) start_time: Option<Instant>,
pub(crate) duration: Option<Duration>,
}
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall) -> Self {
Self { calls: vec![call] }
}
pub(crate) fn with_added_call(
&self,
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
};
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
})
} else {
None
}
}
pub(crate) fn complete_call(
&mut self,
call_id: &str,
output: CommandOutput,
duration: Duration,
) {
if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) {
call.output = Some(output);
call.duration = Some(duration);
call.start_time = None;
}
}
pub(crate) fn should_flush(&self) -> bool {
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
}
pub(crate) fn mark_failed(&mut self) {
for call in self.calls.iter_mut() {
if call.output.is_none() {
let elapsed = call
.start_time
.map(|st| st.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
call.start_time = None;
call.duration = Some(elapsed);
call.output = Some(CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: String::new(),
formatted_output: String::new(),
});
}
}
}
pub(crate) fn is_exploring_cell(&self) -> bool {
self.calls.iter().all(Self::is_exploring_call)
}
pub(crate) fn is_active(&self) -> bool {
self.calls.iter().any(|c| c.output.is_none())
}
pub(crate) fn active_start_time(&self) -> Option<Instant> {
self.calls
.iter()
.find(|c| c.output.is_none())
.and_then(|c| c.start_time)
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
!call.parsed.is_empty()
&& call.parsed.iter().all(|p| {
matches!(
p,
ParsedCommand::Read { .. }
| ParsedCommand::ListFiles { .. }
| ParsedCommand::Search { .. }
)
})
}
}

View File

@@ -0,0 +1,492 @@
use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_protocol::parse_command::ParsedCommand;
use itertools::Itertools;
use ratatui::prelude::*;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use textwrap::WordSplitter;
use unicode_width::UnicodeWidthStr;
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
pub(crate) struct OutputLinesParams {
pub(crate) only_err: bool,
pub(crate) include_angle_pipe: bool,
pub(crate) include_prefix: bool,
}
pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
})
}
pub(crate) fn output_lines(
output: Option<&CommandOutput>,
params: OutputLinesParams,
) -> Vec<Line<'static>> {
let OutputLinesParams {
only_err,
include_angle_pipe,
include_prefix,
} = params;
let CommandOutput {
exit_code,
stdout,
stderr,
..
} = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output,
None => return vec![],
};
let src = if *exit_code == 0 { stdout } else { stderr };
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let limit = TOOL_CALL_MAX_LINES;
let mut out = Vec::new();
let head_end = total.min(limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if !include_prefix {
""
} else if i == 0 && include_angle_pipe {
""
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
let show_ellipsis = total > 2 * limit;
if show_ellipsis {
let omitted = total - 2 * limit;
out.push(format!("… +{omitted} lines").into());
}
let tail_start = if show_ellipsis {
total - limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
if include_prefix {
line.spans.insert(0, " ".into());
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
out
}
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
ch.to_string().into()
}
impl HistoryCell for ExecCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.is_exploring_cell() {
self.exploring_display_lines(width)
} else {
self.command_display_lines(width)
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
for call in self.iter_calls() {
let cmd_display = strip_bash_lc_and_escape(&call.command);
for (i, part) in cmd_display.lines().enumerate() {
if i == 0 {
lines.push(vec!["$ ".magenta(), part.to_string().into()].into());
} else {
lines.push(vec![" ".into(), part.to_string().into()].into());
}
}
if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
let duration = call
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result: Line = if output.exit_code == 0 {
Line::from("".green().bold())
} else {
Line::from(vec![
"".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!("{duration}").dim());
lines.push(result);
}
lines.push("".into());
}
lines
}
}
impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
let lines = self.display_lines(area.width);
let max_rows = area.height as usize;
let rendered = if lines.len() > max_rows {
lines[lines.len() - max_rows..].to_vec()
} else {
lines
};
Paragraph::new(Text::from(rendered))
.wrap(Wrap { trim: false })
.render(content_area, buf);
}
}
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time())
} else {
"".bold()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
let mut calls = self.calls.clone();
let mut out_indented = Vec::new();
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
while let Some(next) = calls.first() {
if next
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
call.parsed.extend(next.parsed.clone());
calls.remove(0);
} else {
break;
}
}
}
let reads_only = call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }));
let call_lines: Vec<(&str, Vec<Span<'static>>)> = if reads_only {
let names = call
.parsed
.iter()
.map(|parsed| match parsed {
ParsedCommand::Read { name, .. } => name.clone(),
_ => unreachable!(),
})
.unique();
vec![(
"Read",
Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(),
)]
} else {
let mut lines = Vec::new();
for parsed in &call.parsed {
match parsed {
ParsedCommand::Read { name, .. } => {
lines.push(("Read", vec![name.clone().into()]));
}
ParsedCommand::ListFiles { cmd, path } => {
lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()]));
}
ParsedCommand::Search { cmd, query, path } => {
let spans = match (query, path) {
(Some(q), Some(p)) => {
vec![q.clone().into(), " in ".dim(), p.clone().into()]
}
(Some(q), None) => vec![q.clone().into()],
_ => vec![cmd.clone().into()],
};
lines.push(("Search", spans));
}
ParsedCommand::Unknown { cmd } => {
lines.push(("Run", vec![cmd.clone().into()]));
}
}
}
lines
};
for (title, line) in call_lines {
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = word_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
}
}
out.extend(prefix_lines(out_indented, "".dim(), " ".into()));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let [call] = &self.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
let layout = EXEC_DISPLAY_LAYOUT;
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time),
};
let title = if self.is_active() { "Running" } else { "Ran" };
let mut header_line =
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]);
let header_prefix_width = header_line.width();
let cmd_display = strip_bash_lc_and_escape(&call.command);
let highlighted_lines = highlight_bash_to_lines(&cmd_display);
let continuation_wrap_width = layout.command_continuation.wrap_width(width);
let continuation_opts =
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
if let Some((first, rest)) = highlighted_lines.split_first() {
let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1);
let first_opts =
RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation);
let mut first_wrapped: Vec<Line<'static>> = Vec::new();
push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped);
let mut first_wrapped_iter = first_wrapped.into_iter();
if let Some(first_segment) = first_wrapped_iter.next() {
header_line.extend(first_segment);
}
continuation_lines.extend(first_wrapped_iter);
for line in rest {
push_owned_lines(
&word_wrap_line(line, continuation_opts.clone()),
&mut continuation_lines,
);
}
}
let mut lines: Vec<Line<'static>> = vec![header_line];
let continuation_lines = Self::limit_lines_from_start(
&continuation_lines,
layout.command_continuation_max_lines,
);
if !continuation_lines.is_empty() {
lines.extend(prefix_lines(
continuation_lines,
Span::from(layout.command_continuation.initial_prefix).dim(),
Span::from(layout.command_continuation.subsequent_prefix).dim(),
));
}
if let Some(output) = call.output.as_ref() {
let raw_output_lines = output_lines(
Some(output),
OutputLinesParams {
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
);
let trimmed_output =
Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines);
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
for line in trimmed_output {
push_owned_lines(
&word_wrap_line(&line, output_opts.clone()),
&mut wrapped_output,
);
}
if !wrapped_output.is_empty() {
lines.extend(prefix_lines(
wrapped_output,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
}
}
lines
}
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
if lines.len() <= keep {
return lines.to_vec();
}
if keep == 0 {
return vec![Self::ellipsis_line(lines.len())];
}
let mut out: Vec<Line<'static>> = lines[..keep].to_vec();
out.push(Self::ellipsis_line(lines.len() - keep));
out
}
fn truncate_lines_middle(lines: &[Line<'static>], max: usize) -> Vec<Line<'static>> {
if max == 0 {
return Vec::new();
}
if lines.len() <= max {
return lines.to_vec();
}
if max == 1 {
return vec![Self::ellipsis_line(lines.len())];
}
let head = (max - 1) / 2;
let tail = max - head - 1;
let mut out: Vec<Line<'static>> = Vec::new();
if head > 0 {
out.extend(lines[..head].iter().cloned());
}
let omitted = lines.len().saturating_sub(head + tail);
out.push(Self::ellipsis_line(omitted));
if tail > 0 {
out.extend(lines[lines.len() - tail..].iter().cloned());
}
out
}
fn ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![format!("… +{omitted} lines").dim()])
}
}
#[derive(Clone, Copy)]
struct PrefixedBlock {
initial_prefix: &'static str,
subsequent_prefix: &'static str,
}
impl PrefixedBlock {
const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self {
Self {
initial_prefix,
subsequent_prefix,
}
}
fn wrap_width(self, total_width: u16) -> usize {
let prefix_width = UnicodeWidthStr::width(self.initial_prefix)
.max(UnicodeWidthStr::width(self.subsequent_prefix));
usize::from(total_width).saturating_sub(prefix_width).max(1)
}
}
#[derive(Clone, Copy)]
struct ExecDisplayLayout {
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
}
impl ExecDisplayLayout {
const fn new(
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
) -> Self {
Self {
command_continuation,
command_continuation_max_lines,
output_block,
output_max_lines,
}
}
}
const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
PrefixedBlock::new("", ""),
2,
PrefixedBlock::new("", " "),
5,
);

View File

@@ -1,10 +1,14 @@
use crate::diff_render::create_diff_summary;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
pub(crate) use crate::status::RateLimitSnapshotDisplay;
pub(crate) use crate::status::new_status_output;
pub(crate) use crate::status::rate_limit_snapshot_display;
@@ -14,8 +18,6 @@ use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_core::config::Config;
use codex_core::config_types::ReasoningSummaryFormat;
use codex_core::plan_tool::PlanItemArg;
@@ -25,10 +27,8 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::parse_command::ParsedCommand;
use image::DynamicImage;
use image::ImageReader;
use itertools::Itertools;
use mcp_types::EmbeddedResourceResource;
use mcp_types::ResourceLink;
use ratatui::prelude::*;
@@ -49,14 +49,6 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) formatted_output: String,
}
#[derive(Clone, Debug)]
pub(crate) enum PatchEventType {
ApprovalRequest,
@@ -266,357 +258,6 @@ impl HistoryCell for PatchHistoryCell {
}
}
#[derive(Debug, Clone)]
pub(crate) struct ExecCall {
pub(crate) call_id: String,
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
start_time: Option<Instant>,
duration: Option<Duration>,
}
#[derive(Debug)]
pub(crate) struct ExecCell {
calls: Vec<ExecCall>,
}
impl HistoryCell for ExecCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.is_exploring_cell() {
self.exploring_display_lines(width)
} else {
self.command_display_lines(width)
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
for call in &self.calls {
let cmd_display = strip_bash_lc_and_escape(&call.command);
for (i, part) in cmd_display.lines().enumerate() {
if i == 0 {
lines.push(vec!["$ ".magenta(), part.to_string().into()].into());
} else {
lines.push(vec![" ".into(), part.to_string().into()].into());
}
}
if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
let duration = call
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result: Line = if output.exit_code == 0 {
Line::from("".green().bold())
} else {
Line::from(vec![
"".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!("{duration}").dim());
lines.push(result);
}
lines.push("".into());
}
lines
}
}
impl ExecCell {
fn is_active(&self) -> bool {
self.calls.iter().any(|c| c.output.is_none())
}
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let active_start_time = self
.calls
.iter()
.find(|c| c.output.is_none())
.and_then(|c| c.start_time);
out.push(Line::from(vec![
if self.is_active() {
// Show an animated spinner while exploring
spinner(active_start_time)
} else {
"".bold()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
let mut calls = self.calls.clone();
let mut out_indented = Vec::new();
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
.parsed
.iter()
.all(|c| matches!(c, ParsedCommand::Read { .. }))
{
while let Some(next) = calls.first() {
if next
.parsed
.iter()
.all(|c| matches!(c, ParsedCommand::Read { .. }))
{
call.parsed.extend(next.parsed.clone());
calls.remove(0);
} else {
break;
}
}
}
let call_lines: Vec<(&str, Vec<Span<'static>>)> = if call
.parsed
.iter()
.all(|c| matches!(c, ParsedCommand::Read { .. }))
{
let names = call
.parsed
.iter()
.map(|c| match c {
ParsedCommand::Read { name, .. } => name.clone(),
_ => unreachable!(),
})
.unique();
vec![(
"Read",
itertools::Itertools::intersperse(
names.into_iter().map(Into::into),
", ".dim(),
)
.collect(),
)]
} else {
let mut lines = Vec::new();
for p in call.parsed {
match p {
ParsedCommand::Read { name, .. } => {
lines.push(("Read", vec![name.into()]));
}
ParsedCommand::ListFiles { cmd, path } => {
lines.push(("List", vec![path.unwrap_or(cmd).into()]));
}
ParsedCommand::Search { cmd, query, path } => {
lines.push((
"Search",
match (query, path) {
(Some(q), Some(p)) => {
vec![q.into(), " in ".dim(), p.into()]
}
(Some(q), None) => vec![q.into()],
_ => vec![cmd.into()],
},
));
}
ParsedCommand::Unknown { cmd } => {
lines.push(("Run", vec![cmd.into()]));
}
}
}
lines
};
for (title, line) in call_lines {
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = word_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
}
}
out.extend(prefix_lines(out_indented, "".dim(), " ".into()));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
use textwrap::Options as TwOptions;
let mut lines: Vec<Line<'static>> = Vec::new();
let [call] = &self.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time),
};
let title = if self.is_active() { "Running" } else { "Ran" };
let cmd_display = strip_bash_lc_and_escape(&call.command);
// If the command fits on the same line as the header at the current width,
// show a single compact line: "• Ran <command>". Use the width of
// "• Running " (including trailing space) as the reserved prefix width.
// If the command contains newlines, always use the multi-line variant.
let reserved = "• Running ".width();
let mut body_lines: Vec<Line<'static>> = Vec::new();
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display);
if highlighted_lines.len() == 1
&& highlighted_lines[0].width() < (width as usize).saturating_sub(reserved)
{
let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]);
line.extend(highlighted_lines[0].clone());
lines.push(line);
} else {
lines.push(vec![bullet, " ".into(), title.bold()].into());
for hl_line in highlighted_lines.iter() {
let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4))
.initial_indent("".into())
.subsequent_indent(" ".into())
// Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags.
.word_splitter(textwrap::WordSplitter::NoHyphenation);
let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts);
body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l)));
}
}
if let Some(output) = call.output.as_ref()
&& output.exit_code != 0
{
let out = output_lines(
Some(output),
OutputLinesParams {
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
)
.into_iter()
.join("\n");
if !out.trim().is_empty() {
// Wrap the output.
for line in out.lines() {
let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4));
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));
}
}
}
lines.extend(prefix_lines(body_lines, "".dim(), " ".into()));
lines
}
}
impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
let lines = self.display_lines(area.width);
let max_rows = area.height as usize;
let rendered = if lines.len() > max_rows {
// Keep the last `max_rows` lines in original order
lines[lines.len() - max_rows..].to_vec()
} else {
lines
};
Paragraph::new(Text::from(rendered))
.wrap(Wrap { trim: false })
.render(content_area, buf);
}
}
impl ExecCell {
pub(crate) fn mark_failed(&mut self) {
for call in self.calls.iter_mut() {
if call.output.is_none() {
let elapsed = call
.start_time
.map(|st| st.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
call.start_time = None;
call.duration = Some(elapsed);
call.output = Some(CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: String::new(),
formatted_output: String::new(),
});
}
}
}
pub(crate) fn new(call: ExecCall) -> Self {
ExecCell { calls: vec![call] }
}
fn is_exploring_call(call: &ExecCall) -> bool {
!call.parsed.is_empty()
&& call.parsed.iter().all(|p| {
matches!(
p,
ParsedCommand::Read { .. }
| ParsedCommand::ListFiles { .. }
| ParsedCommand::Search { .. }
)
})
}
fn is_exploring_cell(&self) -> bool {
self.calls.iter().all(Self::is_exploring_call)
}
pub(crate) fn with_added_call(
&self,
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
};
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
})
} else {
None
}
}
pub(crate) fn complete_call(
&mut self,
call_id: &str,
output: CommandOutput,
duration: Duration,
) {
if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) {
call.output = Some(output);
call.duration = Some(duration);
call.start_time = None;
}
}
pub(crate) fn should_flush(&self) -> bool {
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
}
}
#[derive(Debug)]
struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
@@ -627,7 +268,6 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
}
}
const TOOL_CALL_MAX_LINES: usize = 5;
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
@@ -783,21 +423,6 @@ pub(crate) fn new_user_approval_decision(lines: Vec<Line<'static>>) -> PlainHist
PlainHistoryCell { lines }
}
pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
})
}
#[derive(Debug)]
struct SessionHeaderHistoryCell {
version: &'static str,
@@ -1116,15 +741,6 @@ impl WidgetRef for &McpToolCallCell {
}
}
fn spinner(start_time: Option<Instant>) -> Span<'static> {
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
ch.to_string().into()
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
@@ -1444,79 +1060,6 @@ pub(crate) fn new_reasoning_summary_block(
Box::new(new_reasoning_block(full_reasoning_buffer, config))
}
struct OutputLinesParams {
only_err: bool,
include_angle_pipe: bool,
include_prefix: bool,
}
fn output_lines(output: Option<&CommandOutput>, params: OutputLinesParams) -> Vec<Line<'static>> {
let OutputLinesParams {
only_err,
include_angle_pipe,
include_prefix,
} = params;
let CommandOutput {
exit_code,
stdout,
stderr,
..
} = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output,
None => return vec![],
};
let src = if *exit_code == 0 { stdout } else { stderr };
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let limit = TOOL_CALL_MAX_LINES;
let mut out = Vec::new();
let head_end = total.min(limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if !include_prefix {
""
} else if i == 0 && include_angle_pipe {
""
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
// If we will ellipsize less than the limit, just show it.
let show_ellipsis = total > 2 * limit;
if show_ellipsis {
let omitted = total - 2 * limit;
out.push(format!("… +{omitted} lines").into());
}
let tail_start = if show_ellipsis {
total - limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
if include_prefix {
line.spans.insert(0, " ".into());
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
out
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
let args_str = invocation
.arguments
@@ -1541,9 +1084,13 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_protocol::parse_command::ParsedCommand;
use dirs::home_dir;
use pretty_assertions::assert_eq;
use serde_json::json;

View File

@@ -10,20 +10,6 @@ const ALT_PREFIX: &str = "⌥";
#[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "Alt+";
#[cfg(test)]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const CTRL_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const CTRL_PREFIX: &str = "Ctrl+";
#[cfg(test)]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const SHIFT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const SHIFT_PREFIX: &str = "Shift+";
fn key_hint_style() -> Style {
Style::default().fg(Color::Cyan)
}
@@ -32,18 +18,6 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style())
}
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
modifier_span(CTRL_PREFIX, key)
}
pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key)
}
pub(crate) fn shift(key: impl Display) -> Span<'static> {
modifier_span(SHIFT_PREFIX, key)
}
pub(crate) fn plain(key: impl Display) -> Span<'static> {
Span::styled(format!("{key}"), key_hint_style())
}

View File

@@ -41,6 +41,7 @@ mod cli;
mod clipboard_paste;
pub mod custom_terminal;
mod diff_render;
mod exec_cell;
mod exec_command;
mod file_search;
mod frames;
@@ -66,7 +67,6 @@ mod streaming;
mod text_formatting;
mod tui;
mod ui_consts;
mod user_approval_widget;
mod version;
mod wrapping;

View File

@@ -556,7 +556,7 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::CommandOutput;
use crate::exec_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::history_cell::new_patch_event;
@@ -687,7 +687,7 @@ mod tests {
]));
cells.push(apply_end_cell);
let mut exec_cell = crate::history_cell::new_active_exec_command(
let mut exec_cell = crate::exec_cell::new_active_exec_command(
"exec-1".into(),
vec!["bash".into(), "-lc".into(), "ls".into()],
vec![ParsedCommand::Unknown { cmd: "ls".into() }],

View File

@@ -1,10 +1,8 @@
---
source: tui/src/history_cell.rs
assertion_line: 1942
expression: rendered
---
• Ran
└ first_token_is_long_enou
gh_to_wrap
second_token_is_also_lon
g_enough_to_wrap
• Ran first_token_is_long_en
│ ough_to_wrap
│ second_token_is_also_lon
│ … +1 lines

View File

@@ -2,6 +2,5 @@
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
echo one
echo two
• Ran echo one
echo two

View File

@@ -1,10 +1,7 @@
---
source: tui/src/history_cell.rs
assertion_line: 1797
expression: rendered
---
• Ran
└ set -o pipefail
cargo test
--all-features
--quiet
• Ran set -o pipefail
│ cargo test
│ --all-features --quiet

View File

@@ -2,13 +2,11 @@
source: tui/src/history_cell.rs
expression: rendered
---
• Ran
└ echo
this_is_a_very_long_
single_token_that_wi
ll_wrap_across_the_a
vailable_width
error: first line on
• Ran echo
│ this_is_a_very_long_si
│ ngle_token_that_will_w
│ … +2 lines
└ error: first line on
stderr
error: second line on
stderr

View File

@@ -1,9 +1,7 @@
---
source: tui/src/history_cell.rs
assertion_line: 1869
expression: rendered
---
• Ran
└ a_very_long_token_wi
thout_spaces_to_
force_wrapping
• Ran a_very_long_token_
│ without_spaces_to_
│ force_wrapping

View File

@@ -5,11 +5,6 @@ expression: rendered
• Ran seq 1 10 1>&2 && false
└ 1
2
3
4
5
6
7
8
… +6 lines
9
10

View File

@@ -1,448 +0,0 @@
//! A modal widget that prompts the user to approve or deny an action
//! requested by the agent.
//!
//! This is a (very) rough port of
//! `src/components/chat/terminal-chat-command-review.tsx` from the TypeScript
//! UI to Rust using [`ratatui`]. The goal is featureparity for the keyboard
//! driven workflow a fullyfledged visual match is not required.
use std::path::PathBuf;
use std::sync::LazyLock;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
reason: Option<String>,
},
ApplyPatch {
id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
},
}
/// Options displayed in the *select* mode.
///
/// The `key` is matched case-insensitively.
struct SelectOption {
label: Line<'static>,
description: &'static str,
key: KeyCode,
decision: ReviewDecision,
}
static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and run the command",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["A".underlined(), "lways".into()]),
description: "Approve the command for the remainder of this session",
key: KeyCode::Char('a'),
decision: ReviewDecision::ApprovedForSession,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
description: "Do not run the command; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and apply the changes",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
description: "Do not apply the changes; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
/// A modal prompting the user to approve or deny the pending request.
pub(crate) struct UserApprovalWidget {
approval_request: ApprovalRequest,
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'static>,
select_options: &'static Vec<SelectOption>,
/// Currently selected index in *select* mode.
selected_option: usize,
/// Set to `true` once a decision has been sent the parent view can then
/// remove this widget from its queue.
done: bool,
}
impl UserApprovalWidget {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec { reason, .. } => {
let mut contents: Vec<Line> = vec![];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
} => {
let mut contents: Vec<Line> = vec![];
if let Some(r) = reason {
contents.push(Line::from(r.clone().italic()));
contents.push(Line::from(""));
}
if let Some(root) = grant_root {
contents.push(Line::from(format!(
"This will grant write access to {} for the remainder of this session.",
root.display()
)));
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
}
};
Self {
select_options: match &approval_request {
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
},
approval_request,
app_event_tx,
confirmation_prompt,
selected_option: 0,
done: false,
}
}
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
// Should cache this for last value of width.
self.confirmation_prompt.line_count(width) as u16
}
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
/// while the modal is visible.
/// Process a key event originating from crossterm. As the modal fully
/// captures input while visible, we dont need to report whether the event
/// was consumed—callers can assume it always is.
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Press {
self.handle_select_key(key);
}
}
/// Normalize a key for comparison.
/// - For `KeyCode::Char`, converts to lowercase for case-insensitive matching.
/// - Other key codes are returned unchanged.
fn normalize_keycode(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
other => other,
}
}
/// Handle Ctrl-C pressed by the user while the modal is visible.
/// Behaves like pressing Escape: abort the request and close the modal.
pub(crate) fn on_ctrl_c(&mut self) {
self.send_decision(ReviewDecision::Abort);
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Left => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
}
KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
}
KeyCode::Enter => {
let opt = &self.select_options[self.selected_option];
self.send_decision(opt.decision);
}
KeyCode::Esc => {
self.send_decision(ReviewDecision::Abort);
}
other => {
let normalized = Self::normalize_keycode(other);
if let Some(opt) = self
.select_options
.iter()
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
{
self.send_decision(opt.decision);
}
}
}
}
fn send_decision(&mut self, decision: ReviewDecision) {
self.send_decision_with_feedback(decision, String::new())
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let full_cmd = strip_bash_lc_and_escape(command);
// Construct a concise, single-line summary of the command:
// - If multi-line, take the first line and append " ...".
// - Truncate to 80 graphemes.
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.clone(),
};
// Enforce the 80 character length limit.
snippet = truncate_text(&snippet, 80);
let mut result_spans: Vec<Span<'static>> = Vec::new();
match decision {
ReviewDecision::Approved => {
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.dim(),
" this time".bold(),
]);
}
ReviewDecision::ApprovedForSession => {
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.dim(),
" every time this session".bold(),
]);
}
ReviewDecision::Denied => {
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet.dim(),
]);
}
ReviewDecision::Abort => {
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet.dim(),
]);
}
}
let mut lines: Vec<Line<'static>> = vec![Line::from(result_spans)];
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_user_approval_decision(lines),
)));
}
ApprovalRequest::ApplyPatch { .. } => {
// No history line for patch approval decisions.
}
}
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
id: id.clone(),
decision,
},
ApprovalRequest::ApplyPatch { id, .. } => Op::PatchApproval {
id: id.clone(),
decision,
},
};
self.app_event_tx.send(AppEvent::CodexOp(op));
self.done = true;
}
/// Returns `true` once the user has made a decision and the widget no
/// longer needs to be displayed.
pub(crate) fn is_complete(&self) -> bool {
self.done
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
// Reserve space for:
// - 1 title line ("Allow command?" or "Apply changes?")
// - 1 buttons line (options rendered horizontally on a single row)
// - 1 description line (context for the currently selected option)
self.get_confirmation_prompt_height(width) + 3
}
}
impl WidgetRef for &UserApprovalWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let prompt_height = self.get_confirmation_prompt_height(area.width);
let [prompt_chunk, response_chunk] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
.areas(area);
let lines: Vec<Line> = self
.select_options
.iter()
.enumerate()
.map(|(idx, opt)| {
let style = if idx == self.selected_option {
Style::new().bg(Color::Cyan).fg(Color::Black)
} else {
Style::new().add_modifier(Modifier::DIM)
};
opt.label.clone().alignment(Alignment::Center).style(style)
})
.collect();
let [title_area, button_area, description_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(response_chunk.inner(Margin::new(1, 0)));
let title = match &self.approval_request {
ApprovalRequest::Exec { .. } => "Allow command?",
ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
};
Line::from(title).render(title_area, buf);
self.confirmation_prompt.clone().render(prompt_chunk, buf);
let areas = Layout::horizontal(
lines
.iter()
.map(|l| Constraint::Length(l.width() as u16 + 2)),
)
.spacing(1)
.split(button_area);
for (idx, area) in areas.iter().enumerate() {
let line = &lines[idx];
line.render(*area, buf);
}
Line::from(self.select_options[self.selected_option].description)
.style(Style::new().italic().add_modifier(Modifier::DIM))
.render(description_area.inner(Margin::new(1, 0)), buf);
Block::bordered()
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::Cyan))
.borders(Borders::LEFT)
.render_ref(
Rect::new(0, response_chunk.y, 1, response_chunk.height),
buf,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn lowercase_shortcut_is_accepted() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".to_string()],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(widget.is_complete());
let mut events: Vec<AppEvent> = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
assert!(events.iter().any(|e| matches!(
e,
AppEvent::CodexOp(Op::ExecApproval {
decision: ReviewDecision::Approved,
..
})
)));
}
#[test]
fn uppercase_shortcut_is_accepted() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "2".to_string(),
command: vec!["echo".to_string()],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
assert!(widget.is_complete());
let mut events: Vec<AppEvent> = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
assert!(events.iter().any(|e| matches!(
e,
AppEvent::CodexOp(Op::ExecApproval {
decision: ReviewDecision::Approved,
..
})
)));
}
}