Compare commits

...

18 Commits

Author SHA1 Message Date
easong-openai
f0d9f24fc0 improvements, more dry 2025-08-01 20:01:49 -07:00
easong-openai
60e9eb683c tests 2025-08-01 17:38:30 -07:00
easong-openai
f7f43f1b5b Merge remote-tracking branch 'origin/main' into scrollable-slash-menu 2025-08-01 10:15:54 -07:00
easong-openai
9c0f8a50b6 comments 2025-08-01 10:15:07 -07:00
aibrahim-oai
f918198bbb Introduce a new function to just send user message [Stack 3/3] (#1686)
- MCP server: add send-user-message tool to send user input to a running
Codex session
- Added an integration tests for the happy and sad paths

Changes:
•	Add tool definition and schema.
•	Expose tool in capabilities.
•	Route and handle tool requests with validation.
•	Tests for success, bad UUID, and missing session.


follow‑ups
• Listen path not implemented yet; the tool is present but marked “don’t
use yet” in code comments.
• Session run flag reset: clear running_session_id_set appropriately
after turn completion/errors.

This is the third PR in a stack.
Stack:
Final: #1686
Intermediate: #1751
First: #1750
2025-08-01 17:04:12 +00:00
pakrym-oai
88ea215c80 Add a custom originator setting (#1781) 2025-08-01 09:55:23 -07:00
aibrahim-oai
b67c485d84 ci fix (#1782) 2025-08-01 09:17:13 -07:00
easong-openai
53c19b4d07 tests, scroll to bottom from top 2025-08-01 05:00:45 -07:00
easong-openai
9f45d477e5 comments 2025-08-01 04:34:40 -07:00
easong-openai
1aad659eba scrollable slash menu, esc to exit 2025-08-01 04:33:01 -07:00
aibrahim-oai
e2c994e32a Add /compact (#1527)
- Add operation to summarize the context so far.
- The operation runs a compact task that summarizes the context.
- The operation clear the previous context to free the context window
- The operation didn't use `run_task` to avoid corrupting the session
- Add /compact in the tui



https://github.com/user-attachments/assets/e06c24e5-dcfb-4806-934a-564d425a919c
2025-07-31 21:34:32 -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
33 changed files with 1610 additions and 431 deletions

21
SUMMARY.md Normal file
View File

@@ -0,0 +1,21 @@
You are a summarization assistant. A conversation follows between a user and a coding-focused AI (Codex). Your task is to generate a clear summary capturing:
• High-level objective or problem being solved
• Key instructions or design decisions given by the user
• Main code actions or behaviors from the AI
• Important variables, functions, modules, or outputs discussed
• Any unresolved questions or next steps
Produce the summary in a structured format like:
**Objective:**
**User instructions:** … (bulleted)
**AI actions / code behavior:** … (bulleted)
**Important entities:** … (e.g. function names, variables, files)
**Open issues / next steps:** … (if any)
**Summary (concise):** (one or two sentences)

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

@@ -207,7 +207,14 @@ impl ModelClient {
}
}
let req_builder = self.provider.apply_http_headers(req_builder);
req_builder = self.provider.apply_http_headers(req_builder);
let originator = self
.config
.internal_originator
.as_deref()
.unwrap_or("codex_cli_rs");
req_builder = req_builder.header("originator", originator);
let res = req_builder.send().await;
if let Ok(resp) = &res {

View File

@@ -442,6 +442,12 @@ impl Session {
let _ = self.tx_event.send(event).await;
}
/// Build the full turn input by concatenating the current conversation
/// history with additional items for this turn.
pub fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
[self.state.lock().unwrap().history.contents(), extra].concat()
}
/// Returns the input if there was no task running to inject into
pub fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
let mut state = self.state.lock().unwrap();
@@ -564,6 +570,25 @@ impl AgentTask {
handle,
}
}
fn compact(
sess: Arc<Session>,
sub_id: String,
input: Vec<InputItem>,
compact_instructions: String,
) -> Self {
let handle = tokio::spawn(run_compact_task(
Arc::clone(&sess),
sub_id.clone(),
input,
compact_instructions,
))
.abort_handle();
Self {
sess,
sub_id,
handle,
}
}
fn abort(self) {
if !self.handle.is_finished() {
@@ -884,6 +909,31 @@ async fn submission_loop(
}
});
}
Op::Compact => {
let sess = match sess.as_ref() {
Some(sess) => sess,
None => {
send_no_session_event(sub.id).await;
continue;
}
};
// Create a summarization request as user input
const SUMMARIZATION_PROMPT: &str = include_str!("../../../SUMMARY.md");
// Attempt to inject input into current task
if let Err(items) = sess.inject_input(vec![InputItem::Text {
text: "Start Summarization".to_string(),
}]) {
let task = AgentTask::compact(
sess.clone(),
sub.id,
items,
SUMMARIZATION_PROMPT.to_string(),
);
sess.set_task(task);
}
}
Op::Shutdown => {
info!("Shutting down Codex instance");
@@ -945,7 +995,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
return;
}
let initial_input_for_turn = ResponseInputItem::from(input);
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
sess.record_conversation_items(&[initial_input_for_turn.clone().into()])
.await;
@@ -966,8 +1016,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
// conversation history on each turn. The rollout file, however, should
// only record the new items that originated in this turn so that it
// represents an append-only log without duplicates.
let turn_input: Vec<ResponseItem> =
[sess.state.lock().unwrap().history.contents(), pending_input].concat();
let turn_input: Vec<ResponseItem> = sess.turn_input_with_history(pending_input);
let turn_input_messages: Vec<String> = turn_input
.iter()
@@ -1293,6 +1342,88 @@ async fn try_run_turn(
}
}
async fn run_compact_task(
sess: Arc<Session>,
sub_id: String,
input: Vec<InputItem>,
compact_instructions: String,
) {
let start_event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskStarted,
};
if sess.tx_event.send(start_event).await.is_err() {
return;
}
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let turn_input: Vec<ResponseItem> =
sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]);
let prompt = Prompt {
input: turn_input,
user_instructions: None,
store: !sess.disable_response_storage,
extra_tools: HashMap::new(),
base_instructions_override: Some(compact_instructions.clone()),
};
let max_retries = sess.client.get_provider().stream_max_retries();
let mut retries = 0;
loop {
let attempt_result = drain_to_completed(&sess, &prompt).await;
match attempt_result {
Ok(()) => break,
Err(CodexErr::Interrupted) => return,
Err(e) => {
if retries < max_retries {
retries += 1;
let delay = backoff(retries);
sess.notify_background_event(
&sub_id,
format!(
"stream error: {e}; retrying {retries}/{max_retries} in {delay:?}"
),
)
.await;
tokio::time::sleep(delay).await;
continue;
} else {
let event = Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: e.to_string(),
}),
};
sess.send_event(event).await;
return;
}
}
}
}
sess.remove_task(&sub_id);
let event = Event {
id: sub_id.clone(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Compact task completed".to_string(),
}),
};
sess.send_event(event).await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
sess.send_event(event).await;
let mut state = sess.state.lock().unwrap();
state.history.keep_last_messages(1);
}
async fn handle_response_item(
sess: &Session,
sub_id: &str,
@@ -1858,3 +1989,20 @@ fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<St
}
})
}
async fn drain_to_completed(sess: &Session, prompt: &Prompt) -> CodexResult<()> {
let mut stream = sess.client.clone().stream(prompt).await?;
loop {
let maybe_event = stream.next().await;
let Some(event) = maybe_event else {
return Err(CodexErr::Stream(
"stream closed before response.completed".into(),
));
};
match event {
Ok(ResponseEvent::Completed { .. }) => return Ok(()),
Ok(_) => continue,
Err(e) => return Err(e),
}
}
}

