Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Coury
b71aa4aba3 fix(tui): reflow transcript on terminal resize 2026-04-21 16:36:09 -03:00
29 changed files with 3118 additions and 530 deletions

View File

@@ -89,6 +89,8 @@ pub enum Feature {
UnifiedExec,
/// Route shell tool execution through the zsh exec bridge.
ShellZshFork,
/// Reflow transcript scrollback when the terminal is resized.
TerminalResizeReflow,
/// Include the freeform apply_patch tool.
ApplyPatchFreeform,
/// Stream structured progress while apply_patch input is being generated.
@@ -657,6 +659,16 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::TerminalResizeReflow,
key: "terminal_resize_reflow",
stage: Stage::Experimental {
name: "Terminal resize reflow",
menu_description: "Rebuild Codex-owned transcript scrollback when the terminal width changes.",
announcement: "",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::WebSearchRequest,
key: "web_search_request",

View File

@@ -138,6 +138,19 @@ fn request_permissions_tool_is_under_development() {
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
}
#[test]
fn terminal_resize_reflow_is_experimental_and_disabled_by_default() {
assert_eq!(
feature_for_key("terminal_resize_reflow"),
Some(Feature::TerminalResizeReflow)
);
assert!(matches!(
Feature::TerminalResizeReflow.stage(),
Stage::Experimental { .. }
));
assert_eq!(Feature::TerminalResizeReflow.default_enabled(), false);
}
#[test]
fn tool_suggest_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable);

View File

@@ -71,6 +71,7 @@ use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
#[cfg(test)]
use crate::test_support::test_path_display;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -190,6 +191,7 @@ mod loaded_threads;
mod pending_interactive_replay;
mod platform_actions;
mod replay_filter;
mod resize_reflow;
mod session_lifecycle;
mod side;
mod startup_prompts;
@@ -481,6 +483,27 @@ fn list_skills_response_to_core(response: SkillsListResponse) -> ListSkillsRespo
}
}
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>,
@@ -508,6 +531,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,
@@ -893,6 +917,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(),
@@ -1085,7 +1110,11 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
if matches!(event, TuiEvent::Draw) {
let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled();
tui.set_terminal_resize_reflow_enabled(terminal_resize_reflow_enabled);
if terminal_resize_reflow_enabled && matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
self.handle_draw_pre_render(tui)?;
} else if matches!(event, TuiEvent::Draw) {
let size = tui.terminal.size()?;
if size != tui.terminal.last_known_screen_size {
self.refresh_status_line();
@@ -1107,7 +1136,7 @@ impl App {
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
self.render_transcript_once(tui);

View File

@@ -183,23 +183,73 @@ 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 } => {
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()));
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
self.maybe_finish_stream_reflow(tui)?;
} 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 } => {

View File

@@ -0,0 +1,325 @@
//! Connects terminal resize events to source-backed transcript scrollback rebuilds.
//!
//! The app stores conversation history as `HistoryCell`s, but it also writes finalized history into
//! terminal scrollback for the normal chat view. When the terminal width changes, this module uses
//! the stored cells as source, clears the Codex-owned terminal history, and re-emits the transcript
//! for the new terminal size.
//!
//! Streaming output is the fragile part of this lifecycle. Active streams first appear as transient
//! stream cells, then consolidate into source-backed finalized cells. Resize work that happens
//! before consolidation is marked as stream-time work so consolidation can force one final rebuild
//! from the finalized source.
use std::time::Instant;
use codex_features::Feature;
use color_eyre::eyre::Result;
use ratatui::text::Line;
use super::App;
use super::trailing_run_start;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
use crate::transcript_reflow::TranscriptReflowKind;
use crate::tui;
impl App {
pub(super) 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
}
pub(super) 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);
}
}
pub(super) fn append_history_cell_lines_for_insert(
&mut self,
lines: &mut Vec<Line<'static>>,
cell: &dyn HistoryCell,
width: u16,
) {
lines.extend(self.display_lines_for_history_insert(cell, width));
}
pub(super) fn terminal_resize_reflow_enabled(&self) -> bool {
self.config.features.enabled(Feature::TerminalResizeReflow)
}
fn schedule_resize_reflow(&mut self, kind: TranscriptReflowKind) -> bool {
debug_assert!(self.terminal_resize_reflow_enabled());
self.transcript_reflow.schedule_debounced(kind)
}
/// Finish stream consolidation by repairing any resize work that happened during streaming.
///
/// This is called after agent-message stream cells have either been replaced by an
/// `AgentMarkdownCell` or found to need no replacement. If a resize happened while the stream
/// was active or while its transient cells were still present, this method runs an immediate
/// source-backed reflow so terminal scrollback reflects the finalized cell instead of the
/// transient stream rows.
pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return Ok(());
}
if self.transcript_reflow.take_stream_finish_reflow_needed() {
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
} else if self.transcript_reflow.pending_is_due(Instant::now()) {
tui.frame_requester().schedule_frame();
}
Ok(())
}
fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return;
}
self.transcript_reflow
.schedule_immediate(TranscriptReflowKind::Full);
tui.frame_requester().schedule_frame();
}
/// Force stream-finalized output through the resize reflow path.
///
/// Proposed plan consolidation uses this stricter path because a completed plan is inserted or
/// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time
/// resize, the visible scrollback can keep the pre-consolidation wrapping.
pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return Ok(());
}
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
if !self.transcript_reflow.has_pending_reflow() {
self.transcript_reflow.clear_stream_flags();
}
Ok(())
}
/// Record terminal size changes and schedule any resize-sensitive transcript work.
///
/// Width changes need a full rebuild because transcript wrapping changes. Height growth only
/// needs a visible-row repaint: a tmux split can remove rows from the visible pane, and closing
/// that split can expose blank or shifted rows even when the inline viewport's logical position
/// did not move. The first observed width initializes resize tracking without scheduling a
/// rebuild, because there is no previously emitted width to repair yet.
pub(super) fn handle_draw_size_change(
&mut self,
size: ratatui::layout::Size,
last_known_screen_size: ratatui::layout::Size,
frame_requester: &tui::FrameRequester,
) -> bool {
let width = self.transcript_reflow.note_width(size.width);
let height_growth_exposes_rows = size.height > last_known_screen_size.height;
let should_rebuild_transcript = width.changed || height_growth_exposes_rows;
if width.changed || width.initialized {
self.chat_widget.on_terminal_resize(size.width);
}
if should_rebuild_transcript {
if self.terminal_resize_reflow_enabled() {
let reflow_kind = if width.changed {
TranscriptReflowKind::Full
} else {
TranscriptReflowKind::VisibleRows
};
if matches!(reflow_kind, TranscriptReflowKind::Full)
&& self.should_mark_reflow_as_stream_time()
{
self.transcript_reflow.mark_resize_requested_during_stream();
}
if self.schedule_resize_reflow(reflow_kind) {
frame_requester.schedule_frame();
} else {
frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
}
} else if width.changed {
self.transcript_reflow.clear();
}
}
if size != last_known_screen_size {
self.refresh_status_line();
}
if self.terminal_resize_reflow_enabled() {
self.maybe_clear_resize_reflow_without_terminal();
}
should_rebuild_transcript
}
fn maybe_clear_resize_reflow_without_terminal(&mut self) {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return;
}
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();
}
pub(super) fn handle_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<()> {
let size = tui.terminal.size()?;
let should_rebuild_transcript = self.handle_draw_size_change(
size,
tui.terminal.last_known_screen_size,
&tui.frame_requester(),
);
if should_rebuild_transcript && self.terminal_resize_reflow_enabled() {
// Resize-sensitive history inserts queued before this frame may be wrapped for the old
// viewport or targeted at rows no longer visible. Drop them and let resize reflow
// rebuild from transcript cells.
tui.clear_pending_history_lines();
}
self.maybe_run_resize_reflow(tui)?;
Ok(())
}
/// Run a pending transcript reflow when its debounce deadline has arrived.
///
/// Reflow is deferred while an overlay is active because the overlay owns the current draw
/// surface. Callers must keep using `HistoryCell` source as the rebuild input; attempting to
/// reuse terminal-wrapped output here would preserve exactly the stale wrapping this feature is
/// meant to remove.
pub(super) fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return Ok(());
}
let Some(deadline) = self.transcript_reflow.pending_until() else {
return Ok(());
};
if Instant::now() < deadline || self.overlay.is_some() {
return Ok(());
}
let reflow_kind = self
.transcript_reflow
.pending_kind()
.unwrap_or(TranscriptReflowKind::Full);
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();
match reflow_kind {
TranscriptReflowKind::Full => self.reflow_transcript_now(tui)?,
TranscriptReflowKind::VisibleRows => self.repaint_visible_transcript_rows(tui)?,
}
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;
let mut reflowed_lines = Vec::new();
// 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.append_history_cell_lines_for_insert(&mut reflowed_lines, cell.as_ref(), width);
}
if !reflowed_lines.is_empty() {
tui.insert_reflowed_history_lines(reflowed_lines);
}
Ok(())
}
fn repaint_visible_transcript_rows(&mut self, tui: &mut tui::Tui) -> Result<()> {
tui.clear_pending_history_lines();
if self.transcript_cells.is_empty() {
self.reset_history_emission_state();
return Ok(());
}
tui.terminal.clear_visible_screen()?;
self.reset_history_emission_state();
let width = tui.terminal.size()?.width;
let mut reflowed_lines = Vec::new();
for i in 0..self.transcript_cells.len() {
let cell = self.transcript_cells[i].clone();
self.append_history_cell_lines_for_insert(&mut reflowed_lines, cell.as_ref(), width);
}
if !reflowed_lines.is_empty() {
tui.insert_reflowed_history_lines(reflowed_lines);
}
Ok(())
}
/// Return whether current transcript state should be treated as stream-time resize state.
///
/// The active stream controllers cover normal streaming. The trailing-cell checks cover the
/// narrow window after a controller has stopped but before the app has processed the
/// consolidation event that replaces transient stream cells with source-backed cells.
pub(super) 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()
}
}

View File

@@ -30,6 +30,7 @@ pub(super) async fn make_test_app() -> App {
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),

View File

@@ -3568,6 +3568,7 @@ async fn make_test_app() -> App {
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)),
@@ -3625,6 +3626,7 @@ async fn make_test_app_with_channels() -> (
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),

View File

