Compare commits

..

8 Commits

Author SHA1 Message Date
aibrahim-oai
320aec304a feat: stream agent message deltas 2025-07-31 21:13:10 -07:00
aibrahim-oai
ad0295b893 MCP server: route structured tool-call requests and expose mcp_protocol [Stack 2/3] (#1751)
- Expose mcp_protocol from mcp-server for reuse in tests and callers.
- In MessageProcessor, detect structured ToolCallRequestParams in
tools/call and forward to a new handler.
- Add handle_new_tool_calls scaffold (returns error for now).
- Test helper: add send_send_user_message_tool_call to McpProcess to
send ConversationSendMessage requests;

This is the second PR in a stack.
Stack:
Final: #1686
Intermediate: #1751
First: #1750
2025-08-01 02:46:04 +00:00
aibrahim-oai
d3aa5f46b7 MCP Protocol: Align tool-call response with CallToolResult [Stack 1/3] (#1750)
# Summary
- Align MCP server responses with mcp_types by emitting [CallToolResult,
RequestId] instead of an object.
Update send-message result to a tagged enum: Ok or Error { message }.

# Why
Protocol compliance with current MCP schema.

# Tests
- Updated assertions in mcp_protocol.rs for create/stream/send/list and
error cases.

This is the first PR in a stack.
Stack:
Final: #1686
Intermediate: #1751
First: #1750
2025-08-01 02:30:03 +00:00
easong-openai
575590e4c2 Detect kitty terminals (#1748)
We want to detect kitty terminals so we can preferentially upgrade their UX without degrading older terminals.
2025-08-01 00:30:44 +00:00
Jeremy Rose
4aca3e46c8 insert history lines with redraw (#1769)
This delays the call to insert_history_lines until a redraw is
happening. Crucially, the new lines are inserted _after the viewport is
resized_. This results in fewer stray blank lines below the viewport
when modals (e.g. user approval) are closed.
2025-07-31 17:15:26 -07:00
Jeremy Rose
d787434aa8 fix: always send KeyEvent, we now check kind in the handler (#1772)
https://github.com/openai/codex/pull/1754 and #1771 fixed the same thing
in colliding ways.
2025-08-01 00:13:36 +00:00
Jeremy Rose
ea69a1d72f lighter approval modal (#1768)
The yellow hazard stripes were too scary :)

This also has the added benefit of not rendering anything at the full
width of the terminal, so resizing is a little easier to handle.

<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 03 29 PM"
src="https://github.com/user-attachments/assets/18476e1a-065d-4da9-92fe-e94978ab0fce"
/>

<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 05 03 PM"
src="https://github.com/user-attachments/assets/337db0da-de40-48c6-ae71-0e40f24b87e7"
/>
2025-07-31 17:10:52 -07:00
Jeremy Rose
610addbc2e do not dispatch key releases (#1771)
when we enabled KKP in https://github.com/openai/codex/pull/1743, we
started receiving keyup events, but didn't expect them anywhere in our
code. for now, just don't dispatch them at all.
2025-07-31 17:00:48 -07:00
22 changed files with 532 additions and 593 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2643,6 +2643,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-core",
"codex-mcp-server",
"mcp-types",
"pretty_assertions",
@@ -2650,6 +2651,7 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
"uuid",
"wiremock",
]

View File

@@ -4,7 +4,6 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -92,7 +91,6 @@ use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use tokio::fs;
/// The high-level interface to the Codex system.
/// It operates as a queue pair where you send submissions and receive events.
@@ -947,21 +945,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
return;
}
let mut effective_input = input;
let is_first_turn = {
let st = sess.state.lock().unwrap();
st.history.contents().is_empty()
};
if is_first_turn {
if let Some(env_text) = get_initial_env_context(&sess.cwd).await {
let mut prefixed = Vec::with_capacity(effective_input.len() + 1);
prefixed.push(InputItem::Text { text: env_text });
prefixed.extend(effective_input.into_iter());
effective_input = prefixed;
}
}
let initial_input_for_turn = ResponseInputItem::from(effective_input);
let initial_input_for_turn = ResponseInputItem::from(input);
sess.record_conversation_items(&[initial_input_for_turn.clone().into()])
.await;
@@ -1122,128 +1106,6 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
sess.tx_event.send(event).await.ok();
}
async fn get_initial_env_context(cwd: &Path) -> Option<String> {
const MAX_LINES: usize = 50;
let mut dir = match fs::read_dir(cwd).await {
Ok(d) => d,
Err(_) => return None,
};
let mut lines = Vec::with_capacity(MAX_LINES);
let mut seen = 0usize;
let mut truncated = false;
loop {
match dir.next_entry().await {
Ok(Some(entry)) => {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') {
continue;
}
seen += 1;
if lines.len() < MAX_LINES {
lines.push(name.to_string());
} else {
truncated = true;
break;
}
}
Ok(None) => break,
Err(_) => return None,
}
}
if lines.is_empty() {
return None;
}
let mut out = String::new();
out.push_str("Environment snapshot (output of `ls | head -n 50` in cwd):\n");
out.push_str(&format!("{}\n\n", cwd.display()));
for line in &lines {
out.push_str(line);
out.push('\n');
}
if truncated || seen > lines.len() {
out.push_str("… (truncated)\n");
}
Some(out)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::get_initial_env_context;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn env_context_truncates_after_50_visible_entries() {
let tmp = TempDir::new().unwrap();
// Create 65 non-hidden files and a few hidden ones. Dotfiles should be skipped.
for i in 0..65u32 {
fs::write(tmp.path().join(format!("file_{i:03}.txt")), b"x").unwrap();
}
fs::write(tmp.path().join(".hidden_a"), b"x").unwrap();
fs::write(tmp.path().join(".hidden_b"), b"x").unwrap();
let ctx = get_initial_env_context(tmp.path())
.await
.expect("should produce context");
// Header and cwd should be present.
assert!(ctx.starts_with("Environment snapshot (output of `ls | head -n 50` in cwd):\n"));
assert!(ctx.contains(&format!("{}\n\n", tmp.path().display())));
// Count listed entries (exclude header/cwd/blank and possible truncation marker)
let mut count = 0usize;
let mut saw_truncated = false;
for line in ctx.lines().skip(2) {
if line.is_empty() {
continue;
}
if line == "… (truncated)" {
saw_truncated = true;
break;
}
count += 1;
}
assert_eq!(count, 50, "should list exactly 50 visible entries");
assert!(
saw_truncated,
"should indicate truncation when more than 50 entries exist"
);
}
#[tokio::test]
async fn env_context_no_truncation_when_fewer_than_50() {
let tmp = TempDir::new().unwrap();
// 3 visible + 1 hidden
for name in ["a.txt", "b.txt", "c.txt", ".secret"] {
fs::write(tmp.path().join(name), b"x").unwrap();
}
let ctx = get_initial_env_context(tmp.path())
.await
.expect("should produce context");
let mut lines = ctx.lines().skip(2).filter(|l| !l.is_empty());
let mut names = Vec::new();
for l in &mut lines {
if l == "… (truncated)" {
panic!("should not be truncated");
}
names.push(l.to_string());
}
// Hidden should be excluded; only 3 visible entries.
assert_eq!(names.len(), 3);
assert!(names.iter().all(|n| n != ".secret"));
}
}
async fn run_turn(
sess: &Session,
sub_id: String,

View File

@@ -1,189 +0,0 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn first_turn_includes_environment_snapshot() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
// Create a temporary working directory with a few files (including a hidden one).
let cwd = TempDir::new().unwrap();
std::fs::write(cwd.path().join("a.txt"), b"x").unwrap();
std::fs::write(cwd.path().join("b.txt"), b"x").unwrap();
std::fs::write(cwd.path().join(".hidden"), b"x").unwrap();
// Mock Responses API server that immediately completes the turn.
let server = MockServer::start().await;
let sse = load_sse_fixture_with_id("tests/fixtures/completed_template.json", "resp1");
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(first)
.mount(&server)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
// Initialize session using the temp cwd and the mock provider.
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
config.cwd = cwd.path().to_path_buf();
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
// Submit a simple user message the agent should inject the environment snapshot as
// an additional content item at the start of the first user message.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
// Wait for the task to complete so the request is dispatched.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Read the captured request and verify the first message content includes the snapshot.
let request = &server.received_requests().await.unwrap()[0];
let body = request.body_json::<serde_json::Value>().unwrap();
// We expect the first (and only) input item to be a user message with multiple content entries.
let first_input = &body["input"][0];
assert_eq!(first_input["role"], "user");
// The first content item should be the injected environment snapshot.
let first_text = first_input["content"][0]["text"].as_str().unwrap();
assert!(first_text.starts_with("Environment snapshot (output of `ls | head -n 50` in cwd):"));
// It should reference the cwd and include visible files, but not hidden ones.
assert!(first_text.contains(&cwd.path().display().to_string()));
assert!(first_text.contains("a.txt"));
assert!(first_text.contains("b.txt"));
assert!(!first_text.contains(".hidden"));
// The user's original message should appear in the second content item.
let second_text = first_input["content"][1]["text"].as_str().unwrap();
assert_eq!(second_text, "hello");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_is_not_injected_on_second_turn() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
// Prepare cwd with a couple of files (and a hidden one).
let cwd = TempDir::new().unwrap();
std::fs::write(cwd.path().join("first.txt"), b"x").unwrap();
std::fs::write(cwd.path().join("second.txt"), b"x").unwrap();
std::fs::write(cwd.path().join(".dot"), b"x").unwrap();
// Mock server that accepts two requests and completes both.
let server = MockServer::start().await;
let sse = load_sse_fixture_with_id("tests/fixtures/completed_template.json", "resp1");
let responder = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(responder)
.expect(2)
.mount(&server)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
config.cwd = cwd.path().to_path_buf();
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("Test API Key".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
// First user message.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "first".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Second user message.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "second".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Verify the second request's last user message does not include the environment snapshot.
let requests = server.received_requests().await.unwrap();
assert!(
requests.len() >= 2,
"expected two requests to the mock server"
);
let second_req = &requests[1];
let body = second_req.body_json::<serde_json::Value>().unwrap();
let input = body["input"].as_array().expect("input array");
let last = input.last().expect("at least one input item");
assert_eq!(last["role"], "user");
let last_text = last["content"][0]["text"].as_str().unwrap();
// Should be exactly the submitted text, without the snapshot header prefix.
assert_eq!(last_text, "second");
}

View File

@@ -19,7 +19,7 @@ mod codex_tool_config;
mod codex_tool_runner;
mod exec_approval;
mod json_to_toml;
mod mcp_protocol;
pub mod mcp_protocol;
mod message_processor;
mod outgoing_message;
mod patch_approval;

View File

@@ -7,7 +7,10 @@ use serde::Serialize;
use strum_macros::Display;
use uuid::Uuid;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
use mcp_types::TextContent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
@@ -118,10 +121,47 @@ pub struct ToolCallResponse {
pub request_id: RequestId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none", flatten)]
pub result: Option<ToolCallResponseResult>,
}
impl From<ToolCallResponse> for CallToolResult {
fn from(val: ToolCallResponse) -> Self {
let ToolCallResponse {
request_id: _request_id,
is_error,
result,
} = val;
let (content, structured_content, is_error_out) = match result {
Some(res) => match serde_json::to_value(&res) {
Ok(v) => {
let content = vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: v.to_string(),
annotations: None,
})];
(content, Some(v), is_error)
}
Err(e) => {
let content = vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Failed to serialize tool result: {e}"),
annotations: None,
})];
(content, None, Some(true))
}
},
None => (vec![], None, is_error),
};
CallToolResult {
content,
is_error: is_error_out,
structured_content,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolCallResponseResult {
@@ -141,8 +181,10 @@ pub struct ConversationCreateResult {
pub struct ConversationStreamResult {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationSendMessageResult {
pub success: bool,
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ConversationSendMessageResult {
Ok,
Error { message: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -455,10 +497,13 @@ mod tests {
},
)),
};
let observed = to_val(&env);
let req_id = env.request_id.clone();
let observed = to_val(&CallToolResult::from(env));
let expected = json!({
"requestId": 1,
"result": {
"content": [
{ "type": "text", "text": "{\"conversation_id\":\"d0f6ecbe-84a2-41c1-b23d-b20473b25eab\",\"model\":\"o3\"}" }
],
"structuredContent": {
"conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab",
"model": "o3"
}
@@ -467,6 +512,7 @@ mod tests {
observed, expected,
"response (ConversationCreate) must match"
);
assert_eq!(req_id, RequestId::Integer(1));
}
#[test]
@@ -478,15 +524,17 @@ mod tests {
ConversationStreamResult {},
)),
};
let observed = to_val(&env);
let req_id = env.request_id.clone();
let observed = to_val(&CallToolResult::from(env));
let expected = json!({
"requestId": 2,
"result": {}
"content": [ { "type": "text", "text": "{}" } ],
"structuredContent": {}
});
assert_eq!(
observed, expected,
"response (ConversationStream) must have empty object result"
);
assert_eq!(req_id, RequestId::Integer(2));
}
#[test]
@@ -495,18 +543,20 @@ mod tests {
request_id: RequestId::Integer(3),
is_error: None,
result: Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult { success: true },
ConversationSendMessageResult::Ok,
)),
};
let observed = to_val(&env);
let req_id = env.request_id.clone();
let observed = to_val(&CallToolResult::from(env));
let expected = json!({
"requestId": 3,
"result": { "success": true }
"content": [ { "type": "text", "text": "{\"status\":\"ok\"}" } ],
"structuredContent": { "status": "ok" }
});
assert_eq!(
observed, expected,
"response (ConversationSendMessageAccepted) must match"
);
assert_eq!(req_id, RequestId::Integer(3));
}
#[test]
@@ -526,10 +576,13 @@ mod tests {
},
)),
};
let observed = to_val(&env);
let req_id = env.request_id.clone();
let observed = to_val(&CallToolResult::from(env));
let expected = json!({
"requestId": 4,
"result": {
"content": [
{ "type": "text", "text": "{\"conversations\":[{\"conversation_id\":\"67e55044-10b1-426f-9247-bb680e5fe0c8\",\"title\":\"Refactor config loader\"}],\"next_cursor\":\"next123\"}" }
],
"structuredContent": {
"conversations": [
{
"conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
@@ -543,6 +596,7 @@ mod tests {
observed, expected,
"response (ConversationsList with cursor) must match"
);
assert_eq!(req_id, RequestId::Integer(4));
}
#[test]
@@ -552,15 +606,17 @@ mod tests {
is_error: Some(true),
result: None,
};
let observed = to_val(&env);
let req_id = env.request_id.clone();
let observed = to_val(&CallToolResult::from(env));
let expected = json!({
"requestId": 4,
"content": [],
"isError": true
});
assert_eq!(
observed, expected,
"error response must omit `result` and include `isError`"
);
assert_eq!(req_id, RequestId::Integer(4));
}
// ----- Notifications -----

View File

@@ -6,6 +6,7 @@ use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::mcp_protocol::ToolCallRequestParams;
use crate::outgoing_message::OutgoingMessageSender;
use codex_core::Codex;
@@ -300,6 +301,14 @@ impl MessageProcessor {
params: <mcp_types::CallToolRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("tools/call -> params: {:?}", params);
// Serialize params into JSON and try to parse as new type
if let Ok(new_params) =
serde_json::to_value(&params).and_then(serde_json::from_value::<ToolCallRequestParams>)
{
// New tool call matched → forward
self.handle_new_tool_calls(id, new_params).await;
return;
}
let CallToolRequestParams { name, arguments } = params;
match name.as_str() {
@@ -323,6 +332,20 @@ impl MessageProcessor {
}
}
}
async fn handle_new_tool_calls(&self, request_id: RequestId, _params: ToolCallRequestParams) {
// TODO: implement the new tool calls
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: "Unknown tool".to_string(),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
};
self.send_response::<mcp_types::CallToolRequest>(request_id, result)
.await;
}
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
let (initial_prompt, config): (String, CodexConfig) = match arguments {

View File

@@ -10,6 +10,7 @@ path = "lib.rs"
anyhow = "1"
assert_cmd = "2"
codex-mcp-server = { path = "../.." }
codex-core = { path = "../../../core" }
mcp-types = { path = "../../../mcp-types" }
pretty_assertions = "1.4.1"
serde_json = "1"
@@ -22,3 +23,4 @@ tokio = { version = "1", features = [
"rt-multi-thread",
] }
wiremock = "0.6"
uuid = { version = "1", features = ["serde", "v4"] }

View File

@@ -11,8 +11,13 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_core::protocol::InputItem;
use codex_mcp_server::CodexToolCallParam;
use codex_mcp_server::CodexToolCallReplyParam;
use codex_mcp_server::mcp_protocol::ConversationId;
use codex_mcp_server::mcp_protocol::ConversationSendMessageArgs;
use codex_mcp_server::mcp_protocol::ToolCallRequestParams;
use mcp_types::CallToolRequestParams;
use mcp_types::ClientCapabilities;
use mcp_types::Implementation;
@@ -29,6 +34,7 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::process::Command as StdCommand;
use tokio::process::Command;
use uuid::Uuid;
pub struct McpProcess {
next_request_id: AtomicI64,
@@ -174,6 +180,26 @@ impl McpProcess {
.await
}
pub async fn send_user_message_tool_call(
&mut self,
message: &str,
session_id: &str,
) -> anyhow::Result<i64> {
let params = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs {
conversation_id: ConversationId(Uuid::parse_str(session_id)?),
content: vec![InputItem::Text {
text: message.to_string(),
}],
parent_message_id: None,
conversation_overrides: None,
});
self.send_request(
mcp_types::CallToolRequest::METHOD,
Some(serde_json::to_value(params)?),
)
.await
}
async fn send_request(
&mut self,
method: &str,

View File

@@ -62,6 +62,8 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"

View File

@@ -10,13 +10,15 @@ use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -26,6 +28,11 @@ use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
enum PendingHistoryOp {
Insert(Vec<Line<'static>>),
Overwrite(Line<'static>),
}
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
@@ -55,9 +62,13 @@ pub(crate) struct App<'a> {
/// True when a redraw has been scheduled but not yet executed.
pending_redraw: Arc<AtomicBool>,
pending_history_ops: Vec<PendingHistoryOp>,
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
/// after dismissing the Git-repo warning.
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -67,6 +78,7 @@ struct ChatWidgetArgs {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
}
impl App<'_> {
@@ -80,6 +92,8 @@ impl App<'_> {
let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
{
@@ -96,9 +110,7 @@ impl App<'_> {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
if key_event.kind == crossterm::event::KeyEventKind::Press {
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
@@ -134,6 +146,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
}),
)
} else {
@@ -142,6 +155,7 @@ impl App<'_> {
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
);
(
AppState::Chat {
@@ -154,12 +168,14 @@ impl App<'_> {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
Self {
app_event_tx,
pending_history_ops: Vec::new(),
app_event_rx,
app_state,
config,
file_search,
pending_redraw,
chat_args,
enhanced_keys_supported,
}
}
@@ -199,7 +215,13 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
crate::insert_history::insert_history_lines(terminal, lines);
self.pending_history_ops
.push(PendingHistoryOp::Insert(lines));
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::OverwriteHistoryLine(line) => {
self.pending_history_ops
.push(PendingHistoryOp::Overwrite(line));
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
@@ -213,6 +235,7 @@ impl App<'_> {
KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
@@ -227,6 +250,7 @@ impl App<'_> {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
@@ -245,9 +269,15 @@ impl App<'_> {
}
}
}
_ => {
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events for now.
}
};
}
AppEvent::Paste(text) => {
@@ -274,6 +304,7 @@ impl App<'_> {
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -304,14 +335,41 @@ impl App<'_> {
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::FileChange;
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id: "1".to_string(),
command: vec!["git".into(), "apply".into()],
cwd: self.config.cwd.clone(),
reason: Some("test".to_string()),
}),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
),
}));
}
},
@@ -363,6 +421,7 @@ impl App<'_> {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10,
};
let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height);
area.width = size.width;
@@ -376,6 +435,18 @@ impl App<'_> {
terminal.clear()?;
terminal.set_viewport_area(area);
}
if !self.pending_history_ops.is_empty() {
for op in self.pending_history_ops.drain(..) {
match op {
PendingHistoryOp::Insert(lines) => {
crate::insert_history::insert_history_lines(terminal, lines);
}
PendingHistoryOp::Overwrite(line) => {
crate::insert_history::overwrite_last_history_line(line);
}
}
}
}
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
@@ -407,6 +478,7 @@ impl App<'_> {
self.app_event_tx.clone(),
args.initial_prompt,
args.initial_images,
args.enhanced_keys_supported,
));
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);

