mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
Compare commits
1 Commits
starr/skil
...
fcoury/res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71aa4aba3 |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
325
codex-rs/tui/src/app/resize_reflow.rs
Normal file
325
codex-rs/tui/src/app/resize_reflow.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
})?;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -566,6 +566,49 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a range of committed cells with a single consolidated cell.
|
||||
///
|
||||
/// Mirrors the splice performed on `App::transcript_cells` during
|
||||
/// `ConsolidateAgentMessage` so the Ctrl+T overlay stays in sync with the
|
||||
/// main transcript. The range is clamped defensively: cells may have been
|
||||
/// inserted after the overlay opened, leaving it with fewer entries than
|
||||
/// the main transcript.
|
||||
pub(crate) fn consolidate_cells(
|
||||
&mut self,
|
||||
range: std::ops::Range<usize>,
|
||||
consolidated: Arc<dyn HistoryCell>,
|
||||
) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
// Clamp the range to the overlay's cell count to avoid panic if the overlay has fewer
|
||||
// cells than the main transcript (e.g. cells were inserted after the overlay has opened).
|
||||
let clamped_end = range.end.min(self.cells.len());
|
||||
let clamped_start = range.start.min(clamped_end);
|
||||
if clamped_start < clamped_end {
|
||||
let removed = clamped_end - clamped_start;
|
||||
if let Some(highlight_cell) = self.highlight_cell.as_mut()
|
||||
&& *highlight_cell >= clamped_start
|
||||
{
|
||||
if *highlight_cell < clamped_end {
|
||||
*highlight_cell = clamped_start;
|
||||
} else {
|
||||
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
self.cells
|
||||
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
|
||||
if self
|
||||
.highlight_cell
|
||||
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
|
||||
{
|
||||
self.highlight_cell = None;
|
||||
}
|
||||
self.rebuild_renderables();
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync the active-cell live tail with the current width and cell state.
|
||||
///
|
||||
/// Recomputes the tail only when the cache key changes, preserving scroll
|
||||
@@ -700,7 +743,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::Draw => {
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.render(frame.area(), frame.buffer);
|
||||
})?;
|
||||
@@ -764,7 +807,7 @@ impl StaticOverlay {
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::Draw => {
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.render(frame.area(), frame.buffer);
|
||||
})?;
|
||||
@@ -1090,6 +1133,60 @@ mod tests {
|
||||
assert_eq!(overlay.view.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
(0..6)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(3));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(2),
|
||||
"highlight inside consolidated range should point to replacement cell",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
(0..7)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(6));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(4),
|
||||
"highlight after consolidated range should shift left by removed cells",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_overlay_snapshot_basic() {
|
||||
// Prepare a static overlay with a few lines and a title
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn push_owned_lines<'a>(src: &[Line<'a>], out: &mut Vec<Line<'static>>) {
|
||||
|
||||
/// Consider a line blank if it has no spans or only spans whose contents are
|
||||
/// empty or consist solely of spaces (no tabs/newlines).
|
||||
#[cfg(test)]
|
||||
pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
|
||||
if line.spans.is_empty() {
|
||||
return true;
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
230
codex-rs/tui/src/transcript_reflow.rs
Normal file
230
codex-rs/tui/src/transcript_reflow.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
72
codex-rs/tui/src/width.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
613
codex-rs/tui/tests/suite/resize_reflow_smoke.rs
Normal file
613
codex-rs/tui/tests/suite/resize_reflow_smoke.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user