@@ -363,7 +363,7 @@ impl App {
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let TuiEvent::Draw = &event
if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();
@@ -482,8 +482,6 @@ impl App {
if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {
return false;
}
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
self.backtrack_render_pending = true;
true
@@ -505,8 +503,6 @@ impl App {
&mut self.transcript_cells,
pending.selection.nth_user_message,
) {
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
self.backtrack_render_pending = true;
}

View File

@@ -356,6 +356,26 @@ 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.
ConsolidateAgentMessage {
source: String,
cwd: PathBuf,
},
/// Replace the contiguous run of streaming `ProposedPlanStreamCell`s at the
/// end of the transcript with a single source-backed `ProposedPlanCell`.
///
/// Emitted by `ChatWidget::on_plan_item_completed` after plan stream
/// finalization.
ConsolidateProposedPlan(String),
/// Apply rollback semantics to local transcript cells.
///
/// This is emitted when rollback was not initiated by the current

View File

@@ -1816,12 +1816,25 @@ 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 (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
}
// 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(),
});
}
}
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 {
@@ -2382,7 +2395,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(/*reserved_cols*/ 4),
&self.config.cwd,
));
}
@@ -2412,18 +2425,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;
@@ -2541,10 +2561,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 {
@@ -4656,7 +4681,7 @@ 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(/*reserved_cols*/ 2),
&self.config.cwd,
));
}
@@ -10726,6 +10751,41 @@ impl ChatWidget {
self.bottom_pane.is_task_running() || self.is_review_mode
}
fn current_stream_width(&self, reserved_cols: usize) -> Option<usize> {
self.last_rendered_width.get().and_then(|width| {
if width == 0 {
None
} else {
Some(crate::width::usable_content_width(width, 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(/*reserved_cols*/ 2);
let plan_stream_width = self.current_stream_width(/*reserved_cols*/ 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);
}
if !had_rendered_width {
self.request_redraw();
}
}
/// 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()
}
fn is_plan_streaming_in_tui(&self) -> bool {
self.plan_stream_controller.is_some()
}
@@ -10852,6 +10912,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);
}
@@ -10870,6 +10931,20 @@ 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
{
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.request_redraw();
}
}
#[cfg(test)]
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
self.add_to_history(history_cell::new_mcp_tools_output(

View File

@@ -416,8 +416,12 @@ where
if self.viewport_area.is_empty() {
return Ok(());
}
self.backend
.set_cursor_position(self.viewport_area.as_position())?;
self.clear_after_position(self.viewport_area.as_position())
}
/// Clear from `position` through the end of the visible screen and force a full redraw.
pub(crate) fn clear_after_position(&mut self, position: Position) -> io::Result<()> {
self.backend.set_cursor_position(position)?;
self.backend.clear_region(ClearType::AfterCursor)?;
// Reset the back buffer to make sure the next update will redraw everything.
self.previous_buffer_mut().reset();

View File

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

View File

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

View File

@@ -80,6 +80,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;
@@ -100,9 +101,6 @@ pub(crate) use hook_cell::HookCell;
pub(crate) use hook_cell::new_active_hook_cell;
pub(crate) use hook_cell::new_completed_hook_cell;
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
/// A single renderable unit of conversation history.
///
/// Each cell produces logical `Line`s and reports how many viewport
@@ -196,6 +194,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 {
@@ -413,7 +414,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, /*reserved_cols*/ 2),
Some(self.cwd.as_path()),
&mut lines,
);
@@ -487,6 +488,57 @@ impl HistoryCell for AgentMessageCell {
}
}
/// A consolidated agent message cell that stores raw markdown source and re-renders from it.
///
/// 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`.
///
/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the
/// session that produced the message. Reusing the current process cwd during reflow would make old
/// transcript content change meaning after a later `/cd` or resumed session.
#[derive(Debug)]
pub(crate) struct AgentMarkdownCell {
markdown_source: String,
cwd: PathBuf,
}
impl AgentMarkdownCell {
/// Create a finalized source-backed assistant message cell.
///
/// `markdown_source` must be the raw source accumulated by the stream controller, not already
/// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve
/// stale wrapping instead of repairing it.
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, /*reserved_cols*/ 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(
&self.markdown_source,
Some(wrap_width),
Some(self.cwd.as_path()),
&mut lines,
);
prefix_lines(lines, "".dim(), " ".into())
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
@@ -2459,6 +2511,10 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
}
/// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering.
///
/// The plan body is stored as raw markdown so terminal resize reflow can render it again at the
/// current width. Callers should use `new_proposed_plan_stream` only for transient live streaming
/// cells, then consolidate to this source-backed cell when the plan is complete.
pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPlanCell {
ProposedPlanCell {
plan_markdown,
@@ -2466,6 +2522,10 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl
}
}
/// Create a transient proposed-plan stream cell from already rendered lines.
///
/// Stream cells are display fragments, not source-backed history. They should be replaced by
/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history.
pub(crate) fn new_proposed_plan_stream(
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
@@ -2476,6 +2536,10 @@ pub(crate) fn new_proposed_plan_stream(
}
}
/// Finalized proposed-plan history that can render itself again for a new width.
///
/// This is the source-backed counterpart to `ProposedPlanStreamCell`. It owns raw markdown and the
/// session cwd needed for stable local-link rendering during later transcript reflow.
#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
plan_markdown: String,
@@ -2483,6 +2547,11 @@ pub(crate) struct ProposedPlanCell {
cwd: PathBuf,
}
/// Transient proposed-plan history emitted while a plan is still streaming.
///
/// The lines are already rendered for the stream's current width. A finalized transcript should not
/// keep these cells after consolidation, because they cannot re-render their source on a later
/// terminal resize.
#[derive(Debug)]
pub(crate) struct ProposedPlanStreamCell {
lines: Vec<Line<'static>>,
@@ -2873,6 +2942,7 @@ mod tests {
use crate::exec_cell::ExecCell;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::wrapping::word_wrap_lines;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_otel::RuntimeMetricTotals;
@@ -2887,6 +2957,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;
@@ -4865,4 +4937,211 @@ mod tests {
]
);
}
#[test]
fn agent_markdown_cell_renders_source_at_different_widths() {
let source =
"A long agent message that should wrap differently when the terminal width changes.\n";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
let lines_80 = render_lines(&cell.display_lines(/*width*/ 80));
assert!(
lines_80.first().is_some_and(|line| line.starts_with("")),
"first line should start with bullet prefix: {:?}",
lines_80[0]
);
let lines_32 = render_lines(&cell.display_lines(/*width*/ 32));
assert!(
lines_32.len() > lines_80.len(),
"narrower width should produce more wrapped lines: {lines_32:?}",
);
}
#[test]
fn agent_markdown_cell_narrow_width_shows_prefix_only() {
let source = "narrow width coverage\n";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
let lines = render_lines(&cell.display_lines(/*width*/ 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()],
/*is_first_line*/ true,
);
let reasoning_cell = ReasoningSummaryCell::new(
"Plan".to_string(),
"Reasoning summary content for tiny widths.".to_string(),
&test_cwd(),
/*transcript_only*/ false,
);
let agent_markdown_cell =
AgentMarkdownCell::new("tiny width agent markdown line\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 = "\
Canary rollout remained at limited traffic longer than planned because p95
latency briefly regressed during cold-cache periods.
Regional 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"
);
}
/// 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")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>;
let cont1 = Arc::new(AgentMessageCell::new(
vec![Line::from("line 2")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>;
let cont2 = Arc::new(AgentMessageCell::new(
vec![Line::from("line 3")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>;
let mut transcript_cells: Vec<Arc<dyn HistoryCell>> =
vec![user.clone(), head, cont1, cont2];
// Run the same consolidation logic as the handler.
let source = "line 1\nline 2\nline 3\n".to_string();
let end = transcript_cells.len();
let mut start = end;
while start > 0
&& transcript_cells[start - 1].is_stream_continuation()
&& transcript_cells[start - 1]
.as_any()
.is::<AgentMessageCell>()
{
start -= 1;
}
if start > 0
&& transcript_cells[start - 1]
.as_any()
.is::<AgentMessageCell>()
&& !transcript_cells[start - 1].is_stream_continuation()
{
start -= 1;
}
assert_eq!(
start, 1,
"should find all 3 agent cells starting at index 1"
);
assert_eq!(end, 4);
// Splice.
let consolidated: Arc<dyn HistoryCell> =
Arc::new(AgentMarkdownCell::new(source, &test_cwd()));
transcript_cells.splice(start..end, std::iter::once(consolidated));
assert_eq!(transcript_cells.len(), 2, "should be [user, consolidated]");
// Verify first cell is still the user cell.
assert!(
transcript_cells[0].as_any().is::<UserHistoryCell>(),
"first cell should be UserHistoryCell"
);
// Verify second cell is AgentMarkdownCell.
assert!(
transcript_cells[1].as_any().is::<AgentMarkdownCell>(),
"second cell should be AgentMarkdownCell"
);
}
}

View File

@@ -35,10 +35,16 @@ use ratatui::text::Span;
/// which let us slide existing content down without redrawing it. Zellij silently
/// drops or mishandles those sequences, so `Zellij` mode falls back to emitting
/// newlines at the bottom of the screen and writing lines at absolute positions.
///
/// The preserve-viewport variants are for redraw/reflow work that is rebuilding already-emitted
/// history. They write history above the current viewport without treating blank space below the
/// viewport as room for new live output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertHistoryMode {
Standard,
StandardPreserveViewport,
Zellij,
ZellijPreserveViewport,
}
impl InsertHistoryMode {
@@ -49,6 +55,25 @@ impl InsertHistoryMode {
Self::Standard
}
}
pub fn new_preserving_viewport(is_zellij: bool) -> Self {
if is_zellij {
Self::ZellijPreserveViewport
} else {
Self::StandardPreserveViewport
}
}
pub fn uses_zellij(self) -> bool {
matches!(self, Self::Zellij | Self::ZellijPreserveViewport)
}
pub(crate) fn preserves_viewport(self) -> bool {
matches!(
self,
Self::StandardPreserveViewport | Self::ZellijPreserveViewport
)
}
}
/// Insert `lines` above the viewport using the terminal's backend writer
@@ -68,9 +93,9 @@ where
/// In `Standard` mode this manipulates DECSTBM scroll regions to slide existing
/// scrollback down and writes new lines into the freed space. In `Zellij` mode it
/// emits newlines at the screen bottom to create space (since Zellij ignores scroll
/// region escapes) and writes lines at computed absolute positions. Both modes
/// update `terminal.viewport_area` so subsequent draw passes know where the
/// viewport moved to.
/// region escapes) and writes lines at computed absolute positions. The preserve-viewport variants
/// skip the "slide existing content down" step so resize reflow can redraw history without moving
/// the composer.
pub fn insert_history_lines_with_mode<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
@@ -116,10 +141,18 @@ where
}
let wrapped_lines = wrapped_rows as u16;
if matches!(mode, InsertHistoryMode::Zellij) {
if mode.uses_zellij() {
let space_below = screen_size.height.saturating_sub(area.bottom());
let shift_down = wrapped_lines.min(space_below);
let scroll_up_amount = wrapped_lines.saturating_sub(shift_down);
let shift_down = if mode.preserves_viewport() {
0
} else {
wrapped_lines.min(space_below)
};
let scroll_up_amount = if mode.preserves_viewport() {
0
} else {
wrapped_lines.saturating_sub(shift_down)
};
if scroll_up_amount > 0 {
// Scroll the entire screen up by emitting \n at the bottom
@@ -134,17 +167,27 @@ where
should_update_area = true;
}
let cursor_top = area.top().saturating_sub(scroll_up_amount + shift_down);
let zellij_start = if mode.preserves_viewport() {
wrapped.len().saturating_sub(area.top() as usize)
} else {
0
};
let zellij_lines = &wrapped[zellij_start..];
let cursor_top = if mode.preserves_viewport() {
area.top().saturating_sub(zellij_lines.len() as u16)
} else {
area.top().saturating_sub(scroll_up_amount + shift_down)
};
queue!(writer, MoveTo(0, cursor_top))?;
for (i, line) in wrapped.iter().enumerate() {
for (i, line) in zellij_lines.iter().enumerate() {
if i > 0 {
queue!(writer, Print("\r\n"))?;
}
write_history_line(writer, line, wrap_width)?;
}
} else {
let cursor_top = if area.bottom() < screen_size.height {
let cursor_top = if !mode.preserves_viewport() && area.bottom() < screen_size.height {
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
let top_1based = area.top() + 1;
@@ -178,19 +221,28 @@ where
// ││ ││
// │╰────────────────────────────╯│
// └──────────────────────────────┘
queue!(writer, SetScrollRegion(1..area.top()))?;
if area.top() > 0 {
queue!(writer, SetScrollRegion(1..area.top()))?;
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(writer, MoveTo(0, cursor_top))?;
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
queue!(writer, MoveTo(0, cursor_top))?;
for line in &wrapped {
queue!(writer, Print("\r\n"))?;
write_history_line(writer, line, wrap_width)?;
let scroll_bottom = area.top().saturating_sub(1);
let mut advance_row = cursor_top;
for line in &wrapped {
// 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)?;
}
queue!(writer, ResetScrollRegion)?;
}
queue!(writer, ResetScrollRegion)?;
}
// Restore the cursor position to where it was before we started.
@@ -821,4 +873,58 @@ mod tests {
assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2));
assert_eq!(term.visible_history_rows(), 1);
}
#[test]
fn vt100_exact_width_rows_keep_stable_line_progression() {
let width: u16 = 10;
let height: u16 = 7;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(0, height - 1, width, 1);
term.set_viewport_area(viewport);
let lines = vec![
Line::from("1234567890"),
Line::from("abcdefghij"),
Line::from("KLMNOPQRST"),
];
insert_history_lines(&mut term, lines).expect("insert_history_lines should succeed");
let screen = term.backend().vt100().screen();
let mut non_empty_rows: Vec<(u16, String)> = Vec::new();
for row in 0..height.saturating_sub(1) {
let row_text = (0..width)
.map(|col| {
screen
.cell(row, col)
.map(|cell| cell.contents().to_string())
.unwrap_or_default()
})
.collect::<String>();
if !row_text.trim().is_empty() {
non_empty_rows.push((row, row_text));
}
}
assert_eq!(
non_empty_rows.len(),
3,
"expected exactly three populated rows, got {non_empty_rows:?}",
);
let expected = ["1234567890", "abcdefghij", "KLMNOPQRST"];
for (idx, (row, row_text)) in non_empty_rows.iter().enumerate() {
assert_eq!(
row_text, expected[idx],
"unexpected row content at y={row}: {row_text:?}",
);
}
for pair in non_empty_rows.windows(2) {
assert_eq!(
pair[1].0,
pair[0].0 + 1,
"expected contiguous row progression, got {non_empty_rows:?}",
);
}
}
}

View File

@@ -159,6 +159,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;
@@ -168,6 +169,7 @@ mod updates;
mod version;
#[cfg(not(target_os = "linux"))]
mod voice;
mod width;
#[cfg(target_os = "linux")]
#[allow(dead_code)]
mod voice {

View File

@@ -1,55 +1,136 @@
//! Collects markdown stream source at newline boundaries.
//!
//! `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.
///
/// 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.
///
/// The collector does not parse markdown in production. It only defines stable source boundaries;
/// rendering lives in the stream controllers so width changes can re-render from one accumulated
/// source string.
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.
///
/// This returns only source that has not been returned by a previous commit. Calling it after a
/// delta without a newline returns `None`, which prevents the live stream from rendering
/// incomplete markdown blocks that may change meaning when the rest of the line arrives.
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)
}
/// 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. This method clears the collector;
/// callers should not invoke it until the stream is truly complete or interrupted output is
/// being intentionally consolidated.
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 +149,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---");
@@ -416,6 +501,42 @@ mod tests {
.collect()
}
#[tokio::test]
async fn table_header_commits_without_holdback() {
let mut c = super::MarkdownStreamCollector::new(/*width*/ 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(/*width*/ 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 +843,9 @@ 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;
}
}

View File

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

View File

@@ -139,6 +139,7 @@ impl OnboardingScreen {
error: None,
}))
}
// TODO: add git warning.
Self {
request_frame: tui.frame_requester(),
steps,
@@ -480,7 +481,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Draw => {
TuiEvent::Draw | TuiEvent::Resize => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {

View File

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

View File

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

View File

@@ -1,103 +1,276 @@
//! Streams markdown deltas while retaining source for later transcript reflow.
//!
//! Streaming has two outputs with different lifetimes. The live viewport needs incremental
//! `HistoryCell`s so the user sees progress, while finalized transcript history needs raw markdown
//! source so it can be rendered again after a terminal resize. These controllers keep those outputs
//! tied together: newline-complete source is rendered into queued live cells, and finalization
//! returns the accumulated source to the app for consolidation.
//!
//! Width changes are handled by re-rendering from source and rebuilding only the not-yet-emitted
//! queue. Already emitted rows stay emitted until the app-level transcript reflow rebuilds the full
//! scrollback from finalized cells.
use crate::history_cell::HistoryCell;
use crate::history_cell::{self};
use crate::markdown::append_markdown;
use crate::render::line_utils::prefix_lines;
use crate::style::proposed_plan_style;
use ratatui::prelude::Stylize;
use ratatui::text::Line;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use super::StreamState;
/// Controller that manages newline-gated streaming, header emission, and
/// commit animation across streams.
pub(crate) struct StreamController {
struct StreamCore {
state: StreamState,
finishing_after_drain: bool,
width: Option<usize>,
raw_source: String,
rendered_lines: Vec<Line<'static>>,
enqueued_len: usize,
emitted_len: usize,
cwd: PathBuf,
}
impl StreamCore {
fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
state: StreamState::new(width, cwd),
width,
raw_source: String::with_capacity(1024),
rendered_lines: Vec::with_capacity(64),
enqueued_len: 0,
emitted_len: 0,
cwd: cwd.to_path_buf(),
}
}
fn push_delta(&mut self, delta: &str) -> bool {
if !delta.is_empty() {
self.state.has_seen_delta = true;
}
self.state.collector.push_delta(delta);
if delta.contains('\n')
&& let Some(committed_source) = self.state.collector.commit_complete_source()
{
self.raw_source.push_str(&committed_source);
self.recompute_render();
return self.sync_queue_to_render();
}
false
}
fn finalize_remaining(&mut self) -> Vec<Line<'static>> {
let remainder_source = self.state.collector.finalize_and_drain_source();
if !remainder_source.is_empty() {
self.raw_source.push_str(&remainder_source);
}
let mut rendered = Vec::new();
append_markdown(
&self.raw_source,
self.width,
Some(self.cwd.as_path()),
&mut rendered,
);
if self.emitted_len >= rendered.len() {
Vec::new()
} else {
rendered[self.emitted_len..].to_vec()
}
}
fn tick(&mut self) -> Vec<Line<'static>> {
let step = self.state.step();
self.emitted_len += step.len();
step
}
fn tick_batch(&mut self, max_lines: usize) -> Vec<Line<'static>> {
if max_lines == 0 {
return Vec::new();
}
let step = self.state.drain_n(max_lines);
self.emitted_len += step.len();
step
}
fn queued_lines(&self) -> usize {
self.state.queued_len()
}
fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
self.state.oldest_queued_age(now)
}
fn is_idle(&self) -> bool {
self.state.is_idle()
}
fn set_width(&mut self, width: Option<usize>) {
if self.width == width {
return;
}
let had_pending_queue = self.state.queued_len() > 0;
self.width = width;
self.state.collector.set_width(width);
if self.raw_source.is_empty() {
return;
}
self.recompute_render();
self.emitted_len = self.emitted_len.min(self.rendered_lines.len());
if had_pending_queue
&& self.emitted_len == self.rendered_lines.len()
&& self.emitted_len > 0
{
// If wrapped remainder compresses into fewer lines at the new width,
// keep at least one line un-emitted so pre-resize pending content is
// not skipped permanently.
self.emitted_len -= 1;
}
self.state.clear_queue();
if self.emitted_len > 0 && !had_pending_queue {
self.enqueued_len = self.rendered_lines.len();
return;
}
self.rebuild_queue_from_render();
}
fn clear_queue(&mut self) {
self.state.clear_queue();
self.enqueued_len = self.emitted_len;
}
fn reset(&mut self) {
self.state.clear();
self.raw_source.clear();
self.rendered_lines.clear();
self.enqueued_len = 0;
self.emitted_len = 0;
}
fn recompute_render(&mut self) {
self.rendered_lines.clear();
append_markdown(
&self.raw_source,
self.width,
Some(self.cwd.as_path()),
&mut self.rendered_lines,
);
}
fn sync_queue_to_render(&mut self) -> bool {
let target_len = self.rendered_lines.len().max(self.emitted_len);
if target_len < self.enqueued_len {
self.rebuild_queue_from_render();
return self.state.queued_len() > 0;
}
if target_len == self.enqueued_len {
return false;
}
self.state
.enqueue(self.rendered_lines[self.enqueued_len..target_len].to_vec());
self.enqueued_len = target_len;
true
}
fn rebuild_queue_from_render(&mut self) {
self.state.clear_queue();
let target_len = self.rendered_lines.len().max(self.emitted_len);
if self.emitted_len < target_len {
self.state
.enqueue(self.rendered_lines[self.emitted_len..target_len].to_vec());
}
self.enqueued_len = target_len;
}
}
/// Controls newline-gated streaming for assistant messages.
///
/// The controller emits transient `AgentMessageCell`s for live display and returns raw markdown
/// source on `finalize` so the app can replace those transient cells with a source-backed
/// `AgentMarkdownCell`. Callers should use `set_width` on terminal resize; rebuilding the queue
/// from already emitted cells would duplicate output instead of preserving the stream position.
pub(crate) struct StreamController {
core: StreamCore,
header_emitted: bool,
}
impl StreamController {
/// Create a controller whose markdown renderer shortens local file links relative to `cwd`.
/// Create a stream controller that renders markdown relative to the given width and cwd.
///
/// The controller snapshots the path into stream state so later commit ticks and finalization
/// render against the same session cwd that was active when streaming started.
/// `width` is the content width available to markdown rendering, not necessarily the full
/// terminal width. Passing a stale width after resize will keep queued live output wrapped for
/// the old viewport until app-level reflow repairs the finalized transcript.
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
state: StreamState::new(width, cwd),
finishing_after_drain: false,
core: StreamCore::new(width, cwd),
header_emitted: false,
}
}
/// Push a delta; if it contains a newline, commit completed lines and start animation.
pub(crate) fn push(&mut self, delta: &str) -> bool {
let state = &mut self.state;
if !delta.is_empty() {
state.has_seen_delta = true;
}
state.collector.push_delta(delta);
if delta.contains('\n') {
let newly_completed = state.collector.commit_complete_lines();
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
return true;
}
}
false
}
/// Finalize the active stream. Drain and emit now.
pub(crate) fn finalize(&mut self) -> Option<Box<dyn HistoryCell>> {
// Finalize collector first.
let remaining = {
let state = &mut self.state;
state.collector.finalize_and_drain()
};
// Collect all output first to avoid emitting headers when there is no content.
let mut out_lines = Vec::new();
{
let state = &mut self.state;
if !remaining.is_empty() {
state.enqueue(remaining);
}
let step = state.drain_all();
out_lines.extend(step);
}
// Cleanup
self.state.clear();
self.finishing_after_drain = false;
self.emit(out_lines)
}
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.state.step();
(self.emit(step), self.state.is_idle())
}
/// Step animation: commit at most `max_lines` queued lines.
/// Push a raw model delta and return whether it produced queued complete lines.
///
/// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a
/// very large value can collapse perceived animation into a single jump.
/// Deltas are committed only through newline boundaries. A `false` return can still mean source
/// was buffered; it only means no newly renderable complete line is ready for live emission.
pub(crate) fn push(&mut self, delta: &str) -> bool {
self.core.push_delta(delta)
}
/// Finish the stream and return the final transient cell plus accumulated markdown source.
///
/// The source is `None` only when the stream never accumulated content. Callers that discard the
/// returned source cannot later consolidate the transcript into a width-sensitive finalized
/// cell.
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
let remaining = self.core.finalize_remaining();
if self.core.raw_source.is_empty() {
self.core.reset();
return (None, None);
}
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining);
self.core.reset();
(out, Some(source))
}
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.core.tick();
(self.emit(step), self.core.is_idle())
}
pub(crate) fn on_commit_tick_batch(
&mut self,
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.state.drain_n(max_lines.max(1));
(self.emit(step), self.state.is_idle())
let step = self.core.tick_batch(max_lines);
(self.emit(step), self.core.is_idle())
}
/// Returns the current number of queued lines waiting to be displayed.
pub(crate) fn queued_lines(&self) -> usize {
self.state.queued_len()
self.core.queued_lines()
}
/// Returns the age of the oldest queued line.
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
self.state.oldest_queued_age(now)
self.core.oldest_queued_age(now)
}
pub(crate) fn clear_queue(&mut self) {
self.core.clear_queue();
}
pub(crate) fn set_width(&mut self, width: Option<usize>) {
self.core.set_width(width);
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
@@ -112,96 +285,88 @@ impl StreamController {
}
}
/// Controller that streams proposed plan markdown into a styled plan block.
/// Controls newline-gated streaming for proposed plan markdown.
///
/// This follows the same source-retention contract as `StreamController`, but wraps emitted lines
/// in the proposed-plan header, padding, and style. Finalization must return source for
/// `ProposedPlanCell`; otherwise a resized finalized plan would keep the transient stream shape.
pub(crate) struct PlanStreamController {
state: StreamState,
core: StreamCore,
header_emitted: bool,
top_padding_emitted: bool,
}
impl PlanStreamController {
/// Create a plan-stream controller whose markdown renderer shortens local file links relative
/// to `cwd`.
/// Create a proposed-plan stream controller that renders markdown relative to the given cwd.
///
/// The controller snapshots the path into stream state so later commit ticks and finalization
/// render against the same session cwd that was active when streaming started.
/// The width has the same meaning as in `StreamController`: it is the markdown body width, and
/// callers must update it when the terminal width changes.
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
state: StreamState::new(width, cwd),
core: StreamCore::new(width, cwd),
header_emitted: false,
top_padding_emitted: false,
}
}
/// Push a delta; if it contains a newline, commit completed lines and start animation.
/// Push a raw proposed-plan delta and return whether it produced queued complete lines.
///
/// Source may be buffered even when this returns `false`; callers should continue ticking only
/// when queued lines exist.
pub(crate) fn push(&mut self, delta: &str) -> bool {
let state = &mut self.state;
if !delta.is_empty() {
state.has_seen_delta = true;
}
state.collector.push_delta(delta);
if delta.contains('\n') {
let newly_completed = state.collector.commit_complete_lines();
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
return true;
}
}
false
self.core.push_delta(delta)
}
/// Finalize the active stream. Drain and emit now.
pub(crate) fn finalize(&mut self) -> Option<Box<dyn HistoryCell>> {
let remaining = {
let state = &mut self.state;
state.collector.finalize_and_drain()
};
let mut out_lines = Vec::new();
{
let state = &mut self.state;
if !remaining.is_empty() {
state.enqueue(remaining);
}
let step = state.drain_all();
out_lines.extend(step);
/// Finish the plan stream and return the final transient cell plus accumulated markdown source.
///
/// The returned source is consumed by app-level consolidation to create the source-backed
/// `ProposedPlanCell` used for later resize reflow.
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
let remaining = self.core.finalize_remaining();
if self.core.raw_source.is_empty() {
self.core.reset();
return (None, None);
}
self.state.clear();
self.emit(out_lines, /*include_bottom_padding*/ true)
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining, /*include_bottom_padding*/ true);
self.core.reset();
(out, Some(source))
}
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.state.step();
let step = self.core.tick();
(
self.emit(step, /*include_bottom_padding*/ false),
self.state.is_idle(),
self.core.is_idle(),
)
}
/// Step animation: commit at most `max_lines` queued lines.
///
/// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a
/// very large value can collapse perceived animation into a single jump.
pub(crate) fn on_commit_tick_batch(
&mut self,
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.state.drain_n(max_lines.max(1));
let step = self.core.tick_batch(max_lines);
(
self.emit(step, /*include_bottom_padding*/ false),
self.state.is_idle(),
self.core.is_idle(),
)
}
/// Returns the current number of queued plan lines waiting to be displayed.
pub(crate) fn queued_lines(&self) -> usize {
self.state.queued_len()
self.core.queued_lines()
}
/// Returns the age of the oldest queued plan line.
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
self.state.oldest_queued_age(now)
self.core.oldest_queued_age(now)
}
pub(crate) fn clear_queue(&mut self) {
self.core.clear_queue();
}
pub(crate) fn set_width(&mut self, width: Option<usize>) {
self.core.set_width(width);
}
fn emit(
@@ -213,7 +378,7 @@ impl PlanStreamController {
return None;
}
let mut out_lines: Vec<Line<'static>> = Vec::new();
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(4);
let is_stream_continuation = self.header_emitted;
if !self.header_emitted {
out_lines.push(vec!["".dim(), "Proposed Plan".bold()].into());
@@ -221,7 +386,7 @@ impl PlanStreamController {
self.header_emitted = true;
}
let mut plan_lines: Vec<Line<'static>> = Vec::new();
let mut plan_lines: Vec<Line<'static>> = Vec::with_capacity(4);
if !self.top_padding_emitted {
plan_lines.push(Line::from(" "));
self.top_padding_emitted = true;
@@ -248,106 +413,37 @@ impl PlanStreamController {
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
fn test_cwd() -> PathBuf {
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
// Windows-specific root semantics into the fixtures.
std::env::temp_dir()
}
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
fn stream_controller(width: Option<usize>) -> StreamController {
StreamController::new(width, &test_cwd())
}
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
PlanStreamController::new(width, &test_cwd())
}
fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.map(|line| {
line.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
.map(|span| span.content.clone())
.collect::<String>()
})
.collect()
}
#[tokio::test]
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
let mut ctrl = StreamController::new(/*width*/ None, &test_cwd());
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
let mut ctrl = stream_controller(width);
let mut lines = Vec::new();
// Exact deltas from the session log (section: Loose vs. tight list items)
let deltas = vec![
"\n\n",
"Loose",
" vs",
".",
" tight",
" list",
" items",
":\n",
"1",
".",
" Tight",
" item",
"\n",
"2",
".",
" Another",
" tight",
" item",
"\n\n",
"1",
".",
" Loose",
" item",
" with",
" its",
" own",
" paragraph",
".\n\n",
" ",
" This",
" paragraph",
" belongs",
" to",
" the",
" same",
" list",
" item",
".\n\n",
"2",
".",
" Second",
" loose",
" item",
" with",
" a",
" nested",
" list",
" after",
" a",
" blank",
" line",
".\n\n",
" ",
" -",
" Nested",
" bullet",
" under",
" a",
" loose",
" item",
"\n",
" ",
" -",
" Another",
" nested",
" bullet",
"\n\n",
];
// Simulate streaming with a commit tick attempt after each delta.
for d in deltas.iter() {
ctrl.push(d);
for delta in deltas {
ctrl.push(delta);
while let (Some(cell), idle) = ctrl.on_commit_tick() {
lines.extend(cell.transcript_lines(u16::MAX));
if idle {
@@ -355,47 +451,122 @@ mod tests {
}
}
}
// Finalize and flush remaining lines now.
if let Some(cell) = ctrl.finalize() {
if let (Some(cell), _source) = ctrl.finalize() {
lines.extend(cell.transcript_lines(u16::MAX));
}
let streamed: Vec<_> = lines_to_plain_strings(&lines)
lines_to_plain_strings(&lines)
.into_iter()
// skip • and 2-space indentation
.map(|s| s.chars().skip(2).collect::<String>())
.collect();
.map(|line| line.chars().skip(2).collect::<String>())
.collect()
}
// Full render of the same source
let source: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = test_cwd();
crate::markdown::append_markdown(
&source,
/*width*/ None,
Some(test_cwd.as_path()),
&mut rendered,
fn collect_plan_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
let mut ctrl = plan_stream_controller(width);
let mut lines = Vec::new();
for delta in deltas {
ctrl.push(delta);
while let (Some(cell), idle) = ctrl.on_commit_tick() {
lines.extend(cell.transcript_lines(u16::MAX));
if idle {
break;
}
}
}
if let (Some(cell), _source) = ctrl.finalize() {
lines.extend(cell.transcript_lines(u16::MAX));
}
lines_to_plain_strings(&lines)
}
#[test]
fn controller_set_width_rebuilds_queued_lines() {
let mut ctrl = stream_controller(Some(120));
let delta = "This is a long line that should wrap into multiple rows when resized.\n";
assert!(ctrl.push(delta));
assert_eq!(ctrl.queued_lines(), 1);
ctrl.set_width(Some(24));
let (cell, idle) = ctrl.on_commit_tick_batch(usize::MAX);
let rendered = lines_to_plain_strings(
&cell
.expect("expected resized queued lines")
.transcript_lines(u16::MAX),
);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed, rendered_strs);
assert!(idle);
assert!(
rendered.len() > 1,
"expected resized content to occupy multiple lines, got {rendered:?}",
);
}
#[test]
fn controller_set_width_no_duplicate_after_emit() {
let mut ctrl = stream_controller(Some(120));
let line =
"This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n";
ctrl.push(line);
let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX);
assert!(cell.is_some(), "expected emitted cell");
assert_eq!(ctrl.queued_lines(), 0);
ctrl.set_width(Some(24));
// Also assert exact expected plain strings for clarity.
let expected = vec![
"Loose vs. tight list items:".to_string(),
"".to_string(),
"1. Tight item".to_string(),
"2. Another tight item".to_string(),
"3. Loose item with its own paragraph.".to_string(),
"".to_string(),
" This paragraph belongs to the same list item.".to_string(),
"4. Second loose item with a nested list after a blank line.".to_string(),
" - Nested bullet under a loose item".to_string(),
" - Another nested bullet".to_string(),
];
assert_eq!(
streamed, expected,
"expected exact rendered lines for loose/tight section"
ctrl.queued_lines(),
0,
"already-emitted content must not be re-queued after resize",
);
}
#[test]
fn controller_tick_batch_zero_is_noop() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("line one\n"));
assert_eq!(ctrl.queued_lines(), 1);
let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0);
assert!(cell.is_none(), "batch size 0 should not emit lines");
assert!(!idle, "batch size 0 should not drain queued lines");
assert_eq!(
ctrl.queued_lines(),
1,
"queue depth should remain unchanged"
);
}
#[test]
fn controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("hello\n"));
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("hello\n".to_string()));
}
#[test]
fn plan_controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = plan_stream_controller(Some(80));
assert!(ctrl.push("- step\n"));
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("- step\n".to_string()));
}
#[test]
fn simple_lines_stream_in_order() {
let actual = collect_streamed_lines(&["hello\n", "world\n"], Some(80));
assert_eq!(actual, vec!["hello".to_string(), "world".to_string()]);
}
#[test]
fn plan_lines_stream_in_order() {
let actual = collect_plan_streamed_lines(&["- one\n", "- two\n"], Some(80));
assert!(
actual.iter().any(|line| line.contains("Proposed Plan")),
"expected plan header in streamed plan: {actual:?}",
);
assert!(
actual.iter().any(|line| line.contains("one")),
"expected plan body in streamed plan: {actual:?}",
);
}
}

