mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
24 Commits
dh--git-in
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1a26a749 | ||
|
|
8d01f6f200 | ||
|
|
d8e63e10ee | ||
|
|
e30e1c9c68 | ||
|
|
354801f0dd | ||
|
|
863c52bb87 | ||
|
|
faac84a8b4 | ||
|
|
bd0498744c | ||
|
|
1a7f036fb7 | ||
|
|
4b86407536 | ||
|
|
03cd87309b | ||
|
|
e4d9fc1591 | ||
|
|
a31abedd60 | ||
|
|
eb32d232db | ||
|
|
38850770fe | ||
|
|
f61d83f5f7 | ||
|
|
7cc12879ac | ||
|
|
16bfbc883e | ||
|
|
525eb6845e | ||
|
|
d1275897ab | ||
|
|
961fb2d14f | ||
|
|
990709969a | ||
|
|
9f65dafe55 | ||
|
|
caab634c57 |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 in‑progress 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
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user