Revert "Streaming markdown (#1920)" (#1981)

This reverts commit 2b7139859e.
This commit is contained in:
easong-openai
2025-08-07 18:38:39 -07:00
committed by GitHub
parent 2b7139859e
commit 52e12f2b6c
14 changed files with 481 additions and 1940 deletions

View File

@@ -504,17 +504,11 @@ async fn process_sse<S>(
| "response.in_progress"
| "response.output_item.added"
| "response.output_text.done"
| "response.reasoning_summary_part.added" => {
// Currently, we ignore this event, but we handle it
| "response.reasoning_summary_part.added"
| "response.reasoning_summary_text.done" => {
// Currently, we ignore these events, but we handle them
// separately to skip the logging message in the `other` case.
}
"response.reasoning_summary_text.done" => {
// End reasoning summary with a blank separator.
let event = ResponseEvent::ReasoningSummaryDelta("\n\n".to_string());
if tx_event.send(Ok(event)).await.is_err() {
return;
}
}
other => debug!(other, "sse event"),
}
}

View File

@@ -64,9 +64,6 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec<Line<'static>>,
enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -176,7 +173,6 @@ impl App<'_> {
file_search,
pending_redraw,
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
}
}
@@ -193,7 +189,7 @@ impl App<'_> {
// redraw is already pending so we can return early.
if self
.pending_redraw
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
@@ -204,7 +200,7 @@ impl App<'_> {
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
pending_redraw.store(false, Ordering::Release);
pending_redraw.store(false, Ordering::SeqCst);
});
}
@@ -225,30 +221,6 @@ impl App<'_> {
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(50));
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_commit_tick();
}
}
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {

View File

@@ -50,10 +50,6 @@ pub(crate) enum AppEvent {
InsertHistory(Vec<Line<'static>>),
StartCommitAnimation,
StopCommitAnimation,
CommitTick,
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),

View File

@@ -0,0 +1,45 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
/// Minimal rendering-only widget for the transient ring rows.
pub(crate) struct LiveRingWidget {
max_rows: u16,
rows: Vec<Line<'static>>, // newest at the end
}
impl LiveRingWidget {
pub fn new() -> Self {
Self {
max_rows: 3,
rows: Vec::new(),
}
}
pub fn set_max_rows(&mut self, n: u16) {
self.max_rows = n.max(1);
}
pub fn set_rows(&mut self, rows: Vec<Line<'static>>) {
self.rows = rows;
}
pub fn desired_height(&self, _width: u16) -> u16 {
let len = self.rows.len() as u16;
len.min(self.max_rows)
}
}
impl WidgetRef for LiveRingWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let visible = self.rows.len().saturating_sub(self.max_rows as usize);
let slice = &self.rows[visible..];
let para = Paragraph::new(slice.to_vec());
para.render_ref(area, buf);
}
}

View File

