hide CoT by default; show headers in status indicator (#2316)
Plan is for full CoT summaries to be visible in a "transcript view" when we implement that, but for now they're hidden. https://github.com/user-attachments/assets/e8a1b0ef-8f2a-48ff-9625-9c3c67d92cdb
This commit is contained in:
@@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView {
|
|||||||
/// Render the view: this will be displayed in place of the composer.
|
/// Render the view: this will be displayed in place of the composer.
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||||
|
|
||||||
|
/// Update the status indicator animated header. Default no-op.
|
||||||
|
fn update_status_header(&mut self, _header: String) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when task completes to check if the view should be hidden.
|
/// Called when task completes to check if the view should be hidden.
|
||||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ impl BottomPane {
|
|||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the animated header shown to the left of the brackets in the
|
||||||
|
/// status indicator (defaults to "Working"). This will update the active
|
||||||
|
/// StatusIndicatorView if present; otherwise, if a live overlay is active,
|
||||||
|
/// it will update that. If neither is present, this call is a no-op.
|
||||||
|
pub(crate) fn update_status_header(&mut self, header: String) {
|
||||||
|
if let Some(view) = self.active_view.as_mut() {
|
||||||
|
view.update_status_header(header.clone());
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||||
self.ctrl_c_quit_hint = true;
|
self.ctrl_c_quit_hint = true;
|
||||||
self.composer
|
self.composer
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ impl StatusIndicatorView {
|
|||||||
pub fn update_text(&mut self, text: String) {
|
pub fn update_text(&mut self, text: String) {
|
||||||
self.view.update_text(text);
|
self.view.update_text(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_header(&mut self, header: String) {
|
||||||
|
self.view.update_header(header);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BottomPaneView for StatusIndicatorView {
|
impl BottomPaneView for StatusIndicatorView {
|
||||||
|
fn update_status_header(&mut self, header: String) {
|
||||||
|
self.update_header(header);
|
||||||
|
}
|
||||||
|
|
||||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ pub(crate) struct ChatWidget {
|
|||||||
last_token_usage: TokenUsage,
|
last_token_usage: TokenUsage,
|
||||||
// Stream lifecycle controller
|
// Stream lifecycle controller
|
||||||
stream: StreamController,
|
stream: StreamController,
|
||||||
// Track the most recently active stream kind in the current turn
|
|
||||||
last_stream_kind: Option<StreamKind>,
|
|
||||||
running_commands: HashMap<String, RunningCommand>,
|
running_commands: HashMap<String, RunningCommand>,
|
||||||
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
|
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
|
||||||
task_complete_pending: bool,
|
task_complete_pending: bool,
|
||||||
@@ -98,6 +96,8 @@ pub(crate) struct ChatWidget {
|
|||||||
interrupts: InterruptManager,
|
interrupts: InterruptManager,
|
||||||
// Whether a redraw is needed after handling the current event
|
// Whether a redraw is needed after handling the current event
|
||||||
needs_redraw: bool,
|
needs_redraw: bool,
|
||||||
|
// Accumulates the current reasoning block text to extract a header
|
||||||
|
reasoning_buffer: String,
|
||||||
session_id: Option<Uuid>,
|
session_id: Option<Uuid>,
|
||||||
frame_requester: FrameRequester,
|
frame_requester: FrameRequester,
|
||||||
}
|
}
|
||||||
@@ -107,8 +107,6 @@ struct UserMessage {
|
|||||||
image_paths: Vec<PathBuf>,
|
image_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::streaming::StreamKind;
|
|
||||||
|
|
||||||
impl From<String> for UserMessage {
|
impl From<String> for UserMessage {
|
||||||
fn from(text: String) -> Self {
|
fn from(text: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -133,7 +131,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
fn flush_answer_stream_with_separator(&mut self) {
|
fn flush_answer_stream_with_separator(&mut self) {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||||
let _ = self.stream.finalize(StreamKind::Answer, true, &sink);
|
let _ = self.stream.finalize(true, &sink);
|
||||||
}
|
}
|
||||||
// --- Small event handlers ---
|
// --- Small event handlers ---
|
||||||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||||||
@@ -150,30 +148,38 @@ impl ChatWidget {
|
|||||||
fn on_agent_message(&mut self, message: String) {
|
fn on_agent_message(&mut self, message: String) {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||||
let finished = self.stream.apply_final_answer(&message, &sink);
|
let finished = self.stream.apply_final_answer(&message, &sink);
|
||||||
self.last_stream_kind = Some(StreamKind::Answer);
|
|
||||||
self.handle_if_stream_finished(finished);
|
self.handle_if_stream_finished(finished);
|
||||||
self.mark_needs_redraw();
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_agent_message_delta(&mut self, delta: String) {
|
fn on_agent_message_delta(&mut self, delta: String) {
|
||||||
self.handle_streaming_delta(StreamKind::Answer, delta);
|
self.handle_streaming_delta(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
||||||
self.handle_streaming_delta(StreamKind::Reasoning, delta);
|
// For reasoning deltas, do not stream to history. Accumulate the
|
||||||
|
// current reasoning block and extract the first bold element
|
||||||
|
// (between **/**) as the chunk header. Show this header as status.
|
||||||
|
self.reasoning_buffer.push_str(&delta);
|
||||||
|
|
||||||
|
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||||
|
// Update the shimmer header to the extracted reasoning chunk header.
|
||||||
|
self.bottom_pane.update_status_header(header);
|
||||||
|
} else {
|
||||||
|
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||||
|
}
|
||||||
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_agent_reasoning_final(&mut self, text: String) {
|
fn on_agent_reasoning_final(&mut self) {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
// Clear the reasoning buffer at the end of a reasoning block.
|
||||||
let finished = self.stream.apply_final_reasoning(&text, &sink);
|
self.reasoning_buffer.clear();
|
||||||
self.last_stream_kind = Some(StreamKind::Reasoning);
|
|
||||||
self.handle_if_stream_finished(finished);
|
|
||||||
self.mark_needs_redraw();
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_reasoning_section_break(&mut self) {
|
fn on_reasoning_section_break(&mut self) {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
// Start a new reasoning block for header extraction.
|
||||||
self.stream.insert_reasoning_section_break(&sink);
|
self.reasoning_buffer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw reasoning uses the same flow as summarized reasoning
|
// Raw reasoning uses the same flow as summarized reasoning
|
||||||
@@ -182,7 +188,7 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||||
self.bottom_pane.set_task_running(true);
|
self.bottom_pane.set_task_running(true);
|
||||||
self.stream.reset_headers_for_new_turn();
|
self.stream.reset_headers_for_new_turn();
|
||||||
self.last_stream_kind = None;
|
self.reasoning_buffer.clear();
|
||||||
self.mark_needs_redraw();
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +197,7 @@ impl ChatWidget {
|
|||||||
// without emitting stray headers for other streams.
|
// without emitting stray headers for other streams.
|
||||||
if self.stream.is_write_cycle_active() {
|
if self.stream.is_write_cycle_active() {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||||
if let Some(kind) = self.last_stream_kind {
|
let _ = self.stream.finalize(true, &sink);
|
||||||
let _ = self.stream.finalize(kind, true, &sink);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Mark task stopped and request redraw now that all content is in history.
|
// Mark task stopped and request redraw now that all content is in history.
|
||||||
self.bottom_pane.set_task_running(false);
|
self.bottom_pane.set_task_running(false);
|
||||||
@@ -355,10 +359,9 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
|
fn handle_streaming_delta(&mut self, delta: String) {
|
||||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||||
self.stream.begin(kind, &sink);
|
self.stream.begin(&sink);
|
||||||
self.last_stream_kind = Some(kind);
|
|
||||||
self.stream.push_and_maybe_commit(&delta, &sink);
|
self.stream.push_and_maybe_commit(&delta, &sink);
|
||||||
self.mark_needs_redraw();
|
self.mark_needs_redraw();
|
||||||
}
|
}
|
||||||
@@ -532,12 +535,12 @@ impl ChatWidget {
|
|||||||
total_token_usage: TokenUsage::default(),
|
total_token_usage: TokenUsage::default(),
|
||||||
last_token_usage: TokenUsage::default(),
|
last_token_usage: TokenUsage::default(),
|
||||||
stream: StreamController::new(config),
|
stream: StreamController::new(config),
|
||||||
last_stream_kind: None,
|
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
pending_exec_completions: Vec::new(),
|
pending_exec_completions: Vec::new(),
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
interrupts: InterruptManager::new(),
|
interrupts: InterruptManager::new(),
|
||||||
needs_redraw: false,
|
needs_redraw: false,
|
||||||
|
reasoning_buffer: String::new(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,9 +644,9 @@ impl ChatWidget {
|
|||||||
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
|
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
|
||||||
delta,
|
delta,
|
||||||
}) => self.on_agent_reasoning_delta(delta),
|
}) => self.on_agent_reasoning_delta(delta),
|
||||||
EventMsg::AgentReasoning(AgentReasoningEvent { text })
|
EventMsg::AgentReasoning(AgentReasoningEvent { .. })
|
||||||
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
|
||||||
self.on_agent_reasoning_final(text)
|
self.on_agent_reasoning_final()
|
||||||
}
|
}
|
||||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||||
EventMsg::TaskStarted => self.on_task_started(),
|
EventMsg::TaskStarted => self.on_task_started(),
|
||||||
@@ -932,5 +935,35 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the first bold (Markdown) element in the form **...** from `s`.
|
||||||
|
// Returns the inner text if found; otherwise `None`.
|
||||||
|
fn extract_first_bold(s: &str) -> Option<String> {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
if bytes[i] == b'*' && bytes[i + 1] == b'*' {
|
||||||
|
let start = i + 2;
|
||||||
|
let mut j = start;
|
||||||
|
while j + 1 < bytes.len() {
|
||||||
|
if bytes[j] == b'*' && bytes[j + 1] == b'*' {
|
||||||
|
// Found closing **
|
||||||
|
let inner = &s[start..j];
|
||||||
|
let trimmed = inner.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
// No closing; stop searching (wait for more deltas)
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
assertion_line: 886
|
|
||||||
expression: combined
|
expression: combined
|
||||||
---
|
---
|
||||||
thinking
|
|
||||||
I will first analyze the request.
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
Here is the result.
|
Here is the result.
|
||||||
|
|||||||
@@ -2,8 +2,5 @@
|
|||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: combined
|
expression: combined
|
||||||
---
|
---
|
||||||
thinking
|
|
||||||
I will first analyze the request.
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
Here is the result.
|
Here is the result.
|
||||||
|
|||||||
@@ -144,12 +144,12 @@ fn make_chatwidget_manual() -> (
|
|||||||
total_token_usage: TokenUsage::default(),
|
total_token_usage: TokenUsage::default(),
|
||||||
last_token_usage: TokenUsage::default(),
|
last_token_usage: TokenUsage::default(),
|
||||||
stream: StreamController::new(cfg),
|
stream: StreamController::new(cfg),
|
||||||
last_stream_kind: None,
|
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
pending_exec_completions: Vec::new(),
|
pending_exec_completions: Vec::new(),
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
interrupts: InterruptManager::new(),
|
interrupts: InterruptManager::new(),
|
||||||
needs_redraw: false,
|
needs_redraw: false,
|
||||||
|
reasoning_buffer: String::new(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||||
};
|
};
|
||||||
@@ -375,6 +375,11 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
|||||||
.expect("read ideal-binary-response.txt");
|
.expect("read ideal-binary-response.txt");
|
||||||
// Normalize line endings for Windows vs. Unix checkouts
|
// Normalize line endings for Windows vs. Unix checkouts
|
||||||
let ideal = ideal.replace("\r\n", "\n");
|
let ideal = ideal.replace("\r\n", "\n");
|
||||||
|
let ideal_first_line = ideal
|
||||||
|
.lines()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
|
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
|
||||||
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
|
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
|
||||||
@@ -400,22 +405,68 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
|||||||
while lines.last().is_some_and(|l| l.is_empty()) {
|
while lines.last().is_some_and(|l| l.is_empty()) {
|
||||||
lines.pop();
|
lines.pop();
|
||||||
}
|
}
|
||||||
// Compare only after the last session banner marker, and start at the next 'thinking' line.
|
// Compare only after the last session banner marker. Skip the transient
|
||||||
|
// 'thinking' header if present, and start from the first non-empty line
|
||||||
|
// of content that follows.
|
||||||
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
|
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
|
||||||
let last_marker_line_idx = lines
|
let last_marker_line_idx = lines
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|l| l.starts_with(MARKER_PREFIX))
|
.rposition(|l| l.starts_with(MARKER_PREFIX))
|
||||||
.expect("marker not found in visible output");
|
.expect("marker not found in visible output");
|
||||||
let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
|
// Anchor to the first ideal line if present; otherwise use heuristics.
|
||||||
.find(|&idx| lines[idx].trim_start() == "thinking")
|
let start_idx = (last_marker_line_idx + 1..lines.len())
|
||||||
.expect("no 'thinking' line found after marker");
|
.find(|&idx| lines[idx].trim_start() == ideal_first_line)
|
||||||
|
.or_else(|| {
|
||||||
|
// Prefer the first assistant content line (blockquote '>' prefix) after the marker.
|
||||||
|
(last_marker_line_idx + 1..lines.len())
|
||||||
|
.find(|&idx| lines[idx].trim_start().starts_with('>'))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// Fallback: first non-empty, non-'thinking' line
|
||||||
|
(last_marker_line_idx + 1..lines.len())
|
||||||
|
.find(|&idx| {
|
||||||
|
let t = lines[idx].trim_start();
|
||||||
|
!t.is_empty() && t != "thinking"
|
||||||
|
})
|
||||||
|
.expect("no content line found after marker")
|
||||||
|
});
|
||||||
|
|
||||||
let mut compare_lines: Vec<String> = Vec::new();
|
let mut compare_lines: Vec<String> = Vec::new();
|
||||||
// Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
|
// Ensure the first line is trimmed-left to match the fixture shape.
|
||||||
compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
|
compare_lines.push(lines[start_idx].trim_start().to_string());
|
||||||
compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
|
compare_lines.extend(lines[(start_idx + 1)..].iter().cloned());
|
||||||
let visible_after = compare_lines.join("\n");
|
let visible_after = compare_lines.join("\n");
|
||||||
|
|
||||||
|
// Normalize: drop a leading 'thinking' line if present in either side to
|
||||||
|
// avoid coupling to whether the reasoning header is rendered in history.
|
||||||
|
fn drop_leading_thinking(s: &str) -> String {
|
||||||
|
let mut it = s.lines();
|
||||||
|
let first = it.next();
|
||||||
|
let rest = it.collect::<Vec<_>>().join("\n");
|
||||||
|
if first.is_some_and(|l| l.trim() == "thinking") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let visible_after = drop_leading_thinking(&visible_after);
|
||||||
|
let ideal = drop_leading_thinking(&ideal);
|
||||||
|
|
||||||
|
// Normalize: strip leading Markdown blockquote markers ('>' or '> ') which
|
||||||
|
// may be present in rendered transcript lines but not in the ideal text.
|
||||||
|
fn strip_blockquotes(s: &str) -> String {
|
||||||
|
s.lines()
|
||||||
|
.map(|l| {
|
||||||
|
l.strip_prefix("> ")
|
||||||
|
.or_else(|| l.strip_prefix('>'))
|
||||||
|
.unwrap_or(l)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
let visible_after = strip_blockquotes(&visible_after);
|
||||||
|
let ideal = strip_blockquotes(&ideal);
|
||||||
|
|
||||||
// Optionally update the fixture when env var is set
|
// Optionally update the fixture when env var is set
|
||||||
if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
|
if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
|
||||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
@@ -746,7 +797,7 @@ fn plan_update_renders_history_cell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|
||||||
// Answer: no header until a newline commit
|
// Answer: no header until a newline commit
|
||||||
@@ -804,7 +855,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
|||||||
"expected 'codex' header to be emitted after first newline commit"
|
"expected 'codex' header to be emitted after first newline commit"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reasoning: header immediately
|
// Reasoning: do NOT emit a history header; status text is updated instead
|
||||||
let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
|
let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
|
||||||
chat2.handle_codex_event(Event {
|
chat2.handle_codex_event(Event {
|
||||||
id: "sub-b".into(),
|
id: "sub-b".into(),
|
||||||
@@ -828,8 +879,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
saw_thinking,
|
!saw_thinking,
|
||||||
"expected 'thinking' header to be emitted at stream start"
|
"reasoning deltas should not emit history headers"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,22 +45,6 @@ impl MarkdownStreamCollector {
|
|||||||
self.buffer.push_str(delta);
|
self.buffer.push_str(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a paragraph/section separator if one is not already present at the
|
|
||||||
/// end of the buffer. Ensures the next content starts after a blank line.
|
|
||||||
pub fn insert_section_break(&mut self) {
|
|
||||||
if self.buffer.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.buffer.ends_with("\n\n") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.buffer.ends_with('\n') {
|
|
||||||
self.buffer.push('\n');
|
|
||||||
} else {
|
|
||||||
self.buffer.push_str("\n\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the full buffer and return only the newly completed logical lines
|
/// Render the full buffer and return only the newly completed logical lines
|
||||||
/// since the last commit. When the buffer does not end with a newline, the
|
/// since the last commit. When the buffer does not end with a newline, the
|
||||||
/// final rendered line is considered incomplete and is not emitted.
|
/// final rendered line is considered incomplete and is not emitted.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
|
|||||||
// Use time-based sweep synchronized to process start.
|
// Use time-based sweep synchronized to process start.
|
||||||
let padding = 10usize;
|
let padding = 10usize;
|
||||||
let period = chars.len() + padding * 2;
|
let period = chars.len() + padding * 2;
|
||||||
let sweep_seconds = 2.5f32;
|
let sweep_seconds = 2.0f32;
|
||||||
let pos_f =
|
let pos_f =
|
||||||
(elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32);
|
(elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32);
|
||||||
let pos = pos_f as usize;
|
let pos = pos_f as usize;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub(crate) struct StatusIndicatorWidget {
|
|||||||
/// Latest text to display (truncated to the available width at render
|
/// Latest text to display (truncated to the available width at render
|
||||||
/// time).
|
/// time).
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Animated header text (defaults to "Working").
|
||||||
|
header: String,
|
||||||
|
|
||||||
/// Animation state: reveal target `text` progressively like a typewriter.
|
/// Animation state: reveal target `text` progressively like a typewriter.
|
||||||
/// We compute the currently visible prefix length based on the current
|
/// We compute the currently visible prefix length based on the current
|
||||||
@@ -47,6 +49,7 @@ impl StatusIndicatorWidget {
|
|||||||
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
||||||
Self {
|
Self {
|
||||||
text: String::from("waiting for model"),
|
text: String::from("waiting for model"),
|
||||||
|
header: String::from("Working"),
|
||||||
last_target_len: 0,
|
last_target_len: 0,
|
||||||
base_frame: 0,
|
base_frame: 0,
|
||||||
reveal_len_at_base: 0,
|
reveal_len_at_base: 0,
|
||||||
@@ -95,6 +98,13 @@ impl StatusIndicatorWidget {
|
|||||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the animated header label (left of the brackets).
|
||||||
|
pub(crate) fn update_header(&mut self, header: String) {
|
||||||
|
if self.header != header {
|
||||||
|
self.header = header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset the animation and start revealing `text` from the beginning.
|
/// Reset the animation and start revealing `text` from the beginning.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn restart_with_text(&mut self, text: String) {
|
pub(crate) fn restart_with_text(&mut self, text: String) {
|
||||||
@@ -147,12 +157,12 @@ impl WidgetRef for StatusIndicatorWidget {
|
|||||||
|
|
||||||
// Schedule next animation frame.
|
// Schedule next animation frame.
|
||||||
self.frame_requester
|
self.frame_requester
|
||||||
.schedule_frame_in(Duration::from_millis(100));
|
.schedule_frame_in(Duration::from_millis(32));
|
||||||
let idx = self.current_frame();
|
let idx = self.current_frame();
|
||||||
let elapsed = self.start_time.elapsed().as_secs();
|
let elapsed = self.start_time.elapsed().as_secs();
|
||||||
let shown_now = self.current_shown_len(idx);
|
let shown_now = self.current_shown_len(idx);
|
||||||
let status_prefix: String = self.text.chars().take(shown_now).collect();
|
let status_prefix: String = self.text.chars().take(shown_now).collect();
|
||||||
let animated_spans = shimmer_spans("Working");
|
let animated_spans = shimmer_spans(&self.header);
|
||||||
|
|
||||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||||
let inner_width = area.width as usize;
|
let inner_width = area.width as usize;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use codex_core::config::Config;
|
|||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
|
||||||
use super::HeaderEmitter;
|
use super::HeaderEmitter;
|
||||||
use super::StreamKind;
|
|
||||||
use super::StreamState;
|
use super::StreamState;
|
||||||
|
|
||||||
/// Sink for history insertions and animation control.
|
/// Sink for history insertions and animation control.
|
||||||
@@ -36,8 +35,8 @@ type Lines = Vec<Line<'static>>;
|
|||||||
pub(crate) struct StreamController {
|
pub(crate) struct StreamController {
|
||||||
config: Config,
|
config: Config,
|
||||||
header: HeaderEmitter,
|
header: HeaderEmitter,
|
||||||
states: [StreamState; 2],
|
state: StreamState,
|
||||||
current_stream: Option<StreamKind>,
|
active: bool,
|
||||||
finishing_after_drain: bool,
|
finishing_after_drain: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +45,8 @@ impl StreamController {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
header: HeaderEmitter::new(),
|
header: HeaderEmitter::new(),
|
||||||
states: [StreamState::new(), StreamState::new()],
|
state: StreamState::new(),
|
||||||
current_stream: None,
|
active: false,
|
||||||
finishing_after_drain: false,
|
finishing_after_drain: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,29 +56,18 @@ impl StreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_write_cycle_active(&self) -> bool {
|
pub(crate) fn is_write_cycle_active(&self) -> bool {
|
||||||
self.current_stream.is_some()
|
self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_all(&mut self) {
|
pub(crate) fn clear_all(&mut self) {
|
||||||
self.states.iter_mut().for_each(|s| s.clear());
|
self.state.clear();
|
||||||
self.current_stream = None;
|
self.active = false;
|
||||||
self.finishing_after_drain = false;
|
self.finishing_after_drain = false;
|
||||||
// leave header state unchanged; caller decides when to reset
|
// leave header state unchanged; caller decides when to reset
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool {
|
||||||
fn idx(kind: StreamKind) -> usize {
|
self.header.maybe_emit(out_lines)
|
||||||
kind as usize
|
|
||||||
}
|
|
||||||
fn state(&self, kind: StreamKind) -> &StreamState {
|
|
||||||
&self.states[Self::idx(kind)]
|
|
||||||
}
|
|
||||||
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
|
|
||||||
&mut self.states[Self::idx(kind)]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool {
|
|
||||||
self.header.maybe_emit(kind, out_lines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -93,56 +81,23 @@ impl StreamController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begin a stream, flushing previously completed lines from any other
|
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
|
||||||
/// active stream to maintain ordering.
|
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
|
||||||
pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) {
|
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
||||||
if let Some(current) = self.current_stream
|
if !self.active {
|
||||||
&& current != kind
|
self.header.reset_for_stream();
|
||||||
{
|
|
||||||
// Synchronously flush completed lines from previous stream.
|
|
||||||
let cfg = self.config.clone();
|
|
||||||
let prev_state = self.state_mut(current);
|
|
||||||
let newly_completed = prev_state.collector.commit_complete_lines(&cfg);
|
|
||||||
if !newly_completed.is_empty() {
|
|
||||||
prev_state.enqueue(newly_completed);
|
|
||||||
}
|
|
||||||
let step = prev_state.drain_all();
|
|
||||||
if !step.history.is_empty() {
|
|
||||||
let mut lines: Lines = Vec::new();
|
|
||||||
self.emit_header_if_needed(current, &mut lines);
|
|
||||||
lines.extend(step.history);
|
|
||||||
// Ensure at most one trailing blank after the flushed block.
|
|
||||||
Self::ensure_single_trailing_blank(&mut lines);
|
|
||||||
sink.insert_history(lines);
|
|
||||||
}
|
|
||||||
self.current_stream = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.current_stream != Some(kind) {
|
|
||||||
let prev = self.current_stream;
|
|
||||||
self.current_stream = Some(kind);
|
|
||||||
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
|
||||||
self.finishing_after_drain = false;
|
|
||||||
if prev.is_some() {
|
|
||||||
self.header.reset_for_stream(kind);
|
|
||||||
}
|
|
||||||
// Emit header immediately for reasoning; for answers, defer to first commit.
|
|
||||||
if matches!(kind, StreamKind::Reasoning) {
|
|
||||||
let mut header_lines = Vec::new();
|
|
||||||
if self.emit_header_if_needed(kind, &mut header_lines) {
|
|
||||||
sink.insert_history(header_lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.finishing_after_drain = false;
|
||||||
|
self.active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a delta; if it contains a newline, commit completed lines and start animation.
|
/// Push a delta; if it contains a newline, commit completed lines and start animation.
|
||||||
pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) {
|
pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) {
|
||||||
let Some(kind) = self.current_stream else {
|
if !self.active {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let cfg = self.config.clone();
|
let cfg = self.config.clone();
|
||||||
let state = self.state_mut(kind);
|
let state = &mut self.state;
|
||||||
// Record that at least one delta was received for this stream
|
// Record that at least one delta was received for this stream
|
||||||
if !delta.is_empty() {
|
if !delta.is_empty() {
|
||||||
state.has_seen_delta = true;
|
state.has_seen_delta = true;
|
||||||
@@ -157,42 +112,22 @@ impl StreamController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a reasoning section break and commit any newly completed lines.
|
|
||||||
pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) {
|
|
||||||
if self.current_stream != Some(StreamKind::Reasoning) {
|
|
||||||
self.begin(StreamKind::Reasoning, sink);
|
|
||||||
}
|
|
||||||
let cfg = self.config.clone();
|
|
||||||
let state = self.state_mut(StreamKind::Reasoning);
|
|
||||||
state.collector.insert_section_break();
|
|
||||||
let newly_completed = state.collector.commit_complete_lines(&cfg);
|
|
||||||
if !newly_completed.is_empty() {
|
|
||||||
state.enqueue(newly_completed);
|
|
||||||
sink.start_commit_animation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finalize the active stream. If `flush_immediately` is true, drain and emit now.
|
/// Finalize the active stream. If `flush_immediately` is true, drain and emit now.
|
||||||
pub(crate) fn finalize(
|
pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool {
|
||||||
&mut self,
|
if !self.active {
|
||||||
kind: StreamKind,
|
|
||||||
flush_immediately: bool,
|
|
||||||
sink: &impl HistorySink,
|
|
||||||
) -> bool {
|
|
||||||
if self.current_stream != Some(kind) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let cfg = self.config.clone();
|
let cfg = self.config.clone();
|
||||||
// Finalize collector first.
|
// Finalize collector first.
|
||||||
let remaining = {
|
let remaining = {
|
||||||
let state = self.state_mut(kind);
|
let state = &mut self.state;
|
||||||
state.collector.finalize_and_drain(&cfg)
|
state.collector.finalize_and_drain(&cfg)
|
||||||
};
|
};
|
||||||
if flush_immediately {
|
if flush_immediately {
|
||||||
// Collect all output first to avoid emitting headers when there is no content.
|
// Collect all output first to avoid emitting headers when there is no content.
|
||||||
let mut out_lines: Lines = Vec::new();
|
let mut out_lines: Lines = Vec::new();
|
||||||
{
|
{
|
||||||
let state = self.state_mut(kind);
|
let state = &mut self.state;
|
||||||
if !remaining.is_empty() {
|
if !remaining.is_empty() {
|
||||||
state.enqueue(remaining);
|
state.enqueue(remaining);
|
||||||
}
|
}
|
||||||
@@ -201,28 +136,28 @@ impl StreamController {
|
|||||||
}
|
}
|
||||||
if !out_lines.is_empty() {
|
if !out_lines.is_empty() {
|
||||||
let mut lines_with_header: Lines = Vec::new();
|
let mut lines_with_header: Lines = Vec::new();
|
||||||
self.emit_header_if_needed(kind, &mut lines_with_header);
|
self.emit_header_if_needed(&mut lines_with_header);
|
||||||
lines_with_header.extend(out_lines);
|
lines_with_header.extend(out_lines);
|
||||||
Self::ensure_single_trailing_blank(&mut lines_with_header);
|
Self::ensure_single_trailing_blank(&mut lines_with_header);
|
||||||
sink.insert_history(lines_with_header);
|
sink.insert_history(lines_with_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
self.state_mut(kind).clear();
|
self.state.clear();
|
||||||
// Allow a subsequent block of the same kind in this turn to emit its header.
|
// Allow a subsequent block in this turn to emit its header.
|
||||||
self.header.allow_reemit_for_same_kind_in_turn(kind);
|
self.header.allow_reemit_in_turn();
|
||||||
// Also clear the per-stream emitted flag so the header can render again.
|
// Also clear the per-stream emitted flag so the header can render again.
|
||||||
self.header.reset_for_stream(kind);
|
self.header.reset_for_stream();
|
||||||
self.current_stream = None;
|
self.active = false;
|
||||||
self.finishing_after_drain = false;
|
self.finishing_after_drain = false;
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
if !remaining.is_empty() {
|
if !remaining.is_empty() {
|
||||||
let state = self.state_mut(kind);
|
let state = &mut self.state;
|
||||||
state.enqueue(remaining);
|
state.enqueue(remaining);
|
||||||
}
|
}
|
||||||
// Spacer animated out
|
// Spacer animated out
|
||||||
self.state_mut(kind).enqueue(vec![Line::from("")]);
|
self.state.enqueue(vec![Line::from("")]);
|
||||||
self.finishing_after_drain = true;
|
self.finishing_after_drain = true;
|
||||||
sink.start_commit_animation();
|
sink.start_commit_animation();
|
||||||
false
|
false
|
||||||
@@ -231,32 +166,29 @@ impl StreamController {
|
|||||||
|
|
||||||
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
|
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
|
||||||
pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool {
|
pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool {
|
||||||
let Some(kind) = self.current_stream else {
|
if !self.active {
|
||||||
return false;
|
return false;
|
||||||
};
|
}
|
||||||
let step = {
|
let step = { self.state.step() };
|
||||||
let state = self.state_mut(kind);
|
|
||||||
state.step()
|
|
||||||
};
|
|
||||||
if !step.history.is_empty() {
|
if !step.history.is_empty() {
|
||||||
let mut lines: Lines = Vec::new();
|
let mut lines: Lines = Vec::new();
|
||||||
self.emit_header_if_needed(kind, &mut lines);
|
self.emit_header_if_needed(&mut lines);
|
||||||
let mut out = lines;
|
let mut out = lines;
|
||||||
out.extend(step.history);
|
out.extend(step.history);
|
||||||
sink.insert_history(out);
|
sink.insert_history(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_idle = self.state(kind).is_idle();
|
let is_idle = self.state.is_idle();
|
||||||
if is_idle {
|
if is_idle {
|
||||||
sink.stop_commit_animation();
|
sink.stop_commit_animation();
|
||||||
if self.finishing_after_drain {
|
if self.finishing_after_drain {
|
||||||
// Reset and notify
|
// Reset and notify
|
||||||
self.state_mut(kind).clear();
|
self.state.clear();
|
||||||
// Allow a subsequent block of the same kind in this turn to emit its header.
|
// Allow a subsequent block in this turn to emit its header.
|
||||||
self.header.allow_reemit_for_same_kind_in_turn(kind);
|
self.header.allow_reemit_in_turn();
|
||||||
// Also clear the per-stream emitted flag so the header can render again.
|
// Also clear the per-stream emitted flag so the header can render again.
|
||||||
self.header.reset_for_stream(kind);
|
self.header.reset_for_stream();
|
||||||
self.current_stream = None;
|
self.active = false;
|
||||||
self.finishing_after_drain = false;
|
self.finishing_after_drain = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -267,24 +199,14 @@ impl StreamController {
|
|||||||
/// Apply a full final answer: replace queued content with only the remaining tail,
|
/// Apply a full final answer: replace queued content with only the remaining tail,
|
||||||
/// then finalize immediately and notify completion.
|
/// then finalize immediately and notify completion.
|
||||||
pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
||||||
self.apply_full_final(StreamKind::Answer, message, true, sink)
|
self.apply_full_final(message, sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_final_reasoning(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
||||||
self.apply_full_final(StreamKind::Reasoning, message, false, sink)
|
self.begin(sink);
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_full_final(
|
|
||||||
&mut self,
|
|
||||||
kind: StreamKind,
|
|
||||||
message: &str,
|
|
||||||
immediate: bool,
|
|
||||||
sink: &impl HistorySink,
|
|
||||||
) -> bool {
|
|
||||||
self.begin(kind, sink);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let state = self.state_mut(kind);
|
let state = &mut self.state;
|
||||||
// Only inject the final full message if we have not seen any deltas for this stream.
|
// Only inject the final full message if we have not seen any deltas for this stream.
|
||||||
// If deltas were received, rely on the collector's existing buffer to avoid duplication.
|
// If deltas were received, rely on the collector's existing buffer to avoid duplication.
|
||||||
if !state.has_seen_delta && !message.is_empty() {
|
if !state.has_seen_delta && !message.is_empty() {
|
||||||
@@ -301,7 +223,6 @@ impl StreamController {
|
|||||||
.replace_with_and_mark_committed(&msg, committed);
|
.replace_with_and_mark_committed(&msg, committed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.finalize(true, sink)
|
||||||
self.finalize(kind, immediate, sink)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ use crate::markdown_stream::AnimatedLineStreamer;
|
|||||||
use crate::markdown_stream::MarkdownStreamCollector;
|
use crate::markdown_stream::MarkdownStreamCollector;
|
||||||
pub(crate) mod controller;
|
pub(crate) mod controller;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum StreamKind {
|
|
||||||
Answer,
|
|
||||||
Reasoning,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct StreamState {
|
pub(crate) struct StreamState {
|
||||||
pub(crate) collector: MarkdownStreamCollector,
|
pub(crate) collector: MarkdownStreamCollector,
|
||||||
pub(crate) streamer: AnimatedLineStreamer,
|
pub(crate) streamer: AnimatedLineStreamer,
|
||||||
@@ -42,92 +36,44 @@ impl StreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct HeaderEmitter {
|
pub(crate) struct HeaderEmitter {
|
||||||
reasoning_emitted_this_turn: bool,
|
emitted_this_turn: bool,
|
||||||
answer_emitted_this_turn: bool,
|
emitted_in_stream: bool,
|
||||||
reasoning_emitted_in_stream: bool,
|
|
||||||
answer_emitted_in_stream: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HeaderEmitter {
|
impl HeaderEmitter {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
reasoning_emitted_this_turn: false,
|
emitted_this_turn: false,
|
||||||
answer_emitted_this_turn: false,
|
emitted_in_stream: false,
|
||||||
reasoning_emitted_in_stream: false,
|
|
||||||
answer_emitted_in_stream: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reset_for_new_turn(&mut self) {
|
pub(crate) fn reset_for_new_turn(&mut self) {
|
||||||
self.reasoning_emitted_this_turn = false;
|
self.emitted_this_turn = false;
|
||||||
self.answer_emitted_this_turn = false;
|
self.emitted_in_stream = false;
|
||||||
self.reasoning_emitted_in_stream = false;
|
|
||||||
self.answer_emitted_in_stream = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) {
|
pub(crate) fn reset_for_stream(&mut self) {
|
||||||
match kind {
|
self.emitted_in_stream = false;
|
||||||
StreamKind::Reasoning => self.reasoning_emitted_in_stream = false,
|
|
||||||
StreamKind::Answer => self.answer_emitted_in_stream = false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool {
|
/// Allow emitting the header again within the current turn after a finalize.
|
||||||
match kind {
|
pub(crate) fn allow_reemit_in_turn(&mut self) {
|
||||||
StreamKind::Reasoning => self.reasoning_emitted_in_stream,
|
self.emitted_this_turn = false;
|
||||||
StreamKind::Answer => self.answer_emitted_in_stream,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow emitting the header again for the same kind within the current turn.
|
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool {
|
||||||
///
|
if !self.emitted_in_stream && !self.emitted_this_turn {
|
||||||
/// This is used when a stream (e.g., Answer) is finalized and a subsequent
|
out_lines.push(render_header_line());
|
||||||
/// block of the same kind is started within the same turn. Without this,
|
self.emitted_in_stream = true;
|
||||||
/// only the first block would render a header.
|
self.emitted_this_turn = true;
|
||||||
pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) {
|
return true;
|
||||||
match kind {
|
|
||||||
StreamKind::Reasoning => self.reasoning_emitted_this_turn = false,
|
|
||||||
StreamKind::Answer => self.answer_emitted_this_turn = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn maybe_emit(
|
|
||||||
&mut self,
|
|
||||||
kind: StreamKind,
|
|
||||||
out_lines: &mut Vec<ratatui::text::Line<'static>>,
|
|
||||||
) -> bool {
|
|
||||||
let already_emitted_this_turn = match kind {
|
|
||||||
StreamKind::Reasoning => self.reasoning_emitted_this_turn,
|
|
||||||
StreamKind::Answer => self.answer_emitted_this_turn,
|
|
||||||
};
|
|
||||||
let already_emitted_in_stream = self.has_emitted_for_stream(kind);
|
|
||||||
if !already_emitted_in_stream && !already_emitted_this_turn {
|
|
||||||
out_lines.push(render_header_line(kind));
|
|
||||||
match kind {
|
|
||||||
StreamKind::Reasoning => {
|
|
||||||
self.reasoning_emitted_in_stream = true;
|
|
||||||
self.reasoning_emitted_this_turn = true;
|
|
||||||
// Reset opposite header so it may be emitted again this turn
|
|
||||||
self.answer_emitted_this_turn = false;
|
|
||||||
}
|
|
||||||
StreamKind::Answer => {
|
|
||||||
self.answer_emitted_in_stream = true;
|
|
||||||
self.answer_emitted_this_turn = true;
|
|
||||||
// Reset opposite header so it may be emitted again this turn
|
|
||||||
self.reasoning_emitted_this_turn = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
|
fn render_header_line() -> ratatui::text::Line<'static> {
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
match kind {
|
ratatui::text::Line::from("codex".magenta().bold())
|
||||||
StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()),
|
|
||||||
StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
thinking
|
To get started, describe a task or try one of these commands:
|
||||||
Investigating binary sizes
|
|
||||||
|
|
||||||
I need to check the codex-rs repository to explain why the project's binaries
|
/init - create an AGENTS.md file with instructions for Codex
|
||||||
are large. The user is likely seeking specifics about the setup: are Rust builds
|
/status - show current session configuration and token usage
|
||||||
static, what features are enabled, and is debug information included? It could
|
/approvals - choose what Codex can do without approval
|
||||||
be due to static linking, included OpenSSL, or how panic handling is set up. I
|
/model - choose what model and reasoning effort to use
|
||||||
should look into the Cargo.toml file to confirm features and profiles without
|
|
||||||
needing to edit any code. Let's get started on this!
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
I’m going to scan the workspace and Cargo manifests to see build profiles and
|
I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||||
@@ -37,16 +34,6 @@ dependencies that impact binary size. Then I’ll summarize the main causes.
|
|||||||
# ratatui = { path = "../../ratatui" }
|
# ratatui = { path = "../../ratatui" }
|
||||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
|
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
|
||||||
"nornagon-v0.29.0-patch" }
|
"nornagon-v0.29.0-patch" }
|
||||||
Optimizing Rust Release Profile
|
|
||||||
|
|
||||||
I'm reviewing the workspace's release profile, which has settings like lto=fat,
|
|
||||||
strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that
|
|
||||||
"strip = symbols" only removes symbols, not debug information. In Rust 1.73+,
|
|
||||||
both "symbols" and "debuginfo" stripping options are available. I should check
|
|
||||||
whether using these results in a smaller binary. I’ll also skim through crates
|
|
||||||
for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox,
|
|
||||||
tui, login, ollama, and mcp.
|
|
||||||
|
|
||||||
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||||
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
|
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
|
||||||
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
||||||
@@ -181,85 +168,6 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
thinking
|
|
||||||
Considering binary size impacts
|
|
||||||
|
|
||||||
I’m evaluating how binary size is influenced by various factors. There are many
|
|
||||||
binaries, such as codex and codex-exec, which include dependencies by default,
|
|
||||||
pulling in features like Tokio and hyper. For instance, reqwest includes
|
|
||||||
default features like TLS through rustls. If default features remain enabled,
|
|
||||||
it inflates the size with many additional components. I notice that for musl
|
|
||||||
targets, using vendored builds of OpenSSL could significantly increase binary
|
|
||||||
size. This is quite the intricate web of dependencies!
|
|
||||||
|
|
||||||
Analyzing musl and binary size
|
|
||||||
|
|
||||||
I’m considering the implications of building for musl with static linking.
|
|
||||||
In this case, OpenSSL might come into play through some crates, but reqwest
|
|
||||||
defaults to using rustls, although openssl-sys vendored could increase binary
|
|
||||||
size. It’s interesting to think about whether they’re targeting musl, especially
|
|
||||||
since it seems they might often be building for Mac and Linux with dynamic
|
|
||||||
linking instead.
|
|
||||||
|
|
||||||
I also realize there’s a difference between debug and release builds — release
|
|
||||||
should be smaller due to optimizations, but if they’re defaulting to debug
|
|
||||||
builds, that could lead to much larger binaries.
|
|
||||||
|
|
||||||
Assessing crate impacts on size
|
|
||||||
|
|
||||||
I need to be precise when analyzing binary sizes from various crates. Some
|
|
||||||
crates, like ratatui-image, include heavy features such as libjpeg decoders,
|
|
||||||
which can really bulk things up. Then there are the tree-sitter crates that
|
|
||||||
contain large static grammar tables, adding significant size. The starlark crate
|
|
||||||
and tracing-subscriber also contribute to size increases.
|
|
||||||
|
|
||||||
With static linking per binary being standard in Rust, it leads to multiple
|
|
||||||
copies of the same dependencies unless cdylibs are used. That makes the compile
|
|
||||||
times longer, but using LTO helps reduce bloat somewhat through deduplication.
|
|
||||||
|
|
||||||
Evaluating binary size factors
|
|
||||||
|
|
||||||
Having multiple binaries indeed increases the overall size significantly.
|
|
||||||
I'm considering options like upgrading to "opt-level = 'z'" or using
|
|
||||||
"strip=debuginfo" for reducing size. It's interesting that while the profile
|
|
||||||
only sets symbols for stripping, not debuginfo, this could inflate sizes if
|
|
||||||
retained in release builds.
|
|
||||||
|
|
||||||
The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps
|
|
||||||
debug information which may not align with the goal of minimizing size. I’d like
|
|
||||||
to verify with the Rust documentation, but my network access is restricted.
|
|
||||||
|
|
||||||
Explaining large binary sizes
|
|
||||||
|
|
||||||
I’m analyzing why binaries in this project are so large. The primary reasons
|
|
||||||
include monolithic static linking, where each binary includes the full
|
|
||||||
dependency graph without shared libraries. Heavy dependencies such as reqwest,
|
|
||||||
hyper, and TLS components also contribute significantly to size, along with
|
|
||||||
image decoders and other libraries.
|
|
||||||
|
|
||||||
Using MUSL targets means vendored OpenSSL can drastically increase the size due
|
|
||||||
to static linking. Furthermore, if the release profile keeps debug information
|
|
||||||
due to using "strip = symbols," that can lead to larger binaries as well. It’s a
|
|
||||||
mix of several factors, including panic strategies and platform-specific linking
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
Identifying binary size contributors
|
|
||||||
|
|
||||||
I’m considering why binaries are still large even without static linking. The
|
|
||||||
tracing-subscriber crate potentially adds weight because it includes regex,
|
|
||||||
though it can sometimes use a lighter version. References from files like "core/
|
|
||||||
Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the
|
|
||||||
image crate.
|
|
||||||
|
|
||||||
To present this clearly, I’ll format the final answer with two main sections:
|
|
||||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
|
||||||
reducing size, but I want to stay focused on answering the user's question.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
Here’s what’s driving size in this workspace’s binaries.
|
Here’s what’s driving size in this workspace’s binaries.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user