Compare commits

...

7 Commits

Author SHA1 Message Date
Felipe Coury
96775bfb4b chore(tui): wip instrument resize render diagnostics
Add temporary resize-path diagnostics for terminal resize investigation.
The logs capture terminal identity, platform, VSCode/macOS env markers,
watchdog state, resize reflow mode, pre-render decisions, scheduled draw
events, and terminal draw start/end timing boundaries.

This is intentionally a WIP commit that should be amended as we learn
which resize path needs a real fix.
2026-04-15 12:57:37 -03:00
Felipe Coury
e2b217a1a0 fix(tui): repaint superset resize state immediately
Detect Superset as an xterm.js host, add a resize watchdog for missed
PTY size changes, and force immediate transcript reflow for Superset
resize handling so stale scrollback cells are rebuilt during split drag.

Keep the behavior gated to Superset and add resize logs that identify
which path handled the resize event for follow-up terminal debugging.
2026-04-15 12:41:48 -03:00
Felipe Coury
fd6585e863 fix(tui): align table emoji widths with terminals
Compute markdown table cell widths with per-character terminal cell
widths so boxed table padding matches xterm.js for emoji presentation,
keycap, and ZWJ sequences.

This keeps emoji tables rendered as boxed tables while preventing border
columns from drifting during resize in xterm-based terminals.
2026-04-15 11:54:16 -03:00
Felipe Coury
96ea12ec91 test(tui): cover emoji sequence table widths
Add a markdown table regression test with emoji presentation, keycap, and ZWJ sequences. This documents that the Rust renderer produces boxed table rows with consistent `unicode-width` display widths before xterm-specific behavior is handled separately.
2026-04-15 10:39:15 -03:00
Felipe Coury
8495fd57ed fix(app-server): remove unused processor mutability
Make the message processor bindings immutable where they are only used to subscribe to thread creation notifications.

This keeps `cargo check` clean by resolving the unused mutability warnings in the app-server startup paths.
2026-04-15 10:06:38 -03:00
Felipe Coury
b368aa722f fix(tui): force repaint on terminal resize
Handle terminal resize as a distinct TUI event so resize-driven renders can clear and repaint the viewport instead of relying on the normal diff path.

This avoids stale xterm.js rendering when blurred split panes resize quickly, where focus later repaired the duplicated composer state.
2026-04-15 09:56:22 -03:00
Felipe Coury
9716aa5091 feat(tui): render markdown tables responsively
Move the markdown-table work onto current main while keeping the
change scoped to TUI rendering and streaming. Add source-backed table
rendering, streaming holdback, live-tail resize reflow, and transcript
reflow.

Keep the branch reviewable by excluding unrelated dependency, core,
and config drift, and by moving table holdback and transcript reflow
state into focused modules.
2026-04-09 18:52:37 -03:00
37 changed files with 7285 additions and 287 deletions

View File