@@ -9,6 +9,7 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
mod approval_modal_view;
@@ -17,6 +18,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod live_ring_widget;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
@@ -55,6 +57,10 @@ pub(crate) struct BottomPane<'a> {
/// not replace the composer; it augments it.
live_status: Option<StatusIndicatorWidget>,
/// Optional transient ring shown above the composer. This is a rendering-only
/// container used during development before we wire it to ChatWidget events.
live_ring: Option<live_ring_widget::LiveRingWidget>,
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
@@ -82,6 +88,7 @@ impl BottomPane<'_> {
is_task_running: false,
ctrl_c_quit_hint: false,
live_status: None,
live_ring: None,
status_view_active: false,
}
}
@@ -92,14 +99,26 @@ impl BottomPane<'_> {
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
let ring_h = self
.live_ring
.as_ref()
.map(|r| r.desired_height(width))
.unwrap_or(0);
let view_height = if let Some(view) = self.active_view.as_ref() {
view.desired_height(width)
// Add a single blank spacer line between live ring and status view when active.
let spacer = if self.live_ring.is_some() && self.status_view_active {
1
} else {
0
};
spacer + view.desired_height(width)
} else {
self.composer.desired_height(width)
};
overlay_status_h
.saturating_add(ring_h)
.saturating_add(view_height)
.saturating_add(Self::BOTTOM_PAD_LINES)
}
@@ -333,11 +352,43 @@ impl BottomPane<'_> {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
/// Set the rows and cap for the transient live ring overlay.
pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec<Line<'static>>) {
let mut w = live_ring_widget::LiveRingWidget::new();
w.set_max_rows(max_rows);
w.set_rows(rows);
self.live_ring = Some(w);
}
pub(crate) fn clear_live_ring(&mut self) {
self.live_ring = None;
}
// Removed restart_live_status_with_text no longer used by the current streaming UI.
}
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut y_offset = 0u16;
if let Some(ring) = &self.live_ring {
let live_h = ring.desired_height(area.width).min(area.height);
if live_h > 0 {
let live_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: live_h,
};
ring.render_ref(live_rect, buf);
y_offset = live_h;
}
}
// Spacer between live ring and status view when active
if self.live_ring.is_some() && self.status_view_active && y_offset < area.height {
// Leave one empty line
y_offset = y_offset.saturating_add(1);
}
if let Some(status) = &self.live_status {
let live_h = status
.desired_height(area.width)
@@ -387,6 +438,7 @@ mod tests {
use crate::app_event::AppEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::mpsc::channel;
@@ -414,7 +466,103 @@ mod tests {
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
}
// live ring removed; related tests deleted.
#[test]
fn live_ring_renders_above_composer() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
enhanced_keys_supported: false,
});
// Provide 4 rows with max_rows=3; only the last 3 should be visible.
pane.set_live_ring_rows(
3,
vec![
Line::from("one".to_string()),
Line::from("two".to_string()),
Line::from("three".to_string()),
Line::from("four".to_string()),
],
);
let area = Rect::new(0, 0, 10, 5);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Extract the first 3 rows and assert they contain the last three lines.
let mut lines: Vec<String> = Vec::new();
for y in 0..3 {
let mut s = String::new();
for x in 0..area.width {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
lines.push(s.trim_end().to_string());
}
assert_eq!(lines, vec!["two", "three", "four"]);
}
#[test]
fn status_indicator_visible_with_live_ring() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
enhanced_keys_supported: false,
});
// Simulate task running which replaces composer with the status indicator.
pane.set_task_running(true);
pane.update_status_text("waiting for model".to_string());
// Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the
// status indicator remains visible below them.
pane.set_live_ring_rows(
2,
vec![
Line::from("cot1".to_string()),
Line::from("cot2".to_string()),
],
);
// Allow some frames so the dot animation is present.
std::thread::sleep(std::time::Duration::from_millis(120));
// Height should include both ring rows, 1 spacer, and the 1-line status.
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
// Top two rows are the live ring.
let mut r0 = String::new();
let mut r1 = String::new();
for x in 0..area.width {
r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(r0.contains("cot1"), "expected first live row: {r0:?}");
assert!(r1.contains("cot2"), "expected second live row: {r1:?}");
// Row 2 is the spacer (blank)
let mut r2 = String::new();
for x in 0..area.width {
r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
}
assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
// Bottom row is the status line; it should contain the left bar and "Working".
let mut r3 = String::new();
for x in 0..area.width {
r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(
r3.contains("Working"),
"expected Working header in status line: {r3:?}"
);
}
#[test]
fn overlay_not_shown_above_approval_modal() {

View File

@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
@@ -46,14 +45,13 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
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_stream::MarkdownNewlineCollector;
use crate::markdown_stream::RenderedLineStreamer;
use crate::live_wrap::RowBuilder;
use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
use ratatui::style::Stylize;
struct RunningCommand {
command: Vec<String>,
@@ -70,21 +68,17 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
// Newline-gated markdown streaming state
reasoning_collector: MarkdownNewlineCollector,
answer_collector: MarkdownNewlineCollector,
reasoning_streamer: RenderedLineStreamer,
answer_streamer: RenderedLineStreamer,
reasoning_buffer: String,
content_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.
answer_buffer: String,
running_commands: HashMap<String, RunningCommand>,
live_builder: RowBuilder,
current_stream: Option<StreamKind>,
// Track header emission per stream kind to avoid cross-stream duplication
answer_header_emitted: bool,
reasoning_header_emitted: bool,
stream_header_emitted: bool,
live_max_rows: u16,
task_complete_pending: bool,
finishing_after_drain: bool,
// Queue of interruptive UI events deferred during an active write cycle
interrupt_queue: VecDeque<QueuedInterrupt>,
}
struct UserMessage {
@@ -98,15 +92,6 @@ enum StreamKind {
Reasoning,
}
#[derive(Debug)]
enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
ExecBegin(ExecCommandBeginEvent),
McpBegin(McpToolCallBeginEvent),
McpEnd(McpToolCallEndEvent),
}
impl From<String> for UserMessage {
fn from(text: String) -> Self {
Self {
@@ -125,173 +110,19 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget<'_> {
fn header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
use ratatui::style::Stylize;
match kind {
StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()),
StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()),
}
}
fn line_is_blank(line: &ratatui::text::Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans.iter().all(|s| s.content.trim().is_empty())
}
/// Periodic tick to commit at most one queued line to history with a small delay,
/// animating the output.
pub(crate) fn on_commit_tick(&mut self) {
// Choose the active streamer
let (streamer, kind_opt) = match self.current_stream {
Some(StreamKind::Reasoning) => {
(&mut self.reasoning_streamer, Some(StreamKind::Reasoning))
}
Some(StreamKind::Answer) => (&mut self.answer_streamer, Some(StreamKind::Answer)),
None => {
// No active stream. Nothing to animate.
return;
}
};
// Prepare header if needed
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if let Some(k) = kind_opt {
let header_needed = match k {
StreamKind::Reasoning => !self.reasoning_header_emitted,
StreamKind::Answer => !self.answer_header_emitted,
};
if header_needed {
lines.push(Self::header_line(k));
match k {
StreamKind::Reasoning => self.reasoning_header_emitted = true,
StreamKind::Answer => self.answer_header_emitted = true,
}
}
}
let step = streamer.step(self.live_max_rows as usize);
if !step.history.is_empty() || !lines.is_empty() {
lines.extend(step.history);
self.app_event_tx.send(AppEvent::InsertHistory(lines));
}
// If streamer is now idle and there is no more active stream data, finalize state.
let is_idle = streamer.is_idle();
if is_idle {
// Stop animation ticks between bursts.
self.app_event_tx.send(AppEvent::StopCommitAnimation);
if self.finishing_after_drain {
// Final cleanup once fully drained at end-of-stream.
self.current_stream = None;
self.finishing_after_drain = false;
if self.task_complete_pending {
self.bottom_pane.set_task_running(false);
self.task_complete_pending = false;
}
// After the write cycle completes, release any queued interrupts.
self.flush_interrupt_queue();
}
}
}
fn is_write_cycle_active(&self) -> bool {
self.current_stream.is_some()
}
fn flush_interrupt_queue(&mut self) {
while let Some(q) = self.interrupt_queue.pop_front() {
match q {
QueuedInterrupt::ExecApproval(id, ev) => self.handle_exec_approval_now(id, ev),
QueuedInterrupt::ApplyPatchApproval(id, ev) => {
self.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::ExecBegin(ev) => self.handle_exec_begin_now(ev),
QueuedInterrupt::McpBegin(ev) => self.handle_mcp_begin_now(ev),
QueuedInterrupt::McpEnd(ev) => self.handle_mcp_end_now(ev),
}
}
}
fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
// Log a background summary immediately so the history is chronological.
let cmdline = strip_bash_lc_and_escape(&ev.command);
let text = format!(
"command requires approval:\n$ {cmdline}{reason}",
reason = ev
.reason
.as_ref()
.map(|r| format!("\n{r}"))
.unwrap_or_default()
);
self.add_to_history(HistoryCell::new_background_event(text));
let request = ApprovalRequest::Exec {
id,
command: ev.command,
cwd: ev.cwd,
reason: ev.reason,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
}
fn handle_apply_patch_approval_now(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) {
self.add_to_history(HistoryCell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
));
let request = ApprovalRequest::ApplyPatch {
id,
reason: ev.reason,
grant_root: ev.grant_root,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
}
fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.bottom_pane
.update_status_text("running command".to_string());
self.running_commands.insert(
ev.call_id.clone(),
RunningCommand {
command: ev.command.clone(),
cwd: ev.cwd.clone(),
},
);
self.active_history_cell = Some(HistoryCell::new_active_exec_command(ev.command));
}
fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation));
}
fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
ev.result
.as_ref()
.map(|r| r.is_error.unwrap_or(false))
.unwrap_or(false),
ev.result,
));
}
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
self.active_history_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
self.reasoning_collector.clear();
self.answer_collector.clear();
self.reasoning_streamer.clear();
self.answer_streamer.clear();
self.bottom_pane.clear_live_ring();
self.live_builder = RowBuilder::new(self.live_builder.width());
self.current_stream = None;
self.answer_header_emitted = false;
self.reasoning_header_emitted = false;
self.stream_header_emitted = false;
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.content_buffer.clear();
self.request_redraw();
}
}
@@ -306,7 +137,24 @@ impl ChatWidget<'_> {
])
.areas(area)
}
fn emit_stream_header(&mut self, kind: StreamKind) {
use ratatui::text::Line as RLine;
if self.stream_header_emitted {
return;
}
let header = match kind {
StreamKind::Reasoning => RLine::from("thinking".magenta().italic()),
StreamKind::Answer => RLine::from("codex".magenta().bold()),
};
self.app_event_tx
.send(AppEvent::InsertHistory(vec![header]));
self.stream_header_emitted = true;
}
fn finalize_active_stream(&mut self) {
if let Some(kind) = self.current_stream {
self.finalize_stream(kind);
}
}
pub(crate) fn new(
config: Config,
app_event_tx: AppEventSender,
@@ -368,18 +216,14 @@ impl ChatWidget<'_> {
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
reasoning_collector: MarkdownNewlineCollector::new(),
answer_collector: MarkdownNewlineCollector::new(),
reasoning_streamer: RenderedLineStreamer::new(),
answer_streamer: RenderedLineStreamer::new(),
reasoning_buffer: String::new(),
content_buffer: String::new(),
answer_buffer: String::new(),
running_commands: HashMap::new(),
live_builder: RowBuilder::new(80),
current_stream: None,
answer_header_emitted: false,
reasoning_header_emitted: false,
stream_header_emitted: false,
live_max_rows: 3,
task_complete_pending: false,
finishing_after_drain: false,
interrupt_queue: VecDeque::new(),
}
}
@@ -476,6 +320,7 @@ impl ChatWidget<'_> {
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
self.begin_stream(StreamKind::Answer);
self.answer_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
@@ -483,6 +328,7 @@ impl ChatWidget<'_> {
// Stream CoT into the live pane; keep input visible and commit
// overflow rows incrementally to scrollback.
self.begin_stream(StreamKind::Reasoning);
self.reasoning_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
@@ -496,6 +342,7 @@ impl ChatWidget<'_> {
}) => {
// Treat raw reasoning content the same as summarized reasoning for UI flow.
self.begin_stream(StreamKind::Reasoning);
self.reasoning_buffer.push_str(&delta);
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
@@ -515,18 +362,9 @@ impl ChatWidget<'_> {
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: _,
}) => {
// Defer clearing status/live ring until streaming fully completes.
let streaming_active = match self.current_stream {
Some(StreamKind::Reasoning) => !self.reasoning_streamer.is_idle(),
Some(StreamKind::Answer) => !self.answer_streamer.is_idle(),
None => false,
};
if streaming_active {
self.task_complete_pending = true;
} else {
self.bottom_pane.set_task_running(false);
self.request_redraw();
}
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
self.request_redraw();
}
EventMsg::TokenCount(token_usage) => {
self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
@@ -540,42 +378,83 @@ impl ChatWidget<'_> {
EventMsg::Error(ErrorEvent { message }) => {
self.add_to_history(HistoryCell::new_error_event(message.clone()));
self.bottom_pane.set_task_running(false);
self.reasoning_collector.clear();
self.answer_collector.clear();
self.reasoning_streamer.clear();
self.answer_streamer.clear();
self.bottom_pane.clear_live_ring();
self.live_builder = RowBuilder::new(self.live_builder.width());
self.current_stream = None;
self.answer_header_emitted = false;
self.reasoning_header_emitted = false;
self.stream_header_emitted = false;
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.content_buffer.clear();
self.request_redraw();
}
EventMsg::PlanUpdate(update) => {
// Commit plan updates directly to history (no status-line preview).
self.add_to_history(HistoryCell::new_plan_update(update));
}
EventMsg::ExecApprovalRequest(ev) => {
if self.is_write_cycle_active() {
self.interrupt_queue
.push_back(QueuedInterrupt::ExecApproval(id, ev));
} else {
self.handle_exec_approval_now(id, ev);
}
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id: _,
command,
cwd,
reason,
}) => {
self.finalize_active_stream();
let request = ApprovalRequest::Exec {
id,
command,
cwd,
reason,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
if self.is_write_cycle_active() {
self.interrupt_queue
.push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
} else {
self.handle_apply_patch_approval_now(id, ev);
}
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: _,
changes,
reason,
grant_root,
}) => {
self.finalize_active_stream();
// ------------------------------------------------------------------
// Before we even prompt the user for approval we surface the patch
// summary in the main conversation so that the dialog appears in a
// sensible chronological order:
// (1) codex → proposes patch (HistoryCell::PendingPatch)
// (2) UI → asks for approval (BottomPane)
// This mirrors how command execution is shown (command begins →
// approval dialog) and avoids surprising the user with a modal
// prompt before they have seen *what* is being requested.
// ------------------------------------------------------------------
self.add_to_history(HistoryCell::new_patch_event(
PatchEventType::ApprovalRequest,
changes,
));
// Now surface the approval request in the BottomPane as before.
let request = ApprovalRequest::ApplyPatch {
id,
reason,
grant_root,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
}
EventMsg::ExecCommandBegin(ev) => {
if self.is_write_cycle_active() {
self.interrupt_queue
.push_back(QueuedInterrupt::ExecBegin(ev));
} else {
self.handle_exec_begin_now(ev);
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id,
command,
cwd,
}) => {
self.finalize_active_stream();
// Ensure the status indicator is visible while the command runs.
self.bottom_pane
.update_status_text("running command".to_string());
self.running_commands.insert(
call_id,
RunningCommand {
command: command.clone(),
cwd: cwd.clone(),
},
);
self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
@@ -614,20 +493,29 @@ impl ChatWidget<'_> {
},
));
}
EventMsg::McpToolCallBegin(ev) => {
if self.is_write_cycle_active() {
self.interrupt_queue
.push_back(QueuedInterrupt::McpBegin(ev));
} else {
self.handle_mcp_begin_now(ev);
}
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
invocation,
}) => {
self.finalize_active_stream();
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
}
EventMsg::McpToolCallEnd(ev) => {
if self.is_write_cycle_active() {
self.interrupt_queue.push_back(QueuedInterrupt::McpEnd(ev));
} else {
self.handle_mcp_end_now(ev);
}
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: _,
duration,
invocation,
result,
}) => {
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
80,
invocation,
duration,
result
.as_ref()
.map(|r| r.is_error.unwrap_or(false))
.unwrap_or(false),
result,
));
}
EventMsg::GetHistoryEntryResponse(event) => {
let codex_core::protocol::GetHistoryEntryResponseEvent {
@@ -747,98 +635,62 @@ impl ChatWidget<'_> {
}
}
#[cfg(test)]
impl ChatWidget<'_> {
/// Test-only control to tune the maximum rows shown in the live overlay.
/// Useful for verifying queue-head behavior without changing production defaults.
pub fn test_set_live_max_rows(&mut self, n: u16) {
self.live_max_rows = n;
}
}
impl ChatWidget<'_> {
fn begin_stream(&mut self, kind: StreamKind) {
if let Some(current) = self.current_stream {
if current != kind {
// Synchronously flush the previous stream to keep ordering sane.
let (collector, streamer) = match current {
StreamKind::Reasoning => {
(&mut self.reasoning_collector, &mut self.reasoning_streamer)
}
StreamKind::Answer => (&mut self.answer_collector, &mut self.answer_streamer),
};
let remaining = collector.finalize_and_drain(&self.config);
if !remaining.is_empty() {
streamer.enqueue(remaining);
}
let step = streamer.drain_all(self.live_max_rows as usize);
let prev_header_emitted = match current {
StreamKind::Reasoning => self.reasoning_header_emitted,
StreamKind::Answer => self.answer_header_emitted,
};
if !step.history.is_empty() || !prev_header_emitted {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !prev_header_emitted {
lines.push(Self::header_line(current));
match current {
StreamKind::Reasoning => self.reasoning_header_emitted = true,
StreamKind::Answer => self.answer_header_emitted = true,
}
}
lines.extend(step.history);
// Ensure at most one blank separator after the flushed block.
if let Some(last) = lines.last() {
if !Self::line_is_blank(last) {
lines.push(ratatui::text::Line::from(""));
}
} else {
lines.push(ratatui::text::Line::from(""));
}
self.app_event_tx.send(AppEvent::InsertHistory(lines));
}
// Reset for new stream
self.current_stream = None;
self.finalize_stream(current);
}
}
if self.current_stream != Some(kind) {
// Only reset the header flag when switching FROM a different stream kind.
// If current_stream is None (e.g., transient idle), preserve header flags
// to avoid duplicate headers on re-entry into the same stream.
let prev = self.current_stream;
self.current_stream = Some(kind);
if prev.is_some() {
match kind {
StreamKind::Reasoning => self.reasoning_header_emitted = false,
StreamKind::Answer => self.answer_header_emitted = false,
}
}
self.stream_header_emitted = false;
// Clear any previous live content; we're starting a new stream.
self.live_builder = RowBuilder::new(self.live_builder.width());
// Ensure the waiting status is visible (composer replaced).
self.bottom_pane
.update_status_text("waiting for model".to_string());
// No live ring overlay; headers will be inserted with the first commit.
self.emit_stream_header(kind);
}
}
fn stream_push_and_maybe_commit(&mut self, delta: &str) {
// Newline-gated: only consider committing when a newline is present.
let (collector, streamer) = match self.current_stream {
Some(StreamKind::Reasoning) => {
(&mut self.reasoning_collector, &mut self.reasoning_streamer)
}
Some(StreamKind::Answer) => (&mut self.answer_collector, &mut self.answer_streamer),
None => return,
};
self.live_builder.push_fragment(delta);
collector.push_delta(delta);
if delta.contains('\n') {
let newly_completed = collector.commit_complete_lines(&self.config);
if !newly_completed.is_empty() {
streamer.enqueue(newly_completed);
// Start or continue commit animation.
self.app_event_tx.send(AppEvent::StartCommitAnimation);
// Commit overflow rows (small batches) while keeping the last N rows visible.
let drained = self
.live_builder
.drain_commit_ready(self.live_max_rows as usize);
if !drained.is_empty() {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !self.stream_header_emitted {
match self.current_stream {
Some(StreamKind::Reasoning) => {
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
Some(StreamKind::Answer) => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
}
None => {}
}
self.stream_header_emitted = true;
}
for r in drained {
lines.push(ratatui::text::Line::from(r.text));
}
self.app_event_tx.send(AppEvent::InsertHistory(lines));
}
// Update the live ring overlay lines (text-only, newest at bottom).
let rows = self
.live_builder
.display_rows()
.into_iter()
.map(|r| ratatui::text::Line::from(r.text))
.collect::<Vec<_>>();
self.bottom_pane
.set_live_ring_rows(self.live_max_rows, rows);
}
fn finalize_stream(&mut self, kind: StreamKind) {
@@ -846,21 +698,38 @@ impl ChatWidget<'_> {
// Nothing to do; either already finalized or not the active stream.
return;
}
let (collector, streamer) = match kind {
StreamKind::Reasoning => (&mut self.reasoning_collector, &mut self.reasoning_streamer),
StreamKind::Answer => (&mut self.answer_collector, &mut self.answer_streamer),
};
let remaining = collector.finalize_and_drain(&self.config);
if !remaining.is_empty() {
streamer.enqueue(remaining);
// Flush any partial line as a full row, then drain all remaining rows.
self.live_builder.end_line();
let remaining = self.live_builder.drain_rows();
// TODO: Re-add markdown rendering for assistant answers and reasoning.
// When finalizing, pass the accumulated text through `markdown::append_markdown`
// to build styled `Line<'static>` entries instead of raw plain text lines.
if !remaining.is_empty() || !self.stream_header_emitted {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
if !self.stream_header_emitted {
match kind {
StreamKind::Reasoning => {
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
}
StreamKind::Answer => {
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
}
}
self.stream_header_emitted = true;
}
for r in remaining {
lines.push(ratatui::text::Line::from(r.text));
}
// Close the block with a blank line for readability.
lines.push(ratatui::text::Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistory(lines));
}
// Trailing blank spacer
streamer.enqueue(vec![ratatui::text::Line::from("")]);
// Mark that we should clear state after draining.
self.finishing_after_drain = true;
// Start animation to drain remaining lines. Final cleanup will occur when drained.
self.app_event_tx.send(AppEvent::StartCommitAnimation);
// Clear the live overlay and reset state for the next stream.
self.live_builder = RowBuilder::new(self.live_builder.width());
self.bottom_pane.clear_live_ring();
self.current_stream = None;
self.stream_header_emitted = false;
}
}
@@ -901,34 +770,3 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
#[cfg(test)]
mod chatwidget_helper_tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::config::ConfigOverrides;
use std::sync::mpsc::channel;
fn test_config() -> Config {
let overrides = ConfigOverrides {
cwd: std::env::current_dir().ok(),
..Default::default()
};
match Config::load_with_cli_overrides(vec![], overrides) {
Ok(c) => c,
Err(e) => panic!("load test config: {e}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let cfg = test_config();
let mut w = ChatWidget::new(cfg, tx, None, Vec::new(), false);
// Adjust the live ring capacity (no-op for rendering) and ensure no panic.
w.test_set_live_max_rows(4);
}
}

View File

@@ -1,392 +0,0 @@
#[cfg(test)]
mod tests {
use std::sync::mpsc::{channel, Receiver};
use std::time::Duration;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::{
AgentMessageDeltaEvent, AgentMessageEvent, AgentReasoningDeltaEvent, AgentReasoningEvent, Event, EventMsg,
};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
fn test_config() -> Config {
let overrides = ConfigOverrides {
cwd: std::env::current_dir().ok(),
..Default::default()
};
match Config::load_with_cli_overrides(vec![], overrides) {
Ok(c) => c,
Err(e) => panic!("load test config: {e}"),
}
}
fn recv_insert_history(
rx: &Receiver<AppEvent>,
timeout_ms: u64,
) -> Option<Vec<ratatui::text::Line<'static>>> {
let to = Duration::from_millis(timeout_ms);
match rx.recv_timeout(to) {
Ok(AppEvent::InsertHistory(lines)) => Some(lines),
Ok(_) => None,
Err(_) => None,
}
}
#[test]
fn widget_streams_on_newline_and_header_once() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let config = test_config();
let mut w = ChatWidget::new(config.clone(), tx.clone(), None, Vec::new(), false);
// Start reasoning stream with partial content (no newline): expect no history yet.
w.handle_codex_event(Event {
id: "1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: "Hello".into(),
}),
});
// No history commit before newline.
assert!(
recv_insert_history(&rx, 50).is_none(),
"unexpected history before newline"
);
// No live overlay anymore; nothing visible until commit.
// Push a newline which should cause commit of the first logical line.
w.handle_codex_event(Event {
id: "1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: " world\nNext".into(),
}),
});
let lines = match recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("expected history after newline"),
};
let rendered: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
// First commit should include the header and the completed first line once.
assert!(
rendered.iter().any(|s| s.contains("thinking")),
"missing reasoning header: {rendered:?}"
);
assert!(
rendered.iter().any(|s| s.contains("Hello world")),
"missing committed line: {rendered:?}"
);
// Send finalize; expect remaining content to flush and a trailing blank line.
w.handle_codex_event(Event {
id: "1".into(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent {
text: String::new(),
}),
});
let lines2 = match recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("expected history after finalize"),
};
let rendered2: Vec<String> = lines2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
// Ensure header not repeated on finalize and a blank spacer exists at the end.
let header_count = rendered
.iter()
.chain(rendered2.iter())
.filter(|s| s.contains("thinking"))
.count();
assert_eq!(header_count, 1, "reasoning header should be emitted exactly once");
assert!(
rendered2.last().is_some_and(|s| s.is_empty()),
"expected trailing blank line on finalize"
);
}
}
#[cfg(test)]
mod widget_stream_extra {
use super::*;
#[test]
fn widget_fenced_code_slow_streaming_no_dup() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let config = test_config();
let mut w = ChatWidget::new(config.clone(), tx.clone(), None, Vec::new(), false);
// Begin answer stream: push opening fence in pieces with no newline -> no history.
for d in ["```", ""] {
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: d.into() }),
});
assert!(super::recv_insert_history(&rx, 30).is_none(), "no history before newline for fence");
}
// Newline after fence line.
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "\n".into() }),
});
// This may or may not produce a visible line depending on renderer; accept either.
let _ = super::recv_insert_history(&rx, 100);
// Stream the code line without newline -> no history.
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "code line".into() }),
});
assert!(super::recv_insert_history(&rx, 30).is_none(), "no history before newline for code line");
// Now newline to commit the code line.
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "\n".into() }),
});
let commit1 = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("history after code line newline"),
};
// Close fence slowly then newline.
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "```".into() }),
});
assert!(super::recv_insert_history(&rx, 30).is_none(), "no history before closing fence newline");
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "\n".into() }),
});
let _ = super::recv_insert_history(&rx, 100);
// Finalize should not duplicate the code line and should add a trailing blank.
w.handle_codex_event(Event {
id: "a".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: String::new() }),
});
let commit2 = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("history after finalize"),
};
let texts1: Vec<String> = commit1
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
let texts2: Vec<String> = commit2
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
let all = [texts1, texts2].concat();
let code_count = all.iter().filter(|s| s.contains("code line")).count();
assert_eq!(code_count, 1, "code line should appear exactly once in history: {all:?}");
assert!(all.iter().all(|s| !s.contains("```")), "backticks should not be shown in history: {all:?}");
}
#[test]
fn widget_rendered_trickle_live_ring_head() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let config = test_config();
let mut w = ChatWidget::new(config.clone(), tx.clone(), None, Vec::new(), false);
// Increase live ring capacity so it can include queue head.
w.test_set_live_max_rows(4);
// Enqueue 5 completed lines in a single delta.
let payload = "l1\nl2\nl3\nl4\nl5\n".to_string();
w.handle_codex_event(Event {
id: "b".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: payload }),
});
// First batch commit: expect header + 3 lines.
let lines = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("history after batch"),
};
let rendered: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
assert!(rendered.iter().any(|s| s.contains("codex")), "answer header missing");
let committed: Vec<_> = rendered.into_iter().filter(|s| s.starts_with('l')).collect();
assert_eq!(committed.len(), 3, "expected 3 committed lines in first batch");
// No live overlay anymore; only committed lines appear in history.
// Finalize: drain the remaining lines.
w.handle_codex_event(Event {
id: "b".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: String::new() }),
});
let lines2 = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("history after finalize"),
};
let rendered2: Vec<String> = lines2
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
assert!(rendered2.iter().any(|s| s == "l4"));
assert!(rendered2.iter().any(|s| s == "l5"));
assert!(rendered2.last().is_some_and(|s| s.is_empty()), "expected trailing blank line after finalize");
}
#[test]
fn widget_reasoning_then_answer_ordering() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let config = test_config();
let mut w = ChatWidget::new(config.clone(), tx.clone(), None, Vec::new(), false);
// Reasoning: one completed line then finalize.
w.handle_codex_event(Event {
id: "ra".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: "think1\n".into() }),
});
let r_commit = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("reasoning history"),
};
w.handle_codex_event(Event {
id: "ra".into(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text: String::new() }),
});
let r_final = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("reasoning finalize"),
};
// Answer: one completed line then finalize.
w.handle_codex_event(Event {
id: "ra".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "ans1\n".into() }),
});
let a_commit = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("answer history"),
};
w.handle_codex_event(Event {
id: "ra".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: String::new() }),
});
let a_final = match super::recv_insert_history(&rx, 200) {
Some(v) => v,
None => panic!("answer finalize"),
};
let to_texts = |lines: &Vec<ratatui::text::Line<'static>>| -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect()
};
let r_all = [to_texts(&r_commit), to_texts(&r_final)].concat();
let a_all = [to_texts(&a_commit), to_texts(&a_final)].concat();
// Expect headers present and in order: reasoning first, then answer.
let r_header_idx = match r_all.iter().position(|s| s.contains("thinking")) {
Some(i) => i,
None => panic!("missing reasoning header"),
};
let a_header_idx = match a_all.iter().position(|s| s.contains("codex")) {
Some(i) => i,
None => panic!("missing answer header"),
};
assert!(r_all.iter().any(|s| s == "think1"), "missing reasoning content: {:?}", r_all);
assert!(a_all.iter().any(|s| s == "ans1"), "missing answer content: {:?}", a_all);
// Implicitly, reasoning events happened before answer events if we got here without timeouts.
assert_eq!(r_header_idx, 0, "reasoning header should be first in its batch");
assert_eq!(a_header_idx, 0, "answer header should be first in its batch");
}
#[test]
fn header_not_repeated_across_pauses() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let config = test_config();
let mut w = ChatWidget::new(config.clone(), tx.clone(), None, Vec::new(), false);
// Begin reasoning, enqueue first line, start animation.
w.handle_codex_event(Event {
id: "r1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: "first\n".into() }),
});
// Simulate one animation tick: should emit header + first.
w.on_commit_tick();
let lines1 = super::recv_insert_history(&rx, 200).expect("history after first tick");
let texts1: Vec<String> = lines1
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
assert!(texts1.iter().any(|s| s.contains("thinking")), "missing header on first tick: {texts1:?}");
assert!(texts1.iter().any(|s| s == "first"), "missing first line: {texts1:?}");
// Stop ticks naturally by draining queue (second tick consumes nothing).
w.on_commit_tick();
let _ = super::recv_insert_history(&rx, 100);
// Later, enqueue another completed line; header must NOT repeat.
w.handle_codex_event(Event {
id: "r1".into(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: "second\n".into() }),
});
w.on_commit_tick();
let lines2 = super::recv_insert_history(&rx, 200).expect("history after second tick");
let texts2: Vec<String> = lines2
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
let header_count2 = texts2.iter().filter(|s| s.contains("thinking")).count();
assert_eq!(header_count2, 0, "header should not repeat after pause: {texts2:?}");
assert!(texts2.iter().any(|s| s == "second"), "missing second line: {texts2:?}");
// Finalize; trailing blank should be added; no extra header.
w.handle_codex_event(Event {
id: "r1".into(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text: String::new() }),
});
// Drain remaining with ticks.
w.on_commit_tick();
let lines3 = super::recv_insert_history(&rx, 200).expect("history after finalize tick");
let texts3: Vec<String> = lines3
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
let header_total = texts1
.into_iter()
.chain(texts2.into_iter())
.chain(texts3.iter().cloned())
.filter(|s| s.contains("thinking"))
.count();
assert_eq!(header_total, 1, "header should appear exactly once across pauses and finalize");
assert!(texts3.last().is_some_and(|s| s.is_empty()), "expected trailing blank line");
}
}