View File

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

View File

@@ -0,0 +1,230 @@
//! Tracks when Codex-owned transcript scrollback must be repaired after terminal resize.
//!
//! Terminal scrollback is not a retained widget tree: once Codex writes wrapped lines into the
//! terminal, the terminal owns those rows. Width resize reflow treats the in-memory transcript cells
//! as the source of truth, clears Codex-owned history, and re-emits the cells at the current width.
//! Height-only growth uses a narrower repaint that fills rows exposed above the inline viewport
//! without purging scrollback, because wrapping did not change.
//!
//! This module owns only scheduling and stream-time repair state. It does not know how to render
//! cells or clear terminal output; `app::resize_reflow` consumes this state and performs the
//! rebuild. The key invariant is that a full reflow request which happens while streaming output is
//! active, or while transient stream cells are still waiting for consolidation, must trigger one
//! final reflow after the stream becomes source-backed history.
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>,
pending_kind: Option<TranscriptReflowKind>,
ran_during_stream: bool,
resize_requested_during_stream: bool,
}
/// Describes how much terminal history repair is needed for a pending resize.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TranscriptReflowKind {
/// Repaint only the visible transcript rows above the inline viewport.
VisibleRows,
/// Purge and rebuild Codex-owned scrollback from source-backed transcript cells.
Full,
}
impl TranscriptReflowState {
/// Reset all width, pending deadline, and stream repair state.
///
/// Call this when resize reflow is disabled or when the app discards the transcript state that
/// pending reflow work would have rebuilt. Leaving stale deadlines behind would make a later
/// draw attempt to rebuild history from unrelated cells.
pub(crate) fn clear(&mut self) {
*self = Self::default();
}
/// Record the width observed during a draw and report whether it is new or changed.
///
/// The first observed width initializes the state without scheduling a rebuild because no
/// old-width transcript has been emitted yet. Treating initialization as a real resize would
/// make the first draw do redundant scrollback work.
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(),
}
}
/// Schedule a coalesced reflow. Returns true if the pending reflow is already due.
///
/// Repeated resize events keep the existing deadline instead of pushing it out, so continuous
/// resizing cannot postpone scrollback repair indefinitely.
pub(crate) fn schedule_debounced(&mut self, kind: TranscriptReflowKind) -> bool {
let now = Instant::now();
let due_now = self.pending_is_due(now);
self.record_pending_kind(kind);
if self.pending_until.is_none() {
self.pending_until = Some(now + TRANSCRIPT_REFLOW_DEBOUNCE);
}
due_now
}
/// Schedule an immediate reflow for the next draw opportunity.
///
/// This is used after stream consolidation when waiting for the debounce interval would leave
/// visible terminal-wrapped stream rows in the finalized transcript.
pub(crate) fn schedule_immediate(&mut self, kind: TranscriptReflowKind) {
self.record_pending_kind(kind);
self.pending_until = Some(Instant::now());
}
fn record_pending_kind(&mut self, kind: TranscriptReflowKind) {
self.pending_kind = Some(match (self.pending_kind, kind) {
(Some(TranscriptReflowKind::Full), _) | (_, TranscriptReflowKind::Full) => {
TranscriptReflowKind::Full
}
_ => TranscriptReflowKind::VisibleRows,
});
}
#[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 pending_kind(&self) -> Option<TranscriptReflowKind> {
self.pending_kind
}
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;
self.pending_kind = None;
}
/// Remember that a reflow actually rebuilt history before stream consolidation completed.
///
/// A mid-stream rebuild can only render the transient stream cells that exist at that moment.
/// The consolidation handler must later rebuild again from the finalized source-backed cell or
/// the transcript can keep old stream wrapping.
pub(crate) fn mark_ran_during_stream(&mut self) {
self.ran_during_stream = true;
}
/// Remember that the terminal width changed while streaming or pre-consolidation cells existed.
///
/// This captures the case where the debounce did not fire before the stream finished. Without
/// this flag, consolidation could complete without the final source-backed resize repair.
pub(crate) fn mark_resize_requested_during_stream(&mut self) {
self.resize_requested_during_stream = true;
}
/// Return whether stream finalization needs a source-backed reflow and clear the request.
///
/// This is a draining read because each resize-during-stream episode should force at most one
/// post-consolidation repair. Calling it before consolidation would drop the repair request and
/// leave finalized scrollback shaped by transient stream rows.
pub(crate) fn take_stream_finish_reflow_needed(&mut self) -> bool {
let needed = self.ran_during_stream || self.resize_requested_during_stream;
self.ran_during_stream = false;
self.resize_requested_during_stream = false;
needed
}
/// Clear only the stream repair flags while preserving width and pending-deadline state.
///
/// Use this after a required final stream reflow has completed. Calling `clear()` here would
/// also forget the last observed width and make the next draw look like first initialization.
pub(crate) fn clear_stream_flags(&mut self) {
self.ran_during_stream = false;
self.resize_requested_during_stream = false;
}
}
/// Describes how the latest draw width relates to the previous draw width.
///
/// `initialized` means this was the first width observed by the state machine. `changed` means a
/// previously rendered transcript width exists and differs from the new width.
pub(crate) struct TranscriptWidthChange {
pub(crate) changed: bool,
pub(crate) initialized: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schedule_debounced_does_not_postpone_existing_reflow() {
let mut state = TranscriptReflowState::default();
assert!(!state.schedule_debounced(TranscriptReflowKind::VisibleRows));
let first_deadline = state.pending_until().expect("pending reflow");
std::thread::sleep(Duration::from_millis(1));
assert!(!state.schedule_debounced(TranscriptReflowKind::VisibleRows));
assert_eq!(state.pending_until(), Some(first_deadline));
}
#[test]
fn schedule_debounced_reports_due_existing_reflow() {
let mut state = TranscriptReflowState::default();
state.set_due_for_test();
assert!(state.schedule_debounced(TranscriptReflowKind::VisibleRows));
}
#[test]
fn full_reflow_request_promotes_visible_rows_request() {
let mut state = TranscriptReflowState::default();
state.schedule_debounced(TranscriptReflowKind::VisibleRows);
state.schedule_debounced(TranscriptReflowKind::Full);
assert_eq!(state.pending_kind(), Some(TranscriptReflowKind::Full));
}
#[test]
fn take_stream_finish_reflow_needed_drains_resize_request() {
let mut state = TranscriptReflowState::default();
state.mark_resize_requested_during_stream();
assert!(state.take_stream_finish_reflow_needed());
assert!(!state.take_stream_finish_reflow_needed());
}
#[test]
fn take_stream_finish_reflow_needed_drains_ran_during_stream() {
let mut state = TranscriptReflowState::default();
state.mark_ran_during_stream();
assert!(state.take_stream_finish_reflow_needed());
assert!(!state.take_stream_finish_reflow_needed());
}
#[test]
fn clear_resets_stream_reflow_flags() {
let mut state = TranscriptReflowState::default();
state.mark_ran_during_stream();
state.mark_resize_requested_during_stream();
state.clear();
assert!(!state.take_stream_finish_reflow_needed());
}
}