@@ -386,7 +386,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env);
let (processor_tx, mut processor_rx) = mpsc::channel::<ProcessorCommand>(channel_capacity);
let mut processor_handle = tokio::spawn(async move {
let mut processor = MessageProcessor::new(MessageProcessorArgs {
let processor = MessageProcessor::new(MessageProcessorArgs {
outgoing: Arc::clone(&processor_outgoing),
arg0_paths: args.arg0_paths,
config: args.config,

View File

@@ -646,7 +646,7 @@ pub async fn run_main_with_transport(
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false);
let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone();
let loader_overrides = loader_overrides_for_config_api;
let mut processor = MessageProcessor::new(MessageProcessorArgs {
let processor = MessageProcessor::new(MessageProcessorArgs {
outgoing: outgoing_message_sender,
arg0_paths,
config: Arc::new(config),

View File

@@ -485,6 +485,9 @@
"steer": {
"type": "boolean"
},
"stream_table_live_tail_reflow": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
@@ -2287,6 +2290,9 @@
"steer": {
"type": "boolean"
},
"stream_table_live_tail_reflow": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},

View File

@@ -182,6 +182,8 @@ pub enum Feature {
TuiAppServer,
/// Prevent idle system sleep while a turn is actively running.
PreventIdleSleep,
/// Speculatively re-render table holdback tail with uncommitted source.
StreamTableLiveTailReflow,
/// Legacy rollout flag for Responses API WebSocket transport experiments.
ResponsesWebsockets,
/// Legacy rollout flag for Responses API WebSocket transport v2 experiments.
@@ -897,6 +899,12 @@ pub const FEATURES: &[FeatureSpec] = &[
},
default_enabled: false,
},
FeatureSpec {
id: Feature::StreamTableLiveTailReflow,
key: "stream_table_live_tail_reflow",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",

View File

@@ -33,6 +33,8 @@ pub enum TerminalName {
WarpTerminal,
/// Visual Studio Code integrated terminal.
VsCode,
/// Superset xterm.js terminal host.
Superset,
/// WezTerm terminal emulator.
WezTerm,
/// kitty terminal emulator.
@@ -190,6 +192,7 @@ impl TerminalInfo {
format_terminal_version("WarpTerminal", &self.version)
}
TerminalName::VsCode => format_terminal_version("vscode", &self.version),
TerminalName::Superset => format_terminal_version("Superset", &self.version),
TerminalName::WezTerm => format_terminal_version("WezTerm", &self.version),
TerminalName::Kitty => "kitty".to_string(),
TerminalName::Alacritty => "Alacritty".to_string(),
@@ -482,6 +485,7 @@ fn terminal_name_from_term_program(value: &str) -> Option<TerminalName> {
"iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2),
"warp" | "warpterminal" => Some(TerminalName::WarpTerminal),
"vscode" => Some(TerminalName::VsCode),
"superset" => Some(TerminalName::Superset),
"wezterm" => Some(TerminalName::WezTerm),
"kitty" => Some(TerminalName::Kitty),
"alacritty" => Some(TerminalName::Alacritty),

View File

@@ -252,6 +252,30 @@ fn detects_vscode() {
);
}
#[test]
fn detects_superset() {
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "Superset")
.with_var("TERM_PROGRAM_VERSION", "2.0.0");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Superset,
Some("Superset"),
Some("2.0.0"),
/*term*/ None,
/*multiplexer*/ None,
),
"superset_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"Superset/2.0.0",
"superset_term_program_user_agent"
);
}
#[test]
fn detects_warp_terminal() {
let env = FakeEnvironment::new()

View File

@@ -2,6 +2,7 @@ use crate::app_backtrack::BacktrackState;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use crate::app_event::AppEvent;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::app_event::ExitMode;
use crate::app_event::FeedbackCategory;
use crate::app_event::RateLimitRefreshOrigin;
@@ -50,6 +51,8 @@ use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
#[cfg(test)]
use crate::test_support::PathBufExt;
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -280,7 +283,6 @@ fn guardian_approvals_mode() -> GuardianApprovalsMode {
/// Smooth-mode streaming drains one line per tick, so this interval controls
/// perceived typing speed for non-backlogged output.
const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
#[derive(Debug, Clone)]
pub struct AppExitInfo {
pub token_usage: TokenUsage,
@@ -488,6 +490,27 @@ fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) {
)));
}
fn trailing_run_start<T: 'static>(transcript_cells: &[Arc<dyn HistoryCell>]) -> usize {
let end = transcript_cells.len();
let mut start = end;
while start > 0
&& transcript_cells[start - 1].is_stream_continuation()
&& transcript_cells[start - 1].as_any().is::<T>()
{
start -= 1;
}
if start > 0
&& transcript_cells[start - 1].as_any().is::<T>()
&& !transcript_cells[start - 1].is_stream_continuation()
{
start -= 1;
}
start
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionSummary {
usage_line: Option<String>,
@@ -962,6 +985,7 @@ pub(crate) struct App {
pub(crate) overlay: Option<Overlay>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
transcript_reflow: TranscriptReflowState,
pub(crate) enhanced_keys_supported: bool,
@@ -3274,7 +3298,9 @@ impl App {
self.overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
tui.clear_pending_history_lines();
self.has_emitted_history_lines = false;
self.transcript_reflow.clear();
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
tui.terminal.clear_scrollback()?;
@@ -3282,6 +3308,264 @@ impl App {
Ok(())
}
fn reset_history_emission_state(&mut self) {
self.has_emitted_history_lines = false;
self.deferred_history_lines.clear();
}
fn display_lines_for_history_insert(
&mut self,
cell: &dyn HistoryCell,
width: u16,
) -> Vec<Line<'static>> {
let mut display = cell.display_lines(width);
if !display.is_empty() && !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
}
display
}
fn insert_history_cell_lines(
&mut self,
tui: &mut tui::Tui,
cell: &dyn HistoryCell,
width: u16,
) {
let display = self.display_lines_for_history_insert(cell, width);
if display.is_empty() {
return;
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
}
}
fn schedule_resize_reflow(&mut self) -> bool {
self.transcript_reflow.schedule_debounced()
}
/// After stream consolidation, schedule a follow-up reflow if one ran mid-stream.
fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) {
if self.transcript_reflow.take_ran_during_stream() {
if self.schedule_resize_reflow() {
tui.frame_requester().schedule_frame();
} else {
tui.frame_requester()
.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
}
} else if self.transcript_reflow.pending_is_due(Instant::now()) {
tui.frame_requester().schedule_frame();
}
}
fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) {
self.transcript_reflow.schedule_immediate();
tui.frame_requester().schedule_frame();
}
fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
if !self.transcript_reflow.has_pending_reflow() {
self.transcript_reflow.clear_ran_during_stream();
}
Ok(())
}
fn handle_draw_size_change(
&mut self,
size: ratatui::layout::Size,
last_known_screen_size: ratatui::layout::Size,
frame_requester: &tui::FrameRequester,
resize_reflow_mode: tui::ResizeReflowMode,
) -> bool {
let pending_reflow_before = self.transcript_reflow.has_pending_reflow();
let width = self.transcript_reflow.note_width(size.width);
tracing::info!(
event = "resize_width_observed",
cols = size.width,
rows = size.height,
last_known_cols = last_known_screen_size.width,
last_known_rows = last_known_screen_size.height,
width_changed = width.changed,
width_initialized = width.initialized,
pending_reflow_before,
resize_reflow_mode = ?resize_reflow_mode,
"observed terminal width during draw pre-render"
);
if width.changed {
self.chat_widget.on_terminal_resize(size.width);
match resize_reflow_mode {
tui::ResizeReflowMode::Debounced => {
let due_now = self.schedule_resize_reflow();
tracing::info!(
event = "resize_reflow_scheduled_debounced",
cols = size.width,
rows = size.height,
due_now,
debounce_ms = TRANSCRIPT_REFLOW_DEBOUNCE.as_millis(),
"scheduled debounced resize reflow"
);
if due_now {
frame_requester.schedule_frame();
} else {
frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
}
}
tui::ResizeReflowMode::Immediate => {
tracing::info!(
event = "resize_reflow_scheduled_immediate",
cols = size.width,
rows = size.height,
"scheduling immediate resize reflow for terminal"
);
self.transcript_reflow.schedule_immediate();
frame_requester.schedule_frame();
}
}
} else if width.initialized {
self.chat_widget.on_terminal_resize(size.width);
}
if size != last_known_screen_size {
self.refresh_status_line();
}
self.maybe_clear_resize_reflow_without_terminal();
width.changed
}
fn maybe_clear_resize_reflow_without_terminal(&mut self) {
let Some(deadline) = self.transcript_reflow.pending_until() else {
return;
};
if Instant::now() < deadline || self.overlay.is_some() || !self.transcript_cells.is_empty()
{
return;
}
self.transcript_reflow.clear_pending_reflow();
self.reset_history_emission_state();
}
fn handle_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<()> {
let size = tui.terminal.size()?;
let width_changed = self.handle_draw_size_change(
size,
tui.terminal.last_known_screen_size,
&tui.frame_requester(),
tui.resize_reflow_mode(),
);
if width_changed {
// Width-sensitive history inserts queued before this frame may be wrapped for the old
// viewport. Drop them and let resize reflow rebuild from transcript cells.
tui.clear_pending_history_lines();
}
self.maybe_run_resize_reflow(tui)?;
Ok(())
}
fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
let Some(deadline) = self.transcript_reflow.pending_until() else {
return Ok(());
};
let now = Instant::now();
let due = now >= deadline;
let overlay_active = self.overlay.is_some();
if !due || overlay_active {
tracing::info!(
event = "resize_reflow_deferred",
due,
overlay_active,
"resize reflow remains pending"
);
return Ok(());
}
self.transcript_reflow.clear_pending_reflow();
// Track that a reflow happened during an active stream or while trailing
// unconsolidated AgentMessageCells are still pending consolidation so
// ConsolidateAgentMessage can schedule a follow-up reflow.
let reflow_ran_during_stream =
!self.transcript_cells.is_empty() && self.should_mark_reflow_as_stream_time();
tracing::info!(
event = "resize_reflow_started",
transcript_cells = self.transcript_cells.len(),
reflow_ran_during_stream,
"running resize reflow"
);
self.reflow_transcript_now(tui)?;
tracing::info!(
event = "resize_reflow_completed",
reflow_ran_during_stream,
"completed resize reflow"
);
if reflow_ran_during_stream {
self.transcript_reflow.mark_ran_during_stream();
}
Ok(())
}
fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<()> {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
if self.transcript_cells.is_empty() {
self.reset_history_emission_state();
return Ok(());
}
if tui.is_alt_screen_active() {
tui.terminal.clear_visible_screen()?;
} else {
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
self.reset_history_emission_state();
let width = tui.terminal.size()?.width;
// Iterate by index to avoid cloning the Vec and bumping Arc refcounts.
for i in 0..self.transcript_cells.len() {
let cell = self.transcript_cells[i].clone();
self.insert_history_cell_lines(tui, cell.as_ref(), width);
}
Ok(())
}
fn finish_agent_message_consolidation(
&mut self,
tui: &mut tui::Tui,
scrollback_reflow: ConsolidationScrollbackReflow,
) -> Result<()> {
match scrollback_reflow {
ConsolidationScrollbackReflow::IfResizeReflowRan => {
self.maybe_finish_stream_reflow(tui);
}
ConsolidationScrollbackReflow::Required => {
self.finish_required_stream_reflow(tui)?;
}
}
Ok(())
}
fn should_mark_reflow_as_stream_time(&self) -> bool {
self.chat_widget.has_active_agent_stream()
|| self.chat_widget.has_active_plan_stream()
|| trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells)
< self.transcript_cells.len()
|| trailing_run_start::<history_cell::ProposedPlanStreamCell>(&self.transcript_cells)
< self.transcript_cells.len()
}
fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.thread_event_channels.clear();
@@ -3807,6 +4091,7 @@ impl App {
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
@@ -3992,11 +4277,39 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
if matches!(event, TuiEvent::Draw) {
let size = tui.terminal.size()?;
if size != tui.terminal.last_known_screen_size {
self.refresh_status_line();
}
let is_resize = matches!(event, TuiEvent::Resize);
let render_trigger = match &event {
TuiEvent::Draw => Some("draw"),
TuiEvent::Resize => Some("resize"),
TuiEvent::Key(_) | TuiEvent::Paste(_) => None,
};
if is_resize {
let terminal_size = tui.terminal.size().ok();
tracing::info!(
event = "tui_resize_event_handled",
cols = terminal_size.map(|size| size.width),
rows = terminal_size.map(|size| size.height),
terminal_name = ?tui.terminal_name(),
resize_reflow_mode = ?tui.resize_reflow_mode(),
target_os = std::env::consts::OS,
is_windows = cfg!(windows),
is_macos = cfg!(target_os = "macos"),
"handling TUI resize event with forced full repaint"
);
tui.force_full_repaint();
}
if let Some(render_trigger) = render_trigger {
tracing::info!(
event = "tui_render_pre_render_started",
render_trigger,
"starting draw pre-render handling"
);
self.handle_draw_pre_render(tui)?;
tracing::info!(
event = "tui_render_pre_render_completed",
render_trigger,
"completed draw pre-render handling"
);
}
if self.overlay.is_some() {
@@ -4014,7 +4327,8 @@ impl App {
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
let render_trigger = render_trigger.unwrap_or("unknown");
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
self.render_transcript_once(tui);
@@ -4028,15 +4342,30 @@ impl App {
}
// Allow widgets to process any pending timers before rendering.
self.chat_widget.pre_draw_tick();
tui.draw(
self.chat_widget.desired_height(tui.terminal.size()?.width),
|frame| {
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
},
)?;
let terminal_size = tui.terminal.size()?;
let desired_height = self.chat_widget.desired_height(terminal_size.width);
tracing::info!(
event = "tui_render_started",
render_trigger,
cols = terminal_size.width,
rows = terminal_size.height,
desired_height,
"starting TUI render"
);
tui.draw(desired_height, |frame| {
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
})?;
tracing::info!(
event = "tui_render_completed",
render_trigger,
cols = terminal_size.width,
rows = terminal_size.height,
desired_height,
"completed TUI render"
);
if self.chat_widget.external_editor_state() == ExternalEditorState::Requested {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Active);
@@ -4306,23 +4635,89 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
// part of an ongoing stream. Streaming continuations should not
// accrue extra blank lines between chunks.
if !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
self.insert_history_cell_lines(
tui,
cell.as_ref(),
tui.terminal.last_known_screen_size.width,
);
}
AppEvent::ConsolidateAgentMessage {
source,
cwd,
scrollback_reflow,
deferred_history_cell,
} => {
if let Some(cell) = deferred_history_cell {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
self.transcript_cells.push(cell);
}
// Walk backward to find the contiguous run of streaming AgentMessageCells that
// belong to the just-finalized stream
let end = self.transcript_cells.len();
tracing::debug!(
"ConsolidateAgentMessage: transcript_cells.len()={end}, source_len={}",
source.len()
);
let start =
trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells);
if start < end {
tracing::debug!(
"ConsolidateAgentMessage: replacing cells [{start}..{end}] with AgentMarkdownCell"
);
let consolidated: Arc<dyn HistoryCell> =
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
// Keep the transcript overlay in sync.
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.finish_agent_message_consolidation(tui, scrollback_reflow)?;
} else {
tracing::debug!(
"ConsolidateAgentMessage: no cells to consolidate(start={start}, end={end})",
);
self.maybe_finish_stream_reflow(tui);
}
}
AppEvent::ConsolidateProposedPlan(source) => {
let end = self.transcript_cells.len();
let start = trailing_run_start::<history_cell::ProposedPlanStreamCell>(
&self.transcript_cells,
);
let consolidated: Arc<dyn HistoryCell> =
Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd));
if start < end {
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.finish_required_stream_reflow(tui)?;
} else {
self.transcript_cells.push(consolidated.clone());
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.insert_history_cell_lines(
tui,
consolidated.as_ref(),
tui.terminal.last_known_screen_size.width,
);
self.maybe_finish_stream_reflow(tui);
}
}
AppEvent::ApplyThreadRollback { num_turns } => {
@@ -4351,6 +4746,7 @@ impl App {
}
AppEvent::CommitTick => {
self.chat_widget.on_commit_tick();
self.maybe_run_resize_reflow(tui)?;
}
AppEvent::Exit(mode) => {
return Ok(self.handle_exit_mode(app_server, mode).await);
@@ -4359,7 +4755,9 @@ impl App {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
AppEvent::CodexOp(op) => {
self.submit_active_thread_op(app_server, op.into()).await?;
let op: AppCommand = op.into();
self.chat_widget.prepare_local_op_submission(&op);
self.submit_active_thread_op(app_server, op).await?;
}
AppEvent::SubmitThreadOp { thread_id, op } => {
self.submit_thread_op(app_server, thread_id, op.into())
@@ -6401,6 +6799,7 @@ mod tests {
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::layout::Size;
use ratatui::prelude::Line;
use std::path::PathBuf;
use std::sync::Arc;
@@ -9217,6 +9616,78 @@ guardian_approval = true
assert_snapshot!("clear_ui_header_fast_status_fast_capable_models", rendered);
}
#[tokio::test]
async fn resize_reflow_repro_draw_should_drain_pending_without_commit_tick() {
let mut app = make_test_app().await;
let frame_requester = crate::tui::FrameRequester::test_dummy();
let size = Size::new(120, 40);
app.transcript_reflow.set_last_render_width_for_test(100);
app.handle_draw_size_change(
size,
size,
&frame_requester,
tui::ResizeReflowMode::Debounced,
);
app.transcript_reflow.set_due_for_test();
app.handle_draw_size_change(
size,
size,
&frame_requester,
tui::ResizeReflowMode::Debounced,
);
assert!(
!app.transcript_reflow.has_pending_reflow(),
"resize reflow should drain on draw even when commit animation is idle",
);
}
#[tokio::test]
async fn immediate_resize_reflow_mode_makes_reflow_due_in_same_draw() {
let mut app = make_test_app().await;
let frame_requester = crate::tui::FrameRequester::test_dummy();
let size = Size::new(120, 40);
app.transcript_cells.push(Arc::new(AgentMessageCell::new(
vec![Line::from("| Key | Value |")],
false,
)));
app.transcript_reflow.set_last_render_width_for_test(100);
let width_changed = app.handle_draw_size_change(
size,
size,
&frame_requester,
tui::ResizeReflowMode::Immediate,
);
assert!(width_changed);
assert!(
app.transcript_reflow.pending_is_due(Instant::now()),
"immediate mode should let the current pre-render pass reflow without waiting"
);
}
#[tokio::test]
async fn resize_reflow_repro_marks_stream_time_before_consolidation() {
let mut app = make_test_app().await;
app.transcript_cells.push(Arc::new(AgentMessageCell::new(
vec![Line::from("| Key | Value |")],
false,
)));
assert!(
!app.chat_widget.has_active_agent_stream(),
"repro requires stream controller to be cleared before consolidate event",
);
assert!(
app.should_mark_reflow_as_stream_time(),
"reflow in the pre-consolidation window should still be treated as stream-time",
);
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
@@ -9240,6 +9711,7 @@ guardian_approval = true
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
@@ -9295,6 +9767,7 @@ guardian_approval = true
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),

View File

@@ -363,7 +363,7 @@ impl App {
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let TuiEvent::Draw = &event
if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();

View File

@@ -64,6 +64,12 @@ impl RealtimeAudioDeviceKind {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConsolidationScrollbackReflow {
IfResizeReflowRan,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@@ -288,6 +294,32 @@ pub(crate) enum AppEvent {
InsertHistoryCell(Box<dyn HistoryCell>),
/// Replace the contiguous run of streaming `AgentMessageCell`s at the end of
/// the transcript with a single `AgentMarkdownCell` that stores the raw
/// markdown source and re-renders from it on resize.
///
/// Emitted by `ChatWidget::flush_answer_stream_with_separator` after stream
/// finalization. The `App` handler walks backward through `transcript_cells`
/// to find the `AgentMessageCell` run and splices in the consolidated cell.
/// The `cwd` keeps local file-link display stable across the final re-render.
/// `scrollback_reflow` lets table-tail finalization force the already-emitted
/// terminal scrollback to be rebuilt from the consolidated source-backed cell.
/// `deferred_history_cell` lets callers add the final stream tail to the
/// transcript without first writing its provisional render to scrollback.
ConsolidateAgentMessage {
source: String,
cwd: PathBuf,
scrollback_reflow: ConsolidationScrollbackReflow,
deferred_history_cell: Option<Box<dyn HistoryCell>>,
},
/// Replace the contiguous run of streaming `ProposedPlanStreamCell`s at the
/// end of the transcript with a single source-backed `ProposedPlanCell`.
///
/// Emitted by `ChatWidget::on_plan_item_completed` after plan stream
/// finalization.
ConsolidateProposedPlan(String),
/// Apply rollback semantics to local transcript cells.
///
/// This is emitted when rollback was not initiated by the current

View File

@@ -15,6 +15,7 @@
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
@@ -31,6 +32,7 @@ use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_features::Features;
use codex_file_search::FileMatch;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -429,10 +431,13 @@ impl BottomPane {
&& self.is_task_running
&& !is_agent_command
&& !self.composer.popup_active()
&& let Some(status) = &self.status
{
// Send Op::Interrupt
status.interrupt();
if let Some(status) = &self.status {
// Send Op::Interrupt
status.interrupt();
} else {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
self.request_redraw();
return InputResult::None;
}
@@ -1511,6 +1516,36 @@ mod tests {
assert!(rendered.contains("background terminal running · /ps to view"));
}
#[test]
fn esc_interrupts_running_task_when_status_hidden() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.hide_status_indicator();
assert!(
!pane.status_indicator_visible(),
"status indicator must be hidden for this repro"
);
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt while status is hidden"
);
}
#[test]
fn status_with_details_and_queued_messages_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -285,6 +285,7 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB
| TerminalName::GnomeTerminal
| TerminalName::Vte
| TerminalName::WindowsTerminal
| TerminalName::Superset
| TerminalName::Dumb
| TerminalName::Unknown => key_hint::alt(KeyCode::Up),
}
@@ -521,6 +522,43 @@ impl RateLimitWarningState {
}
}
#[cfg(test)]
fn is_interrupt_droppable_stream_event(msg: &EventMsg) -> bool {
match msg {
EventMsg::AgentMessage(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::AgentReasoning(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContent(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_) => true,
EventMsg::ItemCompleted(event) => matches!(
&event.item,
codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Plan(_)
),
_ => false,
}
}
fn is_interrupt_droppable_server_notification(notification: &ServerNotification) -> bool {
match notification {
ServerNotification::AgentMessageDelta(_)
| ServerNotification::PlanDelta(_)
| ServerNotification::ReasoningSummaryTextDelta(_)
| ServerNotification::ReasoningTextDelta(_) => true,
ServerNotification::ItemCompleted(notification) => matches!(
&notification.item,
ThreadItem::AgentMessage { .. } | ThreadItem::Plan { .. }
),
_ => false,
}
}
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
const MINUTES_PER_HOUR: i64 = 60;
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
@@ -811,6 +849,11 @@ pub(crate) struct ChatWidget {
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
/// currently executing.
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
/// True once an interrupt was requested for the active turn.
///
/// While this is set, inbound stream deltas are dropped locally so a large
/// queued backlog cannot keep rendering stale content after user interrupt.
interrupt_requested_for_turn: bool,
/// Expected MCP servers for the current startup round, seeded from enabled local config.
mcp_startup_expected_servers: Option<HashSet<String>>,
/// After startup settles, ignore stale updates until enough notifications confirm a new round.
@@ -1702,23 +1745,60 @@ impl ChatWidget {
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
{
self.add_boxed_history(cell);
let had_stream_controller = self.stream_controller.is_some();
if let Some(mut controller) = self.stream_controller.take() {
let scrollback_reflow = if controller.has_live_tail() {
crate::app_event::ConsolidationScrollbackReflow::Required
} else {
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
};
self.clear_active_stream_tail();
let (cell, source) = controller.finalize();
let deferred_history_cell =
if scrollback_reflow == crate::app_event::ConsolidationScrollbackReflow::Required {
cell
} else {
if let Some(cell) = cell {
self.add_boxed_history(cell);
}
None
};
if let Some(cell) = deferred_history_cell.as_ref() {
debug_assert!(
cell.as_any()
.downcast_ref::<history_cell::AgentMessageCell>()
.is_some(),
"only agent message stream tails should be deferred for consolidation",
);
}
// Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell
// that can re-render from source on resize.
if let Some(source) = source {
self.app_event_tx.send(AppEvent::ConsolidateAgentMessage {
source,
cwd: self.config.cwd.to_path_buf(),
scrollback_reflow,
deferred_history_cell,
});
} else if let Some(cell) = deferred_history_cell {
self.add_boxed_history(cell);
}
}
self.adaptive_chunking.reset();
if had_stream_controller && self.stream_controllers_idle() {
self.app_event_tx.send(AppEvent::StopCommitAnimation);
}
}
fn stream_controllers_idle(&self) -> bool {
self.stream_controller
.as_ref()
.map(|controller| controller.queued_lines() == 0)
.map(|controller| controller.queued_lines() == 0 && !controller.has_live_tail())
.unwrap_or(true)
&& self
.plan_stream_controller
.as_ref()
.map(|controller| controller.queued_lines() == 0)
.map(|controller| controller.queued_lines() == 0 && !controller.has_live_tail())
.unwrap_or(true)
}
@@ -2210,7 +2290,7 @@ impl ChatWidget {
if self.plan_stream_controller.is_none() {
self.plan_stream_controller = Some(PlanStreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(4)),
self.current_stream_width(4),
&self.config.cwd,
));
}
@@ -2239,18 +2319,25 @@ impl ChatWidget {
self.plan_delta_buffer.clear();
self.plan_item_active = false;
self.saw_plan_item_this_turn = true;
let finalized_streamed_cell =
let (finalized_streamed_cell, consolidated_plan_source) =
if let Some(mut controller) = self.plan_stream_controller.take() {
controller.finalize()
} else {
None
(None, None)
};
if let Some(cell) = finalized_streamed_cell {
self.add_boxed_history(cell);
// TODO: Replace streamed output with the final plan item text if plan streaming is
// removed or if we need to reconcile mismatches between streamed and final content.
if let Some(source) = consolidated_plan_source {
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
} else if !plan_text.is_empty() {
self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd));
} else if let Some(source) = consolidated_plan_source {
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
if should_restore_after_stream {
self.pending_status_indicator_restore = true;
@@ -2309,6 +2396,7 @@ impl ChatWidget {
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ true);
self.saw_copy_source_this_turn = false;
self.interrupt_requested_for_turn = false;
self.saw_plan_update_this_turn = false;
self.saw_plan_item_this_turn = false;
self.last_plan_progress = None;
@@ -2363,10 +2451,15 @@ impl ChatWidget {
self.saw_copy_source_this_turn = false;
// If a stream is currently active, finalize it.
self.flush_answer_stream_with_separator();
if let Some(mut controller) = self.plan_stream_controller.take()
&& let Some(cell) = controller.finalize()
{
self.add_boxed_history(cell);
if let Some(mut controller) = self.plan_stream_controller.take() {
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
}
if let Some(source) = source {
self.app_event_tx
.send(AppEvent::ConsolidateProposedPlan(source));
}
}
self.flush_unified_exec_wait_streak();
if !from_replay {
@@ -2398,6 +2491,7 @@ impl ChatWidget {
self.agent_turn_running = false;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ false);
self.interrupt_requested_for_turn = false;
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
@@ -2752,12 +2846,16 @@ impl ChatWidget {
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
/// and should continue to drive the bottom-pane running indicator while it is in progress.
fn finalize_turn(&mut self) {
// Drop preview-only stream tail content on any termination path before
// failed-cell finalization, so transient tail cells are never persisted.
self.clear_active_stream_tail();
// Ensure any spinner is replaced by a red ✗ and flushed into history.
self.finalize_active_cell_as_failed();
// Reset running state and clear streaming buffers.
self.agent_turn_running = false;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ false);
self.interrupt_requested_for_turn = false;
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
@@ -4139,6 +4237,7 @@ impl ChatWidget {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
self.sync_active_stream_tail();
if outcome.has_controller && outcome.all_idle {
self.maybe_restore_status_indicator_after_stream_idle();
@@ -4183,11 +4282,11 @@ impl ChatWidget {
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.stream_controller.is_none() {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
// If the previous turn inserted non-stream history (exec output, patch status, MCP
// calls), render a separator before starting the next streamed assistant message.
if self.needs_final_message_separator && self.had_work_activity {
@@ -4207,8 +4306,11 @@ impl ChatWidget {
self.needs_final_message_separator = false;
}
self.stream_controller = Some(StreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
self.current_stream_width(2),
&self.config.cwd,
self.config
.features
.enabled(Feature::StreamTableLiveTailReflow),
));
}
if let Some(controller) = self.stream_controller.as_mut()
@@ -4217,9 +4319,39 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_active_stream_tail();
self.request_redraw();
}
fn current_stream_width(&self, reserved_cols: usize) -> Option<usize> {
self.last_rendered_width.get().and_then(|w| {
if w == 0 {
None
} else {
// Keep a 1-column minimum for active stream controllers so they can
// continue accepting deltas on ultra-narrow layouts.
Some(crate::width::usable_content_width(w, reserved_cols).unwrap_or(1))
}
})
}
pub(crate) fn on_terminal_resize(&mut self, width: u16) {
let had_rendered_width = self.last_rendered_width.get().is_some();
self.last_rendered_width.set(Some(width as usize));
let stream_width = self.current_stream_width(2);
let plan_stream_width = self.current_stream_width(4);
if let Some(controller) = self.stream_controller.as_mut() {
controller.set_width(stream_width);
}
if let Some(controller) = self.plan_stream_controller.as_mut() {
controller.set_width(plan_stream_width);
}
self.sync_active_stream_tail();
if !had_rendered_width {
self.request_redraw();
}
}
fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 {
let baseline = match self.last_separator_elapsed_secs {
Some(last) if current_elapsed < last => 0,
@@ -4702,6 +4834,7 @@ impl ChatWidget {
mcp_startup_status: None,
last_agent_markdown: None,
saw_copy_source_this_turn: false,
interrupt_requested_for_turn: false,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
@@ -4945,7 +5078,7 @@ impl ChatWidget {
return;
};
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
self.is_session_configured() && !self.has_active_plan_stream();
if should_submit_now {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
@@ -5604,6 +5737,16 @@ impl ChatWidget {
}
fn flush_active_cell(&mut self) {
if self.active_cell_is_stream_tail() {
if self.stream_controller.is_some() {
return;
}
// If stream cleanup already cleared the controller, drop the transient tail instead
// of committing preview-only content to transcript history.
self.active_cell.take();
return;
}
if let Some(active) = self.active_cell.take() {
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
@@ -5625,12 +5768,47 @@ impl ChatWidget {
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
let keep_stream_tail_active =
self.stream_controller.is_some() && self.active_cell_is_stream_tail();
if !keep_stream_tail_active {
self.flush_active_cell();
}
self.needs_final_message_separator = true;
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
fn sync_active_stream_tail(&mut self) {
let Some((tail_lines, tail_starts_stream)) =
self.stream_controller.as_mut().map(|controller| {
(
controller.current_tail_lines(),
controller.tail_starts_stream(),
)
})
else {
return;
};
if tail_lines.is_empty() {
self.clear_active_stream_tail();
return;
}
self.bottom_pane.hide_status_indicator();
let tail_cell = history_cell::StreamingAgentTailCell::new(tail_lines, tail_starts_stream);
self.active_cell = Some(Box::new(tail_cell));
self.bump_active_cell_revision();
}
fn clear_active_stream_tail(&mut self) {
if self.active_cell_is_stream_tail() {
self.active_cell = None;
self.bump_active_cell_revision();
}
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
@@ -6375,6 +6553,13 @@ impl ChatWidget {
if !is_resume_initial_replay && !is_retry_error {
self.restore_retry_status_header_if_present();
}
if !from_replay
&& self.interrupt_requested_for_turn
&& is_interrupt_droppable_server_notification(&notification)
{
tracing::trace!("dropping app-server stream notification while interrupt is pending");
return;
}
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.set_token_info(Some(token_usage_info_from_app_server(
@@ -6895,6 +7080,15 @@ impl ChatWidget {
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
if !from_replay
&& self.interrupt_requested_for_turn
&& is_interrupt_droppable_stream_event(&msg)
{
tracing::trace!("dropping stream event while interrupt is pending");
return;
}
let is_stream_error = matches!(&msg, EventMsg::StreamError(_));
if !is_resume_initial_replay && !is_stream_error {
self.restore_retry_status_header_if_present();
@@ -10440,10 +10634,22 @@ impl ChatWidget {
self.bottom_pane.is_task_running() || self.is_review_mode
}
fn is_plan_streaming_in_tui(&self) -> bool {
/// Whether an agent message stream is active (not a plan stream).
pub(crate) fn has_active_agent_stream(&self) -> bool {
self.stream_controller.is_some()
}
pub(crate) fn has_active_plan_stream(&self) -> bool {
self.plan_stream_controller.is_some()
}
/// Whether the active cell is a transient streaming tail preview.
fn active_cell_is_stream_tail(&self) -> bool {
self.active_cell
.as_ref()
.is_some_and(|cell| cell.as_any().is::<history_cell::StreamingAgentTailCell>())
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
}
@@ -10472,7 +10678,7 @@ impl ChatWidget {
return;
}
self.set_collaboration_mask(collaboration_mode);
let should_queue = self.is_plan_streaming_in_tui();
let should_queue = self.has_active_plan_stream();
let user_message = UserMessage {
text,
local_images: Vec::new(),
@@ -10558,6 +10764,7 @@ impl ChatWidget {
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
self.prepare_local_op_submission(&op);
if op.is_review() && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
@@ -10576,6 +10783,22 @@ impl ChatWidget {
true
}
pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) {
if matches!(op.view(), crate::app_command::AppCommandView::Interrupt)
&& self.agent_turn_running
{
self.interrupt_requested_for_turn = true;
if let Some(controller) = self.stream_controller.as_mut() {
controller.clear_queue();
}
if let Some(controller) = self.plan_stream_controller.as_mut() {
controller.clear_queue();
}
self.clear_active_stream_tail();
self.request_redraw();
}
}
#[cfg(test)]
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
self.add_to_history(history_cell::new_mcp_tools_output(

View File

@@ -1064,6 +1064,207 @@ async fn interrupt_restores_queued_messages_into_composer() {
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn interrupt_drops_stream_deltas_until_turn_aborted() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
started_at: None,
}),
});
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
let mut saw_interrupt = false;
while let Ok(op) = op_rx.try_recv() {
if matches!(op, Op::Interrupt) {
saw_interrupt = true;
break;
}
}
assert!(saw_interrupt, "expected Ctrl+C to submit Op::Interrupt");
// Simulate stale stream backlog that arrives before TurnAborted.
chat.stream_controller = None;
chat.handle_codex_event(Event {
id: "delta-stale".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "stale row\n".to_string(),
}),
});
assert!(
chat.stream_controller.is_none(),
"expected stale delta to be dropped while interrupt is pending",
);
chat.handle_codex_event(Event {
id: "abort-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
// A subsequent turn should stream normally.
chat.handle_codex_event(Event {
id: "turn-2".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
started_at: None,
}),
});
chat.handle_codex_event(Event {
id: "delta-fresh".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "fresh row\n".to_string(),
}),
});
assert!(
chat.stream_controller.is_some(),
"expected new-turn delta to stream after interrupt completion",
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn app_event_interrupt_prepares_local_stream_cleanup() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller =
crate::streaming::controller::StreamController::new(Some(80), cwd.as_path(), false);
assert!(controller.push("stale backlog\n"));
chat.agent_turn_running = true;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.bottom_pane.hide_status_indicator();
chat.stream_controller = Some(controller);
chat.active_cell = Some(Box::new(crate::history_cell::StreamingAgentTailCell::new(
vec![ratatui::text::Line::from("tail")],
true,
)));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let command = loop {
match rx.try_recv() {
Ok(AppEvent::CodexOp(op)) => break AppCommand::from(op),
Ok(_) => continue,
Err(err) => panic!("expected app-level interrupt event, got {err:?}"),
}
};
chat.prepare_local_op_submission(&command);
assert!(chat.interrupt_requested_for_turn);
assert!(
chat.active_cell.is_none(),
"interrupt cleanup should clear the active stream tail",
);
assert_eq!(
chat.stream_controller
.as_ref()
.map(crate::streaming::controller::StreamController::queued_lines),
Some(0),
);
}
#[tokio::test]
async fn interrupt_remains_responsive_during_resized_table_stream() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.last_rendered_width.set(Some(120));
chat.handle_codex_event(Event {
id: "turn-resize".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-resize".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
started_at: None,
}),
});
chat.handle_codex_event(Event {
id: "table-head".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta:
"| Row | Initiative Summary | Extended Notes | URL |\n| --- | --- | --- | --- |\n"
.to_string(),
}),
});
for idx in 0..40 {
chat.handle_codex_event(Event {
id: format!("table-row-{idx}"),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: format!(
"| {idx:03} | Workstream {idx:03} provides a long narrative about scope boundaries, sequencing assumptions, contingency paths, stakeholder dependencies, and quality criteria to keep complex coordination readable under pressure. | Record {idx:03} stores extended execution commentary including risk signals, approvals, rollback conditions, evidence links, and checkpoint outcomes so auditors and new contributors can understand context without reopening old threads. | https://example.com/program/workstream-{idx:03}-detailed-governance-and-delivery-context |\n",
),
}),
});
chat.on_terminal_resize(if idx % 2 == 0 { 72 } else { 116 });
chat.on_commit_tick();
let _ = drain_insert_history(&mut rx);
}
assert!(
chat.stream_controller.is_some(),
"expected active stream during table tail stress",
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
let mut saw_interrupt = false;
while let Ok(op) = op_rx.try_recv() {
if matches!(op, Op::Interrupt) {
saw_interrupt = true;
break;
}
}
assert!(saw_interrupt, "expected Ctrl+C to submit Op::Interrupt");
chat.on_terminal_resize(64);
let resized_tail = {
let controller = chat
.stream_controller
.as_mut()
.expect("expected stream controller after resize");
lines_to_single_string(&controller.current_tail_lines())
};
chat.handle_codex_event(Event {
id: "table-row-stale-after-interrupt".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "| 999 | INTERRUPT_STALE_SENTINEL | INTERRUPT_STALE_SENTINEL | https://example.com/stale |\n"
.to_string(),
}),
});
let tail_after_stale_delta = {
let controller = chat
.stream_controller
.as_mut()
.expect("expected stream controller after stale delta");
lines_to_single_string(&controller.current_tail_lines())
};
assert_eq!(
tail_after_stale_delta, resized_tail,
"expected stale table delta to be dropped while interrupt is pending",
);
assert!(
!tail_after_stale_delta.contains("INTERRUPT_STALE_SENTINEL"),
"stale sentinel should never reach the active stream tail",
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -222,6 +222,7 @@ pub(super) async fn make_chatwidget_manual(
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
interrupt_requested_for_turn: false,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,

View File

@@ -112,6 +112,305 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() {
"expected /status to avoid raw config context window, got: {context_line}"
);
}
#[tokio::test]
async fn current_stream_width_clamps_to_minimum_when_reserved_columns_exhaust_width() {
let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.last_rendered_width.set(None);
assert_eq!(chat.current_stream_width(2), None);
chat.last_rendered_width.set(Some(2));
assert_eq!(chat.current_stream_width(2), Some(1));
chat.last_rendered_width.set(Some(4));
assert_eq!(chat.current_stream_width(4), Some(1));
chat.last_rendered_width.set(Some(5));
assert_eq!(chat.current_stream_width(4), Some(1));
}
#[tokio::test]
async fn on_terminal_resize_initial_width_requests_redraw() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let (draw_tx, mut draw_rx) = tokio::sync::broadcast::channel(8);
chat.frame_requester = FrameRequester::new(draw_tx);
chat.last_rendered_width.set(None);
chat.on_terminal_resize(120);
let draw = tokio::time::timeout(std::time::Duration::from_millis(200), draw_rx.recv())
.await
.expect("timed out waiting for redraw request");
assert!(draw.is_ok(), "expected redraw notification to be sent");
}
#[tokio::test]
async fn add_to_history_does_not_commit_transient_stream_tail_after_controller_clear() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.active_cell = Some(Box::new(crate::history_cell::StreamingAgentTailCell::new(
vec![ratatui::text::Line::from("transient table tail preview")],
true,
)));
// Interrupt/error cleanup paths clear the controller before appending history.
chat.stream_controller = None;
chat.add_to_history(crate::history_cell::new_error_event(
"stream interrupted".to_string(),
));
let mut inserted_count = 0usize;
let mut saw_transient_tail = false;
let mut saw_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
inserted_count += 1;
if cell
.as_any()
.is::<crate::history_cell::StreamingAgentTailCell>()
{
saw_transient_tail = true;
}
let rendered = lines_to_single_string(&cell.display_lines(80));
if rendered.contains("stream interrupted") {
saw_error = true;
}
}
}
assert!(saw_error, "expected error history cell to be emitted");
assert!(
!saw_transient_tail,
"did not expect transient stream-tail cell to be committed",
);
assert_eq!(
inserted_count, 1,
"expected only one committed history cell after cleanup"
);
}
#[tokio::test]
async fn on_error_does_not_persist_transient_stream_tail_during_finalize_turn() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.stream_controller = Some(crate::streaming::controller::StreamController::new(
Some(80),
chat.config.cwd.as_path(),
false,
));
chat.active_cell = Some(Box::new(crate::history_cell::StreamingAgentTailCell::new(
vec![ratatui::text::Line::from("transient stream tail preview")],
true,
)));
chat.on_error("stream failed".to_string());
let mut saw_transient_tail = false;
let mut saw_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
if cell
.as_any()
.is::<crate::history_cell::StreamingAgentTailCell>()
{
saw_transient_tail = true;
}
let rendered = lines_to_single_string(&cell.display_lines(80));
if rendered.contains("stream failed") {
saw_error = true;
}
}
}
assert!(saw_error, "expected error history cell to be emitted");
assert!(
!saw_transient_tail,
"did not expect transient stream-tail cell to be committed during finalize_turn",
);
}
#[tokio::test]
async fn flush_answer_stream_does_not_stop_animation_while_plan_stream_is_active() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut plan_controller =
crate::streaming::controller::PlanStreamController::new(Some(80), cwd.as_path());
assert!(plan_controller.push("- Step 1\n"));
assert!(
plan_controller.queued_lines() > 0,
"expected plan stream to have queued lines for this repro",
);
chat.plan_stream_controller = Some(plan_controller);
chat.stream_controller = Some(crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
false,
));
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_stop = false;
while let Ok(event) = rx.try_recv() {
if matches!(event, AppEvent::StopCommitAnimation) {
saw_stop = true;
}
}
assert!(
!saw_stop,
"did not expect StopCommitAnimation while plan stream still has queued lines",
);
}
#[tokio::test]
async fn flush_answer_stream_requests_scrollback_reflow_for_live_table_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller =
crate::streaming::controller::StreamController::new(Some(80), cwd.as_path(), false);
controller.push("| Name | Notes |\n");
controller.push("| --- | --- |\n");
controller.push("| alpha | FINAL_DUPLICATE_SENTINEL |\n");
assert!(
controller.has_live_tail(),
"expected table holdback to leave a live tail for this regression",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::Required
);
assert!(
deferred_history_cell.is_some(),
"live table tail should be staged for consolidation without provisional insert",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
!saw_insert_history,
"live table tail should not be inserted before canonical reflow"
);
}
#[tokio::test]
async fn flush_answer_stream_keeps_default_reflow_for_plain_text_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller =
crate::streaming::controller::StreamController::new(Some(80), cwd.as_path(), false);
assert!(controller.push("plain response line\n"));
assert!(
!controller.has_live_tail(),
"plain completed text should not force scrollback reflow",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
);
assert!(
deferred_history_cell.is_none(),
"plain text should keep the normal provisional insert path",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
saw_insert_history,
"plain text should still insert history before consolidation"
);
}
#[tokio::test]
async fn flush_answer_stream_does_not_stop_animation_while_plan_table_stream_is_active() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut plan_controller =
crate::streaming::controller::PlanStreamController::new(Some(80), cwd.as_path());
assert!(
plan_controller.push("| Step | Owner |\n"),
"expected table header to enqueue while plan stream is active",
);
assert!(
plan_controller.queued_lines() > 0,
"expected queued table header lines for this repro",
);
chat.plan_stream_controller = Some(plan_controller);
chat.stream_controller = Some(crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
false,
));
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_stop = false;
while let Ok(event) = rx.try_recv() {
if matches!(event, AppEvent::StopCommitAnimation) {
saw_stop = true;
}
}
assert!(
!saw_stop,
"did not expect StopCommitAnimation while plan table stream is still active",
);
}
#[tokio::test]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -97,7 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;

View File

@@ -79,6 +79,7 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::any::Any;
@@ -190,6 +191,9 @@ impl Renderable for Box<dyn HistoryCell> {
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
// Active-cell content can reflow dramatically during resize/stream updates. Clear the
// entire draw area first so stale glyphs from previous frames never linger.
Clear.render(area, buf);
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
@@ -407,7 +411,7 @@ impl ReasoningSummaryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
Some((width as usize).saturating_sub(2)),
crate::width::usable_content_width_u16(width, 2),
Some(self.cwd.as_path()),
&mut lines,
);
@@ -481,6 +485,98 @@ impl HistoryCell for AgentMessageCell {
}
}
/// A consolidated agent message cell that stores raw markdown source and
/// re-renders from it at any width.
///
/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App`
/// replaces the contiguous run of `AgentMessageCell`s with a single
/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders
/// from source via `append_markdown_agent`, producing correctly-sized tables
/// with box-drawing borders.
///
/// Uses `prefix_lines` (not `word_wrap_lines`) so table rows with box-drawing
/// characters pass through without re-wrapping.
#[derive(Debug)]
pub(crate) struct AgentMarkdownCell {
markdown_source: String,
cwd: PathBuf,
}
impl AgentMarkdownCell {
pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self {
Self {
markdown_source,
cwd: cwd.to_path_buf(),
}
}
}
impl HistoryCell for AgentMarkdownCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(wrap_width) = crate::width::usable_content_width_u16(width, 2) else {
return prefix_lines(vec![Line::default()], "".dim(), " ".into());
};
let mut lines: Vec<Line<'static>> = Vec::new();
// Re-render markdown from source at the current width. Reserve 2 columns for the "• " /
// " " prefix prepended below.
crate::markdown::append_markdown_agent_with_cwd(
&self.markdown_source,
Some(wrap_width),
Some(self.cwd.as_path()),
&mut lines,
);
// Use prefix_lines (not word_wrap_lines) so table rows with box-drawing characters are not
// broken by word-wrapping. The markdown renderer already output to wrap_width.
prefix_lines(lines, "".dim(), " ".into())
}
}
/// Transient active-cell representation of the mutable tail of an agent stream.
///
/// During streaming, lines that have not yet been committed to scrollback (because they belong to
/// an in-progress table or are the last incomplete line) are displayed via this cell in the
/// `active_cell` slot. It is replaced on every delta and cleared when the stream finalizes.
///
/// Unlike `AgentMessageCell`, this cell is never committed to the transcript. It exists only as a
/// live preview of content that will eventually be emitted as stable `AgentMessageCell`s or
/// consolidated into an `AgentMarkdownCell`.
#[derive(Debug)]
pub(crate) struct StreamingAgentTailCell {
lines: Vec<Line<'static>>,
is_first_line: bool,
}
impl StreamingAgentTailCell {
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
Self {
lines,
is_first_line,
}
}
}
impl HistoryCell for StreamingAgentTailCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
// Tail lines are already rendered at the controller's current stream width.
// Re-wrapping them here can split table borders and produce malformed
// in-flight table rows.
prefix_lines(
self.lines.clone(),
if self.is_first_line {
"".dim()
} else {
" ".into()
},
" ".into(),
)
}
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
@@ -2773,6 +2869,7 @@ mod tests {
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
use crate::wrapping::word_wrap_lines;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_core::config::Config;
@@ -2789,6 +2886,8 @@ mod tests {
use codex_protocol::protocol::SessionConfiguredEvent;
use dirs::home_dir;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -4688,4 +4787,280 @@ mod tests {
]
);
}
#[test]
fn agent_markdown_cell_renders_table_at_different_widths() {
let source = "| Name | Role |\n|------|------|\n| Alice | Engineer |\n| Bob | Designer |\n";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
// At width 80 the table should render with box-drawing characters.
let lines_80 = render_lines(&cell.display_lines(80));
assert!(
lines_80.iter().any(|l| l.contains('┌')),
"expected box-drawing table at width 80: {lines_80:?}"
);
// Verify the "• " leader is present on the first line.
assert!(
lines_80[0].starts_with(""),
"first line should start with bullet prefix: {:?}",
lines_80[0]
);
// At width 40 the table should also render correctly (re-rendered from
// source, not just word-wrapped).
let lines_40 = render_lines(&cell.display_lines(40));
assert!(
lines_40.iter().any(|l| l.contains('┌')),
"expected box-drawing table at width 40: {lines_40:?}"
);
// Verify table borders are intact (not broken by naive word-wrapping).
// Every line with a box char should have matching left/right borders.
for line in &lines_40 {
let trimmed = line.trim();
if trimmed.starts_with('│') {
assert!(
trimmed.ends_with('│'),
"table row should have matching right border: {line:?}"
);
}
}
}
#[test]
fn agent_markdown_cell_narrow_width_shows_prefix_only() {
let source = "| Name | Role |\n|------|------|\n| Alice | Engineer |\n";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
let lines = render_lines(&cell.display_lines(2));
assert_eq!(lines, vec!["".to_string()]);
}
#[test]
fn wrapped_and_prefixed_cells_handle_tiny_widths() {
let user_cell = UserHistoryCell {
message: "tiny width coverage for wrapped user history".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
};
let agent_message_cell = AgentMessageCell::new(vec!["tiny width agent line".into()], true);
let reasoning_cell = ReasoningSummaryCell::new(
"Plan".to_string(),
"Reasoning summary content for tiny widths.".to_string(),
&test_cwd(),
false,
);
let agent_markdown_cell =
AgentMarkdownCell::new("| A | B |\n|---|---|\n| x | y |\n".to_string(), &test_cwd());
for width in 1..=4 {
assert!(
!user_cell.display_lines(width).is_empty(),
"user cell should render at width {width}",
);
assert!(
!agent_message_cell.display_lines(width).is_empty(),
"agent message cell should render at width {width}",
);
assert!(
!reasoning_cell.display_lines(width).is_empty(),
"reasoning cell should render at width {width}",
);
assert!(
!agent_markdown_cell.display_lines(width).is_empty(),
"agent markdown cell should render at width {width}",
);
}
}
#[test]
fn render_clears_area_when_cell_content_shrinks() {
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
let first: Box<dyn HistoryCell> = Box::new(PlainHistoryCell::new(vec![
Line::from("STALE ROW 1"),
Line::from("STALE ROW 2"),
Line::from("STALE ROW 3"),
Line::from("STALE ROW 4"),
]));
first.render(area, &mut buf);
let second: Box<dyn HistoryCell> =
Box::new(PlainHistoryCell::new(vec![Line::from("fresh")]));
second.render(area, &mut buf);
let mut rendered_rows: Vec<String> = Vec::new();
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push_str(buf.cell((x, y)).expect("cell should exist").symbol());
}
rendered_rows.push(row);
}
assert!(
rendered_rows.iter().all(|row| !row.contains("STALE")),
"rendered buffer should not retain stale glyphs: {rendered_rows:?}",
);
assert!(
rendered_rows
.first()
.is_some_and(|row| row.contains("fresh")),
"expected fresh content in first row: {rendered_rows:?}",
);
}
#[test]
fn agent_markdown_cell_survives_insert_history_rewrap() {
let source = "\
| Milestone | Target Date | Outcome | Extended Context |
|-----------|-------------|---------|------------------|
| Canary Rollout | 2026-01-15 | Completed | Canary remained at limited traffic longer than planned because p95 latency briefly regressed during
cold-cache periods |
| Regional Expansion | 2026-01-29 | Completed | Expansion succeeded with stable error rates, though internal analytics lagged temporarily |
";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
let width: u16 = 80;
let lines = cell.display_lines(width);
// Simulate what insert_history_lines does: word_wrap_lines with
// the terminal width and no indent.
let rewrapped = word_wrap_lines(&lines, width as usize);
let before = render_lines(&lines);
let after = render_lines(&rewrapped);
assert_eq!(
before, after,
"word_wrap_lines should not alter lines that already fit within width"
);
}
#[test]
fn agent_markdown_cell_table_fits_within_narrow_width() {
let source = "\
| Milestone | Target Date | Outcome | Extended Context |
|-----------|-------------|---------|------------------|
| Canary Rollout | 2026-01-15 | Completed | Canary remained at limited traffic longer than planned because p95 latency briefly regressed during
cold-cache periods |
| Regional Expansion | 2026-01-29 | Completed | Expansion succeeded with stable error rates, though internal analytics lagged temporarily |
| Legacy Decommission | 2026-02-10 | In Progress | Most legacy jobs are drained, but final shutdown is blocked by one compliance export workflow |
";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
// Render at a narrow width (simulating terminal resize).
let narrow_width: u16 = 80;
let lines = cell.display_lines(narrow_width);
let rendered = render_lines(&lines);
// Every rendered line must fit within the target width.
for line in &rendered {
let display_width = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(
display_width <= narrow_width as usize,
"line exceeds width {narrow_width}: ({display_width} chars) {line:?}"
);
}
// Table should still have box-drawing characters.
assert!(
rendered.iter().any(|l| l.contains('┌')),
"expected box-drawing table: {rendered:?}"
);
}
#[test]
fn streaming_agent_tail_cell_does_not_rewrap_table_rows() {
let source = "\
| # | Type | Example | Details | Score |\n\
| --- | --- | --- | --- | --- |\n\
| 5 | URL link | Rust (https://www.rust-lang.org) | external | 93 |\n\
| 6 | GitHub link | Repo (https://github.com/openai) | external | 89 |\n";
let mut rendered_lines = Vec::new();
crate::markdown::append_markdown_agent(source, Some(120), &mut rendered_lines);
let cell = StreamingAgentTailCell::new(rendered_lines, true);
let lines = render_lines(&cell.display_lines(72));
// Ensure wrapped spillover lines were not introduced by a second wrap pass.
for line in &lines {
let content = line.chars().skip(2).collect::<String>();
let trimmed = content.trim();
if trimmed.starts_with('│') {
assert!(
trimmed.ends_with('│'),
"table row should preserve right border while streaming: {line:?}",
);
}
}
}
/// Simulate the consolidation backward-walk logic from `App::handle_event`
/// to verify it correctly identifies and replaces `AgentMessageCell` runs.
#[test]
fn consolidation_walker_replaces_agent_message_cells() {
use std::sync::Arc;
// Build a transcript with: [UserCell, AgentMsg(head), AgentMsg(cont), AgentMsg(cont)]
let user = Arc::new(UserHistoryCell {
message: "hello".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>;
let head = Arc::new(AgentMessageCell::new(vec![Line::from("line 1")], true))
as Arc<dyn HistoryCell>;
let cont1 = Arc::new(AgentMessageCell::new(vec![Line::from("line 2")], false))
as Arc<dyn HistoryCell>;
let cont2 = Arc::new(AgentMessageCell::new(vec![Line::from("line 3")], false))
as Arc<dyn HistoryCell>;
let mut transcript_cells: Vec<Arc<dyn HistoryCell>> =
vec![user.clone(), head, cont1, cont2];
// Run the same consolidation logic as the handler.
let source = "line 1\nline 2\nline 3\n".to_string();
let end = transcript_cells.len();
let mut start = end;
while start > 0
&& transcript_cells[start - 1].is_stream_continuation()
&& transcript_cells[start - 1]
.as_any()
.is::<AgentMessageCell>()
{
start -= 1;
}
if start > 0
&& transcript_cells[start - 1]
.as_any()
.is::<AgentMessageCell>()
&& !transcript_cells[start - 1].is_stream_continuation()
{
start -= 1;
}
assert_eq!(
start, 1,
"should find all 3 agent cells starting at index 1"
);
assert_eq!(end, 4);
// Splice.
let consolidated: Arc<dyn HistoryCell> =
Arc::new(AgentMarkdownCell::new(source, &test_cwd()));
transcript_cells.splice(start..end, std::iter::once(consolidated));
assert_eq!(transcript_cells.len(), 2, "should be [user, consolidated]");
// Verify first cell is still the user cell.
assert!(
transcript_cells[0].as_any().is::<UserHistoryCell>(),
"first cell should be UserHistoryCell"
);
// Verify second cell is AgentMarkdownCell.
assert!(
transcript_cells[1].as_any().is::<AgentMarkdownCell>(),
"second cell should be AgentMarkdownCell"
);
}
}

View File

@@ -185,8 +185,15 @@ where
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(writer, MoveTo(0, cursor_top))?;
let scroll_bottom = area.top().saturating_sub(1);
let mut advance_row = cursor_top;
for line in &wrapped {
queue!(writer, Print("\r\n"))?;
// Explicitly anchor before each line advance to avoid terminal wrap-pending drift when
// prior content reached the right edge.
queue!(writer, MoveTo(0, advance_row), Print("\n"))?;
if advance_row < scroll_bottom {
advance_row += 1;
}
write_history_line(writer, line, wrap_width)?;
}
@@ -821,4 +828,58 @@ mod tests {
assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2));
assert_eq!(term.visible_history_rows(), 1);
}
#[test]
fn vt100_exact_width_rows_keep_stable_line_progression() {
let width: u16 = 10;
let height: u16 = 7;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(0, height - 1, width, 1);
term.set_viewport_area(viewport);
let lines = vec![
Line::from("1234567890"),
Line::from("abcdefghij"),
Line::from("KLMNOPQRST"),
];
insert_history_lines(&mut term, lines).expect("insert_history_lines should succeed");
let screen = term.backend().vt100().screen();
let mut non_empty_rows: Vec<(u16, String)> = Vec::new();
for row in 0..height.saturating_sub(1) {
let row_text = (0..width)
.map(|col| {
screen
.cell(row, col)
.map(|cell| cell.contents().to_string())
.unwrap_or_default()
})
.collect::<String>();
if !row_text.trim().is_empty() {
non_empty_rows.push((row, row_text));
}
}
assert_eq!(
non_empty_rows.len(),
3,
"expected exactly three populated rows, got {non_empty_rows:?}",
);
let expected = ["1234567890", "abcdefghij", "KLMNOPQRST"];
for (idx, (row, row_text)) in non_empty_rows.iter().enumerate() {
assert_eq!(
row_text, expected[idx],
"unexpected row content at y={row}: {row_text:?}",
);
}
for pair in non_empty_rows.windows(2) {
assert_eq!(
pair[1].0,
pair[0].0 + 1,
"expected contiguous row progression, got {non_empty_rows:?}",
);
}
}
}

View File

@@ -152,6 +152,7 @@ mod terminal_title;
mod text_formatting;
mod theme_picker;
mod tooltips;
mod transcript_reflow;
mod tui;
mod ui_consts;
pub(crate) mod update_action;
@@ -215,9 +216,11 @@ mod voice {
pub(crate) fn clear(&self) {}
}
}
mod width;
mod wrapping;
mod table_detect;
#[cfg(test)]
pub(crate) mod test_backend;
#[cfg(test)]

View File

@@ -1,7 +1,32 @@
//! Markdown-to-ratatui rendering entry points.
//!
//! This module provides the public API surface that the rest of the TUI uses
//! to turn markdown source into `Vec<Line<'static>>`. Two variants exist:
//!
//! - [`append_markdown`] -- general-purpose, used for plan blocks and history
//! cells that already hold pre-processed markdown (no fence unwrapping).
//! - [`append_markdown_agent`] -- for agent responses. Runs
//! [`unwrap_markdown_fences`] first so that `` ```md ``/`` ```markdown ``
//! fences containing tables are stripped and `pulldown-cmark` sees raw
//! table syntax instead of fenced code.
//!
//! ## Why fence unwrapping exists
//!
//! LLM agents frequently wrap tables in `` ```markdown `` fences, treating
//! them as code. Without unwrapping, `pulldown-cmark` parses those lines
//! as a fenced code block and renders them as monospace code rather than a
//! structured table. The unwrapper is intentionally conservative: it
//! buffers the entire fence body before deciding, only unwraps fences whose
//! info string is `md` or `markdown` AND whose body contains a
//! header+delimiter pair, and degrades gracefully on unclosed fences.
use ratatui::text::Line;
use std::borrow::Cow;
use std::ops::Range;
use std::path::Path;
/// Render markdown into `lines` while resolving local file-link display relative to `cwd`.
use crate::table_detect;
/// Render markdown source to styled ratatui lines and append them to `lines`.
///
/// Callers that already know the session working directory should pass it here so streamed and
/// non-streamed rendering show the same relative path text even if the process cwd differs.
@@ -19,6 +44,253 @@ pub(crate) fn append_markdown(
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
/// Render an agent message to styled ratatui lines.
///
/// Before rendering, the source is passed through [`unwrap_markdown_fences`] so that tables
/// wrapped in `` ```md `` fences are rendered as native tables rather than code blocks.
/// Non-markdown fences (e.g. `rust`, `sh`) are left
/// intact.
#[cfg(test)]
pub(crate) fn append_markdown_agent(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
) {
append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines);
}
/// Render an agent message while resolving local file links relative to `cwd`.
pub(crate) fn append_markdown_agent_with_cwd(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
lines: &mut Vec<Line<'static>>,
) {
let normalized = unwrap_markdown_fences(markdown_source);
let rendered =
crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd);
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
/// markdown so `pulldown-cmark` parses the tables natively.
///
/// Fences whose info string is not `md` or `markdown` are passed through unchanged. Markdown
/// fences that do *not* contain a table (detected by checking for a header row + delimiter row)
/// are also passed through so that non-table markdown inside a fence still renders as a code
/// block.
///
/// The fence unwrapping is intentionally conservative: it buffers the entire fence body before
/// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial
/// streams degrade to code display.
fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> {
// Zero-copy fast path: most messages contain no fences at all.
if !markdown_source.contains("```") && !markdown_source.contains("~~~") {
return Cow::Borrowed(markdown_source);
}
#[derive(Clone, Copy)]
struct Fence {
marker: u8,
len: usize,
is_blockquoted: bool,
}
// Strip a trailing newline and up to 3 leading spaces, returning the
// trimmed slice. Returns `None` when the line has 4+ leading spaces
// (which makes it an indented code line per CommonMark).
fn strip_line_indent(line: &str) -> Option<&str> {
let without_newline = line.strip_suffix('\n').unwrap_or(line);
let mut byte_idx = 0usize;
let mut column = 0usize;
for b in without_newline.as_bytes() {
match b {
b' ' => {
byte_idx += 1;
column += 1;
}
b'\t' => {
byte_idx += 1;
column += 4;
}
_ => break,
}
if column >= 4 {
return None;
}
}
Some(&without_newline[byte_idx..])
}
// Parse an opening fence line, returning the fence metadata and whether
// the fence info string indicates markdown content.
fn parse_open_fence(line: &str) -> Option<(Fence, bool)> {
let trimmed = strip_line_indent(line)?;
let is_blockquoted = trimmed.trim_start().starts_with('>');
let fence_scan_text = table_detect::strip_blockquote_prefix(trimmed);
let (marker, len) = table_detect::parse_fence_marker(fence_scan_text)?;
let is_markdown = table_detect::is_markdown_fence_info(fence_scan_text, len);
Some((
Fence {
marker: marker as u8,
len,
is_blockquoted,
},
is_markdown,
))
}
fn is_close_fence(line: &str, fence: Fence) -> bool {
let Some(trimmed) = strip_line_indent(line) else {
return false;
};
let fence_scan_text = if fence.is_blockquoted {
if !trimmed.trim_start().starts_with('>') {
return false;
}
table_detect::strip_blockquote_prefix(trimmed)
} else {
trimmed
};
if let Some((marker, len)) = table_detect::parse_fence_marker(fence_scan_text) {
marker as u8 == fence.marker
&& len >= fence.len
&& fence_scan_text[len..].trim().is_empty()
} else {
false
}
}
fn markdown_fence_contains_table(content: &str, is_blockquoted_fence: bool) -> bool {
let mut previous_line: Option<&str> = None;
for line in content.lines() {
let text = if is_blockquoted_fence {
table_detect::strip_blockquote_prefix(line)
} else {
line
};
let trimmed = text.trim();
if trimmed.is_empty() {
previous_line = None;
continue;
}
if let Some(previous) = previous_line
&& table_detect::is_table_header_line(previous)
&& !table_detect::is_table_delimiter_line(previous)
&& table_detect::is_table_delimiter_line(trimmed)
{
return true;
}
previous_line = Some(trimmed);
}
false
}
fn content_from_ranges(source: &str, ranges: &[Range<usize>]) -> String {
let total_len: usize = ranges.iter().map(ExactSizeIterator::len).sum();
let mut content = String::with_capacity(total_len);
for range in ranges {
content.push_str(&source[range.start..range.end]);
}
content
}
struct MarkdownCandidateData {
fence: Fence,
opening_range: Range<usize>,
content_ranges: Vec<Range<usize>>,
}
// Box the large variant to keep ActiveFence small (~pointer-sized).
enum ActiveFence {
Passthrough(Fence),
MarkdownCandidate(Box<MarkdownCandidateData>),
}
let mut out = String::with_capacity(markdown_source.len());
let mut active_fence: Option<ActiveFence> = None;
let mut source_offset = 0usize;
let mut push_source_range = |range: Range<usize>| {
if !range.is_empty() {
out.push_str(&markdown_source[range]);
}
};
for line in markdown_source.split_inclusive('\n') {
let line_start = source_offset;
source_offset += line.len();
let line_range = line_start..source_offset;
if let Some(active) = active_fence.take() {
match active {
ActiveFence::Passthrough(fence) => {
push_source_range(line_range);
if !is_close_fence(line, fence) {
active_fence = Some(ActiveFence::Passthrough(fence));
}
}
ActiveFence::MarkdownCandidate(mut data) => {
if is_close_fence(line, data.fence) {
if markdown_fence_contains_table(
&content_from_ranges(markdown_source, &data.content_ranges),
data.fence.is_blockquoted,
) {
for range in data.content_ranges {
push_source_range(range);
}
} else {
push_source_range(data.opening_range);
for range in data.content_ranges {
push_source_range(range);
}
push_source_range(line_range);
}
} else {
data.content_ranges.push(line_range);
active_fence = Some(ActiveFence::MarkdownCandidate(data));
}
}
}
continue;
}
if let Some((fence, is_markdown)) = parse_open_fence(line) {
if is_markdown {
active_fence = Some(ActiveFence::MarkdownCandidate(Box::new(
MarkdownCandidateData {
fence,
opening_range: line_range,
content_ranges: Vec::new(),
},
)));
} else {
push_source_range(line_range);
active_fence = Some(ActiveFence::Passthrough(fence));
}
continue;
}
push_source_range(line_range);
}
if let Some(active) = active_fence {
match active {
ActiveFence::Passthrough(_) => {}
ActiveFence::MarkdownCandidate(data) => {
push_source_range(data.opening_range);
for range in data.content_ranges {
push_source_range(range);
}
}
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -118,4 +390,110 @@ mod tests {
"did not expect a split into ['1.', 'Tight item']; got: {lines:?}"
);
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_table_rendering() {
let src = "```markdown\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains("")));
assert!(rendered.iter().any(|line| line.contains("│ 1 │ 2 │")));
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_no_outer_table_rendering() {
let src = "```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains("")));
assert!(
rendered
.iter()
.any(|line| line.contains("│ Col A │ Col B │ Col C │"))
);
assert!(
!rendered
.iter()
.any(|line| line.trim() == "Col A | Col B | Col C")
);
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_two_column_no_outer_table() {
let src = "```md\nA | B\n--- | ---\nleft | right\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains("")));
assert!(rendered.iter().any(|line| line.contains("│ A")));
assert!(!rendered.iter().any(|line| line.trim() == "A | B"));
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_single_column_table() {
let src = "```md\n| Only |\n|---|\n| value |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains("")));
assert!(!rendered.iter().any(|line| line.trim() == "| Only |"));
}
#[test]
fn append_markdown_agent_keeps_non_markdown_fences_as_code() {
let src = "```rust\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(
rendered,
vec![
"| A | B |".to_string(),
"|---|---|".to_string(),
"| 1 | 2 |".to_string(),
]
);
}
#[test]
fn append_markdown_agent_unwraps_blockquoted_markdown_fence_table() {
let src = "> ```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n> ```\n";
let rendered = unwrap_markdown_fences(src);
assert!(
!rendered.contains("```"),
"expected markdown fence markers to be removed: {rendered:?}"
);
}
#[test]
fn append_markdown_agent_keeps_non_blockquoted_markdown_fence_with_blockquote_table_example() {
let src = "```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n```\n";
let normalized = unwrap_markdown_fences(src);
assert_eq!(normalized, src);
}
#[test]
fn append_markdown_agent_keeps_markdown_fence_when_content_is_not_table() {
let src = "```markdown\n**bold**\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(rendered, vec!["**bold**".to_string()]);
}
#[test]
fn unwrap_markdown_fences_repro_keeps_fence_without_header_delimiter_pair() {
let src = "```markdown\n| A | B |\nnot a delimiter row\n| --- | --- |\n# Heading\n```\n";
let normalized = unwrap_markdown_fences(src);
assert_eq!(normalized, src);
}
#[test]
fn append_markdown_agent_keeps_markdown_fence_with_blank_line_between_header_and_delimiter() {
let src = "```markdown\n| A | B |\n\n|---|---|\n| 1 | 2 |\n```\n";
let rendered = unwrap_markdown_fences(src);
assert_eq!(rendered, src);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use std::path::Path;
use unicode_width::UnicodeWidthChar;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
@@ -15,6 +16,12 @@ fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd))
}
fn terminal_cell_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@@ -1111,23 +1118,6 @@ fn code_block_multiple_lines_inside_unordered_list() {
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
}
#[test]
fn code_block_inside_unordered_list_item_multiple_lines() {
let md = "- Item\n\n ```\n first\n second\n ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
}
#[test]
fn markdown_render_complex_snapshot() {
let md = r#"# H1: Markdown Streaming Test
@@ -1368,3 +1358,551 @@ fn code_block_preserves_trailing_blank_lines() {
"trailing blank line inside code fence was lost: {content:?}"
);
}
#[test]
fn table_renders_unicode_box() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert_eq!(
lines,
vec![
"┌─────┬─────┐".to_string(),
"│ A │ B │".to_string(),
"├─────┼─────┤".to_string(),
"│ 1 │ 2 │".to_string(),
"└─────┴─────┘".to_string(),
]
);
}
#[test]
fn table_alignment_respects_markers() {
let md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert_eq!(lines[1], "│ Left │ Center │ Right │");
assert_eq!(lines[3], "│ a │ b │ c │");
}
#[test]
fn table_wraps_cell_content_when_width_is_narrow() {
let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines[0].starts_with('┌') && lines[0].ends_with('┐'));
assert!(
lines
.iter()
.any(|line| line.contains("Enable very verbose"))
&& lines.iter().any(|line| line.contains("logging output")),
"expected wrapped row content: {lines:?}"
);
}
#[test]
fn table_inside_blockquote_has_quote_prefix() {
let md = "> | A | B |\n> |---|---|\n> | 1 | 2 |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines.iter().all(|line| line.starts_with("> ")));
assert!(lines.iter().any(|line| line.contains("┌─────┬─────┐")));
}
#[test]
fn escaped_pipes_render_in_table_cells() {
let md = "| Col |\n| --- |\n| a \\| b |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines.iter().any(|line| line.contains("a | b")));
}
#[test]
fn table_with_emoji_cells_renders_boxed_table() {
let md = "| Task | State |\n|---|---|\n| Unit tests | ✅ |\n| Release notes | 📝 |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(80));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.contains('┌')),
"expected boxed table for emoji content: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.starts_with("|:---")),
"did not expect pipe-delimiter fallback for emoji content: {lines:?}"
);
}
#[test]
fn table_with_emoji_sequence_cells_has_consistent_display_width() {
let md = "| Left aligned | Center aligned | Right aligned |\n\
|:-------------|:--------------:|--------------:|\n\
| alpha | 🅰️ | 10 |\n\
| **beta** | 🅱️ | 200 |\n\
| _gamma_ | 🔤 | 3,000 |\n\
| `delta()` | 🔢 | 40,000 |\n\
| epsilon ([link](https://example.com/epsilon)) | 🔗 | 500,000 |\n\
| zeta | ↔️ | 6,000,000 |\n\
| eta | 1⃣ | 70,000,000 |\n\
| theta | 👩‍💻 | 800,000,000 |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(120));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
let table_lines: Vec<&str> = lines
.iter()
.filter(|line| {
line.contains(|ch| {
matches!(
ch,
'┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘' | '│'
)
})
})
.map(String::as_str)
.collect();
let first_table_line = table_lines
.first()
.expect("expected unicode table to render as a box");
let expected_width = terminal_cell_width(first_table_line);
assert!(
table_lines
.iter()
.all(|line| terminal_cell_width(line) == expected_width),
"expected every rendered table row to have width {expected_width}: {table_lines:?}"
);
assert!(
table_lines.iter().any(|line| line.contains("🅰️"))
&& table_lines.iter().any(|line| line.contains("👩‍💻")),
"expected emoji sequence stress cells inside table: {table_lines:?}"
);
}
#[test]
fn table_falls_back_to_pipe_rendering_if_it_cannot_fit() {
let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(20));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines.first().is_some_and(|line| line.starts_with('|')));
assert!(!lines.iter().any(|line| line.contains('┌')));
}
#[test]
fn table_pipe_fallback_rows_wrap_in_narrow_width() {
let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| 111111 | 222222 | 333333 | 444444 | 555555 | 666666 | 777777 | 888888 | 999999 | 101010 |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(20));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines.first().is_some_and(|line| line.starts_with('|')));
assert!(
lines.len() > 3,
"expected wrapped pipe-fallback rows at narrow width, got {lines:?}"
);
}
#[test]
fn table_pipe_fallback_escapes_literal_pipes_in_cell_content() {
let md = "| c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9 | c10 |\n|---|---|---|---|---|---|---|---|---|---|\n| keep | keep | keep | keep | keep | keep | keep | keep | a \\| b | keep |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(20));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(lines.first().is_some_and(|line| line.starts_with('|')));
assert!(
lines.iter().any(|line| line.contains("\\|")),
"expected escaped pipe marker to be preserved in wrapped fallback rows: {lines:?}"
);
}
#[test]
fn table_link_keeps_url_suffix_inside_cell() {
let md = "| Site |\n|---|\n| [OpenAI](https://openai.com) |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("OpenAI (https://openai.com)")),
"expected link suffix inside table cell: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "(https://openai.com)"),
"did not expect stray url suffix line outside table: {lines:?}"
);
}
#[test]
fn table_does_not_absorb_trailing_html_block_label_line() {
let md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\nInline HTML: <sup>sup</sup> and <sub>sub</sub>.\nHTML block:\n<div style=\"border:1px solid #ccc;padding:2px\">inline block</div>\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.trim() == "HTML block:"),
"expected 'HTML block:' as plain prose line: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.contains("│ HTML block:")),
"did not expect 'HTML block:' inside table grid: {lines:?}"
);
}
#[test]
fn table_spillover_prose_wraps_in_narrow_width() {
let long_label = "This html spillover prose line should wrap on narrow widths to avoid clipping:";
let md = format!(
"| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n{long_label}\n<div style=\"border:1px solid #ccc;padding:2px\">inline block</div>\n"
);
let text = crate::markdown_render::render_markdown_text_with_width(&md, Some(40));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.contains("This html spillover prose")),
"expected spillover prose to be present: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.contains(long_label)),
"did not expect spillover prose to remain as one long clipped line: {lines:?}"
);
}
#[test]
fn table_keeps_sparse_comparison_row_inside_grid() {
let md = "| A | B | C |\n|---|---|---|\n| x < y > z | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ x < y > z") && line.ends_with('│')),
"expected sparse comparison row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "x < y > z"),
"did not expect sparse comparison row to spill outside table: {lines:?}"
);
}
#[test]
fn table_keeps_sparse_rows_with_empty_trailing_cells() {
let md = "| A | B | C |\n|---|---|---|\n| a | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ a") && line.ends_with('│')),
"expected sparse row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line == "a"),
"did not expect sparse row content to spill outside the table: {lines:?}"
);
}
#[test]
fn table_keeps_single_cell_pipe_row_inside_grid() {
let md = "| A | B |\n|---|---|\n| value |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ value") && line.ends_with('│')),
"expected single-cell pipe row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "value"),
"did not expect single-cell pipe row to spill outside the table: {lines:?}"
);
}
#[test]
fn table_keeps_single_cell_row_with_leading_pipe_inside_grid() {
let md = "| A | B |\n|---|---|\n| value\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ value") && line.ends_with('│')),
"expected leading-pipe sparse row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "value"),
"did not expect leading-pipe sparse row to spill outside the table: {lines:?}"
);
}
#[test]
fn table_normalizes_uneven_row_column_counts() {
let md = "| A | B | C |\n|---|---|---|\n| 1 | 2 |\n| 3 | 4 | 5 | 6 |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.starts_with('┌'))
&& lines.iter().any(|line| line.starts_with('└')),
"expected normalized uneven rows to remain in boxed table output: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("│ 1") && line.ends_with('│')),
"expected shorter row to be padded inside grid: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("│ 3") && line.ends_with('│')),
"expected longer row to be truncated to grid width: {lines:?}"
);
}
#[test]
fn table_keeps_sparse_sentence_row_inside_grid() {
let md = "| A | B | C |\n|---|---|---|\n| This is done. | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ This is done.") && line.ends_with('│')),
"expected sparse sentence row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "This is done."),
"did not expect sparse sentence row to spill outside table: {lines:?}"
);
}
#[test]
fn table_keeps_label_only_sparse_row_inside_grid() {
let md = "| A | B | C |\n|---|---|---|\n| Status: | | |\n| ok | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ Status:") && line.ends_with('│')),
"expected label-only sparse row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "Status:"),
"did not expect label-only sparse row to spill outside table: {lines:?}"
);
}
#[test]
fn table_keeps_single_word_label_row_at_end_inside_grid() {
let md = "| A | B | C |\n|---|---|---|\n| Status: | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ Status:") && line.ends_with('│')),
"expected single-word trailing label row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "Status:"),
"did not expect single-word trailing label row to spill outside table: {lines:?}"
);
}
#[test]
fn table_keeps_multi_word_label_row_at_end_inside_grid() {
let md = "| A | B | C |\n|---|---|---|\n| Build status: | | |\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines
.iter()
.any(|line| line.contains("│ Build status:") && line.ends_with('│')),
"expected multi-word trailing label row to remain inside table grid: {lines:?}"
);
assert!(
!lines.iter().any(|line| line.trim() == "Build status:"),
"did not expect multi-word trailing label row to spill outside table: {lines:?}"
);
}
#[test]
fn table_preserves_structured_leading_columns_when_last_column_is_long() {
let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary
traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95
returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible
downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load.
|\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(160));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.contains("Milestone")),
"expected first structured header to remain readable: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("Planned Date")),
"expected date header to remain readable: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("2026-01-10")),
"expected date values to avoid forced mid-token wraps: {lines:?}"
);
}
#[test]
fn table_preserves_status_column_with_long_notes() {
let md = "| Service | Status | Notes |\n|---|---|---|\n| Auth API | Stable | Handles login and token refresh with no major incidents in the last
30 days. |\n| Billing Worker | Monitoring | Throughput is good, but we still see occasional retry storms when upstream settlement providers return
partial failures. |\n| Search Indexer | Tuning | Performance improved after shard balancing, yet memory usage remains elevated during full rebuild
windows. |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(150));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.contains("Status")),
"expected status header to remain readable: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("Monitoring")),
"expected status values to avoid mid-word wraps: {lines:?}"
);
}
#[test]
fn table_keeps_long_body_rows_inside_grid_instead_of_spilling_raw_pipe_rows() {
let md = "| Milestone | Planned Date | Outcome | Retrospective Summary |\n|---|---|---|---|\n| Canary rollout | 2026-01-10 | Completed | Canary
traffic was held at 5% longer than planned due to latency regressions tied to cold cache behavior; after pre-warming and query plan hints, p95
returned to baseline and rollout resumed safely. |\n| Full region cutover | 2026-01-24 | Completed | Cutover succeeded with no customer-visible
downtime, though internal dashboards lagged for approximately 18 minutes because ingestion workers autoscaled slower than forecast under burst load.
|\n| Legacy decommission | 2026-02-07 | In progress | Most workloads have been drained, but final decommission is blocked by one compliance export
task that still depends on a deprecated storage path and requires legal sign-off before removal. |\n";
let text = crate::markdown_render::render_markdown_text_with_width(md, Some(200));
let lines: Vec<String> = text
.lines
.iter()
.map(|line| line.spans.iter().map(|span| span.content.clone()).collect())
.collect();
assert!(
lines.iter().any(|line| line.starts_with('┌'))
&& lines.iter().any(|line| line.starts_with('└')),
"expected boxed table output: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("│ Canary rollout")),
"expected first body row to stay inside table grid: {lines:?}"
);
assert!(
!lines
.iter()
.any(|line| line.trim_start().starts_with("| Canary rollout |")),
"did not expect raw pipe-form body rows outside table: {lines:?}"
);
}