View File

@@ -146,6 +146,9 @@ pub struct Config {
/// Include an experimental plan tool that the model can use to update its current plan and status of each step.
pub include_plan_tool: bool,
/// The value for the `originator` header included with Responses API requests.
pub internal_originator: Option<String>,
}
impl Config {
@@ -336,6 +339,9 @@ pub struct ConfigToml {
/// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS.
pub experimental_instructions_file: Option<PathBuf>,
/// The value for the `originator` header included with Responses API requests.
pub internal_originator: Option<String>,
}
impl ConfigToml {
@@ -529,6 +535,7 @@ impl Config {
experimental_resume,
include_plan_tool: include_plan_tool.unwrap_or(false),
internal_originator: cfg.internal_originator,
};
Ok(config)
}
@@ -887,6 +894,7 @@ disable_response_storage = true
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
internal_originator: None,
},
o3_profile_config
);
@@ -936,6 +944,7 @@ disable_response_storage = true
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
internal_originator: None,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -1000,6 +1009,7 @@ disable_response_storage = true
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
internal_originator: None,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);

View File

@@ -30,6 +30,34 @@ impl ConversationHistory {
}
}
}
pub(crate) fn keep_last_messages(&mut self, n: usize) {
if n == 0 {
self.items.clear();
return;
}
// Collect the last N message items (assistant/user), newest to oldest.
let mut kept: Vec<ResponseItem> = Vec::with_capacity(n);
for item in self.items.iter().rev() {
if let ResponseItem::Message { role, content, .. } = item {
kept.push(ResponseItem::Message {
// we need to remove the id or the model will complain that messages are sent without
// their reasonings
id: None,
role: role.clone(),
content: content.clone(),
});
if kept.len() == n {
break;
}
}
}
// Preserve chronological order (oldest to newest) within the kept slice.
kept.reverse();
self.items = kept;
}
}
/// Anything that is not a system message or "reasoning" message is considered

View File