View File

@@ -3,6 +3,7 @@ use std::future::Future;
use std::io::IsTerminal;
use std::io::Result;
use std::io::Stdout;
use std::io::Write;
use std::io::stdin;
use std::io::stdout;
use std::panic;
@@ -31,6 +32,7 @@ use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
use ratatui::layout::Offset;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::text::Line;
@@ -40,6 +42,7 @@ use tokio_stream::Stream;
pub use self::frame_requester::FrameRequester;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::InsertHistoryMode;
use crate::notifications::DesktopNotificationBackend;
use crate::notifications::detect_backend;
use crate::tui::event_stream::EventBroker;
@@ -57,112 +60,13 @@ mod job_control;
/// Target frame interval for UI redraw scheduling.
pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL;
const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT";
/// A type alias for the terminal type used in this application
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
fn keyboard_enhancement_disabled() -> bool {
let disable_env = std::env::var(DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR).ok();
let is_wsl = running_in_wsl();
let is_vscode_terminal = is_wsl && running_in_vscode_terminal();
keyboard_enhancement_disabled_for(disable_env.as_deref(), is_wsl, is_vscode_terminal)
}
fn keyboard_enhancement_disabled_for(
disable_env: Option<&str>,
is_wsl: bool,
is_vscode_terminal: bool,
) -> bool {
if let Some(disabled) = parse_bool_env(disable_env) {
return disabled;
}
// VS Code running a WSL shell can hide TERM_PROGRAM from the Linux process
// environment, so `running_in_vscode_terminal` also probes the Windows-side
// environment through WSL interop.
is_wsl && is_vscode_terminal
}
fn parse_bool_env(value: Option<&str>) -> Option<bool> {
match value.map(str::trim) {
Some("1") => Some(true),
Some(value) if value.eq_ignore_ascii_case("true") => Some(true),
Some(value) if value.eq_ignore_ascii_case("yes") => Some(true),
Some("0") => Some(false),
Some(value) if value.eq_ignore_ascii_case("false") => Some(false),
Some(value) if value.eq_ignore_ascii_case("no") => Some(false),
_ => None,
}
}
fn running_in_wsl() -> bool {
#[cfg(target_os = "linux")]
{
crate::clipboard_paste::is_probably_wsl()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
fn running_in_vscode_terminal() -> bool {
vscode_terminal_detected(
std::env::var("TERM_PROGRAM").ok().as_deref(),
windows_term_program().as_deref(),
)
}
fn vscode_terminal_detected(
linux_term_program: Option<&str>,
windows_term_program: Option<&str>,
) -> bool {
term_program_is_vscode(linux_term_program) || term_program_is_vscode(windows_term_program)
}
fn term_program_is_vscode(value: Option<&str>) -> bool {
value.is_some_and(|value| value.eq_ignore_ascii_case("vscode"))
}
fn windows_term_program() -> Option<String> {
#[cfg(target_os = "linux")]
{
static WINDOWS_TERM_PROGRAM: std::sync::OnceLock<Option<String>> =
std::sync::OnceLock::new();
WINDOWS_TERM_PROGRAM
.get_or_init(read_windows_term_program)
.clone()
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(target_os = "linux")]
fn read_windows_term_program() -> Option<String> {
let output = std::process::Command::new("cmd.exe")
.args(["/d", "/s", "/c", "set TERM_PROGRAM"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.find_map(|line| {
line.trim_end_matches('\r')
.strip_prefix("TERM_PROGRAM=")
.map(str::to_string)
})
.filter(|value| !value.trim().is_empty())
struct PendingHistoryInsert {
lines: Vec<Line<'static>>,
mode: InsertHistoryMode,
}
fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool {
@@ -174,11 +78,120 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused:
#[cfg(test)]
mod tests {
use super::keyboard_enhancement_disabled_for;
use super::parse_bool_env;
use std::io;
use std::io::Write;
use std::ops::Range;
use super::Tui;
use super::should_emit_notification;
use super::vscode_terminal_detected;
use crate::custom_terminal::Terminal as CustomTerminal;
use codex_config::types::NotificationCondition;
use pretty_assertions::assert_eq;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::WindowSize;
use ratatui::buffer::Cell;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::text::Line;
#[derive(Debug)]
struct RecordingBackend {
size: Size,
cursor_position: Position,
clear_positions: Vec<Position>,
scrolls_up: Vec<(Range<u16>, u16)>,
}
impl RecordingBackend {
fn new(width: u16, height: u16) -> Self {
Self {
size: Size::new(width, height),
cursor_position: Position { x: 0, y: 0 },
clear_positions: Vec::new(),
scrolls_up: Vec::new(),
}
}
fn set_size(&mut self, width: u16, height: u16) {
self.size = Size::new(width, height);
}
fn set_cursor_position(&mut self, x: u16, y: u16) {
self.cursor_position = Position { x, y };
}
}
impl Write for RecordingBackend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Backend for RecordingBackend {
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(self.cursor_position)
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
self.cursor_position = position.into();
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
Ok(())
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
assert_eq!(clear_type, ClearType::AfterCursor);
self.clear_positions.push(self.cursor_position);
Ok(())
}
fn size(&self) -> io::Result<Size> {
Ok(self.size)
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: self.size,
pixels: Size::new(/*width*/ 640, /*height*/ 480),
})
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn scroll_region_up(&mut self, region: Range<u16>, scroll_by: u16) -> io::Result<()> {
self.scrolls_up.push((region, scroll_by));
Ok(())
}
fn scroll_region_down(&mut self, _region: Range<u16>, _scroll_by: u16) -> io::Result<()> {
Ok(())
}
}
#[test]
fn unfocused_notification_condition_is_suppressed_when_focused() {
@@ -205,65 +218,331 @@ mod tests {
}
#[test]
fn keyboard_enhancement_env_flag_parses_common_values() {
assert_eq!(parse_bool_env(Some("1")), Some(true));
assert_eq!(parse_bool_env(Some("true")), Some(true));
assert_eq!(parse_bool_env(Some("YES")), Some(true));
assert_eq!(parse_bool_env(Some("0")), Some(false));
assert_eq!(parse_bool_env(Some("false")), Some(false));
assert_eq!(parse_bool_env(Some("NO")), Some(false));
assert_eq!(parse_bool_env(Some("unexpected")), None);
assert_eq!(parse_bool_env(/*value*/ None), None);
fn height_shrink_reanchors_inline_viewport_without_scrolling_visible_rows() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ true,
)
.expect("update viewport");
assert!(needs_full_repaint);
assert_eq!(terminal.viewport_area, Rect::new(0, 3, 80, 3));
assert!(terminal.backend().scrolls_up.is_empty());
assert_eq!(
terminal.backend().clear_positions,
vec![Position { x: 0, y: 3 }]
);
}
#[test]
fn keyboard_enhancement_auto_disables_for_vscode_in_wsl() {
assert!(keyboard_enhancement_disabled_for(
/*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ true
fn legacy_height_shrink_scrolls_visible_rows() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ false,
)
.expect("update viewport");
assert!(!needs_full_repaint);
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 3
)
);
assert_eq!(terminal.backend().scrolls_up, vec![(0..7, 4)]);
assert_eq!(
terminal.backend().clear_positions,
vec![Position { x: 0, y: 7 }]
);
}
#[test]
fn keyboard_enhancement_auto_disable_requires_wsl_and_vscode() {
assert!(!keyboard_enhancement_disabled_for(
/*disable_env*/ None, /*is_wsl*/ true, /*is_vscode_terminal*/ false
));
assert!(!keyboard_enhancement_disabled_for(
/*disable_env*/ None, /*is_wsl*/ false, /*is_vscode_terminal*/ true
fn height_shrink_ignores_cursor_offset_heuristic_before_resize_event() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
terminal.last_known_cursor_pos = Position { x: 10, y: 9 };
terminal
.backend_mut()
.set_cursor_position(/*x*/ 10, /*y*/ 1);
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
assert_eq!(
Tui::pending_viewport_area_for_terminal(
&mut terminal,
/*terminal_resize_reflow_enabled*/ true
)
.expect("pending viewport"),
None
);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ true,
)
.expect("update viewport");
assert!(needs_full_repaint);
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 3
)
);
crate::insert_history::insert_history_lines(&mut terminal, vec![Line::from("history")])
.expect("insert history");
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 3
)
);
}
#[test]
fn keyboard_enhancement_env_flag_overrides_auto_detection() {
assert!(!keyboard_enhancement_disabled_for(
Some("0"),
/*is_wsl*/ true,
/*is_vscode_terminal*/ true
));
assert!(keyboard_enhancement_disabled_for(
Some("1"),
/*is_wsl*/ false,
/*is_vscode_terminal*/ false
fn legacy_height_resize_preserves_cursor_offset_heuristic() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
terminal.last_known_cursor_pos = Position { x: 10, y: 9 };
terminal
.backend_mut()
.set_cursor_position(/*x*/ 10, /*y*/ 1);
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
assert_eq!(
Tui::pending_viewport_area_for_terminal(
&mut terminal,
/*terminal_resize_reflow_enabled*/ false
)
.expect("pending viewport"),
Some(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3,
))
);
}
#[test]
fn vscode_terminal_detection_uses_linux_and_windows_term_program() {
assert!(vscode_terminal_detected(
Some("vscode"),
/*windows_term_program*/ None
fn height_shrink_preserves_floating_inline_viewport_when_it_still_fits() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3,
));
assert!(vscode_terminal_detected(
/*linux_term_program*/ None,
Some("vscode")
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ true,
)
.expect("update viewport");
assert!(!needs_full_repaint);
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3
)
);
assert!(terminal.backend().scrolls_up.is_empty());
assert!(terminal.backend().clear_positions.is_empty());
}
#[test]
fn resize_reflow_reinserts_history_without_moving_viewport() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 2, /*width*/ 80, /*height*/ 3,
));
assert!(!vscode_terminal_detected(
/*linux_term_program*/ None,
Some("WindowsTerminal")
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 6);
crate::insert_history::insert_history_lines_with_mode(
&mut terminal,
vec![Line::from("reflowed history")],
crate::insert_history::InsertHistoryMode::StandardPreserveViewport,
)
.expect("insert history");
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 2, /*width*/ 80, /*height*/ 3
)
);
}
#[test]
fn resize_reflow_width_only_resize_ignores_cursor_offset_heuristic() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
assert!(!vscode_terminal_detected(
/*linux_term_program*/ None, /*windows_term_program*/ None
terminal.last_known_cursor_pos = Position { x: 10, y: 9 };
terminal
.backend_mut()
.set_cursor_position(/*x*/ 10, /*y*/ 8);
terminal.backend_mut().set_size(/*width*/ 60, /*height*/ 10);
assert_eq!(
Tui::pending_viewport_area_for_terminal(
&mut terminal,
/*terminal_resize_reflow_enabled*/ true
)
.expect("pending viewport"),
None
);
}
#[test]
fn legacy_width_only_resize_preserves_cursor_offset_heuristic() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 7, /*width*/ 80, /*height*/ 3,
));
terminal.last_known_cursor_pos = Position { x: 10, y: 9 };
terminal
.backend_mut()
.set_cursor_position(/*x*/ 10, /*y*/ 8);
terminal.backend_mut().set_size(/*width*/ 60, /*height*/ 10);
assert_eq!(
Tui::pending_viewport_area_for_terminal(
&mut terminal,
/*terminal_resize_reflow_enabled*/ false
)
.expect("pending viewport"),
Some(Rect::new(
/*x*/ 0, /*y*/ 6, /*width*/ 80, /*height*/ 3,
))
);
}
#[test]
fn stable_height_viewport_growth_still_scrolls_history_above_viewport() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 10,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(0, 7, 80, 3));
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 5,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ true,
)
.expect("update viewport");
assert!(needs_full_repaint);
assert_eq!(terminal.viewport_area, Rect::new(0, 5, 80, 5));
assert_eq!(terminal.backend().scrolls_up, vec![(0..7, 2)]);
assert_eq!(
terminal.backend().clear_positions,
vec![Position { x: 0, y: 5 }]
);
}
#[test]
fn height_growth_reanchors_bottom_aligned_inline_viewport() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 6,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(0, 3, 80, 3));
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 10);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ true,
)
.expect("update viewport");
assert!(needs_full_repaint);
assert_eq!(terminal.viewport_area, Rect::new(0, 7, 80, 3));
assert!(terminal.backend().scrolls_up.is_empty());
assert_eq!(
terminal.backend().clear_positions,
vec![Position { x: 0, y: 3 }]
);
}
#[test]
fn legacy_height_growth_keeps_existing_inline_viewport_position() {
let mut terminal = CustomTerminal::with_options(RecordingBackend::new(
/*width*/ 80, /*height*/ 6,
))
.expect("terminal");
terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 3,
));
terminal.backend_mut().set_size(/*width*/ 80, /*height*/ 10);
let needs_full_repaint = Tui::update_inline_viewport(
&mut terminal,
/*height*/ 3,
/*is_zellij*/ false,
/*terminal_resize_reflow_enabled*/ false,
)
.expect("update viewport");
assert!(!needs_full_repaint);
assert_eq!(
terminal.viewport_area,
Rect::new(
/*x*/ 0, /*y*/ 3, /*width*/ 80, /*height*/ 3
)
);
assert!(terminal.backend().scrolls_up.is_empty());
assert!(terminal.backend().clear_positions.is_empty());
}
}
@@ -277,16 +556,14 @@ pub fn set_modes() -> Result<()> {
// Some terminals (notably legacy Windows consoles) do not support
// keyboard enhancement flags. Attempt to enable them, but continue
// gracefully if unsupported.
if !keyboard_enhancement_disabled() {
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
}
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let _ = execute!(stdout(), EnableFocusChange);
Ok(())
@@ -445,6 +722,7 @@ fn set_panic_hook() {
pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Resize,
Draw,
}
@@ -453,7 +731,7 @@ pub struct Tui {
draw_tx: broadcast::Sender<()>,
event_broker: Arc<EventBroker>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<Line<'static>>,
pending_history_inserts: Vec<PendingHistoryInsert>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
suspend_context: SuspendContext,
@@ -465,6 +743,7 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
notification_condition: NotificationCondition,
is_zellij: bool,
terminal_resize_reflow_enabled: bool,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
@@ -476,8 +755,7 @@ impl Tui {
// Detect keyboard enhancement support before any EventStream is created so the
// crossterm poller can acquire its lock without contention.
let enhanced_keys_supported =
!keyboard_enhancement_disabled() && supports_keyboard_enhancement().unwrap_or(false);
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
// Cache this to avoid contention with the event reader.
supports_color::on_cached(supports_color::Stream::Stdout);
let _ = crate::terminal_palette::default_colors();
@@ -491,7 +769,7 @@ impl Tui {
draw_tx,
event_broker: Arc::new(EventBroker::new()),
terminal,
pending_history_lines: vec![],
pending_history_inserts: vec![],
alt_saved_viewport: None,
#[cfg(unix)]
suspend_context: SuspendContext::new(),
@@ -501,6 +779,7 @@ impl Tui {
notification_backend: Some(detect_backend(NotificationMethod::default())),
notification_condition: NotificationCondition::default(),
is_zellij,
terminal_resize_reflow_enabled: false,
alt_screen_enabled: true,
}
}
@@ -523,6 +802,10 @@ impl Tui {
self.frame_requester.clone()
}
pub(crate) fn set_terminal_resize_reflow_enabled(&mut self, enabled: bool) {
self.terminal_resize_reflow_enabled = enabled;
}
pub fn enhanced_keys_supported(&self) -> bool {
self.enhanced_keys_supported
}
@@ -666,45 +949,90 @@ impl Tui {
}
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.pending_history_lines.extend(lines);
self.queue_history_lines(lines, InsertHistoryMode::new(self.is_zellij));
}
pub(crate) fn insert_reflowed_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.queue_history_lines(
lines,
InsertHistoryMode::new_preserving_viewport(self.is_zellij),
);
}
fn queue_history_lines(&mut self, lines: Vec<Line<'static>>, mode: InsertHistoryMode) {
if lines.is_empty() {
return;
}
if let Some(last) = self.pending_history_inserts.last_mut()
&& last.mode == mode
{
last.lines.extend(lines);
self.frame_requester().schedule_frame();
return;
}
self.pending_history_inserts
.push(PendingHistoryInsert { lines, mode });
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();
self.pending_history_inserts.clear();
}
/// Resize the inline viewport to `height` rows, scrolling content above it if
/// the viewport would extend past the bottom of the screen. Returns `true` when
/// the caller must invalidate the diff buffer (Zellij mode), because the scroll
/// was performed with raw newlines that ratatui cannot track.
fn update_inline_viewport(
terminal: &mut Terminal,
fn update_inline_viewport<B>(
terminal: &mut CustomTerminal<B>,
height: u16,
is_zellij: bool,
) -> Result<bool> {
terminal_resize_reflow_enabled: bool,
) -> Result<bool>
where
B: Backend + Write,
{
let size = terminal.size()?;
let mut needs_full_repaint = false;
let terminal_height_shrank = size.height < terminal.last_known_screen_size.height;
let terminal_height_grew = size.height > terminal.last_known_screen_size.height;
let viewport_was_bottom_aligned =
terminal.viewport_area.bottom() == terminal.last_known_screen_size.height;
let previous_area = terminal.viewport_area;
let mut area = terminal.viewport_area;
area.height = height.min(size.height);
area.width = size.width;
if area.bottom() > size.height {
let scroll_by = area.bottom() - size.height;
if is_zellij {
Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?;
needs_full_repaint = true;
} else {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), scroll_by)?;
if !terminal_resize_reflow_enabled || !terminal_height_shrank {
if is_zellij {
Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?;
needs_full_repaint = true;
} else {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), scroll_by)?;
}
}
area.y = size.height - area.height;
} else if terminal_resize_reflow_enabled
&& terminal_height_grew
&& viewport_was_bottom_aligned
{
area.y = size.height - area.height;
}
if area != terminal.viewport_area {
// TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above.
terminal.clear()?;
terminal.set_viewport_area(area);
if terminal_resize_reflow_enabled {
let clear_position = Position::new(/*x*/ 0, previous_area.y.min(area.y));
terminal.set_viewport_area(area);
terminal.clear_after_position(clear_position)?;
needs_full_repaint = true;
} else {
terminal.clear()?;
terminal.set_viewport_area(area);
}
}
Ok(needs_full_repaint)
@@ -714,11 +1042,14 @@ impl Tui {
/// newlines at the screen bottom. This is the Zellij-safe alternative to
/// `scroll_region_up`, which relies on DECSTBM sequences Zellij does not
/// support.
fn scroll_zellij_expanded_viewport(
terminal: &mut Terminal,
fn scroll_zellij_expanded_viewport<B>(
terminal: &mut CustomTerminal<B>,
size: Size,
scroll_by: u16,
) -> Result<()> {
) -> Result<()>
where
B: Backend + Write,
{
crossterm::queue!(
terminal.backend_mut(),
crossterm::cursor::MoveTo(0, size.height.saturating_sub(1))
@@ -734,20 +1065,25 @@ impl Tui {
/// invalidate the diff buffer for a full repaint.
fn flush_pending_history_lines(
terminal: &mut Terminal,
pending_history_lines: &mut Vec<Line<'static>>,
is_zellij: bool,
pending_history_inserts: &mut Vec<PendingHistoryInsert>,
) -> Result<bool> {
if pending_history_lines.is_empty() {
if pending_history_inserts.is_empty() {
return Ok(false);
}
crate::insert_history::insert_history_lines_with_mode(
terminal,
pending_history_lines.clone(),
crate::insert_history::InsertHistoryMode::new(is_zellij),
)?;
pending_history_lines.clear();
Ok(is_zellij)
let mut needs_full_repaint = false;
for insert in pending_history_inserts.drain(..) {
crate::insert_history::insert_history_lines_with_mode(
terminal,
insert.lines,
insert.mode,
)?;
// Preserve-mode replays intentionally mutate terminal rows outside ratatui's normal
// diff path. Repaint the viewport afterward so composer/status rows cannot stay stale
// if the terminal scrolled or cleared adjacent rows during the replay.
needs_full_repaint |= insert.mode.uses_zellij() || insert.mode.preserves_viewport();
}
Ok(needs_full_repaint)
}
pub fn draw(
@@ -763,7 +1099,9 @@ impl Tui {
.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.
// 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 = self.pending_viewport_area()?;
stdout().sync_update(|_| {
@@ -778,15 +1116,17 @@ impl Tui {
terminal.clear()?;
}
let mut needs_full_repaint =
Self::update_inline_viewport(terminal, height, self.is_zellij)?;
needs_full_repaint |= Self::flush_pending_history_lines(
let mut needs_full_repaint = Self::update_inline_viewport(
terminal,
&mut self.pending_history_lines,
height,
self.is_zellij,
self.terminal_resize_reflow_enabled,
)?;
needs_full_repaint |=
Self::flush_pending_history_lines(terminal, &mut self.pending_history_inserts)?;
if needs_full_repaint {
terminal.clear()?;
terminal.invalidate_viewport();
}
@@ -811,16 +1151,30 @@ impl Tui {
}
fn pending_viewport_area(&mut self) -> Result<Option<Rect>> {
let terminal = &mut self.terminal;
Self::pending_viewport_area_for_terminal(
&mut self.terminal,
self.terminal_resize_reflow_enabled,
)
}
fn pending_viewport_area_for_terminal<B>(
terminal: &mut CustomTerminal<B>,
terminal_resize_reflow_enabled: bool,
) -> Result<Option<Rect>>
where
B: Backend + Write,
{
let screen_size = terminal.size()?;
let last_known_screen_size = terminal.last_known_screen_size;
if screen_size != last_known_screen_size
&& let Ok(cursor_pos) = terminal.get_cursor_position()
{
let width_changed = screen_size.width != last_known_screen_size.width;
let height_changed = screen_size.height != last_known_screen_size.height;
let should_apply_cursor_heuristic =
!terminal_resize_reflow_enabled && (width_changed || height_changed);
if should_apply_cursor_heuristic && let Ok(cursor_pos) = terminal.get_cursor_position() {
let last_known_cursor_pos = terminal.last_known_cursor_pos;
// If we resized AND the cursor moved, we adjust the viewport area to keep the
// cursor in the same position. This is a heuristic that seems to work well
// at least in iTerm2.
// The legacy path uses terminal cursor drift as a viewport hint. Resize reflow owns the
// transcript anchor instead, because native terminal rewrap can move the cursor without
// meaning Codex's inline viewport should move.
if cursor_pos.y != last_known_cursor_pos.y {
let offset = Offset {
x: 0,

View File

@@ -244,7 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Resize(_, _) => Some(TuiEvent::Resize),
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
self.terminal_focused.store(true, Ordering::Relaxed);
@@ -451,6 +451,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();

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

@@ -0,0 +1,72 @@
//! 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(/*total_width*/ 0, /*reserved_cols*/ 0),
None
);
assert_eq!(
usable_content_width(/*total_width*/ 2, /*reserved_cols*/ 2),
None
);
assert_eq!(
usable_content_width(/*total_width*/ 3, /*reserved_cols*/ 4),
None
);
assert_eq!(
usable_content_width(/*total_width*/ 5, /*reserved_cols*/ 4),
Some(1)
);
}
#[test]
fn usable_content_width_u16_matches_usize_variant() {
assert_eq!(
usable_content_width_u16(/*total_width*/ 2, /*reserved_cols*/ 2),
None
);
assert_eq!(
usable_content_width_u16(/*total_width*/ 5, /*reserved_cols*/ 4),
Some(1)
);
}
}

View File

@@ -1,6 +1,7 @@
// Aggregates all former standalone integration tests as modules.
mod model_availability_nux;
mod no_panic_on_startup;
mod resize_reflow_smoke;
mod status_indicator;
mod vt100_history;
mod vt100_live_commit;

View File

@@ -0,0 +1,613 @@
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::thread::sleep;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use tempfile::tempdir;
#[test]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let fixture_dir = tempdir()?;
let fixture = fixture_dir.path().join("resize-reflow.sse");
write_fixture(&fixture)?;
write_config(
codex_home.path(),
&repo_root,
/*terminal_resize_reflow_enabled*/ true,
)?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Say hi.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display()))
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-v")
.arg("-l")
.arg("12")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 250));
let first_capture = capture_pane(&codex_pane)?;
let first_row = last_composer_row(&first_capture).context("composer row after split")?;
sleep(Duration::from_millis(/*millis*/ 1_000));
let second_capture = capture_pane(&codex_pane)?;
let second_row =
last_composer_row(&second_capture).context("composer row after reflow wait")?;
anyhow::ensure!(
first_row == second_row,
"composer row drifted after split: before={first_row}, after={second_row}\n\
before:\n{first_capture}\n\
after:\n{second_capture}"
);
anyhow::ensure!(
second_row <= baseline_row + 1,
"composer row snapped downward after split: baseline={baseline_row}, after={second_row}\n\
baseline:\n{baseline_capture}\n\
after:\n{second_capture}"
);
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 500));
let final_capture = capture_pane(&codex_pane)?;
let final_row =
last_composer_row(&final_capture).context("composer row after closing split")?;
anyhow::ensure!(
final_row == baseline_row,
"composer row drifted after closing split: baseline={baseline_row}, after={final_row}\n\
capture:\n{final_capture}"
);
let final_history_row = first_row_containing(&final_capture, "resize reflow sentinel")
.context("history row after closing split")?;
anyhow::ensure!(
final_history_row == baseline_history_row,
"history row drifted after closing split: baseline={baseline_history_row}, \
after={final_history_row}\n\
baseline:\n{baseline_capture}\n\
after:\n{final_capture}"
);
Ok(())
}
#[test]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ false)?;
run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ true)?;
Ok(())
}
#[test]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let fixture_dir = tempdir()?;
let fixture = fixture_dir.path().join("resize-reflow.sse");
write_fixture(&fixture)?;
write_config(
codex_home.path(),
&repo_root,
/*terminal_resize_reflow_enabled*/ true,
)?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-width-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Send me a large paragraph of text for testing.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display()))
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-h")
.arg("-l")
.arg("40")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 750));
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 1_000));
let restored_capture = capture_pane(&codex_pane)?;
let restored_row =
last_composer_row(&restored_capture).context("composer row after width restore")?;
let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel")
.context("history row after width restore")?;
anyhow::ensure!(
restored_row == baseline_row,
"composer row drifted after width restore: baseline={baseline_row}, \
restored={restored_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
anyhow::ensure!(
restored_history_row == baseline_history_row,
"history row drifted after width restore: baseline={baseline_history_row}, \
restored={restored_history_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
Ok(())
}
fn run_repeated_resize_smoke(terminal_resize_reflow_enabled: bool) -> Result<()> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let fixture_dir = tempdir()?;
let fixture = fixture_dir.path().join("resize-reflow.sse");
write_fixture(&fixture)?;
write_config(
codex_home.path(),
&repo_root,
terminal_resize_reflow_enabled,
)?;
write_auth(codex_home.path())?;
let suffix = if terminal_resize_reflow_enabled {
"enabled"
} else {
"disabled"
};
let session_name = format!("codex-resize-repeat-{suffix}-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Send me a large paragraph of text for testing.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display()))
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
for cycle in 1..=3 {
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-v")
.arg("-l")
.arg("12")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 250));
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 500));
let restored_capture = capture_pane(&codex_pane)?;
let restored_row = last_composer_row(&restored_capture)
.with_context(|| format!("composer row after resize cycle {cycle}"))?;
let restored_history_row =
first_row_containing(&restored_capture, "resize reflow sentinel")
.with_context(|| format!("history row after resize cycle {cycle}"))?;
if terminal_resize_reflow_enabled {
anyhow::ensure!(
restored_row == baseline_row,
"composer row drifted after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \
baseline={baseline_row}, restored={restored_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
anyhow::ensure!(
restored_history_row == baseline_history_row,
"history row drifted after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \
baseline={baseline_history_row}, restored={restored_history_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
} else {
anyhow::ensure!(
restored_row <= baseline_row + 1,
"composer row snapped downward after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \
baseline={baseline_row}, restored={restored_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
}
}
Ok(())
}
struct TmuxSession {
name: String,
}
impl Drop for TmuxSession {
fn drop(&mut self) {
let _ = Command::new("tmux")
.arg("kill-session")
.arg("-t")
.arg(&self.name)
.output();
}
}
fn codex_binary(repo_root: &Path) -> Result<PathBuf> {
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
return Ok(path);
}
let fallback = repo_root.join("codex-rs/target/debug/codex");
anyhow::ensure!(
fallback.is_file(),
"codex binary is unavailable; run `cargo build -p codex-cli` first"
);
Ok(fallback)
}
fn write_config(
codex_home: &Path,
repo_root: &Path,
terminal_resize_reflow_enabled: bool,
) -> Result<()> {
let repo_root_display = repo_root.display();
let config = format!(
r#"model = "gpt-5.4"
model_provider = "openai"
suppress_unstable_features_warning = true
[features]
terminal_resize_reflow = {terminal_resize_reflow_enabled}
[projects."{repo_root_display}"]
trust_level = "trusted"
"#
);
std::fs::write(codex_home.join("config.toml"), config)?;
Ok(())
}
fn write_auth(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join("auth.json"),
r#"{"OPENAI_API_KEY":"dummy","tokens":null,"last_refresh":null}"#,
)?;
Ok(())
}
fn write_fixture(path: &Path) -> Result<()> {
let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height.";
let created = serde_json::json!({
"type": "response.created",
"response": { "id": "resp-resize-smoke" },
});
let done = serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [
{ "type": "output_text", "text": text }
],
},
});
let completed = serde_json::json!({
"type": "response.completed",
"response": { "id": "resp-resize-smoke", "output": [] },
});
let fixture = format!(
"event: response.created\ndata: {created}\n\n\
event: response.output_item.done\ndata: {done}\n\n\
event: response.completed\ndata: {completed}\n\n"
);
std::fs::write(path, fixture)?;
Ok(())
}
fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result<String> {
let deadline = Instant::now() + timeout;
let mut last_capture = String::new();
while Instant::now() < deadline {
last_capture = capture_pane(pane)?;
if last_capture.contains(needle) {
return Ok(last_capture);
}
sleep(Duration::from_millis(/*millis*/ 100));
}
anyhow::bail!("timed out waiting for {needle:?}; last capture:\n{last_capture}");
}
fn capture_pane(pane: &str) -> Result<String> {
let output = output(
Command::new("tmux")
.arg("capture-pane")
.arg("-p")
.arg("-t")
.arg(pane),
)?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn last_composer_row(capture: &str) -> Option<usize> {
capture
.lines()
.enumerate()
.filter_map(|(index, line)| {
if line.trim_start().starts_with('\u{203a}') {
Some(index)
} else {
None
}
})
.last()
}
fn first_row_containing(capture: &str, needle: &str) -> Option<usize> {
capture
.lines()
.enumerate()
.find_map(|(index, line)| line.contains(needle).then_some(index))
}
fn check(command: &mut Command) -> Result<()> {
checked_output(command)?;
Ok(())
}
fn checked_output(command: &mut Command) -> Result<Output> {
let output = output(command)?;
anyhow::ensure!(
output.status.success(),
"command failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(output)
}
fn output(command: &mut Command) -> Result<Output> {
command
.output()
.with_context(|| format!("failed to run {command:?}"))
}
fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}