mirror of
https://github.com/openai/codex.git
synced 2026-04-20 20:54:48 +00:00
Compare commits
7 Commits
mikhail/wi
...
fcoury/mar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96775bfb4b | ||
|
|
e2b217a1a0 | ||
|
|
fd6585e863 | ||
|
|
96ea12ec91 | ||
|
|
8495fd57ed | ||
|
|
b368aa722f | ||
|
|
9716aa5091 |
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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!(
|
||||
¬ification.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(¬ification)
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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());
|
||||
})?;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
210
codex-rs/tui/src/streaming/table_holdback.rs
Normal file
210
codex-rs/tui/src/streaming/table_holdback.rs
Normal 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
|
||||
}
|
||||
479
codex-rs/tui/src/table_detect.rs
Normal file
479
codex-rs/tui/src/table_detect.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
84
codex-rs/tui/src/transcript_reflow.rs
Normal file
84
codex-rs/tui/src/transcript_reflow.rs
Normal 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,
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
54
codex-rs/tui/src/width.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user