@@ -12,10 +12,6 @@ use std::env::VarError;
use std::time::Duration;
use crate::error::EnvVarError;
/// Value for the `OpenAI-Originator` header that is sent with requests to
/// OpenAI.
const OPENAI_ORIGINATOR_HEADER: &str = "codex_cli_rs";
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_STREAM_MAX_RETRIES: u64 = 10;
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
@@ -229,15 +225,9 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(
[
(
"originator".to_string(),
OPENAI_ORIGINATOR_HEADER.to_string(),
),
("version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
]
.into_iter()
.collect(),
[("version".to_string(), env!("CARGO_PKG_VERSION").to_string())]
.into_iter()
.collect(),
),
env_http_headers: Some(
[

View File

@@ -121,6 +121,10 @@ pub enum Op {
/// Request a single history entry identified by `log_id` + `offset`.
GetHistoryEntryRequest { offset: usize, log_id: u64 },
/// Request the agent to summarize the current conversation context.
/// The agent will use its existing context (either conversation history or previous response id)
/// to generate a summary which will be returned as an AgentMessage event.
Compact,
/// Request to shut down codex instance.
Shutdown,
}

View File

@@ -95,8 +95,8 @@ async fn includes_session_id_and_model_headers_in_request() {
// get request from the server
let request = &server.received_requests().await.unwrap()[0];
let request_session_id = request.headers.get("session_id").unwrap();
let request_originator = request.headers.get("originator").unwrap();
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
assert!(current_session_id.is_some());
assert_eq!(
@@ -170,6 +170,59 @@ async fn includes_base_instructions_override_in_request() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn originator_config_override_is_used() {
#![allow(clippy::unwrap_used)]
// Mock server
let server = MockServer::start().await;
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse_completed("resp1"), "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(first)
.expect(1)
.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.internal_originator = Some("my_override".to_string());
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();
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let request = &server.received_requests().await.unwrap()[0];
let request_originator = request.headers.get("originator").unwrap();
assert_eq!(request_originator.to_str().unwrap(), "my_override");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn chatgpt_auth_sends_correct_request() {
#![allow(clippy::unwrap_used)]
@@ -235,8 +288,8 @@ async fn chatgpt_auth_sends_correct_request() {
// get request from the server
let request = &server.received_requests().await.unwrap()[0];
let request_session_id = request.headers.get("session_id").unwrap();
let request_originator = request.headers.get("originator").unwrap();
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
let request_chatgpt_account_id = request.headers.get("chatgpt-account-id").unwrap();
let request_body = request.body_json::<serde_json::Value>().unwrap();

View File

@@ -0,0 +1,254 @@
#![expect(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::wait_for_event;
use serde_json::Value;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use pretty_assertions::assert_eq;
// --- Test helpers -----------------------------------------------------------
/// Build an SSE stream body from a list of JSON events.
fn sse(events: Vec<Value>) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for ev in events {
let kind = ev.get("type").and_then(|v| v.as_str()).unwrap();
writeln!(&mut out, "event: {kind}").unwrap();
if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) {
write!(&mut out, "data: {ev}\n\n").unwrap();
} else {
out.push('\n');
}
}
out
}
/// Convenience: SSE event for a completed response with a specific id.
fn ev_completed(id: &str) -> Value {
serde_json::json!({
"type": "response.completed",
"response": {
"id": id,
"usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0}
}
})
}
/// Convenience: SSE event for a single assistant message output item.
fn ev_assistant_message(id: &str, text: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": id,
"content": [{"type": "output_text", "text": text}]
}
})
}
fn sse_response(body: String) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(body, "text/event-stream")
}
async fn mount_sse_once<M>(server: &MockServer, matcher: M, body: String)
where
M: wiremock::Match + Send + Sync + 'static,
{
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(matcher)
.respond_with(sse_response(body))
.expect(1)
.mount(server)
.await;
}
const FIRST_REPLY: &str = "FIRST_REPLY";
const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT";
const SUMMARIZE_TRIGGER: &str = "Start Summarization";
const THIRD_USER_MSG: &str = "next turn";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn summarize_context_three_requests_and_instructions() {
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;
}
// Set up a mock server that we can inspect after the run.
let server = MockServer::start().await;
// SSE 1: assistant replies normally so it is recorded in history.
let sse1 = sse(vec![
ev_assistant_message("m1", FIRST_REPLY),
ev_completed("r1"),
]);
// SSE 2: summarizer returns a summary message.
let sse2 = sse(vec![
ev_assistant_message("m2", SUMMARY_TEXT),
ev_completed("r2"),
]);
// SSE 3: minimal completed; we only need to capture the request body.
let sse3 = sse(vec![ev_completed("r3")]);
// Mount three expectations, one per request, matched by body content.
let first_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains("\"text\":\"hello world\"")
&& !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
};
mount_sse_once(&server, first_matcher, sse1).await;
let second_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
};
mount_sse_once(&server, second_matcher, sse2).await;
let third_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\""))
};
mount_sse_once(&server, third_matcher, sse3).await;
// Build config pointing to the mock server and spawn Codex.
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(CodexAuth::from_api_key("dummy".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
// 1) Normal user input should hit server once.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello world".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// 2) Summarize second hit with summarization instructions.
codex.submit(Op::Compact).await.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// 3) Next user input third hit; history should include only the summary.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: THIRD_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Inspect the three captured requests.
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 3, "expected exactly three requests");
let req1 = &requests[0];
let req2 = &requests[1];
let req3 = &requests[2];
let body1 = req1.body_json::<serde_json::Value>().unwrap();
let body2 = req2.body_json::<serde_json::Value>().unwrap();
let body3 = req3.body_json::<serde_json::Value>().unwrap();
// System instructions should change for the summarization turn.
let instr1 = body1.get("instructions").and_then(|v| v.as_str()).unwrap();
let instr2 = body2.get("instructions").and_then(|v| v.as_str()).unwrap();
assert_ne!(
instr1, instr2,
"summarization should override base instructions"
);
assert!(
instr2.contains("You are a summarization assistant"),
"summarization instructions not applied"
);
// The summarization request should include the injected user input marker.
let input2 = body2.get("input").and_then(|v| v.as_array()).unwrap();
// The last item is the user message created from the injected input.
let last2 = input2.last().unwrap();
assert_eq!(last2.get("type").unwrap().as_str().unwrap(), "message");
assert_eq!(last2.get("role").unwrap().as_str().unwrap(), "user");
let text2 = last2["content"][0]["text"].as_str().unwrap();
assert!(text2.contains(SUMMARIZE_TRIGGER));
// Third request must contain only the summary from step 2 as prior history plus new user msg.
let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap();
println!("third request body: {body3}");
assert!(
input3.len() >= 2,
"expected summary + new user message in third request"
);
// Collect all (role, text) message tuples.
let mut messages: Vec<(String, String)> = Vec::new();
for item in input3 {
if item["type"].as_str() == Some("message") {
let role = item["role"].as_str().unwrap_or_default().to_string();
let text = item["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
messages.push((role, text));
}
}
// Exactly one assistant message should remain after compaction and the new user message is present.
let assistant_count = messages.iter().filter(|(r, _)| r == "assistant").count();
assert_eq!(
assistant_count, 1,
"exactly one assistant message should remain after compaction"
);
assert!(
messages
.iter()
.any(|(r, t)| r == "user" && t == THIRD_USER_MSG),
"third request should include the new user message"
);
assert!(
!messages.iter().any(|(_, t)| t.contains("hello world")),
"third request should not include the original user input"
);
assert!(
!messages.iter().any(|(_, t)| t.contains(SUMMARIZE_TRIGGER)),
"third request should not include the summarize trigger"
);
}

View File

@@ -19,10 +19,11 @@ mod codex_tool_config;
mod codex_tool_runner;
mod exec_approval;
mod json_to_toml;
mod mcp_protocol;
mod message_processor;
pub mod mcp_protocol;
pub(crate) mod message_processor;
mod outgoing_message;
mod patch_approval;
pub(crate) mod tool_handlers;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;

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;
match result {
Some(res) => match serde_json::to_value(&res) {
Ok(v) => CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: v.to_string(),
annotations: None,
})],
is_error,
structured_content: Some(v),
},
Err(e) => CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Failed to serialize tool result: {e}"),
annotations: None,
})],
is_error: Some(true),
structured_content: None,
},
},
None => CallToolResult {
content: vec![],
is_error,
structured_content: None,
},
}
}
}
#[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

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
@@ -6,11 +7,16 @@ 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::mcp_protocol::ToolCallResponse;
use crate::mcp_protocol::ToolCallResponseResult;
use crate::outgoing_message::OutgoingMessageSender;
use crate::tool_handlers::send_message::handle_send_message;
use codex_core::Codex;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::Submission;
use mcp_types::CallToolRequest;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::ClientRequest;
@@ -37,6 +43,7 @@ pub(crate) struct MessageProcessor {
codex_linux_sandbox_exe: Option<PathBuf>,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
running_session_ids: Arc<Mutex<HashSet<Uuid>>>,
}
impl MessageProcessor {
@@ -52,9 +59,18 @@ impl MessageProcessor {
codex_linux_sandbox_exe,
session_map: Arc::new(Mutex::new(HashMap::new())),
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
running_session_ids: Arc::new(Mutex::new(HashSet::new())),
}
}
pub(crate) fn session_map(&self) -> Arc<Mutex<HashMap<Uuid, Arc<Codex>>>> {
self.session_map.clone()
}
pub(crate) fn running_session_ids(&self) -> Arc<Mutex<HashSet<Uuid>>> {
self.running_session_ids.clone()
}
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
// Hold on to the ID so we can respond.
let request_id = request.id.clone();
@@ -300,6 +316,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 +347,26 @@ impl MessageProcessor {
}
}
}
async fn handle_new_tool_calls(&self, request_id: RequestId, params: ToolCallRequestParams) {
match params {
ToolCallRequestParams::ConversationSendMessage(args) => {
handle_send_message(self, request_id, args).await;
}
_ => {
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::<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 {
@@ -631,4 +675,20 @@ impl MessageProcessor {
) {
tracing::info!("notifications/message -> params: {:?}", params);
}
pub(crate) async fn send_response_with_optional_error(
&self,
id: RequestId,
message: Option<ToolCallResponseResult>,
error: Option<bool>,
) {
let response = ToolCallResponse {
request_id: id.clone(),
is_error: error,
result: message,
};
let result: CallToolResult = response.into();
self.send_response::<mcp_types::CallToolRequest>(id.clone(), result)
.await;
}
}

View File

@@ -0,0 +1 @@
pub(crate) mod send_message;

View File

@@ -0,0 +1,124 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::protocol::Op;
use codex_core::protocol::Submission;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::mcp_protocol::ConversationSendMessageArgs;
use crate::mcp_protocol::ConversationSendMessageResult;
use crate::mcp_protocol::ToolCallResponseResult;
use crate::message_processor::MessageProcessor;
pub(crate) async fn handle_send_message(
message_processor: &MessageProcessor,
id: RequestId,
arguments: ConversationSendMessageArgs,
) {
let ConversationSendMessageArgs {
conversation_id,
content: items,
parent_message_id: _,
conversation_overrides: _,
} = arguments;
if items.is_empty() {
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult::Error {
message: "No content items provided".to_string(),
},
)),
Some(true),
)
.await;
return;
}
let session_id = conversation_id.0;
let Some(codex) = get_session(session_id, message_processor.session_map()).await else {
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult::Error {
message: "Session does not exist".to_string(),
},
)),
Some(true),
)
.await;
return;
};
let running = {
let running_sessions = message_processor.running_session_ids();
let mut running_sessions = running_sessions.lock().await;
!running_sessions.insert(session_id)
};
if running {
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult::Error {
message: "Session is already running".to_string(),
},
)),
Some(true),
)
.await;
return;
}
let request_id_string = match &id {
RequestId::String(s) => s.clone(),
RequestId::Integer(i) => i.to_string(),
};
let submit_res = codex
.submit_with_id(Submission {
id: request_id_string,
op: Op::UserInput { items },
})
.await;
if let Err(e) = submit_res {
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult::Error {
message: format!("Failed to submit user input: {e}"),
},
)),
Some(true),
)
.await;
return;
}
message_processor
.send_response_with_optional_error(
id,
Some(ToolCallResponseResult::ConversationSendMessage(
ConversationSendMessageResult::Ok,
)),
Some(false),
)
.await;
}
pub(crate) async fn get_session(
session_id: Uuid,
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
) -> Option<Arc<Codex>> {
let guard = session_map.lock().await;
guard.get(&session_id).cloned()
}

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