View File

@@ -1,6 +1,5 @@
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::insert_history::word_wrap_lines;
use crate::slash_command::SlashCommand;
use crate::text_block::TextBlock;
use crate::text_formatting::format_and_truncate_tool_result;
@@ -31,6 +30,7 @@ use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
@@ -187,8 +187,11 @@ impl HistoryCell {
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
let wrapped = word_wrap_lines(&self.plain_lines(), width);
wrapped.len() as u16
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
pub(crate) fn new_session_info(
@@ -818,8 +821,9 @@ impl HistoryCell {
impl WidgetRef for &HistoryCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let wrapped = word_wrap_lines(&self.plain_lines(), area.width);
Paragraph::new(Text::from(wrapped)).render(area, buf);
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.render(area, buf);
}
}

View File

@@ -18,8 +18,6 @@ use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
use textwrap::Options as TwOptions;
use textwrap::WordSplitter;
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
@@ -42,10 +40,7 @@ pub fn insert_history_lines_to_writer<B, W>(
let mut area = terminal.get_frame().area();
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines(&lines, area.width.max(1));
let wrapped_lines = wrapped.len() as u16;
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.
@@ -96,7 +91,7 @@ pub fn insert_history_lines_to_writer<B, W>(
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(writer, MoveTo(0, cursor_top)).ok();
for line in wrapped {
for line in lines {
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
}
@@ -109,6 +104,36 @@ pub fn insert_history_lines_to_writer<B, W>(
}
}
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 the same visible-width slicing semantics as the live row builder so
// our pre-scroll estimation matches how rows will actually wrap.
let w = width.max(1) as usize;
let mut rows = 0u16;
let mut remaining = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
while !remaining.is_empty() {
let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w);
rows = rows.saturating_add(1);
if taken >= remaining.len() {
break;
}
remaining = suffix.to_string();
}
rows.max(1)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);
@@ -257,126 +282,6 @@ where
)
}
/// Word-aware wrapping for a list of `Line`s preserving styles.
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
let mut out = Vec::new();
let w = width.max(1) as usize;
for line in lines {
out.extend(word_wrap_line(line, w));
}
out
}
fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![to_owned_line(line)];
}
// Concatenate content and keep span boundaries for later re-slicing.
let mut flat = String::new();
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
let mut cursor = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let start = cursor;
flat.push_str(text);
cursor += text.len();
span_bounds.push((start, cursor, s.style));
}
// Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
let opts = TwOptions::new(width)
.break_words(false)
.word_splitter(WordSplitter::NoHyphenation);
let wrapped = textwrap::wrap(&flat, &opts);
if wrapped.len() <= 1 {
return vec![to_owned_line(line)];
}
// Map wrapped pieces back to byte ranges in `flat` sequentially.
let mut start_cursor = 0usize;
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
for piece in wrapped {
let piece_str: &str = &piece;
if piece_str.is_empty() {
out.push(Line {
style: line.style,
alignment: line.alignment,
spans: Vec::new(),
});
continue;
}
// Find the next occurrence of piece_str at or after start_cursor.
// textwrap preserves order, so a linear scan is sufficient.
if let Some(rel) = flat[start_cursor..].find(piece_str) {
let s = start_cursor + rel;
let e = s + piece_str.len();
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
} else {
// Fallback: slice by length from cursor.
let s = start_cursor;
let e = (start_cursor + piece_str.len()).min(flat.len());
out.push(slice_line_spans(line, &span_bounds, s, e));
start_cursor = e;
}
}
out
}
fn to_owned_line(l: &Line<'_>) -> Line<'static> {
Line {
style: l.style,
alignment: l.alignment,
spans: l
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
fn slice_line_spans(
original: &Line<'_>,
span_bounds: &[(usize, usize, ratatui::style::Style)],
start_byte: usize,
end_byte: usize,
) -> Line<'static> {
let mut acc: Vec<Span<'static>> = Vec::new();
for (i, (s, e, style)) in span_bounds.iter().enumerate() {
if *e <= start_byte {
continue;
}
if *s >= end_byte {
break;
}
let seg_start = start_byte.max(*s);
let seg_end = end_byte.min(*e);
if seg_end > seg_start {
let local_start = seg_start - *s;
let local_end = seg_end - *s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
acc.push(Span {
style: *style,
content: std::borrow::Cow::Owned(slice.to_string()),
});
}
if *e >= end_byte {
break;
}
}
Line {
style: original.style,
alignment: original.alignment,
spans: acc,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
@@ -413,34 +318,8 @@ mod tests {
#[test]
fn line_height_counts_double_width_emoji() {
let line = Line::from("😀😀😀"); // each emoji ~ width 2
assert_eq!(word_wrap_line(&line, 4).len(), 2);
assert_eq!(word_wrap_line(&line, 2).len(), 3);
assert_eq!(word_wrap_line(&line, 6).len(), 1);
}
#[test]
fn word_wrap_does_not_split_words_simple_english() {
let sample = "Years passed, and Willowmere thrived in peace and friendship. Miras herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let line = Line::from(sample);
// Force small width to exercise wrapping at spaces.
let wrapped = word_wrap_lines(&[line], 40);
let joined: String = wrapped
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
assert!(
!joined.contains("Willowm\nere"),
"should not split inside words:\n{joined}"
);
assert_eq!(line_height(&line, 4), 2);
assert_eq!(line_height(&line, 2), 3);
assert_eq!(line_height(&line, 6), 1);
}
}

View File

@@ -39,7 +39,6 @@ pub mod insert_history;
pub mod live_wrap;
mod log_layer;
mod markdown;
mod markdown_stream;
pub mod onboarding;
mod shimmer;
mod slash_command;
@@ -56,8 +55,6 @@ use color_eyre::owo_colors::OwoColorize;
pub use cli::Cli;
// (tests access modules directly within the crate)
pub async fn run_main(
cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,

View File

@@ -22,35 +22,35 @@ fn append_markdown_with_opener_and_cwd(
file_opener: UriBasedFileOpener,
cwd: &Path,
) {
// Historically, we fed the entire `markdown_source` into the renderer in
// one pass. However, fenced code blocks sometimes lost leading whitespace
// when formatted by the markdown renderer/highlighter. To preserve code
// block content exactly, split the source into "text" and "code" segments:
// - Render non-code text through `tui_markdown` (with citation rewrite).
// - Render code block content verbatim as plain lines without additional
// formatting, preserving leading spaces.
for seg in split_text_and_fences(markdown_source) {
match seg {
Segment::Text(s) => {
let processed = rewrite_file_citations(&s, file_opener, cwd);
let rendered = tui_markdown::from_str(&processed);
push_owned_lines(rendered.lines, lines);
}
Segment::Code { content, .. } => {
// Emit the code content exactly as-is, line by line.
// We don't attempt syntax highlighting to avoid whitespace bugs.
for line in content.split_inclusive('\n') {
// split_inclusive keeps the trailing \n; we want lines without it.
let line = if let Some(stripped) = line.strip_suffix('\n') {
stripped
} else {
line
};
let owned_line: Line<'static> = Line::from(Span::raw(line.to_string()));
lines.push(owned_line);
}
}
// Perform citation rewrite *before* feeding the string to the markdown
// renderer. When `file_opener` is absent we bypass the transformation to
// avoid unnecessary allocations.
let processed_markdown = rewrite_file_citations(markdown_source, file_opener, cwd);
let markdown = tui_markdown::from_str(&processed_markdown);
// `tui_markdown` returns a `ratatui::text::Text` where every `Line` borrows
// from the input `message` string. Since the `HistoryCell` stores its lines
// with a `'static` lifetime we must create an **owned** copy of each line
// so that it is no longer tied to `message`. We do this by cloning the
// content of every `Span` into an owned `String`.
for borrowed_line in markdown.lines {
let mut owned_spans = Vec::with_capacity(borrowed_line.spans.len());
for span in &borrowed_line.spans {
// Create a new owned String for the span's content to break the lifetime link.
let owned_span = Span::styled(span.content.to_string(), span.style);
owned_spans.push(owned_span);
}
let owned_line: Line<'static> = Line::from(owned_spans).style(borrowed_line.style);
// Preserve alignment if it was set on the source line.
let owned_line = match borrowed_line.alignment {
Some(alignment) => owned_line.alignment(alignment),
None => owned_line,
};
lines.push(owned_line);
}
}
@@ -101,177 +101,6 @@ fn rewrite_file_citations<'a>(
})
}
// Helper to clone borrowed ratatui lines into owned lines with 'static lifetime.
fn push_owned_lines<'a>(borrowed: Vec<ratatui::text::Line<'a>>, out: &mut Vec<Line<'static>>) {
for borrowed_line in borrowed {
let mut owned_spans = Vec::with_capacity(borrowed_line.spans.len());
for span in &borrowed_line.spans {
let owned_span = Span::styled(span.content.to_string(), span.style);
owned_spans.push(owned_span);
}
let owned_line: Line<'static> = Line::from(owned_spans).style(borrowed_line.style);
let owned_line = match borrowed_line.alignment {
Some(alignment) => owned_line.alignment(alignment),
None => owned_line,
};
out.push(owned_line);
}
}
// Minimal code block splitting.
// - Recognizes fenced blocks opened by ``` or ~~~ (allowing leading whitespace).
// The opening fence may include a language string which we ignore.
// The closing fence must be on its own line (ignoring surrounding whitespace).
// - Additionally recognizes indented code blocks that begin after a blank line
// with a line starting with at least 4 spaces or a tab, and continue for
// consecutive lines that are blank or also indented by >= 4 spaces or a tab.
enum Segment {
Text(String),
Code {
_lang: Option<String>,
content: String,
},
}
fn split_text_and_fences(src: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut curr_text = String::new();
#[derive(Copy, Clone, PartialEq)]
enum CodeMode {
None,
Fenced,
Indented,
}
let mut code_mode = CodeMode::None;
let mut fence_token = "";
let mut code_lang: Option<String> = None;
let mut code_content = String::new();
// We intentionally do not require a preceding blank line for indented code blocks,
// since streamed model output often omits it. This favors preserving indentation.
for line in src.split_inclusive('\n') {
let line_no_nl = line.strip_suffix('\n');
let trimmed_start = match line_no_nl {
Some(l) => l.trim_start(),
None => line.trim_start(),
};
if code_mode == CodeMode::None {
let open = if trimmed_start.starts_with("```") {
Some("```")
} else if trimmed_start.starts_with("~~~") {
Some("~~~")
} else {
None
};
if let Some(tok) = open {
// Flush pending text segment.
if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
curr_text.clear();
}
fence_token = tok;
// Capture language after the token on this line (before newline).
let after = &trimmed_start[tok.len()..];
let lang = after.trim();
code_lang = if lang.is_empty() {
None
} else {
Some(lang.to_string())
};
code_mode = CodeMode::Fenced;
code_content.clear();
// Do not include the opening fence line in output.
continue;
}
// Check for start of an indented code block: only after a blank line
// (or at the beginning), and the line must start with >=4 spaces or a tab.
let raw_line = match line_no_nl {
Some(l) => l,
None => line,
};
let leading_spaces = raw_line.chars().take_while(|c| *c == ' ').count();
let starts_with_tab = raw_line.starts_with('\t');
// Consider any line that begins with >=4 spaces or a tab to start an
// indented code block. This favors preserving indentation even when a
// preceding blank line is omitted (common in streamed model output).
let starts_indented_code = (leading_spaces >= 4) || starts_with_tab;
if starts_indented_code {
// Flush pending text and begin an indented code block.
if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
curr_text.clear();
}
code_mode = CodeMode::Indented;
code_content.clear();
code_content.push_str(line);
// Inside code now; do not treat this line as normal text.
continue;
}
// Normal text line.
curr_text.push_str(line);
} else {
match code_mode {
CodeMode::Fenced => {
// inside fenced code: check for closing fence on its own line
let trimmed = match line_no_nl {
Some(l) => l.trim(),
None => line.trim(),
};
if trimmed == fence_token {
// End code block: emit segment without fences
segments.push(Segment::Code {
_lang: code_lang.take(),
content: code_content.clone(),
});
code_content.clear();
code_mode = CodeMode::None;
fence_token = "";
continue;
}
// Accumulate code content exactly as-is.
code_content.push_str(line);
}
CodeMode::Indented => {
// Continue while the line is blank, or starts with >=4 spaces, or a tab.
let raw_line = match line_no_nl {
Some(l) => l,
None => line,
};
let is_blank = raw_line.trim().is_empty();
let leading_spaces = raw_line.chars().take_while(|c| *c == ' ').count();
let starts_with_tab = raw_line.starts_with('\t');
if is_blank || leading_spaces >= 4 || starts_with_tab {
code_content.push_str(line);
} else {
// Close the indented code block and reprocess this line as normal text.
segments.push(Segment::Code {
_lang: None,
content: code_content.clone(),
});
code_content.clear();
code_mode = CodeMode::None;
// Now handle current line as text.
curr_text.push_str(line);
}
}
CodeMode::None => unreachable!(),
}
}
}
if code_mode != CodeMode::None {
// Unterminated code fence: treat accumulated content as a code segment.
segments.push(Segment::Code {
_lang: code_lang.take(),
content: code_content.clone(),
});
} else if !curr_text.is_empty() {
segments.push(Segment::Text(curr_text.clone()));
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
@@ -333,99 +162,4 @@ mod tests {
// Ensure helper rewrites.
assert_ne!(markdown, unchanged);
}
#[test]
fn fenced_code_blocks_preserve_leading_whitespace() {
let src = "```\n indented\n\t\twith tabs\n four spaces\n```\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(
rendered,
vec![
" indented".to_string(),
"\t\twith tabs".to_string(),
" four spaces".to_string()
]
);
}
#[test]
fn citations_not_rewritten_inside_code_blocks() {
let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
// Expect first and last lines rewritten, middle line unchanged.
assert!(rendered[0].contains("vscode://file"));
assert_eq!(rendered[1], "Inside 【F:/x.rs†L2】");
assert!(matches!(rendered.last(), Some(s) if s.contains("vscode://file")));
}
#[test]
fn indented_code_blocks_preserve_leading_whitespace() {
let src = "Before\n code 1\n\tcode with tab\n code 2\nAfter\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(
rendered,
vec![
"Before".to_string(),
" code 1".to_string(),
"\tcode with tab".to_string(),
" code 2".to_string(),
"After".to_string()
]
);
}
#[test]
fn citations_not_rewritten_inside_indented_code_blocks() {
let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
// Expect first and last lines rewritten, and the indented code line present
// unchanged (citations inside not rewritten). We do not assert on blank
// separator lines since the markdown renderer may normalize them.
assert!(rendered.iter().any(|s| s.contains("vscode://file")));
assert!(rendered.iter().any(|s| s == " Inside 【F:/x.rs†L2】"));
}
}