View File

@@ -1,55 +1,132 @@
//! Newline-gated streaming accumulator for markdown source.
//!
//! `MarkdownStreamCollector` buffers incoming token deltas and exposes a commit boundary at each
//! newline. The stream controllers (`streaming/controller.rs`) call `commit_complete_source()`
//! after each newline-bearing delta to obtain the completed prefix for re-rendering, leaving the
//! trailing incomplete line in the buffer for the next delta.
//!
//! On finalization, `finalize_and_drain_source()` flushes whatever remains (the last line, which
//! may lack a trailing newline).
#[cfg(test)]
use ratatui::text::Line;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use crate::markdown;
/// Newline-gated accumulator that renders markdown and commits only fully
/// completed logical lines.
/// Newline-gated accumulator that buffers raw markdown source and commits only completed lines
/// (terminated by `\n`).
///
/// The buffer tracks how many source bytes have already been committed via
/// `committed_source_len`, so each `commit_complete_source()` call returns only the newly
/// completed portion. This design lets the stream controller re-render the entire accumulated
/// source while only appending new content.
pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_source_len: usize,
#[cfg(test)]
committed_line_count: usize,
width: Option<usize>,
#[cfg(test)]
cwd: PathBuf,
}
impl MarkdownStreamCollector {
/// Create a collector that renders markdown using `cwd` for local file-link display.
/// Create a collector that accumulates raw markdown deltas.
///
/// The collector snapshots `cwd` into owned state because stream commits can happen long after
/// construction. The same `cwd` should be reused for the entire stream lifecycle; mixing
/// different working directories within one stream would make the same link render with
/// different path prefixes across incremental commits.
/// `width` and `cwd` are only used by test-only rendering helpers; production stream commits
/// operate on raw source boundaries. The collector snapshots `cwd` so test rendering keeps
/// local file-link display stable across incremental commits.
pub fn new(width: Option<usize>, cwd: &Path) -> Self {
#[cfg(not(test))]
let _ = cwd;
Self {
buffer: String::new(),
committed_source_len: 0,
#[cfg(test)]
committed_line_count: 0,
width,
#[cfg(test)]
cwd: cwd.to_path_buf(),
}
}
pub fn clear(&mut self) {
self.buffer.clear();
self.committed_line_count = 0;
/// Update the rendering width used by test-only line-commit helpers.
pub fn set_width(&mut self, width: Option<usize>) {
self.width = width;
}
/// Reset all buffered source and commit bookkeeping.
pub fn clear(&mut self) {
self.buffer.clear();
self.committed_source_len = 0;
#[cfg(test)]
{
self.committed_line_count = 0;
}
}
/// Append a raw streaming delta to the internal source buffer.
pub fn push_delta(&mut self, delta: &str) {
tracing::trace!("push_delta: {delta:?}");
self.buffer.push_str(delta);
}
/// Commit newly completed raw markdown source up to the last newline.
pub fn commit_complete_source(&mut self) -> Option<String> {
let commit_end = self.buffer.rfind('\n').map(|idx| idx + 1)?;
if commit_end <= self.committed_source_len {
return None;
}
let out = self.buffer[self.committed_source_len..commit_end].to_string();
self.committed_source_len = commit_end;
Some(out)
}
/// Peek at uncommitted source content beyond the latest commit boundary.
pub fn peek_uncommitted(&self) -> &str {
&self.buffer[self.committed_source_len..]
}
/// Finalize the stream and return any remaining raw source.
///
/// Ensures the returned source chunk is newline-terminated when non-empty so callers can
/// safely run markdown block parsing on the final chunk.
pub fn finalize_and_drain_source(&mut self) -> String {
if self.committed_source_len >= self.buffer.len() {
self.clear();
return String::new();
}
let mut out = self.buffer[self.committed_source_len..].to_string();
if !out.ends_with('\n') {
out.push('\n');
}
self.clear();
out
}
/// 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.
///
/// This helper intentionally uses `append_markdown` (not
/// `append_markdown_agent`) so tests can isolate collector newline boundary
/// behavior without stream-controller holdback semantics.
#[cfg(test)]
pub fn commit_complete_lines(&mut self) -> Vec<Line<'static>> {
let source = self.buffer.clone();
let last_newline_idx = source.rfind('\n');
let source = if let Some(last_newline_idx) = last_newline_idx {
source[..=last_newline_idx].to_string()
} else {
let Some(commit_end) = self.buffer.rfind('\n').map(|idx| idx + 1) else {
return Vec::new();
};
if commit_end <= self.committed_source_len {
return Vec::new();
}
let source = self.buffer[..commit_end].to_string();
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
let mut complete_line_count = rendered.len();
@@ -68,25 +145,29 @@ impl MarkdownStreamCollector {
let out_slice = &rendered[self.committed_line_count..complete_line_count];
let out = out_slice.to_vec();
self.committed_source_len = commit_end;
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.
/// for rendering.
#[cfg(test)]
pub fn finalize_and_drain(&mut self) -> Vec<Line<'static>> {
let raw_buffer = self.buffer.clone();
let mut source: String = raw_buffer.clone();
let mut source = self.buffer.clone();
if source.is_empty() {
self.clear();
return Vec::new();
}
if !source.ends_with('\n') {
source.push('\n');
}
};
tracing::debug!(
raw_len = raw_buffer.len(),
raw_len = self.buffer.len(),
source_len = source.len(),
"markdown finalize (raw length: {}, rendered length: {})",
raw_buffer.len(),
self.buffer.len(),
source.len()
);
tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
@@ -156,6 +237,21 @@ mod tests {
assert_eq!(out.len(), 1);
}
#[tokio::test]
async fn peek_uncommitted_tracks_buffer_after_commits() {
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("alpha");
assert_eq!(c.peek_uncommitted(), "alpha");
c.push_delta("\n");
assert_eq!(c.commit_complete_source(), Some("alpha\n".to_string()));
assert_eq!(c.peek_uncommitted(), "");
c.push_delta("beta");
assert_eq!(c.peek_uncommitted(), "beta");
}
#[tokio::test]
async fn e2e_stream_blockquote_simple_is_green() {
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], /*finalize*/ true);
@@ -416,6 +512,42 @@ mod tests {
.collect()
}
#[tokio::test]
async fn table_header_commits_without_holdback() {
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("| A | B |\n");
let out1 = c.commit_complete_lines();
let out1_str = lines_to_plain_strings(&out1);
assert_eq!(out1_str, vec!["| A | B |".to_string()]);
c.push_delta("| --- | --- |\n");
let out = c.commit_complete_lines();
let out_str = lines_to_plain_strings(&out);
assert!(
!out_str.is_empty(),
"expected output to continue committing after delimiter: {out_str:?}"
);
c.push_delta("| 1 | 2 |\n");
let out2 = c.commit_complete_lines();
assert!(
!out2.is_empty(),
"expected output to continue committing after body row"
);
c.push_delta("\n");
let _ = c.commit_complete_lines();
}
#[tokio::test]
async fn pipe_text_without_table_prefix_is_not_delayed() {
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("Escaped pipe in text: a | b | c\n");
let out = c.commit_complete_lines();
let out_str = lines_to_plain_strings(&out);
assert_eq!(out_str, vec!["Escaped pipe in text: a | b | c".to_string()]);
}
#[tokio::test]
async fn lists_and_fences_commit_without_duplication() {
// List case
@@ -722,4 +854,45 @@ mod tests {
])
.await;
}
#[tokio::test]
async fn table_like_lines_inside_fenced_code_are_not_held() {
assert_streamed_equals_full(&["```\n", "| a | b |\n", "```\n"]).await;
}
#[tokio::test]
async fn collector_source_chunks_round_trip_into_agent_fence_unwrapping() {
let deltas = [
"```md\n",
"| A | B |\n",
"|---|---|\n",
"| 1 | 2 |\n",
"```\n",
];
let mut collector = super::MarkdownStreamCollector::new(None, &super::test_cwd());
let mut raw_source = String::new();
for delta in deltas {
collector.push_delta(delta);
if delta.contains('\n')
&& let Some(chunk) = collector.commit_complete_source()
{
raw_source.push_str(&chunk);
}
}
raw_source.push_str(&collector.finalize_and_drain_source());
let mut rendered = Vec::new();
crate::markdown::append_markdown_agent(&raw_source, None, &mut rendered);
let rendered_strs = lines_to_plain_strings(&rendered);
assert!(
rendered_strs.iter().any(|line| line.contains('┌')),
"expected markdown-fenced table to render as boxed table: {rendered_strs:?}"
);
assert!(
!rendered_strs.iter().any(|line| line.trim() == "| A | B |"),
"did not expect raw table header after markdown-fence unwrapping: {rendered_strs:?}"
);
}
}