@@ -0,0 +1,163 @@
#![allow(clippy::expect_used)]
use std::path::Path;
use std::thread::sleep;
use std::time::Duration;
use codex_mcp_server::CodexToolCallParam;
use mcp_test_support::McpProcess;
use mcp_test_support::create_final_assistant_message_sse_response;
use mcp_test_support::create_mock_chat_completions_server;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_message_success() {
// Spin up a mock completions server that immediately ends the Codex turn.
// Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses.
let responses = vec![
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
// Create a temporary Codex home with config pointing at the mock server.
let codex_home = TempDir::new().expect("create temp dir");
create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml");
// Start MCP server process and initialize.
let mut mcp_process = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize())
.await
.expect("init timed out")
.expect("init failed");
// Kick off a Codex session so we have a valid session_id.
let codex_request_id = mcp_process
.send_codex_tool_call(CodexToolCallParam {
prompt: "Start a session".to_string(),
..Default::default()
})
.await
.expect("send codex tool call");
// Wait for the session_configured event to get the session_id.
let session_id = mcp_process
.read_stream_until_configured_response_message()
.await
.expect("read session_configured");
// The original codex call will finish quickly given our mock; consume its response.
timeout(
DEFAULT_READ_TIMEOUT,
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
)
.await
.expect("codex response timeout")
.expect("codex response error");
// Now exercise the send-user-message tool.
let send_msg_request_id = mcp_process
.send_user_message_tool_call("Hello again", &session_id)
.await
.expect("send send-message tool call");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp_process.read_stream_until_response_message(RequestId::Integer(send_msg_request_id)),
)
.await
.expect("send-user-message response timeout")
.expect("send-user-message response error");
assert_eq!(
JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(send_msg_request_id),
result: json!({
"content": [
{
"text": "{\"status\":\"ok\"}",
"type": "text",
}
],
"isError": false,
"structuredContent": {
"status": "ok"
}
}),
},
response
);
// wait for the server to hear the user message
sleep(Duration::from_secs(1));
// Ensure the server and tempdir live until end of test
drop(server);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_message_session_not_found() {
// Start MCP without creating a Codex session
let codex_home = TempDir::new().expect("tempdir");
let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("timeout")
.expect("init");
let unknown = uuid::Uuid::new_v4().to_string();
let req_id = mcp
.send_user_message_tool_call("ping", &unknown)
.await
.expect("send tool");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await
.expect("timeout")
.expect("resp");
let result = resp.result.clone();
let content = result["content"][0]["text"].as_str().unwrap_or("");
assert!(content.contains("Session does not exist"));
assert_eq!(result["isError"], json!(true));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}

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,16 @@ use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::Op;
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;
@@ -55,9 +58,13 @@ pub(crate) struct App<'a> {
/// True when a redraw has been scheduled but not yet executed.
pending_redraw: Arc<AtomicBool>,
pending_history_lines: Vec<Line<'static>>,
/// 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 +74,7 @@ struct ChatWidgetArgs {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
}
impl App<'_> {
@@ -80,6 +88,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 +106,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 +142,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
}),
)
} else {
@@ -142,6 +151,7 @@ impl App<'_> {
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
);
(
AppState::Chat {
@@ -154,12 +164,14 @@ impl App<'_> {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
Self {
app_event_tx,
pending_history_lines: Vec::new(),
app_event_rx,
app_state,
config,
file_search,
pending_redraw,
chat_args,
enhanced_keys_supported,
}
}
@@ -199,7 +211,7 @@ 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_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
@@ -213,6 +225,7 @@ impl App<'_> {
KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
@@ -227,6 +240,7 @@ impl App<'_> {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
@@ -245,9 +259,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,10 +294,17 @@ 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);
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => {
break;
}
@@ -304,14 +331,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 +417,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 +431,13 @@ impl App<'_> {
terminal.clear()?;
terminal.set_viewport_area(area);
}
if !self.pending_history_lines.is_empty() {
crate::insert_history::insert_history_lines(
terminal,
self.pending_history_lines.clone(),
);
self.pending_history_lines.clear();
}
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
@@ -407,6 +469,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

