Compare commits

...

24 Commits

Author SHA1 Message Date
Ahmed Ibrahim
4e1a26a749 fix 2025-08-03 00:38:07 -07:00
Ahmed Ibrahim
8d01f6f200 Revert to e30e1c9 2025-08-03 00:31:41 -07:00
Ahmed Ibrahim
d8e63e10ee Revert branch state to 354801f (without history rewrite) 2025-08-03 00:25:08 -07:00
Ahmed Ibrahim
e30e1c9c68 Merge branch 'dfyp9w-codex/implement-delta-based-streaming-rendering' of github.com:openai/codex into codex/add-raw-chain-of-thought-in-codex-cli 2025-08-03 00:22:49 -07:00
Ahmed Ibrahim
354801f0dd completions 2025-08-03 00:22:16 -07:00
aibrahim-oai
863c52bb87 Merge branch 'codex/add-raw-chain-of-thought-in-codex-cli' into dfyp9w-codex/implement-delta-based-streaming-rendering 2025-08-03 00:20:04 -07:00
aibrahim-oai
faac84a8b4 Merge branch 'main' into codex/add-raw-chain-of-thought-in-codex-cli 2025-08-03 00:19:11 -07:00
Ahmed Ibrahim
bd0498744c fix reasoning 2025-08-03 00:16:30 -07:00
Ahmed Ibrahim
1a7f036fb7 fix reasoning 2025-08-02 12:17:06 -07:00
Ahmed Ibrahim
4b86407536 reasoning in chat 2025-08-01 19:40:14 -07:00
Ahmed Ibrahim
03cd87309b log 2025-08-01 19:05:11 -07:00
Ahmed Ibrahim
e4d9fc1591 fix reasoning 2025-08-01 09:42:06 -07:00
Ahmed Ibrahim
a31abedd60 cleaning supp 2025-07-31 22:10:34 -07:00
Ahmed Ibrahim
eb32d232db fix some bugs 2025-07-31 21:52:08 -07:00
aibrahim-oai
38850770fe Merge branch 'codex/add-raw-chain-of-thought-in-codex-cli' into dfyp9w-codex/implement-delta-based-streaming-rendering 2025-07-31 21:35:31 -07:00
aibrahim-oai
f61d83f5f7 Merge branch 'main' into codex/add-raw-chain-of-thought-in-codex-cli 2025-07-31 21:35:17 -07:00
aibrahim-oai
7cc12879ac Merge branch 'codex/add-raw-chain-of-thought-in-codex-cli' into dfyp9w-codex/implement-delta-based-streaming-rendering 2025-07-31 21:35:08 -07:00
aibrahim-oai
16bfbc883e refactor tui to re-render history for streaming 2025-07-31 21:28:48 -07:00
aibrahim-oai
525eb6845e Merge branch 'main' into codex/add-raw-chain-of-thought-in-codex-cli 2025-07-31 09:52:10 -07:00
Ahmed Ibrahim
d1275897ab adding config 2025-07-31 09:44:22 -07:00
Ahmed Ibrahim
961fb2d14f revive 2025-07-31 09:21:04 -07:00
aibrahim-oai
990709969a Merge branch 'main' into codex/add-raw-chain-of-thought-in-codex-cli 2025-07-31 09:01:48 -07:00
Ahmed Ibrahim
9f65dafe55 fmt 2025-07-14 08:23:14 -07:00
aibrahim-oai
caab634c57 feat(cli): show raw reasoning content 2025-07-13 23:39:56 -07:00
17 changed files with 457 additions and 222 deletions

View File