View File

@@ -153,7 +153,7 @@ pub(crate) async fn run_model_migration_prompt(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});

View File

@@ -474,7 +474,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {

View File

@@ -566,6 +566,49 @@ impl TranscriptOverlay {
}
}
/// Replace a range of committed cells with a single consolidated cell.
///
/// Mirrors the splice performed on `App::transcript_cells` during
/// `ConsolidateAgentMessage` so the Ctrl+T overlay stays in sync with the
/// main transcript. The range is clamped defensively: cells may have been
/// inserted after the overlay opened, leaving it with fewer entries than
/// the main transcript.
pub(crate) fn consolidate_cells(
&mut self,
range: std::ops::Range<usize>,
consolidated: Arc<dyn HistoryCell>,
) {
let follow_bottom = self.view.is_scrolled_to_bottom();
// Clamp the range to the overlay's cell count to avoid panic if the overlay has fewer
// cells than the main transcript (e.g. cells were inserted after the overlay has opened).
let clamped_end = range.end.min(self.cells.len());
let clamped_start = range.start.min(clamped_end);
if clamped_start < clamped_end {
let removed = clamped_end - clamped_start;
if let Some(highlight_cell) = self.highlight_cell.as_mut()
&& *highlight_cell >= clamped_start
{
if *highlight_cell < clamped_end {
*highlight_cell = clamped_start;
} else {
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
}
}
self.cells
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
if self
.highlight_cell
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
{
self.highlight_cell = None;
}
self.rebuild_renderables();
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
/// Sync the active-cell live tail with the current width and cell state.
///
/// Recomputes the tail only when the cache key changes, preserving scroll
@@ -700,7 +743,7 @@ impl TranscriptOverlay {
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
@@ -764,7 +807,7 @@ impl StaticOverlay {
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
@@ -1090,6 +1133,60 @@ mod tests {
assert_eq!(overlay.view.scroll_offset, 0);
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
let mut overlay = TranscriptOverlay::new(
(0..6)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(3));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(2),
"highlight inside consolidated range should point to replacement cell",
);
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
let mut overlay = TranscriptOverlay::new(
(0..7)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(6));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(4),
"highlight after consolidated range should shift left by removed cells",
);
}
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title

View File

@@ -26,6 +26,7 @@ pub fn push_owned_lines<'a>(src: &[Line<'a>], out: &mut Vec<Line<'static>>) {
/// Consider a line blank if it has no spans or only spans whose contents are
/// empty or consist solely of spaces (no tabs/newlines).
#[cfg(test)]
pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;

View File

@@ -288,7 +288,7 @@ async fn run_session_picker_with_loader(
return Ok(sel);
}
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if let Ok(size) = alt.tui.terminal.size() {
let list_height = size.height.saturating_sub(4) as usize;
state.update_view_rows(list_height);

View File

@@ -28,9 +28,12 @@ Image: alt text
———
Table below (alignment test):
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |
┌──────┬────────┬───────┐
│ Left │ Center │ Right │
├──────┼────────┼───────┤
│ a │ b │ c │
└──────┴────────┴───────┘
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
HTML block:
<div style="border:1px solid #ccc;padding:2px">inline block</div>

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod chunking;
pub(crate) mod commit_tick;
pub(crate) mod controller;
mod table_holdback;
struct QueuedLine {
line: Line<'static>,
@@ -70,12 +71,9 @@ impl StreamState {
.map(|queued| queued.line)
.collect()
}
/// Drains all queued lines from the front of the queue.
pub(crate) fn drain_all(&mut self) -> Vec<Line<'static>> {
self.queued_lines
.drain(..)
.map(|queued| queued.line)
.collect()
/// Clears queued lines while keeping collector/turn lifecycle state intact.
pub(crate) fn clear_queue(&mut self) {
self.queued_lines.clear();
}
/// Returns whether no lines are queued for commit.
pub(crate) fn is_idle(&self) -> bool {

View File

@@ -0,0 +1,210 @@
//! Pipe-table holdback scanner for source-backed agent streams.
//!
//! Agent streams with markdown tables keep the active table as mutable tail so
//! adding a row can reflow earlier table rows instead of committing a stale
//! render to scrollback.
use std::time::Instant;
use crate::table_detect::FenceKind;
use crate::table_detect::FenceTracker;
use crate::table_detect::is_table_delimiter_line;
use crate::table_detect::is_table_header_line;
use crate::table_detect::parse_table_segments;
use crate::table_detect::strip_blockquote_prefix;
/// Result of scanning accumulated raw source for pipe-table patterns.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum TableHoldbackState {
/// No table detected -- all rendered lines can flow into the stable queue.
None,
/// The last non-blank line looks like a table header row but no delimiter
/// row has followed yet. Hold back in case the next delta is a delimiter.
PendingHeader { header_start: usize },
/// A header + delimiter pair was found -- the source contains a confirmed
/// table. Content from the table header onward stays mutable.
Confirmed { table_start: usize },
}
#[derive(Clone, Copy)]
struct PreviousLineState {
source_start: usize,
fence_kind: FenceKind,
is_header: bool,
}
/// Incremental scanner for table holdback state on append-only source streams.
pub(super) struct TableHoldbackScanner {
source_offset: usize,
fence_tracker: FenceTracker,
previous_line: Option<PreviousLineState>,
pending_header_start: Option<usize>,
confirmed_table_start: Option<usize>,
}
impl TableHoldbackScanner {
pub(super) fn new() -> Self {
Self {
source_offset: 0,
fence_tracker: FenceTracker::new(),
previous_line: None,
pending_header_start: None,
confirmed_table_start: None,
}
}
pub(super) fn reset(&mut self) {
*self = Self::new();
}
pub(super) fn state(&self) -> TableHoldbackState {
if let Some(table_start) = self.confirmed_table_start {
TableHoldbackState::Confirmed { table_start }
} else if let Some(header_start) = self.pending_header_start {
TableHoldbackState::PendingHeader { header_start }
} else {
TableHoldbackState::None
}
}
pub(super) fn push_source_chunk(&mut self, source_chunk: &str) {
if source_chunk.is_empty() {
return;
}
let scan_start = Instant::now();
let mut lines = 0usize;
for source_line in source_chunk.split_inclusive('\n') {
lines += 1;
self.push_line(source_line);
}
tracing::trace!(
bytes = source_chunk.len(),
lines,
state = ?self.state(),
elapsed_us = scan_start.elapsed().as_micros(),
"table holdback incremental scan",
);
}
fn push_line(&mut self, source_line: &str) {
let line = source_line.strip_suffix('\n').unwrap_or(source_line);
let source_start = self.source_offset;
let fence_kind = self.fence_tracker.kind();
let candidate_text = if fence_kind == FenceKind::Other {
None
} else {
table_candidate_text(line)
};
let is_header = candidate_text.is_some_and(is_table_header_line);
let is_delimiter = candidate_text.is_some_and(is_table_delimiter_line);
if self.confirmed_table_start.is_none()
&& let Some(previous_line) = self.previous_line
&& previous_line.fence_kind != FenceKind::Other
&& fence_kind != FenceKind::Other
&& previous_line.is_header
&& is_delimiter
{
self.confirmed_table_start = Some(previous_line.source_start);
self.pending_header_start = None;
}
if self.confirmed_table_start.is_none() && !line.trim().is_empty() {
if fence_kind != FenceKind::Other && is_header {
self.pending_header_start = Some(source_start);
} else {
self.pending_header_start = None;
}
}
self.previous_line = Some(PreviousLineState {
source_start,
fence_kind,
is_header,
});
self.fence_tracker.advance(line);
self.source_offset = self.source_offset.saturating_add(source_line.len());
}
}
/// Strip blockquote prefixes and return the trimmed text if it contains
/// pipe-table segments, or `None` otherwise.
fn table_candidate_text(line: &str) -> Option<&str> {
let stripped = strip_blockquote_prefix(line).trim();
parse_table_segments(stripped).map(|_| stripped)
}
/// A source line annotated with whether it falls inside a fenced code block.
#[cfg(test)]
struct ParsedLine<'a> {
text: &'a str,
fence_context: FenceKind,
source_start: usize,
}
/// Parse source into lines tagged with fenced-code context for table scanning.
#[cfg(test)]
fn parse_lines_with_fence_state(source: &str) -> Vec<ParsedLine<'_>> {
let mut tracker = FenceTracker::new();
let mut lines = Vec::new();
let mut source_start = 0usize;
for raw_line in source.split('\n') {
lines.push(ParsedLine {
text: raw_line,
fence_context: tracker.kind(),
source_start,
});
tracker.advance(raw_line);
source_start = source_start
.saturating_add(raw_line.len())
.saturating_add(1);
}
lines
}
/// Scan `source` for pipe-table patterns outside of non-markdown fenced code
/// blocks.
#[cfg(test)]
pub(super) fn table_holdback_state(source: &str) -> TableHoldbackState {
let lines = parse_lines_with_fence_state(source);
for pair in lines.windows(2) {
let [header_line, delimiter_line] = pair else {
continue;
};
if header_line.fence_context == FenceKind::Other
|| delimiter_line.fence_context == FenceKind::Other
{
continue;
}
let Some(header_text) = table_candidate_text(header_line.text) else {
continue;
};
let Some(delimiter_text) = table_candidate_text(delimiter_line.text) else {
continue;
};
if is_table_header_line(header_text) && is_table_delimiter_line(delimiter_text) {
return TableHoldbackState::Confirmed {
table_start: header_line.source_start,
};
}
}
let pending_header = lines.iter().rev().find(|line| !line.text.trim().is_empty());
if let Some(line) = pending_header
&& line.fence_context != FenceKind::Other
&& table_candidate_text(line.text).is_some_and(is_table_header_line)
{
return TableHoldbackState::PendingHeader {
header_start: line.source_start,
};
}
TableHoldbackState::None
}

View File

@@ -0,0 +1,479 @@
//! Canonical pipe-table structure detection and fenced-code-block tracking for
//! raw markdown source.
//!
//! Both the streaming controller (`streaming/controller.rs`) and the
//! markdown-fence unwrapper (`markdown.rs`) need to identify pipe-table
//! structure and fenced code blocks in raw markdown source. This module
//! provides the canonical implementations so fixes only need to happen in one
//! place.
//!
//! ## Concepts
//!
//! A GFM pipe table is a sequence of lines where:
//! - A **header line** contains pipe-separated segments with at least one
//! non-empty cell.
//! - A **delimiter line** immediately follows the header and contains only
//! alignment markers (`---`, `:---`, `---:`, `:---:`), each with at least
//! three dashes.
//! - **Body rows** follow the delimiter.
//!
//! A **fenced code block** starts with 3+ backticks or tildes and ends with a
//! matching close marker. [`FenceTracker`] classifies each line as
//! [`FenceKind::Outside`], [`FenceKind::Markdown`], or [`FenceKind::Other`]
//! so callers can skip pipe characters that appear inside non-markdown fences.
//!
//! The table functions operate on single lines and do not maintain cross-line
//! state. Callers (the streaming controller and fence unwrapper) are
//! responsible for pairing consecutive lines to confirm a table.
/// Split a pipe-delimited line into trimmed segments.
///
/// Returns `None` if the line is empty or has no unescaped separator marker.
/// Leading/trailing pipes are stripped before splitting.
pub(crate) fn parse_table_segments(line: &str) -> Option<Vec<&str>> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let has_outer_pipe = trimmed.starts_with('|') || trimmed.ends_with('|');
let content = trimmed.strip_prefix('|').unwrap_or(trimmed);
let content = content.strip_suffix('|').unwrap_or(content);
let raw_segments = split_unescaped_pipe(content);
if !has_outer_pipe && raw_segments.len() <= 1 {
return None;
}
let segments: Vec<&str> = raw_segments.into_iter().map(str::trim).collect();
(!segments.is_empty()).then_some(segments)
}
/// Split `content` on unescaped `|` characters.
///
/// A pipe preceded by `\` is treated as literal text, not a column separator.
/// The backslash remains in the segment (this is structure detection, not
/// rendering).
fn split_unescaped_pipe(content: &str) -> Vec<&str> {
let mut segments = Vec::with_capacity(8);
let mut start = 0;
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
// Skip the escaped character.
i += 2;
} else if bytes[i] == b'|' {
segments.push(&content[start..i]);
start = i + 1;
i += 1;
} else {
i += 1;
}
}
segments.push(&content[start..]);
segments
}
// Small table-detection helpers inlined for the streaming hot path — they are
// called on every source line during incremental holdback scanning.
/// Whether `line` looks like a table header row (has pipe-separated
/// segments with at least one non-empty cell).
#[inline]
pub(crate) fn is_table_header_line(line: &str) -> bool {
parse_table_segments(line).is_some_and(|segments| segments.iter().any(|s| !s.is_empty()))
}
/// Whether a single segment matches the `---`, `:---`, `---:`, or `:---:`
/// alignment-colon syntax used in markdown table delimiter rows.
#[inline]
fn is_table_delimiter_segment(segment: &str) -> bool {
let trimmed = segment.trim();
if trimmed.is_empty() {
return false;
}
let without_leading = trimmed.strip_prefix(':').unwrap_or(trimmed);
let without_ends = without_leading.strip_suffix(':').unwrap_or(without_leading);
without_ends.len() >= 3 && without_ends.chars().all(|c| c == '-')
}
/// Whether `line` is a valid table delimiter row (every segment passes
/// [`is_table_delimiter_segment`]).
#[inline]
pub(crate) fn is_table_delimiter_line(line: &str) -> bool {
parse_table_segments(line)
.is_some_and(|segments| segments.into_iter().all(is_table_delimiter_segment))
}
// ---------------------------------------------------------------------------
// Fenced code block tracking
// ---------------------------------------------------------------------------
/// Where a source line sits relative to fenced code blocks.
///
/// Table holdback only applies to lines that are `Outside` or inside a
/// `Markdown` fence. Lines inside `Other` fences (e.g. `sh`, `rust`) are
/// ignored by the table scanner because their pipe characters are code, not
/// table syntax.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FenceKind {
/// Not inside any fenced code block.
Outside,
/// Inside a `` ```md `` or `` ```markdown `` fence.
Markdown,
/// Inside a fence with a non-markdown info string.
Other,
}
/// Incremental tracker for fenced-code-block open/close transitions.
///
/// Feed lines one at a time via [`advance`](Self::advance); query the current
/// context with [`kind`](Self::kind). The tracker handles leading-whitespace
/// limits (>3 spaces → not a fence), blockquote prefix stripping, and
/// backtick/tilde marker matching.
pub(crate) struct FenceTracker {
state: Option<(char, usize, FenceKind)>,
}
impl FenceTracker {
#[inline]
pub(crate) fn new() -> Self {
Self { state: None }
}
/// Process one raw source line and update fence state.
///
/// Lines with >3 leading spaces are ignored (indented code blocks, not
/// fences). Blockquote prefixes (`>`) are stripped before scanning.
pub(crate) fn advance(&mut self, raw_line: &str) {
let leading_spaces = raw_line
.as_bytes()
.iter()
.take_while(|byte| **byte == b' ')
.count();
if leading_spaces > 3 {
return;
}
let trimmed = &raw_line[leading_spaces..];
let fence_scan_text = strip_blockquote_prefix(trimmed);
if let Some((marker, len)) = parse_fence_marker(fence_scan_text) {
if let Some((open_char, open_len, _)) = self.state {
// Close the current fence if the marker matches.
if marker == open_char
&& len >= open_len
&& fence_scan_text[len..].trim().is_empty()
{
self.state = None;
}
} else {
// Opening a new fence.
let kind = if is_markdown_fence_info(fence_scan_text, len) {
FenceKind::Markdown
} else {
FenceKind::Other
};
self.state = Some((marker, len, kind));
}
}
}
/// Current fence context for the most-recently-advanced line.
#[inline]
pub(crate) fn kind(&self) -> FenceKind {
self.state.map_or(FenceKind::Outside, |(_, _, k)| k)
}
}
/// Return fence marker character and run length for a potential fence line.
///
/// Recognises backtick and tilde fences with a minimum run of 3.
/// The input should already have leading whitespace and blockquote prefixes
/// stripped.
#[inline]
pub(crate) fn parse_fence_marker(line: &str) -> Option<(char, usize)> {
let first = line.as_bytes().first().copied()?;
if first != b'`' && first != b'~' {
return None;
}
let len = line.bytes().take_while(|&b| b == first).count();
if len < 3 {
return None;
}
Some((first as char, len))
}
/// Whether the info string after a fence marker indicates markdown content.
///
/// Matches `md` and `markdown` (case-insensitive).
#[inline]
pub(crate) fn is_markdown_fence_info(trimmed_line: &str, marker_len: usize) -> bool {
let info = trimmed_line[marker_len..]
.split_whitespace()
.next()
.unwrap_or_default();
info.eq_ignore_ascii_case("md") || info.eq_ignore_ascii_case("markdown")
}
/// Peel all leading `>` blockquote markers from a line.
///
/// Tables can appear inside blockquotes (`> | A | B |`), so the holdback
/// scanner must strip these markers before checking for table syntax.
#[inline]
pub(crate) fn strip_blockquote_prefix(line: &str) -> &str {
let mut rest = line.trim_start();
loop {
let Some(stripped) = rest.strip_prefix('>') else {
return rest;
};
rest = stripped.strip_prefix(' ').unwrap_or(stripped).trim_start();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_table_segments_basic() {
assert_eq!(
parse_table_segments("| A | B | C |"),
Some(vec!["A", "B", "C"])
);
}
#[test]
fn parse_table_segments_no_outer_pipes() {
assert_eq!(parse_table_segments("A | B | C"), Some(vec!["A", "B", "C"]));
}
#[test]
fn parse_table_segments_no_leading_pipe() {
assert_eq!(
parse_table_segments("A | B | C |"),
Some(vec!["A", "B", "C"])
);
}
#[test]
fn parse_table_segments_no_trailing_pipe() {
assert_eq!(
parse_table_segments("| A | B | C"),
Some(vec!["A", "B", "C"])
);
}
#[test]
fn parse_table_segments_single_segment_is_allowed() {
assert_eq!(parse_table_segments("| only |"), Some(vec!["only"]));
}
#[test]
fn parse_table_segments_without_pipe_returns_none() {
assert_eq!(parse_table_segments("just text"), None);
}
#[test]
fn parse_table_segments_empty_returns_none() {
assert_eq!(parse_table_segments(""), None);
assert_eq!(parse_table_segments(" "), None);
}
#[test]
fn parse_table_segments_escaped_pipe() {
// Escaped pipe should NOT split — stays inside the segment.
assert_eq!(
parse_table_segments(r"| A \| B | C |"),
Some(vec![r"A \| B", "C"])
);
}
#[test]
fn is_table_delimiter_segment_valid() {
assert!(is_table_delimiter_segment("---"));
assert!(is_table_delimiter_segment(":---"));
assert!(is_table_delimiter_segment("---:"));
assert!(is_table_delimiter_segment(":---:"));
assert!(is_table_delimiter_segment(":-------:"));
}
#[test]
fn is_table_delimiter_segment_invalid() {
assert!(!is_table_delimiter_segment(""));
assert!(!is_table_delimiter_segment("--"));
assert!(!is_table_delimiter_segment("abc"));
assert!(!is_table_delimiter_segment(":--"));
}
#[test]
fn is_table_delimiter_line_valid() {
assert!(is_table_delimiter_line("| --- | --- |"));
assert!(is_table_delimiter_line("|:---:|---:|"));
assert!(is_table_delimiter_line("--- | --- | ---"));
}
#[test]
fn is_table_delimiter_line_invalid() {
assert!(!is_table_delimiter_line("| A | B |"));
assert!(!is_table_delimiter_line("| -- | -- |"));
}
#[test]
fn is_table_header_line_valid() {
assert!(is_table_header_line("| A | B |"));
assert!(is_table_header_line("Name | Value"));
}
#[test]
fn is_table_header_line_all_empty_segments() {
assert!(!is_table_header_line("| | |"));
}
// -----------------------------------------------------------------------
// FenceTracker tests
// -----------------------------------------------------------------------
#[test]
fn fence_tracker_outside_by_default() {
let tracker = FenceTracker::new();
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_opens_and_closes_backtick_fence() {
let mut tracker = FenceTracker::new();
tracker.advance("```rust");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("let x = 1;");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_opens_and_closes_tilde_fence() {
let mut tracker = FenceTracker::new();
tracker.advance("~~~python");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("~~~");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_markdown_fence() {
let mut tracker = FenceTracker::new();
tracker.advance("```md");
assert_eq!(tracker.kind(), FenceKind::Markdown);
tracker.advance("| A | B |");
assert_eq!(tracker.kind(), FenceKind::Markdown);
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_markdown_case_insensitive() {
let mut tracker = FenceTracker::new();
tracker.advance("```Markdown");
assert_eq!(tracker.kind(), FenceKind::Markdown);
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_nested_shorter_marker_does_not_close() {
let mut tracker = FenceTracker::new();
tracker.advance("````sh");
assert_eq!(tracker.kind(), FenceKind::Other);
// Shorter marker inside should not close.
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Other);
// Matching length closes.
tracker.advance("````");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_mismatched_char_does_not_close() {
let mut tracker = FenceTracker::new();
tracker.advance("```sh");
assert_eq!(tracker.kind(), FenceKind::Other);
// Tilde marker should not close a backtick fence.
tracker.advance("~~~");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_indented_4_spaces_ignored() {
let mut tracker = FenceTracker::new();
tracker.advance(" ```sh");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_blockquote_prefix_stripped() {
let mut tracker = FenceTracker::new();
tracker.advance("> ```sh");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("> ```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
#[test]
fn fence_tracker_close_with_trailing_content_does_not_close() {
let mut tracker = FenceTracker::new();
tracker.advance("```sh");
assert_eq!(tracker.kind(), FenceKind::Other);
// Trailing content prevents closing.
tracker.advance("``` extra");
assert_eq!(tracker.kind(), FenceKind::Other);
tracker.advance("```");
assert_eq!(tracker.kind(), FenceKind::Outside);
}
// -----------------------------------------------------------------------
// Fence helper function tests
// -----------------------------------------------------------------------
#[test]
fn parse_fence_marker_backtick() {
assert_eq!(parse_fence_marker("```rust"), Some(('`', 3)));
assert_eq!(parse_fence_marker("````"), Some(('`', 4)));
}
#[test]
fn parse_fence_marker_tilde() {
assert_eq!(parse_fence_marker("~~~python"), Some(('~', 3)));
}
#[test]
fn parse_fence_marker_too_short() {
assert_eq!(parse_fence_marker("``"), None);
assert_eq!(parse_fence_marker("~~"), None);
}
#[test]
fn parse_fence_marker_not_fence() {
assert_eq!(parse_fence_marker("hello"), None);
assert_eq!(parse_fence_marker(""), None);
}
#[test]
fn is_markdown_fence_info_basic() {
assert!(is_markdown_fence_info("```md", 3));
assert!(is_markdown_fence_info("```markdown", 3));
assert!(is_markdown_fence_info("```MD", 3));
assert!(!is_markdown_fence_info("```rust", 3));
assert!(!is_markdown_fence_info("```", 3));
}
#[test]
fn strip_blockquote_prefix_basic() {
assert_eq!(strip_blockquote_prefix("> hello"), "hello");
assert_eq!(strip_blockquote_prefix("> > nested"), "nested");
assert_eq!(strip_blockquote_prefix("no prefix"), "no prefix");
}
}

View File

@@ -0,0 +1,84 @@
//! State machine for transcript scrollback reflow.
use std::time::Duration;
use std::time::Instant;
pub(crate) const TRANSCRIPT_REFLOW_DEBOUNCE: Duration = Duration::from_millis(75);
#[derive(Debug, Default)]
pub(crate) struct TranscriptReflowState {
last_render_width: Option<u16>,
pending_until: Option<Instant>,
ran_during_stream: bool,
}
impl TranscriptReflowState {
pub(crate) fn clear(&mut self) {
*self = Self::default();
}
pub(crate) fn note_width(&mut self, width: u16) -> TranscriptWidthChange {
let previous_width = self.last_render_width.replace(width);
TranscriptWidthChange {
changed: previous_width.is_some_and(|previous| previous != width),
initialized: previous_width.is_none(),
}
}
#[cfg(test)]
pub(crate) fn set_last_render_width_for_test(&mut self, width: u16) {
self.last_render_width = Some(width);
}
/// Schedule a debounced reflow. Returns true if the previous pending reflow was already due.
pub(crate) fn schedule_debounced(&mut self) -> bool {
let now = Instant::now();
let due_now = self.pending_is_due(now);
self.pending_until = Some(now + TRANSCRIPT_REFLOW_DEBOUNCE);
due_now
}
pub(crate) fn schedule_immediate(&mut self) {
self.pending_until = Some(Instant::now());
}
#[cfg(test)]
pub(crate) fn set_due_for_test(&mut self) {
self.pending_until = Some(Instant::now() - Duration::from_millis(1));
}
pub(crate) fn pending_is_due(&self, now: Instant) -> bool {
self.pending_until.is_some_and(|deadline| now >= deadline)
}
pub(crate) fn pending_until(&self) -> Option<Instant> {
self.pending_until
}
pub(crate) fn has_pending_reflow(&self) -> bool {
self.pending_until.is_some()
}
pub(crate) fn clear_pending_reflow(&mut self) {
self.pending_until = None;
}
pub(crate) fn mark_ran_during_stream(&mut self) {
self.ran_during_stream = true;
}
pub(crate) fn take_ran_during_stream(&mut self) -> bool {
let ran = self.ran_during_stream;
self.ran_during_stream = false;
ran
}
pub(crate) fn clear_ran_during_stream(&mut self) {
self.ran_during_stream = false;
}
}
pub(crate) struct TranscriptWidthChange {
pub(crate) changed: bool,
pub(crate) initialized: bool,
}

View File

@@ -48,6 +48,7 @@ use crate::tui::event_stream::TuiEventStream;
use crate::tui::job_control::SuspendContext;
use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_terminal_detection::TerminalName;
mod event_stream;
mod frame_rate_limiter;
@@ -61,6 +62,12 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME
/// A type alias for the terminal type used in this application
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResizeReflowMode {
Debounced,
Immediate,
}
fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool {
match condition {
NotificationCondition::Unfocused => !terminal_focused,
@@ -274,6 +281,7 @@ fn set_panic_hook() {
pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Resize,
Draw,
}
@@ -294,6 +302,9 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
notification_condition: NotificationCondition,
is_zellij: bool,
terminal_name: TerminalName,
resize_reflow_mode: ResizeReflowMode,
force_full_repaint: bool,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
@@ -309,10 +320,36 @@ impl Tui {
// Cache this to avoid contention with the event reader.
supports_color::on_cached(supports_color::Stream::Stdout);
let _ = crate::terminal_palette::default_colors();
let terminal_info = codex_terminal_detection::terminal_info();
let is_zellij = matches!(
codex_terminal_detection::terminal_info().multiplexer,
terminal_info.multiplexer,
Some(codex_terminal_detection::Multiplexer::Zellij {})
);
let resize_reflow_mode = if terminal_info.name == TerminalName::Superset {
ResizeReflowMode::Immediate
} else {
ResizeReflowMode::Debounced
};
tracing::info!(
event = "resize_reflow_mode_configured",
mode = ?resize_reflow_mode,
terminal_name = ?terminal_info.name,
term_program = ?terminal_info.term_program,
term_program_version = ?terminal_info.version,
term = ?terminal_info.term,
colorterm = ?std::env::var("COLORTERM").ok(),
target_os = std::env::consts::OS,
target_arch = std::env::consts::ARCH,
is_windows = cfg!(windows),
is_macos = cfg!(target_os = "macos"),
vscode_ipc_hook_cli_present = std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some(),
vscode_injection_present = std::env::var_os("VSCODE_INJECTION").is_some(),
vscode_shell_login_present = std::env::var_os("VSCODE_SHELL_LOGIN").is_some(),
vscode_cwd_present = std::env::var_os("VSCODE_CWD").is_some(),
term_session_id_present = std::env::var_os("TERM_SESSION_ID").is_some(),
lc_terminal_present = std::env::var_os("LC_TERMINAL").is_some(),
"configured terminal resize reflow mode"
);
Self {
frame_requester,
@@ -329,6 +366,9 @@ impl Tui {
notification_backend: Some(detect_backend(NotificationMethod::default())),
notification_condition: NotificationCondition::default(),
is_zellij,
terminal_name: terminal_info.name,
resize_reflow_mode,
force_full_repaint: false,
alt_screen_enabled: true,
}
}
@@ -351,6 +391,18 @@ impl Tui {
self.frame_requester.clone()
}
pub(crate) fn force_full_repaint(&mut self) {
self.force_full_repaint = true;
}
pub(crate) fn resize_reflow_mode(&self) -> ResizeReflowMode {
self.resize_reflow_mode
}
pub(crate) fn terminal_name(&self) -> TerminalName {
self.terminal_name
}
pub fn enhanced_keys_supported(&self) -> bool {
self.enhanced_keys_supported
}
@@ -498,6 +550,7 @@ impl Tui {
self.frame_requester().schedule_frame();
}
/// Drop any queued history lines that have not yet been flushed to the terminal.
pub fn clear_pending_history_lines(&mut self) {
self.pending_history_lines.clear();
}
@@ -590,11 +643,39 @@ impl Tui {
.suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
// Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader.
let mut pending_viewport_area = self.pending_viewport_area()?;
let force_full_repaint = self.force_full_repaint;
self.force_full_repaint = false;
stdout().sync_update(|_| {
// Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader. Explicit resize
// events skip this heuristic because xterm.js can report stale cursor positions while
// a blurred split pane is being resized rapidly.
let mut pending_viewport_area = if force_full_repaint {
None
} else {
self.pending_viewport_area()?
};
let terminal_size = self.terminal.size().ok();
let viewport_area = self.terminal.viewport_area;
tracing::info!(
event = "terminal_draw_started",
requested_height = height,
force_full_repaint,
pending_viewport_area = pending_viewport_area.is_some(),
pending_history_lines = self.pending_history_lines.len(),
terminal_cols = terminal_size.map(|size| size.width),
terminal_rows = terminal_size.map(|size| size.height),
viewport_x = viewport_area.x,
viewport_y = viewport_area.y,
viewport_width = viewport_area.width,
viewport_height = viewport_area.height,
terminal_name = ?self.terminal_name,
resize_reflow_mode = ?self.resize_reflow_mode,
"starting terminal draw"
);
let draw_result = stdout().sync_update(|_| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
prepared.apply(&mut self.terminal)?;
@@ -614,6 +695,11 @@ impl Tui {
self.is_zellij,
)?;
if force_full_repaint {
terminal.clear()?;
needs_full_repaint = true;
}
if needs_full_repaint {
terminal.invalidate_viewport();
}
@@ -635,7 +721,25 @@ impl Tui {
terminal.draw(|frame| {
draw_fn(frame);
})
})?
})?;
match &draw_result {
Ok(()) => tracing::info!(
event = "terminal_draw_completed",
requested_height = height,
force_full_repaint,
"completed terminal draw"
),
Err(err) => tracing::warn!(
event = "terminal_draw_failed",
requested_height = height,
force_full_repaint,
error = %err,
"terminal draw failed"
),
}
draw_result
}
fn pending_viewport_area(&mut self) -> Result<Option<Rect>> {

View File

@@ -24,10 +24,16 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::task::Context;
use std::task::Poll;
use std::time::Duration;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use crossterm::event::Event;
use crossterm::terminal;
use tokio::sync::broadcast;
use tokio::sync::watch;
use tokio::time::Interval;
use tokio::time::MissedTickBehavior;
use tokio_stream::Stream;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::WatchStream;
@@ -35,6 +41,8 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use super::TuiEvent;
const SUPERSET_RESIZE_WATCHDOG_INTERVAL: Duration = Duration::from_millis(100);
/// Result type produced by an event source.
pub type EventResult = std::io::Result<Event>;
@@ -44,6 +52,99 @@ pub trait EventSource: Send + 'static {
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<EventResult>>;
}
type TerminalSizeReader = Arc<dyn Fn() -> std::io::Result<(u16, u16)> + Send + Sync>;
struct ResizeWatchdog {
interval: Interval,
last_size: Option<(u16, u16)>,
read_size: TerminalSizeReader,
logged_read_error: bool,
}
impl ResizeWatchdog {
fn new(interval_duration: Duration, read_size: TerminalSizeReader) -> Self {
let mut interval = tokio::time::interval(interval_duration);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
Self {
interval,
last_size: None,
read_size,
logged_read_error: false,
}
}
fn superset() -> Self {
Self::new(SUPERSET_RESIZE_WATCHDOG_INTERVAL, Arc::new(terminal::size))
}
fn set_observed_size(&mut self, size: (u16, u16)) {
self.last_size = Some(size);
}
fn observe_current_size(&mut self) -> bool {
let size = match (self.read_size)() {
Ok(size) => {
if self.logged_read_error {
tracing::debug!(
event = "resize_watchdog_size_read_recovered",
"resize watchdog size reads recovered"
);
self.logged_read_error = false;
}
size
}
Err(err) => {
if !self.logged_read_error {
tracing::warn!(
event = "resize_watchdog_size_read_failed",
error = %err,
"resize watchdog failed to read terminal size"
);
self.logged_read_error = true;
}
return false;
}
};
let previous_size = self.last_size;
let changed = previous_size.is_some_and(|last_size| last_size != size);
self.last_size = Some(size);
match previous_size {
Some((old_cols, old_rows)) if changed => {
tracing::info!(
event = "resize_watchdog_size_changed",
old_cols,
old_rows,
new_cols = size.0,
new_rows = size.1,
"resize watchdog detected terminal size change"
);
}
None => {
tracing::info!(
event = "resize_watchdog_size_seeded",
cols = size.0,
rows = size.1,
"resize watchdog seeded terminal size"
);
}
Some(_) => {}
}
changed
}
}
fn should_enable_superset_resize_watchdog(
terminal_info: &TerminalInfo,
has_superset_env_markers: bool,
) -> bool {
terminal_info.name == TerminalName::Superset || has_superset_env_markers
}
fn has_superset_env_markers() -> bool {
std::env::var_os("SUPERSET_TERMINAL_ID").is_some()
&& std::env::var_os("SUPERSET_WORKSPACE_ID").is_some()
}
/// Shared crossterm input state for all [`TuiEventStream`] instances. A single crossterm EventStream
/// is reused so all streams still see the same input source.
///
@@ -140,6 +241,8 @@ pub struct TuiEventStream<S: EventSource + Default + Unpin = CrosstermEventSourc
broker: Arc<EventBroker<S>>,
draw_stream: BroadcastStream<()>,
resume_stream: WatchStream<()>,
resize_watchdog: Option<ResizeWatchdog>,
terminal_name: TerminalName,
terminal_focused: Arc<AtomicBool>,
poll_draw_first: bool,
#[cfg(unix)]
@@ -157,10 +260,47 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
#[cfg(unix)] alt_screen_active: Arc<AtomicBool>,
) -> Self {
let resume_stream = WatchStream::from_changes(broker.resume_events_rx());
let terminal_info = codex_terminal_detection::terminal_info();
let has_superset_env_markers = has_superset_env_markers();
let enable_resize_watchdog =
should_enable_superset_resize_watchdog(&terminal_info, has_superset_env_markers);
tracing::info!(
event = "terminal_resize_event_stream_configured",
terminal_name = ?terminal_info.name,
term_program = ?terminal_info.term_program,
term_program_version = ?terminal_info.version,
term = ?terminal_info.term,
colorterm = ?std::env::var("COLORTERM").ok(),
target_os = std::env::consts::OS,
target_arch = std::env::consts::ARCH,
is_windows = cfg!(windows),
is_macos = cfg!(target_os = "macos"),
vscode_ipc_hook_cli_present = std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some(),
vscode_injection_present = std::env::var_os("VSCODE_INJECTION").is_some(),
vscode_shell_login_present = std::env::var_os("VSCODE_SHELL_LOGIN").is_some(),
vscode_cwd_present = std::env::var_os("VSCODE_CWD").is_some(),
term_session_id_present = std::env::var_os("TERM_SESSION_ID").is_some(),
lc_terminal_present = std::env::var_os("LC_TERMINAL").is_some(),
resize_watchdog_enabled = enable_resize_watchdog,
"configured terminal resize event stream"
);
tracing::info!(
event = "superset_resize_watchdog_configured",
enabled = enable_resize_watchdog,
terminal_name = ?terminal_info.name,
term_program = ?terminal_info.term_program,
term_program_version = ?terminal_info.version,
term = ?terminal_info.term,
has_superset_env_markers,
"configured Superset resize watchdog"
);
let resize_watchdog = enable_resize_watchdog.then(ResizeWatchdog::superset);
Self {
broker,
draw_stream: BroadcastStream::new(draw_rx),
resume_stream,
resize_watchdog,
terminal_name: terminal_info.name,
terminal_focused,
poll_draw_first: false,
#[cfg(unix)]
@@ -224,8 +364,22 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// Poll the draw broadcast stream for the next draw event. Draw events are used to trigger a redraw of the TUI.
pub fn poll_draw_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
match Pin::new(&mut self.draw_stream).poll_next(cx) {
Poll::Ready(Some(Ok(()))) => Poll::Ready(Some(TuiEvent::Draw)),
Poll::Ready(Some(Ok(()))) => {
tracing::info!(
event = "tui_draw_event_received",
terminal_name = ?self.terminal_name,
target_os = std::env::consts::OS,
"received scheduled TUI draw event"
);
Poll::Ready(Some(TuiEvent::Draw))
}
Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_)))) => {
tracing::warn!(
event = "tui_draw_event_lagged",
terminal_name = ?self.terminal_name,
target_os = std::env::consts::OS,
"scheduled TUI draw event lagged"
);
Poll::Ready(Some(TuiEvent::Draw))
}
Poll::Ready(None) => Poll::Ready(None),
@@ -233,6 +387,27 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
fn poll_resize_watchdog(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
let Some(watchdog) = self.resize_watchdog.as_mut() else {
return Poll::Pending;
};
loop {
match Pin::new(&mut watchdog.interval).poll_tick(cx) {
Poll::Ready(_) => {
if watchdog.observe_current_size() {
tracing::info!(
event = "resize_watchdog_resize_emitted",
"resize watchdog emitted synthetic resize event"
);
return Poll::Ready(Some(TuiEvent::Resize));
}
}
Poll::Pending => return Poll::Pending,
}
}
}
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
match event {
@@ -244,7 +419,23 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Resize(cols, rows) => {
tracing::info!(
event = "crossterm_resize_event_received",
cols,
rows,
terminal_name = ?self.terminal_name,
target_os = std::env::consts::OS,
is_windows = cfg!(windows),
is_macos = cfg!(target_os = "macos"),
watchdog_enabled = self.resize_watchdog.is_some(),
"received crossterm terminal resize event"
);
if let Some(watchdog) = self.resize_watchdog.as_mut() {
watchdog.set_observed_size((cols, rows));
}
Some(TuiEvent::Resize)
}
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
self.terminal_focused.store(true, Ordering::Relaxed);
@@ -286,6 +477,10 @@ impl<S: EventSource + Default + Unpin> Stream for TuiEventStream<S> {
}
}
if let Poll::Ready(event) = self.poll_resize_watchdog(cx) {
return Poll::Ready(event);
}
Poll::Pending
}
}
@@ -369,6 +564,31 @@ mod tests {
)
}
fn terminal_info(name: TerminalName) -> TerminalInfo {
TerminalInfo {
name,
term_program: None,
version: None,
term: None,
multiplexer: None,
}
}
fn make_resize_watchdog(
size: Arc<std::sync::Mutex<(u16, u16)>>,
interval_duration: Duration,
) -> ResizeWatchdog {
ResizeWatchdog::new(
interval_duration,
Arc::new(move || {
let size = size
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(*size)
}),
)
}
type SetupState = (
Arc<EventBroker<FakeEventSource>>,
FakeEventSourceHandle,
@@ -438,6 +658,85 @@ mod tests {
assert!(saw_draw && saw_key, "expected both draw and key events");
}
#[test]
fn superset_resize_watchdog_gate_is_narrow() {
assert!(should_enable_superset_resize_watchdog(
&terminal_info(TerminalName::Superset),
/*has_superset_env_markers*/ false,
));
assert!(should_enable_superset_resize_watchdog(
&terminal_info(TerminalName::Unknown),
/*has_superset_env_markers*/ true,
));
assert!(!should_enable_superset_resize_watchdog(
&terminal_info(TerminalName::Unknown),
/*has_superset_env_markers*/ false,
));
assert!(!should_enable_superset_resize_watchdog(
&terminal_info(TerminalName::VsCode),
/*has_superset_env_markers*/ false,
));
}
#[tokio::test(flavor = "current_thread")]
async fn resize_watchdog_reports_changes_after_seed() {
let size = Arc::new(std::sync::Mutex::new((80, 24)));
let mut watchdog = make_resize_watchdog(size.clone(), Duration::from_millis(10));
assert!(!watchdog.observe_current_size(), "first sample seeds size");
assert!(
!watchdog.observe_current_size(),
"same size does not request resize"
);
*size
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = (100, 30);
assert!(
watchdog.observe_current_size(),
"changed size requests resize"
);
}
#[tokio::test(flavor = "current_thread")]
async fn resize_watchdog_emits_resize_when_size_changes() {
let (broker, _handle, _draw_tx, draw_rx, terminal_focused) = setup();
let size = Arc::new(std::sync::Mutex::new((80, 24)));
let mut stream = make_stream(broker, draw_rx, terminal_focused);
stream.resize_watchdog = Some(make_resize_watchdog(size.clone(), Duration::from_millis(5)));
let no_event = timeout(Duration::from_millis(20), stream.next()).await;
assert!(no_event.is_err(), "unchanged terminal size should not emit");
*size
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = (100, 30);
let next = timeout(Duration::from_millis(50), stream.next())
.await
.expect("timed out waiting for watchdog resize");
assert!(matches!(next, Some(TuiEvent::Resize)));
}
#[tokio::test(flavor = "current_thread")]
async fn resize_event_updates_watchdog_size() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let size = Arc::new(std::sync::Mutex::new((100, 30)));
let mut stream = make_stream(broker, draw_rx, terminal_focused);
stream.resize_watchdog = Some(make_resize_watchdog(size, Duration::from_millis(10)));
handle.send(Ok(Event::Resize(100, 30)));
let next = stream.next().await;
assert!(matches!(next, Some(TuiEvent::Resize)));
assert_eq!(
stream
.resize_watchdog
.as_ref()
.and_then(|watchdog| watchdog.last_size),
Some((100, 30))
);
}
#[tokio::test(flavor = "current_thread")]
async fn lagged_draw_maps_to_draw() {
let (broker, _handle, draw_tx, draw_rx, terminal_focused) = setup();
@@ -451,6 +750,17 @@ mod tests {
assert!(matches!(first, Some(TuiEvent::Draw)));
}
#[tokio::test(flavor = "current_thread")]
async fn resize_event_maps_to_resize() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
handle.send(Ok(Event::Resize(80, 24)));
let next = stream.next().await;
assert!(matches!(next, Some(TuiEvent::Resize)));
}
#[tokio::test(flavor = "current_thread")]
async fn error_or_eof_ends_stream() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();

View File

@@ -57,7 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed(
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;

54
codex-rs/tui/src/width.rs Normal file
View File

@@ -0,0 +1,54 @@
//! Width guards for transcript rendering with fixed prefix columns.
//!
//! Several rendering paths reserve a fixed number of columns for bullets,
//! gutters, or labels before laying out content. When the terminal is very
//! narrow, those reserved columns can consume the entire width, leaving zero
//! or negative space for content.
//!
//! These helpers centralise the subtraction and enforce a strict-positive
//! contract: they return `Some(n)` where `n > 0`, or `None` when no usable
//! content width remains. Callers treat `None` as "render prefix-only
//! fallback" rather than attempting wrapped rendering at zero width, which
//! would produce empty or unstable output.
/// Returns usable content width after reserving fixed columns.
///
/// Guarantees a strict positive width (`Some(n)` where `n > 0`) or `None` when
/// the reserved columns consume the full width.
///
/// Treat `None` as "render prefix-only fallback". Coercing it to `0` and still
/// attempting wrapped rendering often produces empty or unstable output at very
/// narrow terminal widths.
pub(crate) fn usable_content_width(total_width: usize, reserved_cols: usize) -> Option<usize> {
total_width
.checked_sub(reserved_cols)
.filter(|remaining| *remaining > 0)
}
/// `u16` convenience wrapper around [`usable_content_width`].
///
/// This keeps width math at callsites that receive terminal dimensions as
/// `u16` while preserving the same `None` contract for exhausted width.
pub(crate) fn usable_content_width_u16(total_width: u16, reserved_cols: u16) -> Option<usize> {
usable_content_width(usize::from(total_width), usize::from(reserved_cols))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn usable_content_width_returns_none_when_reserved_exhausts_width() {
assert_eq!(usable_content_width(0, 0), None);
assert_eq!(usable_content_width(2, 2), None);
assert_eq!(usable_content_width(3, 4), None);
assert_eq!(usable_content_width(5, 4), Some(1));
}
#[test]
fn usable_content_width_u16_matches_usize_variant() {
assert_eq!(usable_content_width_u16(2, 2), None);
assert_eq!(usable_content_width_u16(5, 4), Some(1));
}
}