View File

@@ -48,4 +48,6 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// Overwrite the last line in the scrollback with the provided content.
OverwriteHistoryLine(Line<'static>),
}

View File

@@ -99,6 +99,7 @@ mod tests {
let mut pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx_raw2),
has_input_focus: true,
enhanced_keys_supported: false,
});
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
assert!(view.queue.is_empty());

View File

@@ -41,6 +41,7 @@ pub(crate) struct ChatComposer<'a> {
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
@@ -54,17 +55,24 @@ enum ActivePopup {
}
impl ChatComposer<'_> {
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self {
pub fn new(
has_input_focus: bool,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self {
let mut textarea = TextArea::default();
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
textarea.set_cursor_line_style(ratatui::style::Style::default());
let use_shift_enter_hint = enhanced_keys_supported;
let mut this = Self {
textarea,
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
@@ -712,11 +720,16 @@ impl WidgetRef for &ChatComposer<'_> {
Span::from(" to quit"),
]
} else {
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
} else {
"Ctrl+J"
};
vec![
Span::from(" "),
"".set_style(key_hint_style),
Span::from(" send "),
"Shift+⏎".set_style(key_hint_style),
newline_hint_key.set_style(key_hint_style),
Span::from(" newline "),
"Ctrl+C".set_style(key_hint_style),
Span::from(" quit"),
@@ -890,7 +903,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
@@ -913,7 +926,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone());
@@ -942,7 +955,7 @@ mod tests {
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
composer.handle_paste(large);
assert_eq!(composer.pending_pastes.len(), 1);
@@ -978,7 +991,7 @@ mod tests {
for (name, input) in test_cases {
// Create a fresh composer for each test case
let mut composer = ChatComposer::new(true, sender.clone());
let mut composer = ChatComposer::new(true, sender.clone(), false);
if let Some(text) = input {
composer.handle_paste(text);
@@ -1015,7 +1028,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (paste content, is_large)
let test_cases = [
@@ -1088,7 +1101,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (content, is_large)
let test_cases = [
@@ -1161,7 +1174,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [

View File

@@ -50,12 +50,18 @@ pub(crate) struct BottomPane<'a> {
pub(crate) struct BottomPaneParams {
pub(crate) app_event_tx: AppEventSender,
pub(crate) has_input_focus: bool,
pub(crate) enhanced_keys_supported: bool,
}
impl BottomPane<'_> {
pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported;
Self {
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
composer: ChatComposer::new(
params.has_input_focus,
params.app_event_tx.clone(),
enhanced_keys_supported,
),
active_view: None,
app_event_tx: params.app_event_tx,
has_input_focus: params.has_input_focus,
@@ -298,6 +304,7 @@ mod tests {
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
enhanced_keys_supported: false,
});
pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit "
" ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -25,10 +25,13 @@ use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::style::Stylize;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
@@ -42,6 +45,7 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::markdown::append_markdown;
use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
@@ -59,10 +63,13 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
reasoning_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
// Buffer and tracking for streaming assistant reasoning text.
reasoning_inserted_lines: usize,
reasoning_header_inserted: bool,
// Buffer and tracking for streaming assistant answer text.
answer_buffer: String,
answer_inserted_lines: usize,
answer_header_inserted: bool,
running_commands: HashMap<String, RunningCommand>,
}
@@ -94,6 +101,7 @@ impl ChatWidget<'_> {
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -139,6 +147,7 @@ impl ChatWidget<'_> {
bottom_pane: BottomPane::new(BottomPaneParams {
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
}),
config,
initial_user_message: create_initial_user_message(
@@ -147,17 +156,29 @@ impl ChatWidget<'_> {
),
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
reasoning_inserted_lines: 0,
reasoning_header_inserted: false,
answer_buffer: String::new(),
answer_inserted_lines: 0,
answer_header_inserted: false,
running_commands: HashMap::new(),
}
}
fn render_markdown_line(&self, text: &str) -> Line<'static> {
let mut lines = Vec::new();
append_markdown(text, &mut lines, &self.config);
lines.into_iter().next().unwrap_or_else(|| Line::from(""))
}
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
self.bottom_pane.clear_ctrl_c_quit_hint();
if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint();
}
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
@@ -171,6 +192,76 @@ impl ChatWidget<'_> {
self.bottom_pane.handle_paste(text);
}
fn handle_answer_delta(&mut self, delta: &str) {
self.answer_buffer.push_str(delta);
let lines: Vec<&str> = self.answer_buffer.split('\n').collect();
if !self.answer_header_inserted {
let first = *lines.first().unwrap_or(&"");
let mut initial = vec![Line::from("codex".magenta().bold())];
initial.push(self.render_markdown_line(first));
self.app_event_tx.send(AppEvent::InsertHistory(initial));
self.answer_header_inserted = true;
self.answer_inserted_lines = 1;
} else {
let current_count = lines.len();
if current_count > self.answer_inserted_lines {
if self.answer_inserted_lines > 0 {
let idx = self.answer_inserted_lines - 1;
let line = self.render_markdown_line(lines[idx]);
self.app_event_tx
.send(AppEvent::OverwriteHistoryLine(line));
}
for i in self.answer_inserted_lines..current_count {
let line = self.render_markdown_line(lines[i]);
self.app_event_tx
.send(AppEvent::InsertHistory(vec![line]));
self.answer_inserted_lines += 1;
}
} else if self.answer_inserted_lines > 0 {
let idx = self.answer_inserted_lines - 1;
let line = self.render_markdown_line(lines[idx]);
self.app_event_tx
.send(AppEvent::OverwriteHistoryLine(line));
}
}
self.request_redraw();
}
fn handle_reasoning_delta(&mut self, delta: &str) {
self.reasoning_buffer.push_str(delta);
let lines: Vec<&str> = self.reasoning_buffer.split('\n').collect();
if !self.reasoning_header_inserted {
let first = *lines.first().unwrap_or(&"");
let mut initial = vec![Line::from("thinking".magenta().italic())];
initial.push(self.render_markdown_line(first));
self.app_event_tx.send(AppEvent::InsertHistory(initial));
self.reasoning_header_inserted = true;
self.reasoning_inserted_lines = 1;
} else {
let current_count = lines.len();
if current_count > self.reasoning_inserted_lines {
if self.reasoning_inserted_lines > 0 {
let idx = self.reasoning_inserted_lines - 1;
let line = self.render_markdown_line(lines[idx]);
self.app_event_tx
.send(AppEvent::OverwriteHistoryLine(line));
}
for i in self.reasoning_inserted_lines..current_count {
let line = self.render_markdown_line(lines[i]);
self.app_event_tx
.send(AppEvent::InsertHistory(vec![line]));
self.reasoning_inserted_lines += 1;
}
} else if self.reasoning_inserted_lines > 0 {
let idx = self.reasoning_inserted_lines - 1;
let line = self.render_markdown_line(lines[idx]);
self.app_event_tx
.send(AppEvent::OverwriteHistoryLine(line));
}
}
self.request_redraw();
}
fn add_to_history(&mut self, cell: HistoryCell) {
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
@@ -231,46 +322,67 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// Final assistant answer. Prefer the fully provided message
// from the event; if it is empty fall back to any accumulated
// delta buffer (some providers may only stream deltas and send
// an empty final message).
let full = if message.is_empty() {
std::mem::take(&mut self.answer_buffer)
if self.answer_header_inserted {
if !message.is_empty() && message != self.answer_buffer {
let tail = message
.strip_prefix(&self.answer_buffer)
.unwrap_or(&message);
if !tail.is_empty() {
self.handle_answer_delta(tail);
}
}
self.app_event_tx
.send(AppEvent::InsertHistory(vec![Line::from("")]))
;
} else {
self.answer_buffer.clear();
message
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
let full = if message.is_empty() {
std::mem::take(&mut self.answer_buffer)
} else {
self.answer_buffer.clear();
message
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
}
}
self.answer_buffer.clear();
self.answer_inserted_lines = 0;
self.answer_header_inserted = false;
self.request_redraw();
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
// Buffer only do not emit partial lines. This avoids cases
// where long responses appear truncated if the terminal
// wrapped early. The full message is emitted on
// AgentMessage.
self.answer_buffer.push_str(&delta);
self.handle_answer_delta(&delta);
}
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
// Buffer only disable incremental reasoning streaming so we
// avoid truncated intermediate lines. Full text emitted on
// AgentReasoning.
self.reasoning_buffer.push_str(&delta);
self.handle_reasoning_delta(&delta);
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
// Emit full reasoning text once. Some providers might send
// final event with empty text if only deltas were used.
let full = if text.is_empty() {
std::mem::take(&mut self.reasoning_buffer)
if self.reasoning_header_inserted {
if !text.is_empty() && text != self.reasoning_buffer {
let tail = text
.strip_prefix(&self.reasoning_buffer)
.unwrap_or(&text);
if !tail.is_empty() {
self.handle_reasoning_delta(tail);
}
}
self.app_event_tx
.send(AppEvent::InsertHistory(vec![Line::from("")]))
;
} else {
self.reasoning_buffer.clear();
text
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
let full = if text.is_empty() {
std::mem::take(&mut self.reasoning_buffer)
} else {
self.reasoning_buffer.clear();
text
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
}
}
self.reasoning_buffer.clear();
self.reasoning_inserted_lines = 0;
self.reasoning_header_inserted = false;
self.request_redraw();
}
EventMsg::TaskStarted => {

View File

@@ -5,6 +5,7 @@ use std::io::Write;
use crate::tui;
use crossterm::Command;
use crossterm::cursor::MoveTo;
use crossterm::cursor::MoveToColumn;
use crossterm::queue;
use crossterm::style::Color as CColor;
use crossterm::style::Colors;
@@ -13,6 +14,8 @@ use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
@@ -79,6 +82,17 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
}
}
/// Overwrite the current line in the history area with new content.
pub(crate) fn overwrite_last_history_line(line: Line) {
queue!(
std::io::stdout(),
MoveToColumn(0),
Clear(ClearType::CurrentLine)
)
.ok();
write_spans(&mut std::io::stdout(), line.iter()).ok();
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
let mut count = 0;
for line in lines {

View File

@@ -7,23 +7,24 @@
//! 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::text::Span;
use ratatui::widgets::List;
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 tui_input::Input;
use tui_input::backend::crossterm::EventHandler;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -47,68 +48,62 @@ pub(crate) enum ApprovalRequest {
/// Options displayed in the *select* mode.
struct SelectOption {
label: &'static str,
decision: Option<ReviewDecision>,
/// `true` when this option switches the widget to *input* mode.
enters_input_mode: bool,
label: Line<'static>,
description: &'static str,
key: KeyCode,
decision: ReviewDecision,
}
// keep in same order as in the TS implementation
const SELECT_OPTIONS: &[SelectOption] = &[
SelectOption {
label: "Yes (y)",
decision: Some(ReviewDecision::Approved),
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".into()]),
description: "Do not run the command",
key: KeyCode::Char('n'),
decision: ReviewDecision::Denied,
},
]
});
enters_input_mode: false,
},
SelectOption {
label: "Yes, always approve this exact command for this session (a)",
decision: Some(ReviewDecision::ApprovedForSession),
enters_input_mode: false,
},
SelectOption {
label: "Edit or give feedback (e)",
decision: None,
enters_input_mode: true,
},
SelectOption {
label: "No, and keep going (n)",
decision: Some(ReviewDecision::Denied),
enters_input_mode: false,
},
SelectOption {
label: "No, and stop for now (esc)",
decision: Some(ReviewDecision::Abort),
enters_input_mode: false,
},
];
/// Internal mode the widget is in mirrors the TypeScript component.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Select,
Input,
}
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".into()]),
description: "Do not apply the changes",
key: KeyCode::Char('n'),
decision: ReviewDecision::Denied,
},
]
});
/// A modal prompting the user to approve or deny the pending request.
pub(crate) struct UserApprovalWidget<'a> {
approval_request: ApprovalRequest,
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'a>,
select_options: &'a Vec<SelectOption>,
/// Currently selected index in *select* mode.
selected_option: usize,
/// State for the optional input widget.
input: Input,
/// Current mode.
mode: Mode,
/// Set to `true` once a decision has been sent the parent view can then
/// remove this widget from its queue.
done: bool,
@@ -116,7 +111,6 @@ pub(crate) struct UserApprovalWidget<'a> {
impl UserApprovalWidget<'_> {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let input = Input::default();
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec {
command,
@@ -132,25 +126,20 @@ impl UserApprovalWidget<'_> {
None => cwd.display().to_string(),
};
let mut contents: Vec<Line> = vec![
Line::from(vec![
Span::from(cwd_str).dim(),
Span::from("$"),
Span::from(format!(" {cmd}")),
]),
Line::from(vec!["codex".bold().magenta(), " wants to run:".into()]),
Line::from(vec![cwd_str.dim(), "$".into(), format!(" {cmd}").into()]),
Line::from(""),
];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
}
contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
Paragraph::new(contents).wrap(Wrap { trim: false })
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
} => {
let mut contents: Vec<Line> =
vec![Line::from("Apply patch".bold()), Line::from("")];
let mut contents: Vec<Line> = vec![];
if let Some(r) = reason {
contents.push(Line::from(r.clone().italic()));
@@ -165,20 +154,19 @@ impl UserApprovalWidget<'_> {
contents.push(Line::from(""));
}
contents.push(Line::from("Allow changes?"));
contents.push(Line::from(""));
Paragraph::new(contents)
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,
input,
mode: Mode::Select,
done: false,
}
}
@@ -194,9 +182,8 @@ impl UserApprovalWidget<'_> {
/// 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) {
match self.mode {
Mode::Select => self.handle_select_key(key),
Mode::Input => self.handle_input_key(key),
if key.kind == KeyEventKind::Press {
self.handle_select_key(key);
}
}
@@ -208,58 +195,24 @@ impl UserApprovalWidget<'_> {
fn handle_select_key(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up => {
if self.selected_option == 0 {
self.selected_option = SELECT_OPTIONS.len() - 1;
} else {
self.selected_option -= 1;
}
KeyCode::Left => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
}
KeyCode::Down => {
self.selected_option = (self.selected_option + 1) % SELECT_OPTIONS.len();
}
KeyCode::Char('y') => {
self.send_decision(ReviewDecision::Approved);
}
KeyCode::Char('a') => {
self.send_decision(ReviewDecision::ApprovedForSession);
}
KeyCode::Char('n') => {
self.send_decision(ReviewDecision::Denied);
}
KeyCode::Char('e') => {
self.mode = Mode::Input;
KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
}
KeyCode::Enter => {
let opt = &SELECT_OPTIONS[self.selected_option];
if opt.enters_input_mode {
self.mode = Mode::Input;
} else if let Some(decision) = opt.decision {
self.send_decision(decision);
}
let opt = &self.select_options[self.selected_option];
self.send_decision(opt.decision);
}
KeyCode::Esc => {
self.send_decision(ReviewDecision::Abort);
}
_ => {}
}
}
fn handle_input_key(&mut self, key_event: KeyEvent) {
// Handle special keys first.
match key_event.code {
KeyCode::Enter => {
let feedback = self.input.value().to_string();
self.send_decision_with_feedback(ReviewDecision::Denied, feedback);
}
KeyCode::Esc => {
// Cancel input treat as deny without feedback.
self.send_decision(ReviewDecision::Denied);
}
_ => {
// Feed into input widget for normal editing.
let ct_event = crossterm::event::Event::Key(key_event);
self.input.handle_event(&ct_event);
other => {
if let Some(opt) = self.select_options.iter().find(|opt| opt.key == other) {
self.send_decision(opt.decision);
}
}
}
}
@@ -312,87 +265,68 @@ impl UserApprovalWidget<'_> {
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
}
}
const PLAIN: Style = Style::new();
const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
impl WidgetRef for &UserApprovalWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Take the area, wrap it in a block with a border, and divide up the
// remaining area into two chunks: one for the confirmation prompt and
// one for the response.
let inner = area.inner(Margin::new(0, 2));
// Determine how many rows we can allocate for the static confirmation
// prompt while *always* keeping enough space for the interactive
// response area (select list or input field). When the full prompt
// would exceed the available height we truncate it so the response
// options never get pushed out of view. This keeps the approval modal
// usable even when the overall bottom viewport is small.
// Full height of the prompt (may be larger than the available area).
let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
// Minimum rows that must remain for the interactive section.
let min_response_rows = match self.mode {
Mode::Select => SELECT_OPTIONS.len() as u16,
// In input mode we need exactly two rows: one for the guidance
// prompt and one for the single-line input field.
Mode::Input => 2,
};
// Clamp prompt height so confirmation + response never exceed the
// available space. `saturating_sub` avoids underflow when the area is
// too small even for the minimal layout in this unlikely case we
// fall back to zero-height prompt so at least the options are
// visible.
let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
let chunks = Layout::default()
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)])
.split(inner);
let prompt_chunk = chunks[0];
let response_chunk = chunks[1];
.areas(area);
// Build the inner lines based on the mode. Collect them into a List of
// non-wrapping lines rather than a Paragraph for predictable layout.
let lines = match self.mode {
Mode::Select => SELECT_OPTIONS
.iter()
.enumerate()
.map(|(idx, opt)| {
let (prefix, style) = if idx == self.selected_option {
("", BLUE_FG)
} else {
(" ", PLAIN)
};
Line::styled(format!(" {prefix} {}", opt.label), style)
})
.collect(),
Mode::Input => {
vec![
Line::from("Give the model feedback on this command:"),
Line::from(self.input.value()),
]
}
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().bg(Color::DarkGray)
};
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?",
};
let border = ("◢◤")
.repeat((area.width / 2).into())
.fg(Color::LightYellow);
border.render_ref(area, buf);
Paragraph::new(" Execution Request ".bold().black().on_light_yellow())
.alignment(Alignment::Center)
.render_ref(area, buf);
Line::from(title).render(title_area, buf);
self.confirmation_prompt.clone().render(prompt_chunk, buf);
List::new(lines).render_ref(response_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);
}
border.render_ref(Rect::new(0, area.y + area.height - 1, area.width, 1), buf);
Line::from(self.select_options[self.selected_option].description)
.style(Style::new().italic().fg(Color::DarkGray))
.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,
);
}
}