wip(tui): preserve resize reflow replay tail

This commit is contained in:
Felipe Coury
2026-04-23 20:19:18 -03:00
parent 29e6d132d8
commit fe68d8a5d2
4 changed files with 140 additions and 2 deletions

View File

@@ -285,6 +285,21 @@ impl ChatWidget {
self.open_memories_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
let command = if matches!(cmd, SlashCommand::Quit) {
"/quit"
} else {
"/exit"
};
self.add_to_history(history_cell::new_user_prompt(
command.to_string(),
/*text_elements*/ Vec::new(),
/*local_image_paths*/ Vec::new(),
/*remote_image_urls*/ Vec::new(),
));
self.add_to_history(history_cell::new_info_event(
"Exiting.".to_string(),
/*hint*/ None,
));
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {

View File

@@ -784,7 +784,28 @@ async fn slash_quit_requests_exit() {
chat.dispatch_command(SlashCommand::Quit);
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
let mut rendered = String::new();
let mut saw_exit = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
rendered.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80)));
}
AppEvent::Exit(ExitMode::ShutdownFirst) => {
saw_exit = true;
}
event => panic!("unexpected event: {event:?}"),
}
}
assert!(
rendered.contains("/quit"),
"expected quit command in history, got: {rendered:?}"
);
assert!(
rendered.contains("Exiting."),
"expected exiting notice in history, got: {rendered:?}"
);
assert!(saw_exit, "expected shutdown-first exit event");
}
#[tokio::test]

View File

@@ -165,6 +165,21 @@ where
}
write_history_line(writer, line, wrap_width)?;
}
if matches!(mode, InsertHistoryMode::FullScreenReplayPrefill) {
let reserve_rows = wrapped_lines
.min(screen_size.height)
.saturating_sub(area.top());
if reserve_rows > 0 {
queue!(
writer,
MoveTo(/*x*/ 0, screen_size.height.saturating_sub(1))
)?;
for _ in 0..reserve_rows {
queue!(writer, Print("\n"))?;
}
}
}
}
InsertHistoryMode::FullScreenReplayDirect => {
// Rebuild scrollback with transcript content itself. Pre-scrolling with blank
@@ -897,4 +912,42 @@ mod tests {
assert_eq!(term.viewport_area, viewport);
assert_eq!(term.visible_history_rows(), viewport.top());
}
#[test]
fn vt100_full_screen_replay_reserves_viewport_rows_for_tui() {
let width: u16 = 32;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(/*x*/ 0, /*y*/ 4, width, /*height*/ 3);
term.set_viewport_area(viewport);
let lines: Vec<Line<'static>> = (0..6)
.map(|idx| Line::from(format!("reflow line {idx:02}")))
.collect();
insert_history_lines_with_mode(
&mut term,
lines,
InsertHistoryMode::FullScreenReplayPrefill,
)
.expect("insert reflow history");
let rows: Vec<String> = term
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
assert!(
rows[viewport.top() as usize - 1].contains("reflow line 05"),
"expected replayed tail immediately above viewport, rows: {rows:?}"
);
assert!(
rows[viewport.top() as usize..]
.iter()
.all(|row| !row.contains("reflow line")),
"expected replay not to occupy reserved viewport rows, rows: {rows:?}"
);
assert_eq!(term.viewport_area, viewport);
}
}

View File

@@ -175,8 +175,10 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused:
#[cfg(test)]
mod tests {
use super::ResizeReflowDrawRefresh;
use super::keyboard_enhancement_disabled_for;
use super::parse_bool_env;
use super::resize_reflow_draw_refresh;
use super::should_emit_notification;
use super::vscode_terminal_detected;
use codex_config::types::NotificationCondition;
@@ -266,6 +268,32 @@ mod tests {
/*linux_term_program*/ None, /*windows_term_program*/ None
));
}
#[test]
fn resize_reflow_replay_invalidates_without_clearing_after_insert() {
assert_eq!(
resize_reflow_draw_refresh(
/*needs_full_repaint*/ true, /*flushed_reflow_history*/ true
),
ResizeReflowDrawRefresh {
clear_after_history_flush: false,
invalidate_viewport: true,
}
);
}
#[test]
fn resize_reflow_draw_refresh_preserves_legacy_clear_without_replay() {
assert_eq!(
resize_reflow_draw_refresh(
/*needs_full_repaint*/ true, /*flushed_reflow_history*/ false
),
ResizeReflowDrawRefresh {
clear_after_history_flush: true,
invalidate_viewport: true,
}
);
}
}
pub fn set_modes() -> Result<()> {
@@ -481,6 +509,24 @@ pub struct Tui {
alt_screen_enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ResizeReflowDrawRefresh {
clear_after_history_flush: bool,
invalidate_viewport: bool,
}
fn resize_reflow_draw_refresh(
needs_full_repaint: bool,
flushed_reflow_history: bool,
) -> ResizeReflowDrawRefresh {
ResizeReflowDrawRefresh {
// Resize reflow clears the terminal before queueing rebuilt rows. Clearing again after
// replay can erase the source-backed tail rows that were just inserted.
clear_after_history_flush: needs_full_repaint && !flushed_reflow_history,
invalidate_viewport: needs_full_repaint,
}
}
impl Tui {
pub fn new(terminal: Terminal) -> Self {
let (draw_tx, _) = broadcast::channel(1);
@@ -937,8 +983,11 @@ impl Tui {
)?;
needs_full_repaint |= flushed_reflow_history;
if needs_full_repaint {
let refresh = resize_reflow_draw_refresh(needs_full_repaint, flushed_reflow_history);
if refresh.clear_after_history_flush {
terminal.clear()?;
}
if refresh.invalidate_viewport {
terminal.invalidate_viewport();
}