View File

@@ -1,565 +0,0 @@
use std::collections::VecDeque;
use codex_core::config::Config;
use ratatui::text::Line;
use crate::markdown;
/// Newline-gated accumulator that renders markdown and commits only fully
/// completed logical lines.
pub(crate) struct MarkdownNewlineCollector {
buffer: String,
committed_line_count: usize,
}
impl MarkdownNewlineCollector {
pub fn new() -> Self {
Self {
buffer: String::new(),
committed_line_count: 0,
}
}
pub fn clear(&mut self) {
self.buffer.clear();
self.committed_line_count = 0;
}
pub fn push_delta(&mut self, delta: &str) {
self.buffer.push_str(delta);
}
/// 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
/// final rendered line is considered incomplete and is not emitted.
pub fn commit_complete_lines(&mut self, config: &Config) -> Vec<Line<'static>> {
// In non-test builds, unwrap an outer ```markdown fence during commit as well,
// so fence markers never appear in streamed history.
let source = unwrap_markdown_language_fence_if_enabled(self.buffer.clone());
let source = strip_empty_fenced_code_blocks(&source);
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
let mut complete_line_count = rendered.len();
if complete_line_count > 0 && is_effectively_empty(&rendered[complete_line_count - 1]) {
complete_line_count -= 1;
}
if !self.buffer.ends_with('\n') {
complete_line_count = complete_line_count.saturating_sub(1);
// If we're inside an unclosed fenced code block, also drop the
// last rendered line to avoid committing a partial code line.
if is_inside_unclosed_fence(&source) {
complete_line_count = complete_line_count.saturating_sub(1);
}
}
if self.committed_line_count >= complete_line_count {
return Vec::new();
}
let out_slice = &rendered[self.committed_line_count..complete_line_count];
// Strong correctness: while a fenced code block is open (no closing fence yet),
// do not emit any new lines from inside it. Wait until the fence closes to emit
// the entire block together. This avoids stray backticks and misformatted content.
if is_inside_unclosed_fence(&source) {
return Vec::new();
}
let out = out_slice.to_vec();
self.committed_line_count = complete_line_count;
out
}
/// Finalize the stream: emit all remaining lines beyond the last commit.
/// If the buffer does not end with a newline, a temporary one is appended
/// for rendering. Optionally unwraps ```markdown language fences in
/// non-test builds.
pub fn finalize_and_drain(&mut self, config: &Config) -> Vec<Line<'static>> {
let mut source: String = self.buffer.clone();
if !source.ends_with('\n') {
source.push('\n');
}
let source = unwrap_markdown_language_fence_if_enabled(source);
let source = strip_empty_fenced_code_blocks(&source);
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
} else {
rendered[self.committed_line_count..].to_vec()
};
// Reset collector state for next stream.
self.clear();
out
}
}
fn is_effectively_empty(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans
.iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
}
/// Remove fenced code blocks that contain no content (whitespace-only) to avoid
/// streaming empty code blocks like ```lang\n``` or ```\n```.
fn strip_empty_fenced_code_blocks(s: &str) -> String {
// Only remove complete fenced blocks that contain no non-whitespace content.
// Leave all other content unchanged to avoid affecting partial streams.
let lines: Vec<&str> = s.lines().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0usize;
while i < lines.len() {
let line = lines[i];
let trimmed_start = line.trim_start();
let fence_token = if trimmed_start.starts_with("```") {
"```"
} else if trimmed_start.starts_with("~~~") {
"~~~"
} else {
""
};
if !fence_token.is_empty() {
// Find a matching closing fence on its own line.
let mut j = i + 1;
let mut has_content = false;
let mut found_close = false;
while j < lines.len() {
let l = lines[j];
if l.trim() == fence_token {
found_close = true;
break;
}
if !l.trim().is_empty() {
has_content = true;
}
j += 1;
}
if found_close && !has_content {
// Drop i..=j and insert at most a single blank separator line.
if !out.ends_with('\n') {
out.push('\n');
}
i = j + 1;
continue;
}
// Not an empty fenced block; emit as-is.
out.push_str(line);
out.push('\n');
i += 1;
} else {
out.push_str(line);
out.push('\n');
i += 1;
}
}
out
}
fn is_inside_unclosed_fence(s: &str) -> bool {
let mut open = false;
for line in s.lines() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
if !open {
open = true;
} else {
// closing fence on same pattern toggles off
open = false;
}
}
}
open
}
#[cfg(test)]
fn unwrap_markdown_language_fence_if_enabled(s: String) -> String {
// In tests, keep content exactly as provided to simplify assertions.
s
}
#[cfg(not(test))]
fn unwrap_markdown_language_fence_if_enabled(s: String) -> String {
// Best-effort unwrap of a single outer ```markdown fence.
// This is intentionally simple; we can refine as needed later.
const OPEN: &str = "```markdown\n";
const CLOSE: &str = "\n```\n";
if s.starts_with(OPEN) && s.ends_with(CLOSE) {
let inner = s[OPEN.len()..s.len() - CLOSE.len()].to_string();
return inner;
}
s
}
pub(crate) struct StepResult {
pub history: Vec<Line<'static>>, // lines to insert into history this step
}
/// Streams already-rendered rows into history while computing the newest K
/// rows to show in a live overlay.
pub(crate) struct RenderedLineStreamer {
queue: VecDeque<Line<'static>>,
}
impl RenderedLineStreamer {
pub fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
pub fn clear(&mut self) {
self.queue.clear();
}
pub fn enqueue(&mut self, lines: Vec<Line<'static>>) {
for l in lines {
self.queue.push_back(l);
}
}
pub fn step(&mut self, _live_max_rows: usize) -> StepResult {
let mut history = Vec::new();
// Move exactly one per tick to animate gradual insertion.
let burst = if self.queue.is_empty() { 0 } else { 1 };
for _ in 0..burst {
if let Some(l) = self.queue.pop_front() {
history.push(l);
}
}
StepResult { history }
}
pub fn drain_all(&mut self, _live_max_rows: usize) -> StepResult {
let mut history = Vec::new();
while let Some(l) = self.queue.pop_front() {
history.push(l);
}
StepResult { history }
}
pub fn is_idle(&self) -> bool {
self.queue.is_empty()
}
}
#[cfg(test)]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
config: &Config,
) -> Vec<Line<'static>> {
let mut collector = MarkdownNewlineCollector::new();
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
if d.contains('\n') {
out.extend(collector.commit_complete_lines(config));
}
}
if finalize {
out.extend(collector.finalize_and_drain(config));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
fn test_config() -> Config {
let overrides = ConfigOverrides {
cwd: std::env::current_dir().ok(),
..Default::default()
};
match Config::load_with_cli_overrides(vec![], overrides) {
Ok(c) => c,
Err(e) => panic!("load test config: {e}"),
}
}
#[test]
fn no_commit_until_newline() {
let cfg = test_config();
let mut c = MarkdownNewlineCollector::new();
c.push_delta("Hello, world");
let out = c.commit_complete_lines(&cfg);
assert!(out.is_empty(), "should not commit without newline");
c.push_delta("!\n");
let out2 = c.commit_complete_lines(&cfg);
assert_eq!(out2.len(), 1, "one completed line after newline");
}
#[test]
fn finalize_commits_partial_line() {
let cfg = test_config();
let mut c = MarkdownNewlineCollector::new();
c.push_delta("Line without newline");
let out = c.finalize_and_drain(&cfg);
assert_eq!(out.len(), 1);
}
#[test]
fn heading_starts_on_new_line_when_following_paragraph() {
let cfg = test_config();
// Stream a paragraph line, then a heading on the next line.
// Expect two distinct rendered lines: "Hello." and "Heading".
let mut c = MarkdownNewlineCollector::new();
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
out1.len(),
1,
"first commit should contain only the paragraph line, got {}: {:?}",
out1.len(),
s1
);
c.push_delta("## Heading\n");
let out2 = c.commit_complete_lines(&cfg);
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["", "## Heading"],
"expected a blank separator then the heading line"
);
let line_to_string = |l: &ratatui::text::Line<'_>| -> String {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
};
assert_eq!(line_to_string(&out1[0]), "Hello.");
assert_eq!(line_to_string(&out2[1]), "## Heading");
}
#[test]
fn heading_not_inlined_when_split_across_chunks() {
let cfg = test_config();
// Paragraph without trailing newline, then a chunk that starts with the newline
// and the heading text, then a final newline. The collector should first commit
// only the paragraph line, and later commit the heading as its own line.
let mut c = MarkdownNewlineCollector::new();
c.push_delta("Sounds good!");
// No commit yet
assert!(c.commit_complete_lines(&cfg).is_empty());
// Introduce the newline that completes the paragraph and the start of the heading.
c.push_delta("\n## Adding Bird subcommand");
let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s1,
vec!["Sounds good!", ""],
"expected paragraph followed by blank separator before heading chunk"
);
// Now finish the heading line with the trailing newline.
c.push_delta("\n");
let out2 = c.commit_complete_lines(&cfg);
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["## Adding Bird subcommand"],
"expected the heading line only on the final commit"
);
// Sanity check raw markdown rendering for a simple line does not produce spurious extras.
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("Hello.\n", &mut rendered, &cfg);
let rendered_strings: Vec<String> = rendered
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
rendered_strings,
vec!["Hello."],
"unexpected markdown lines: {rendered_strings:?}"
);
let line_to_string = |l: &ratatui::text::Line<'_>| -> String {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
};
assert_eq!(line_to_string(&out1[0]), "Sounds good!");
assert_eq!(line_to_string(&out1[1]), "");
assert_eq!(line_to_string(&out2[0]), "## Adding Bird subcommand");
}
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect()
}
#[test]
fn lists_and_fences_commit_without_duplication() {
let cfg = test_config();
// List case
let deltas = vec!["- a\n- ", "b\n- c\n"];
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("- a\n- b\n- c\n", &mut rendered_all, &cfg);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_str, rendered_all_str,
"list streaming should equal full render without duplication"
);
// Fenced code case: stream in small chunks
let deltas2 = vec!["```", "\nco", "de 1\ncode 2\n", "```\n"];
let streamed2 = simulate_stream_markdown_for_tests(&deltas2, true, &cfg);
let streamed2_str = lines_to_plain_strings(&streamed2);
let mut rendered_all2: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("```\ncode 1\ncode 2\n```\n", &mut rendered_all2, &cfg);
let rendered_all2_str = lines_to_plain_strings(&rendered_all2);
assert_eq!(
streamed2_str, rendered_all2_str,
"fence streaming should equal full render without duplication"
);
}
#[test]
fn utf8_boundary_safety_and_wide_chars() {
let cfg = test_config();
// Emoji (wide), CJK, control char, digit + combining macron sequences
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
let deltas = vec![
"🙂",
"🙂",
"🙂\n",
"字漢",
"\nA",
"\u{0003}",
"0",
"\u{0304}",
"\n",
];
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(input, &mut rendered_all, &cfg);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_str, rendered_all_str,
"utf8/wide-char streaming should equal full render without duplication or truncation"
);
}
#[test]
fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
let cfg = test_config();
// An empty fenced code block followed by a heading should not render the fence,
// but should preserve a blank separator line so the heading starts on a new line.
let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let texts = lines_to_plain_strings(&streamed);
assert!(
texts.iter().all(|s| !s.contains("```")),
"no fence markers expected: {texts:?}"
);
// Expect the heading and no fence markers. A blank separator may or may not be rendered at start.
assert!(
texts.iter().any(|s| s == "## Heading"),
"expected heading line: {texts:?}"
);
}
#[test]
fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
let cfg = test_config();
let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
let texts = lines_to_plain_strings(&streamed);
let para_idx = match texts.iter().position(|s| s == "Para.") {
Some(i) => i,
None => panic!("para present"),
};
let head_idx = match texts.iter().position(|s| s == "## Title") {
Some(i) => i,
None => panic!("heading present"),
};
assert!(
head_idx > para_idx,
"heading should not merge with paragraph: {texts:?}"
);
}
}