@@ -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,7 +41,9 @@ 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>,
dismissed_slash_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
}
@@ -54,18 +56,115 @@ enum ActivePopup {
}
impl ChatComposer<'_> {
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self {
#[inline]
fn first_line(&self) -> &str {
self.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("")
}
#[inline]
fn slash_token_from_first_line(first_line: &str) -> Option<&str> {
if !first_line.starts_with('/') {
return None;
}
let stripped = first_line.strip_prefix('/').unwrap_or("");
let token = stripped.trim_start();
Some(token.split_whitespace().next().unwrap_or(""))
}
#[inline]
fn emit_unrecognized_slash_command(&mut self, cmd_token: &str) {
let attempted = if cmd_token.is_empty() {
"/".to_string()
} else {
format!("/{cmd_token}")
};
let msg = format!("{attempted} not a recognized command");
self.app_event_tx
.send(AppEvent::InsertHistory(vec![Line::from(msg)]));
self.dismissed_slash_token = Some(cmd_token.to_string());
self.active_popup = ActivePopup::None;
}
#[inline]
fn sync_popups(&mut self) {
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
}
#[inline]
fn compute_textarea_and_popup_rect(&self, area: Rect, desired_popup: u16) -> (Rect, Rect) {
let text_lines = self.textarea.lines().len().max(1) as u16;
let popup_height = desired_popup.min(area.height.saturating_sub(text_lines));
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
(textarea_rect, popup_rect)
}
/// Convert a cursor column (in chars) to a byte offset within `line`.
#[inline]
fn cursor_byte_offset_for_col(line: &str, col_chars: usize) -> usize {
line.chars().take(col_chars).map(|c| c.len_utf8()).sum()
}
/// Determine token boundaries around a cursor position expressed as a byte offset.
/// A token is delimited by any Unicode whitespace on either side.
#[inline]
fn token_bounds(line: &str, cursor_byte_offset: usize) -> Option<(usize, usize)> {
let (before_cursor, after_cursor) = line.split_at(cursor_byte_offset.min(line.len()));
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx;
(start_idx < end_idx).then_some((start_idx, end_idx))
}
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,
dismissed_slash_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
};
@@ -147,8 +246,7 @@ impl ChatComposer<'_> {
} else {
self.textarea.insert_str(&pasted);
}
self.sync_command_popup();
self.sync_file_search_popup();
self.sync_popups();
true
}
@@ -182,19 +280,14 @@ impl ChatComposer<'_> {
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
self.sync_popups();
result
}
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let first_line_owned = self.first_line().to_string();
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -208,16 +301,16 @@ impl ChatComposer<'_> {
popup.move_down();
(InputResult::None, true)
}
Input { key: Key::Esc, .. } => {
if let Some(cmd_token) = Self::slash_token_from_first_line(&first_line_owned) {
self.dismissed_slash_token = Some(cmd_token.to_string());
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
Input { key: Key::Tab, .. } => {
if let Some(cmd) = popup.selected_command() {
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let starts_with_cmd = first_line
let starts_with_cmd = first_line_owned
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
@@ -236,19 +329,17 @@ impl ChatComposer<'_> {
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
// Clear textarea so no residual text remains.
self.textarea.select_all();
self.textarea.cut();
// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
if let Some(cmd_token) = Self::slash_token_from_first_line(&first_line_owned) {
self.emit_unrecognized_slash_command(cmd_token);
return (InputResult::None, true);
}
(InputResult::None, false)
}
input => self.handle_input_basic(input),
}
@@ -309,37 +400,9 @@ impl ChatComposer<'_> {
/// one additional character, that token (without `@`) is returned.
fn current_at_token(textarea: &tui_textarea::TextArea) -> Option<String> {
let (row, col) = textarea.cursor();
// Guard against out-of-bounds rows.
let line = textarea.lines().get(row)?.as_str();
// Calculate byte offset for cursor position
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
// Split the line at the cursor position so we can search for word
// boundaries on both sides.
let before_cursor = &line[..cursor_byte_offset];
let after_cursor = &line[cursor_byte_offset..];
// Find start index (first character **after** the previous multi-byte whitespace).
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
// Find end index (first multi-byte whitespace **after** the cursor position).
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx;
if start_idx >= end_idx {
return None;
}
let cursor_byte_offset = Self::cursor_byte_offset_for_col(line, col);
let (start_idx, end_idx) = Self::token_bounds(line, cursor_byte_offset)?;
let token = &line[start_idx..end_idx];
if token.starts_with('@') && token.len() > 1 {
@@ -356,50 +419,24 @@ impl ChatComposer<'_> {
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
let (row, col) = self.textarea.cursor();
// Materialize the textarea lines so we can mutate them easily.
let mut lines: Vec<String> = self.textarea.lines().to_vec();
if let Some(line) = lines.get_mut(row) {
// Calculate byte offset for cursor position
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
let cursor_byte_offset = Self::cursor_byte_offset_for_col(line, col);
if let Some((start_idx, end_idx)) = Self::token_bounds(line, cursor_byte_offset) {
let mut new_line =
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
new_line.push_str(&line[..start_idx]);
new_line.push_str(path);
new_line.push(' ');
new_line.push_str(&line[end_idx..]);
*line = new_line;
let before_cursor = &line[..cursor_byte_offset];
let after_cursor = &line[cursor_byte_offset..];
// Determine token boundaries.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = cursor_byte_offset + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_line =
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
new_line.push_str(&line[..start_idx]);
new_line.push_str(path);
new_line.push(' ');
new_line.push_str(&line[end_idx..]);
*line = new_line;
// Re-populate the textarea.
let new_text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(new_text);
// Note: tui-textarea currently exposes only relative cursor
// movements. Leaving the cursor position unchanged is acceptable
// as subsequent typing will move the cursor naturally.
let new_text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(new_text);
}
}
}
@@ -571,29 +608,28 @@ impl ChatComposer<'_> {
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) {
// Inspect only the first line to decide whether to show the popup. In
// the common case (no leading slash) we avoid copying the entire
// textarea contents.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let first_line = self.first_line().to_string();
let input_starts_with_slash = first_line.starts_with('/');
if !input_starts_with_slash {
self.dismissed_slash_token = None;
}
let current_cmd_token: Option<&str> = Self::slash_token_from_first_line(&first_line);
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
popup.on_composer_text_change(first_line.to_string());
popup.on_composer_text_change(first_line.clone());
} else {
self.active_popup = ActivePopup::None;
self.dismissed_slash_token = None;
}
}
_ => {
if input_starts_with_slash {
if self.dismissed_slash_token.as_deref() == current_cmd_token {
return;
}
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line.to_string());
command_popup.on_composer_text_change(first_line);
self.active_popup = ActivePopup::Command(command_popup);
}
}
@@ -656,44 +692,16 @@ impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match &self.active_popup {
ActivePopup::Command(popup) => {
let popup_height = popup.calculate_required_height();
// Split the provided rect so that the popup is rendered at the
// **bottom** and the textarea occupies the remaining space above.
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
let desired_popup = popup.calculate_required_height();
let (textarea_rect, popup_rect) =
self.compute_textarea_and_popup_rect(area, desired_popup);
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::File(popup) => {
let popup_height = popup.calculate_required_height();
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
let desired_popup = popup.calculate_required_height();
let (textarea_rect, popup_rect) =
self.compute_textarea_and_popup_rect(area, desired_popup);
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
@@ -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"),
@@ -732,6 +745,7 @@ impl WidgetRef for &ChatComposer<'_> {
#[cfg(test)]
mod tests {
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
@@ -890,7 +904,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 +927,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 +956,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 +992,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);
@@ -1007,6 +1021,97 @@ mod tests {
}
}
#[test]
fn esc_dismiss_slash_popup_reopen_on_token_change() {
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
composer.handle_paste("c".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
#[test]
fn enter_with_unrecognized_slash_command_closes_popup_and_emits_error() {
use crate::app_event::AppEvent;
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
composer.handle_paste("/ notacommand args".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
let mut saw_error = false;
loop {
match rx.try_recv() {
Ok(AppEvent::InsertHistory(lines)) => {
let text = lines
.into_iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
if text.contains("/notacommand not a recognized command") {
saw_error = true;
break;
}
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_error,
"expected InsertHistory error for unrecognized command"
);
}
#[test]
fn esc_dismiss_then_delete_and_retype_slash_reopens_popup() {
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
@@ -1015,20 +1120,17 @@ 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 = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
// Expected states after each paste
let mut expected_text = String::new();
let mut expected_pending_count = 0;
// Apply all pastes and build expected state
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
@@ -1044,7 +1146,6 @@ mod tests {
})
.collect();
// Verify all intermediate states were correct
assert_eq!(
states,
vec![
@@ -1070,7 +1171,6 @@ mod tests {
]
);
// Submit and verify final expansion
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
@@ -1088,16 +1188,14 @@ 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 = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
// Apply all pastes
let mut current_pos = 0;
let states: Vec<_> = test_cases
.iter()
@@ -1117,10 +1215,8 @@ mod tests {
})
.collect();
// Delete placeholders one by one and collect states
let mut deletion_states = vec![];
// First deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
@@ -1130,7 +1226,6 @@ mod tests {
composer.pending_pastes.len(),
));
// Second deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
@@ -1143,7 +1238,6 @@ mod tests {
composer.pending_pastes.len(),
));
// Verify all states
assert_eq!(
deletion_states,
vec![
@@ -1161,9 +1255,8 @@ 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 = [
5, // Delete from middle - should clear tracking
0, // Delete from end - should clear tracking

View File

@@ -25,6 +25,8 @@ pub(crate) struct CommandPopup {
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
selected_idx: Option<usize>,
// Index of the first visible row in the filtered list.
scroll_top: usize,
}
impl CommandPopup {
@@ -33,6 +35,7 @@ impl CommandPopup {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
selected_idx: None,
scroll_top: 0,
}
}
@@ -66,26 +69,28 @@ impl CommandPopup {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
};
self.adjust_scroll(matches_len);
}
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
/// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
/// Return the list of commands that match the current filter. Matching is
/// performed using a *prefix* comparison on the command name.
/// performed using a case-insensitive prefix comparison on the command name.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
let filter = self.command_filter.as_str();
self.all_commands
.iter()
.filter_map(|(_name, cmd)| {
if self.command_filter.is_empty()
|| cmd
.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
{
if filter.is_empty() {
return Some(cmd);
}
let name = cmd.command();
if name.len() >= filter.len() && name[..filter.len()].eq_ignore_ascii_case(filter) {
Some(cmd)
} else {
None
@@ -96,26 +101,30 @@ impl CommandPopup {
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
if len == usize::MAX {
return;
}
let matches = self.filtered_commands();
let len = matches.len();
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.filtered_commands().is_empty() {
self.selected_idx = Some(0);
match self.selected_idx {
Some(idx) if idx > 0 => self.selected_idx = Some(idx - 1),
Some(_) => self.selected_idx = Some(len - 1), // wrap to last
None => self.selected_idx = Some(0),
}
self.adjust_scroll(len);
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_commands().len();
let matches = self.filtered_commands();
let matches_len = matches.len();
if matches_len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
@@ -123,11 +132,15 @@ impl CommandPopup {
Some(idx) if idx + 1 < matches_len => {
self.selected_idx = Some(idx + 1);
}
Some(_idx_last) => {
self.selected_idx = Some(0);
}
None => {
self.selected_idx = Some(0);
}
_ => {}
}
self.adjust_scroll(matches_len);
}
/// Return currently selected command, if any.
@@ -135,6 +148,26 @@ impl CommandPopup {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
}
fn adjust_scroll(&mut self, matches_len: usize) {
if matches_len == 0 {
self.scroll_top = 0;
return;
}
let visible_rows = MAX_POPUP_ROWS.min(matches_len);
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
impl WidgetRef for CommandPopup {
@@ -142,10 +175,8 @@ impl WidgetRef for CommandPopup {
let matches = self.filtered_commands();
let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> =
matches.into_iter().take(MAX_POPUP_ROWS).collect();
if visible_matches.is_empty() {
if matches.is_empty() {
rows.push(Row::new(vec![
Cell::from(""),
Cell::from("No matching commands").add_modifier(Modifier::ITALIC),
@@ -153,10 +184,32 @@ impl WidgetRef for CommandPopup {
} else {
let default_style = Style::default();
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
let max_rows_from_area = area.height as usize;
let visible_rows = MAX_POPUP_ROWS
.min(matches.len())
.min(max_rows_from_area.max(1));
let mut start_idx = self.scroll_top.min(matches.len().saturating_sub(1));
if let Some(sel) = self.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (global_idx, cmd) in matches
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
rows.push(Row::new(vec![
Cell::from(Line::from(vec![
if Some(idx) == self.selected_idx {
if Some(global_idx) == self.selected_idx {
Span::styled(
"",
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
@@ -188,3 +241,67 @@ impl WidgetRef for CommandPopup {
table.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn move_down_wraps_to_top() {
let mut popup = CommandPopup::new();
// Show all commands by simulating composer input starting with '/'.
popup.on_composer_text_change("/".to_string());
let len = popup.filtered_commands().len();
assert!(len > 0);
// Move to last item.
for _ in 0..len.saturating_sub(1) {
popup.move_down();
}
// Next move_down should wrap to index 0.
popup.move_down();
assert_eq!(popup.selected_idx, Some(0));
}
#[test]
fn move_up_wraps_to_bottom() {
let mut popup = CommandPopup::new();
popup.on_composer_text_change("/".to_string());
let len = popup.filtered_commands().len();
assert!(len > 0);
// Initial selection is 0; moving up should wrap to last.
popup.move_up();
assert_eq!(popup.selected_idx, Some(len - 1));
}
#[test]
fn respects_tiny_terminal_height_when_rendering() {
let mut popup = CommandPopup::new();
popup.on_composer_text_change("/".to_string());
assert!(popup.filtered_commands().len() >= 3);
let area = Rect::new(0, 0, 50, 2);
let mut buf = Buffer::empty(area);
popup.render(area, &mut buf);
let mut non_empty_rows = 0u16;
for y in 0..area.height {
let mut row_has_content = false;
for x in 0..area.width {
let c = buf[(x, y)].symbol();
if !c.trim().is_empty() {
row_has_content = true;
break;
}
}
if row_has_content {
non_empty_rows += 1;
}
}
assert_eq!(non_empty_rows, 2);
}
}

View File

@@ -108,7 +108,6 @@ impl FileSearchPopup {
.map(|file_match| file_match.path.as_str())
}
/// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single

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,6 +25,7 @@ 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::widgets::Widget;
@@ -94,6 +95,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 +141,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(
@@ -157,7 +160,9 @@ impl ChatWidget<'_> {
}
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) => {
@@ -497,6 +502,12 @@ impl ChatWidget<'_> {
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.token_usage
}
pub(crate) fn clear_token_usage(&mut self) {
self.token_usage = TokenUsage::default();
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
}
}
impl WidgetRef for &ChatWidget<'_> {

View File

@@ -13,6 +13,7 @@ pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
New,
Compact,
Diff,
Quit,
#[cfg(debug_assertions)]
@@ -24,6 +25,7 @@ impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
SlashCommand::New => "Start a new chat.",
SlashCommand::Compact => "Compact the chat history.",
SlashCommand::Quit => "Exit the application.",
SlashCommand::Diff => {
"Show git diff of the working directory (including untracked files)"

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,
);
}
}