@@ -483,6 +483,16 @@ Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the
hide_agent_reasoning = true # defaults to false
```
## show_reasoning_content
When enabled, Codex will display the raw chain-of-thought text returned by the
model for reasoning events. This content is not moderated and may contain
hallucinations or other undesirable text, so it is disabled by default.
```toml
show_reasoning_content = true # defaults to false
```
## model_context_window
The size of the context window for the model, in tokens.

View File

@@ -13,7 +13,7 @@ use std::task::Poll;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::ModelProviderInfo;
use crate::client_common::Prompt;
@@ -207,6 +207,7 @@ async fn process_chat_sse<S>(
}
let mut fn_call_state = FunctionCallState::default();
let mut assistant_text = String::new();
loop {
let sse = match timeout(idle_timeout, stream.next()).await {
@@ -249,26 +250,50 @@ async fn process_chat_sse<S>(
Ok(v) => v,
Err(_) => continue,
};
trace!("chat_completions received SSE chunk: {chunk:?}");
warn!("chat_completions received SSE chunk: {chunk:?}");
let choice_opt = chunk.get("choices").and_then(|c| c.get(0));
if let Some(choice) = choice_opt {
// Handle assistant content tokens.
// Handle assistant content tokens as streaming deltas.
if let Some(content) = choice
.get("delta")
.and_then(|d| d.get("content"))
.and_then(|c| c.as_str())
{
let item = ResponseItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: content.to_string(),
}],
id: None,
};
// Some providers emit frequent empty-string content deltas.
// Suppress empty deltas to avoid creating a premature answer block
// ahead of reasoning in streaming UIs.
if !content.is_empty() {
assistant_text.push_str(content);
let _ = tx_event
.send(Ok(ResponseEvent::OutputTextDelta(content.to_string())))
.await;
}
}
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
// Forward any reasoning/thinking deltas if present.
if let Some(reasoning) = choice
.get("delta")
.and_then(|d| d.get("reasoning"))
.and_then(|c| c.as_str())
{
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningSummaryDelta(
reasoning.to_string(),
)))
.await;
}
if let Some(reasoning_content) = choice
.get("delta")
.and_then(|d| d.get("reasoning_content"))
.and_then(|c| c.as_str())
{
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningSummaryDelta(
reasoning_content.to_string(),
)))
.await;
}
// Handle streaming function / tool calls.
@@ -317,7 +342,18 @@ async fn process_chat_sse<S>(
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
}
"stop" => {
// Regular turn without tool-call.
// Regular turn without tool-call. Emit the final assistant message
// as a single OutputItemDone so non-delta consumers see the result.
if !assistant_text.is_empty() {
let item = ResponseItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: std::mem::take(&mut assistant_text),
}],
id: None,
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
}
}
_ => {}
}
@@ -358,7 +394,11 @@ async fn process_chat_sse<S>(
pub(crate) struct AggregatedChatStream<S> {
inner: S,
cumulative: String,
pending_completed: Option<ResponseEvent>,
cumulative_reasoning: String,
pending: std::collections::VecDeque<ResponseEvent>,
// When true, do not emit a cumulative assistant message at Completed.
streaming_mode: bool,
// When true, forward reasoning deltas instead of ignoring them.
}
impl<S> Stream for AggregatedChatStream<S>
@@ -370,8 +410,8 @@ where
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
// First, flush any buffered Completed event from the previous call.
if let Some(ev) = this.pending_completed.take() {
// First, flush any buffered events from the previous call.
if let Some(ev) = this.pending.pop_front() {
return Poll::Ready(Some(Ok(ev)));
}
@@ -388,16 +428,21 @@ where
let is_assistant_delta = matches!(&item, crate::models::ResponseItem::Message { role, .. } if role == "assistant");
if is_assistant_delta {
if let crate::models::ResponseItem::Message { content, .. } = &item {
if let Some(text) = content.iter().find_map(|c| match c {
crate::models::ContentItem::OutputText { text } => Some(text),
_ => None,
}) {
this.cumulative.push_str(text);
// Only use the final assistant message if we have not
// seen any deltas; otherwise, deltas already built the
// cumulative text and this would duplicate it.
if this.cumulative.is_empty() {
if let crate::models::ResponseItem::Message { content, .. } = &item {
if let Some(text) = content.iter().find_map(|c| match c {
crate::models::ContentItem::OutputText { text } => Some(text),
_ => None,
}) {
this.cumulative.push_str(text);
}
}
}
// Swallow partial assistant chunk; keep polling.
// Swallow assistant message here; emit on Completed.
continue;
}
@@ -408,24 +453,48 @@ where
response_id,
token_usage,
}))) => {
// Build any aggregated items in the correct order: Reasoning first, then Message.
let mut emitted_any = false;
if !this.cumulative_reasoning.is_empty() {
let aggregated_reasoning = crate::models::ResponseItem::Reasoning {
id: String::new(),
summary: vec![
crate::models::ReasoningItemReasoningSummary::SummaryText {
text: std::mem::take(&mut this.cumulative_reasoning),
},
],
content: None,
encrypted_content: None,
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_reasoning));
emitted_any = true;
}
if !this.cumulative.is_empty() {
let aggregated_item = crate::models::ResponseItem::Message {
let aggregated_message = crate::models::ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![crate::models::ContentItem::OutputText {
text: std::mem::take(&mut this.cumulative),
}],
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_message));
emitted_any = true;
}
// Buffer Completed so it is returned *after* the aggregated message.
this.pending_completed = Some(ResponseEvent::Completed {
response_id,
token_usage,
// Always emit Completed last when anything was aggregated.
if emitted_any {
this.pending.push_back(ResponseEvent::Completed {
response_id: response_id.clone(),
token_usage: token_usage.clone(),
});
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(
aggregated_item,
))));
// Return the first pending event now.
if let Some(ev) = this.pending.pop_front() {
return Poll::Ready(Some(Ok(ev)));
}
}
// Nothing aggregated forward Completed directly.
@@ -439,11 +508,25 @@ where
// will never appear in a Chat Completions stream.
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_))))
| Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
// Deltas are ignored here since aggregation waits for the
// final OutputItemDone.
continue;
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => {
// Always accumulate deltas so we can emit a final OutputItemDone at Completed.
this.cumulative.push_str(&delta);
if this.streaming_mode {
// In streaming mode, also forward the delta immediately.
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta))));
} else {
continue;
}
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))) => {
// Always accumulate reasoning deltas so we can emit a final Reasoning item at Completed.
this.cumulative_reasoning.push_str(&delta);
if this.streaming_mode {
// In streaming mode, also forward the delta immediately.
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta))));
} else {
continue;
}
}
}
}
@@ -475,9 +558,23 @@ pub(crate) trait AggregateStreamExt: Stream<Item = Result<ResponseEvent>> + Size
AggregatedChatStream {
inner: self,
cumulative: String::new(),
pending_completed: None,
cumulative_reasoning: String::new(),
pending: std::collections::VecDeque::new(),
streaming_mode: false,
}
}
}
impl<T> AggregateStreamExt for T where T: Stream<Item = Result<ResponseEvent>> + Sized {}
impl<S> AggregatedChatStream<S> {
pub(crate) fn streaming_mode(inner: S) -> Self {
AggregatedChatStream {
inner,
cumulative: String::new(),
cumulative_reasoning: String::new(),
pending: std::collections::VecDeque::new(),
streaming_mode: true,
}
}
}

View File

@@ -93,7 +93,11 @@ impl ModelClient {
// Wrap it with the aggregation adapter so callers see *only*
// the final assistant message per turn (matching the
// behaviour of the Responses API).
let mut aggregated = response_stream.aggregate();
let mut aggregated = if self.config.show_reasoning_content {
crate::chat_completions::AggregatedChatStream::streaming_mode(response_stream)
} else {
response_stream.aggregate()
};
// Bridge the aggregated stream back into a standard
// `ResponseStream` by forwarding events through a channel.
@@ -438,7 +442,7 @@ async fn process_sse<S>(
}
}
}
"response.reasoning_summary_text.delta" => {
"response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
if let Some(delta) = event.delta {
let event = ResponseEvent::ReasoningSummaryDelta(delta);
if tx_event.send(Ok(event)).await.is_err() {

View File

@@ -56,6 +56,7 @@ use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload;
use crate::models::LocalShellAction;
use crate::models::ReasoningItemContent;
use crate::models::ReasoningItemReasoningSummary;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
@@ -64,6 +65,7 @@ use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningContentEvent;
use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
@@ -225,6 +227,7 @@ pub(crate) struct Session {
state: Mutex<State>,
codex_linux_sandbox_exe: Option<PathBuf>,
user_shell: shell::Shell,
show_reasoning_content: bool,
}
impl Session {
@@ -791,6 +794,7 @@ async fn submission_loop(
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
disable_response_storage,
user_shell: default_shell,
show_reasoning_content: config.show_reasoning_content,
}));
// Patch restored state into the newly created session.
@@ -1098,6 +1102,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
id,
summary,
encrypted_content,
content,
},
None,
) => {
@@ -1105,6 +1110,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
id: id.clone(),
summary: summary.clone(),
encrypted_content: encrypted_content.clone(),
content: content.clone(),
});
}
_ => {
@@ -1278,7 +1284,6 @@ async fn try_run_turn(
};
let mut stream = sess.client.clone().stream(&prompt).await?;
let mut output = Vec::new();
loop {
// Poll the next item from the model stream. We must inspect *both* Ok and Err
@@ -1302,6 +1307,7 @@ async fn try_run_turn(
}
};
warn!("ResponseEvent: {event:?}");
match event {
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
@@ -1444,7 +1450,12 @@ async fn handle_response_item(
}
None
}
ResponseItem::Reasoning { summary, .. } => {
ResponseItem::Reasoning {
id: _,
summary,
content,
encrypted_content: _,
} => {
for item in summary {
let text = match item {
ReasoningItemReasoningSummary::SummaryText { text } => text,
@@ -1455,6 +1466,19 @@ async fn handle_response_item(
};
sess.tx_event.send(event).await.ok();
}
if sess.show_reasoning_content && content.is_some() {
let content = content.unwrap();
for item in content {
let text = match item {
ReasoningItemContent::ReasoningText { text } => text,
};
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningContent(AgentReasoningContentEvent { text }),
};
sess.tx_event.send(event).await.ok();
}
}
None
}
ResponseItem::FunctionCall {

View File

@@ -57,6 +57,10 @@ pub struct Config {
/// users are only interested in the final agent responses.
pub hide_agent_reasoning: bool,
/// When `true`, the raw chain-of-thought text from reasoning events will be
/// displayed in the UI in addition to the reasoning summaries.
pub show_reasoning_content: bool,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
/// who have opted into Zero Data Retention (ZDR).
@@ -325,6 +329,10 @@ pub struct ConfigToml {
/// UI/output. Defaults to `false`.
pub hide_agent_reasoning: Option<bool>,
/// When set to `true`, raw chain-of-thought text from reasoning events will
/// be shown in the UI.
pub show_reasoning_content: Option<bool>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
@@ -516,6 +524,7 @@ impl Config {
codex_linux_sandbox_exe,
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
show_reasoning_content: cfg.show_reasoning_content.unwrap_or(false),
model_reasoning_effort: config_profile
.model_reasoning_effort
.or(cfg.model_reasoning_effort)
@@ -889,6 +898,7 @@ disable_response_storage = true
tui: Tui::default(),
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
show_reasoning_content: false,
model_reasoning_effort: ReasoningEffort::High,
model_reasoning_summary: ReasoningSummary::Detailed,
model_supports_reasoning_summaries: false,
@@ -939,6 +949,7 @@ disable_response_storage = true
tui: Tui::default(),
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
show_reasoning_content: false,
model_reasoning_effort: ReasoningEffort::default(),
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: false,
@@ -1004,6 +1015,7 @@ disable_response_storage = true
tui: Tui::default(),
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
show_reasoning_content: false,
model_reasoning_effort: ReasoningEffort::default(),
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: false,

View File

@@ -45,6 +45,8 @@ pub enum ResponseItem {
Reasoning {
id: String,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<Vec<ReasoningItemContent>>,
encrypted_content: Option<String>,
},
LocalShellCall {
@@ -136,6 +138,12 @@ pub enum ReasoningItemReasoningSummary {
SummaryText { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemContent {
ReasoningText { text: String },
}
impl From<Vec<InputItem>> for ResponseInputItem {
fn from(items: Vec<InputItem>) -> Self {
Self::Message {

View File

@@ -359,6 +359,9 @@ pub enum EventMsg {
/// Agent reasoning delta event from agent.
AgentReasoningDelta(AgentReasoningDeltaEvent),
/// Raw chain-of-thought from agent.
AgentReasoningContent(AgentReasoningContentEvent),
/// Ack the client's configure message.
SessionConfigured(SessionConfiguredEvent),
@@ -462,6 +465,11 @@ pub struct AgentReasoningEvent {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningContentEvent {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningDeltaEvent {
pub delta: String,

View File

@@ -89,13 +89,6 @@ 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;

View File

@@ -40,7 +40,6 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
config.model_reasoning_summary.to_string(),
));
}
entries
}

View File

@@ -4,6 +4,7 @@ use codex_core::config::Config;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningContentEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::ErrorEvent;
@@ -204,6 +205,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningContent(AgentReasoningContentEvent { text }) => {
print!("{text}");
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// if answer_started is false, this means we haven't received any
// delta. Thus, we need to print the message as a new answer.

View File

@@ -255,6 +255,7 @@ async fn run_codex_tool_session_inner(
EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
| EventMsg::AgentReasoningContent(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)

View File

@@ -90,7 +90,8 @@ pub async fn run_conversation_loop(
EventMsg::AgentMessage(AgentMessageEvent { .. }) => {
// TODO: think how we want to support this in the MCP
}
EventMsg::TaskStarted
EventMsg::AgentReasoningContent(_)
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
| EventMsg::McpToolCallBegin(_)

View File

@@ -18,8 +18,7 @@ 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 ratatui::layout::Rect;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -58,8 +57,6 @@ 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>,
@@ -164,7 +161,6 @@ 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,
@@ -211,8 +207,9 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_history_lines(lines);
}
}
AppEvent::RequestRedraw => {
self.schedule_redraw();
@@ -413,30 +410,15 @@ impl App<'_> {
}
let size = terminal.size()?;
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10,
let area = Rect {
x: 0,
y: 0,
width: size.width,
height: size.height,
};
let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height);
area.width = size.width;
if area.bottom() > size.height {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
area.y = size.height - area.height;
}
if area != terminal.viewport_area {
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();
terminal.clear()?;
}
match &mut self.app_state {
AppState::Chat { widget } => {

View File

@@ -8,6 +8,7 @@ use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningContentEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
@@ -28,10 +29,15 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -43,6 +49,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;
@@ -60,10 +67,18 @@ 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 for streaming assistant answer text.
answer_buffer: String,
/// Full history rendered by the widget.
history: Vec<Line<'static>>,
/// Index where the current streaming agent message begins in `history`.
current_answer_start: Option<usize>,
/// Number of lines currently occupied by the streaming agent message in `history`.
current_answer_len: usize,
/// Index where the current streaming reasoning message begins in `history`.
current_reasoning_start: Option<usize>,
/// Number of lines currently occupied by the streaming reasoning block in `history`.
current_reasoning_len: usize,
running_commands: HashMap<String, RunningCommand>,
}
@@ -151,14 +166,15 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
history: Vec::new(),
current_answer_start: None,
current_answer_len: 0,
current_reasoning_start: None,
current_reasoning_len: 0,
running_commands: HashMap::new(),
}
}
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) {
if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint();
@@ -177,8 +193,12 @@ impl ChatWidget<'_> {
}
fn add_to_history(&mut self, cell: HistoryCell) {
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
self.add_history_lines(cell.plain_lines());
}
pub(crate) fn add_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.history.extend(lines);
self.request_redraw();
}
fn submit_user_message(&mut self, user_message: UserMessage) {
@@ -220,6 +240,7 @@ impl ChatWidget<'_> {
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
tracing::trace!("[TUI] codex_event: {:?}", msg);
match msg {
EventMsg::SessionConfigured(event) => {
self.bottom_pane
@@ -236,10 +257,6 @@ 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)
} else {
@@ -247,26 +264,87 @@ impl ChatWidget<'_> {
message
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
let lines = build_agent_message_lines(&self.config, &full, true);
let new_len = lines.len();
match self.current_answer_start.take() {
Some(start) => {
let old_len = self.current_answer_len;
let end = start.saturating_add(old_len).min(self.history.len());
// Replace just the answer block so we don't drop later content.
self.history.splice(start..end, lines);
// Adjust downstream reasoning block start if it comes after this.
if let Some(rstart) = self.current_reasoning_start {
if rstart > start {
let delta = new_len as isize - old_len as isize;
self.current_reasoning_start =
Some((rstart as isize + delta) as usize);
}
}
self.current_answer_len = 0;
}
None => {
self.history.extend(lines);
}
}
self.request_redraw();
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
self.answer_buffer.push_str(&delta);
let lines = build_agent_message_lines(&self.config, &self.answer_buffer, false);
let new_len = lines.len();
match self.current_answer_start {
Some(start) => {
let old_len = self.current_answer_len;
let end = start.saturating_add(old_len).min(self.history.len());
self.history.splice(start..end, lines);
// Adjust downstream reasoning block start if it comes after this.
if let Some(rstart) = self.current_reasoning_start {
if rstart > start {
let delta = new_len as isize - old_len as isize;
self.current_reasoning_start =
Some((rstart as isize + delta) as usize);
}
}
self.current_answer_len = new_len;
}
None => {
self.current_answer_start = Some(self.history.len());
self.current_answer_len = new_len;
self.history.extend(lines);
}
}
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);
}
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);
let lines =
build_agent_reasoning_lines(&self.config, &self.reasoning_buffer, false);
let new_len = lines.len();
match self.current_reasoning_start {
Some(start) => {
let old_len = self.current_reasoning_len;
let end = start.saturating_add(old_len).min(self.history.len());
self.history.splice(start..end, lines);
// Adjust downstream answer block start if it comes after this.
if let Some(astart) = self.current_answer_start {
if astart > start {
let delta = new_len as isize - old_len as isize;
self.current_answer_start =
Some((astart as isize + delta) as usize);
}
}
self.current_reasoning_len = new_len;
}
None => {
self.current_reasoning_start = Some(self.history.len());
self.current_reasoning_len = new_len;
self.history.extend(lines);
}
}
self.request_redraw();
}
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)
} else {
@@ -274,8 +352,32 @@ impl ChatWidget<'_> {
text
};
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
let lines = build_agent_reasoning_lines(&self.config, &full, true);
let new_len = lines.len();
match self.current_reasoning_start.take() {
Some(start) => {
let old_len = self.current_reasoning_len;
let end = start.saturating_add(old_len).min(self.history.len());
self.history.splice(start..end, lines);
// Adjust downstream answer block start if it comes after this.
if let Some(astart) = self.current_answer_start {
if astart > start {
let delta = new_len as isize - old_len as isize;
self.current_answer_start =
Some((astart as isize + delta) as usize);
}
}
self.current_reasoning_len = 0;
}
None => {
self.history.extend(lines);
}
}
self.request_redraw();
}
}
EventMsg::AgentReasoningContent(AgentReasoningContentEvent { text }) => {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, text));
self.request_redraw();
}
EventMsg::TaskStarted => {
@@ -286,6 +388,43 @@ impl ChatWidget<'_> {
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: _,
}) => {
// Finalize any inprogress streaming blocks defensively.
if let Some(start) = self.current_answer_start.take() {
if !self.answer_buffer.is_empty() {
let lines =
build_agent_message_lines(&self.config, &self.answer_buffer, true);
let new_len = lines.len();
let old_len = self.current_answer_len;
let end = start.saturating_add(old_len).min(self.history.len());
self.history.splice(start..end, lines);
if let Some(rstart) = self.current_reasoning_start {
if rstart > start {
let delta = new_len as isize - old_len as isize;
self.current_reasoning_start =
Some((rstart as isize + delta) as usize);
}
}
}
self.current_answer_len = 0;
}
if let Some(start) = self.current_reasoning_start.take() {
if !self.reasoning_buffer.is_empty() {
let lines =
build_agent_reasoning_lines(&self.config, &self.reasoning_buffer, true);
let new_len = lines.len();
let old_len = self.current_reasoning_len;
let end = start.saturating_add(old_len).min(self.history.len());
self.history.splice(start..end, lines);
if let Some(astart) = self.current_answer_start {
if astart > start {
let delta = new_len as isize - old_len as isize;
self.current_answer_start =
Some((astart as isize + delta) as usize);
}
}
}
self.current_reasoning_len = 0;
}
self.bottom_pane.set_task_running(false);
self.request_redraw();
}
@@ -479,6 +618,10 @@ impl ChatWidget<'_> {
self.submit_op(Op::Interrupt);
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.current_answer_start = None;
self.current_answer_len = 0;
self.current_reasoning_start = None;
self.current_reasoning_len = 0;
CancellationEvent::Ignored
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
self.submit_op(Op::Shutdown);
@@ -513,13 +656,52 @@ impl ChatWidget<'_> {
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// In the hybrid inline viewport mode we only draw the interactive
// bottom pane; history entries are injected directly into scrollback
// via `Terminal::insert_before`.
(&self.bottom_pane).render(area, buf);
let bottom_height = self.bottom_pane.desired_height(area.width);
let history_height = area.height.saturating_sub(bottom_height);
if history_height > 0 {
let history_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: history_height,
};
let total_rows = wrapped_row_count(&self.history, history_area.width);
let scroll = total_rows.saturating_sub(history_height);
Paragraph::new(self.history.clone())
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.render(history_area, buf);
}
let bottom_area = Rect {
x: area.x,
y: area.y + history_height,
width: area.width,
height: bottom_height,
};
(&self.bottom_pane).render(bottom_area, buf);
}
}
fn wrapped_row_count(lines: &[Line<'_>], width: u16) -> u16 {
if width == 0 {
return 0;
}
let w = width as u32;
let mut rows: u32 = 0;
for line in lines {
let total_width: u32 = line
.spans
.iter()
.map(|span| span.content.width() as u32)
.sum();
let line_rows = total_width.div_ceil(w).max(1);
rows = rows.saturating_add(line_rows);
}
rows.min(u16::MAX as u32) as u16
}
fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenUsage {
let cached_input_tokens = match (
current_usage.cached_input_tokens,
@@ -547,3 +729,23 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
fn build_agent_message_lines(config: &Config, message: &str, finalize: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("codex".magenta().bold()));
append_markdown(message, &mut lines, config);
if finalize {
lines.push(Line::from(""));
}
lines
}
fn build_agent_reasoning_lines(config: &Config, text: &str, finalize: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(text, &mut lines, config);
if finalize {
lines.push(Line::from(""));
}
lines
}

View File

@@ -324,16 +324,6 @@ where
&mut self.buffers[self.current]
}
/// Gets the backend
pub const fn backend(&self) -> &B {
&self.backend
}
/// Gets the backend as a mutable reference
pub fn backend_mut(&mut self) -> &mut B {
&mut self.backend
}
/// Obtains a difference between the previous and the current buffer and passes it to the
/// current backend for drawing.
pub fn flush(&mut self) -> io::Result<()> {

View File

@@ -68,9 +68,6 @@ pub(crate) enum HistoryCell {
/// Message from the user.
UserPrompt { view: TextBlock },
/// Message from the agent.
AgentMessage { view: TextBlock },
/// Reasoning event from the agent.
AgentReasoning { view: TextBlock },
@@ -128,7 +125,6 @@ impl HistoryCell {
match self {
HistoryCell::WelcomeMessage { view }
| HistoryCell::UserPrompt { view }
| HistoryCell::AgentMessage { view }
| HistoryCell::AgentReasoning { view }
| HistoryCell::BackgroundEvent { view }
| HistoryCell::GitDiffOutput { view }
@@ -231,17 +227,6 @@ impl HistoryCell {
}
}
pub(crate) fn new_agent_message(config: &Config, message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("codex".magenta().bold()));
append_markdown(&message, &mut lines, config);
lines.push(Line::from(""));
HistoryCell::AgentMessage {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));

View File

@@ -2,10 +2,9 @@ use std::fmt;
use std::io;
use std::io::Write;
use crate::tui;
use crossterm::Command;
use crossterm::cursor::MoveTo;
use crossterm::queue;
use crossterm::style::Attribute as CAttribute;
use crossterm::style::Color as CColor;
use crossterm::style::Colors;
use crossterm::style::Print;
@@ -13,96 +12,10 @@ use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let cursor_pos = terminal.get_cursor_position().ok();
let mut area = terminal.get_frame().area();
let wrapped_lines = wrapped_line_count(&lines, area.width);
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
terminal
.backend_mut()
.scroll_region_down(area.top()..screen_size.height, scroll_amount)
.ok();
let cursor_top = area.top().saturating_sub(1);
area.y += scroll_amount;
terminal.set_viewport_area(area);
cursor_top
} else {
area.top().saturating_sub(1)
};
// Limit the scroll region to the lines from the top of the screen to the
// top of the viewport. With this in place, when we add lines inside this
// area, only the lines in this area will be scrolled. We place the cursor
// at the end of the scroll region, and add lines starting there.
//
// ┌─Screen───────────────────────┐
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
// │┆ ┆│
// │┆ ┆│
// │┆ ┆│
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
// │╭─Viewport───────────────────╮│
// ││ ││
// │╰────────────────────────────╯│
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
for line in lines {
queue!(std::io::stdout(), Print("\r\n")).ok();
write_spans(&mut std::io::stdout(), line.iter()).ok();
}
queue!(std::io::stdout(), ResetScrollRegion).ok();
// Restore the cursor position to where it was before we started.
if let Some(cursor_pos) = cursor_pos {
queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok();
}
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
let mut count = 0;
for line in lines {
count += line_height(line, width);
}
count
}
fn line_height(line: &Line, width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
// get the total display width of the line, accounting for double-width chars
let total_width = line
.spans
.iter()
.map(|span| span.content.width())
.sum::<usize>();
// divide by width to get the number of lines, rounding up
if width == 0 {
1
} else {
(total_width as u16).div_ceil(width).max(1)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);