View File

@@ -75,7 +75,7 @@ impl TestScenario {
}
#[test]
fn basic_insertion_no_wrap() {
fn hist_001_basic_insertion_no_wrap() {
// Screen of 20x6; viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -97,7 +97,7 @@ fn basic_insertion_no_wrap() {
}
#[test]
fn long_token_wraps() {
fn hist_002_long_token_wraps() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -130,7 +130,7 @@ fn long_token_wraps() {
}
#[test]
fn emoji_and_cjk() {
fn hist_003_emoji_and_cjk() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -148,7 +148,7 @@ fn emoji_and_cjk() {
}
#[test]
fn mixed_ansi_spans() {
fn hist_004_mixed_ansi_spans() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -162,7 +162,7 @@ fn mixed_ansi_spans() {
}
#[test]
fn cursor_restoration() {
fn hist_006_cursor_restoration() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
@@ -182,39 +182,7 @@ fn cursor_restoration() {
}
#[test]
fn word_wrap_no_mid_word_split() {
// Screen of 40x10; viewport is the last row
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Years passed, and Willowmere thrived in peace and friendship. Miras herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
}
#[test]
fn em_dash_and_space_word_wrap() {
// Repro from report: ensure we break before "inside", not mid-word.
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
let buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("insi\nde"),
"word 'inside' should not be split across lines:\n{joined}"
);
}
#[test]
fn pre_scroll_region_down() {
fn hist_005_pre_scroll_region_down() {
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);

View File

@@ -1,77 +0,0 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::text::Line;
fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal<TestBackend> {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.unwrap_or_else(|e| panic!("failed to construct terminal: {e}"));
term.set_viewport_area(viewport);
term
}
#[test]
fn stream_commit_trickle_no_duplication() {
// Viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Step 1: commit first row
let mut out1 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out1,
vec![Line::from("one")],
);
// Step 2: later commit next row
let mut out2 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out2,
vec![Line::from("two")],
);
let combined = [out1, out2].concat();
let s = String::from_utf8_lossy(&combined);
assert_eq!(
s.matches("one").count(),
1,
"history line duplicated: {s:?}"
);
assert_eq!(
s.matches("two").count(),
1,
"history line duplicated: {s:?}"
);
assert!(
!s.contains("three"),
"live-only content leaked into history: {s:?}"
);
}
#[test]
fn live_ring_rows_not_inserted_into_history() {
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Commit two rows to history.
let mut buf = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut buf,
vec![Line::from("one"), Line::from("two")],
);
// The live ring might display tail+head rows like ["two", "three"],
// but only committed rows should be present in the history ANSI stream.
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("one"));
assert!(s.contains("two"));
assert!(
!s.contains("three"),
"uncommitted live-ring content should not be inserted into history: {s:?}"
);
}