feat(tui): own alternate screen in always mode

This commit is contained in:
Felipe Coury
2026-07-04 01:50:07 -03:00
parent b537258fee
commit 9f06929e21
19 changed files with 548 additions and 42 deletions
+14
View File
@@ -3,6 +3,7 @@
//! This module owns the `App` struct, shared imports, and the high-level run loop that coordinates
//! the focused app submodules.
use self::owned_screen::OwnedScreen;
use crate::AppServerTarget;
use crate::app_backtrack::BacktrackState;
use crate::app_command::AppCommand;
@@ -33,6 +34,7 @@ use crate::chatwidget::ChatWidget;
use crate::chatwidget::ExternalEditorState;
use crate::chatwidget::ReplayKind;
use crate::chatwidget::ThreadInputState;
use crate::conversation_viewport::ConversationViewport;
use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::split_command_string;
@@ -208,6 +210,7 @@ mod event_dispatch;
mod history_ui;
mod input;
mod loaded_threads;
mod owned_screen;
mod pending_interactive_replay;
mod pets;
mod platform_actions;
@@ -519,6 +522,7 @@ pub(crate) struct App {
pub(crate) file_search: FileSearchManager,
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
owned_screen: Option<OwnedScreen>,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
@@ -758,6 +762,7 @@ impl App {
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
alt_screen_behavior: crate::AltScreenBehavior,
mut app_server: AppServerSession,
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
@@ -1014,6 +1019,11 @@ See the Codex keymap documentation for supported actions and examples."
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let owned_screen = Self::owned_screen_for_behavior(
alt_screen_behavior,
&chat_widget,
runtime_keymap.pager.clone(),
);
let mut app = Self {
model_catalog,
session_telemetry: session_telemetry.clone(),
@@ -1032,6 +1042,7 @@ See the Codex keymap documentation for supported actions and examples."
enhanced_keys_supported,
keymap: runtime_keymap,
transcript_cells: Vec::new(),
owned_screen,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -1347,6 +1358,9 @@ See the Codex keymap documentation for supported actions and examples."
}
fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui) -> Result<Rect> {
if let Some(rendered_area) = self.render_owned_screen_frame(tui)? {
return Ok(rendered_area);
}
let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width);
let mut rendered_area = Rect::default();
tui.draw_with_resize_reflow(desired_height, |frame| {
@@ -37,7 +37,8 @@ impl App {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
}
self.transcript_cells.push(cell);
self.transcript_cells.push(cell.clone());
self.owned_screen_push_cell(cell);
}
// Walk backward to find the contiguous run of streaming AgentMessageCells that
@@ -56,6 +57,7 @@ impl App {
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
self.sync_owned_screen_cells();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
@@ -724,6 +724,7 @@ impl App {
};
self.transcript_cells.remove(index);
self.sync_owned_screen_cells();
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
overlay.replace_cells(self.transcript_cells.clone());
}
+4 -2
View File
@@ -33,7 +33,7 @@ impl App {
}
AppEvent::ClearUi => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
self.reset_app_ui_state_after_clear(tui);
self.start_fresh_session_with_summary_hint(
tui,
@@ -48,7 +48,7 @@ impl App {
}
AppEvent::ClearUiAndSubmitUserMessage { text } => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
self.reset_app_ui_state_after_clear(tui);
self.start_fresh_session_with_summary_hint(
tui,
@@ -259,6 +259,7 @@ impl App {
if start < end {
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
self.sync_owned_screen_cells();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
@@ -268,6 +269,7 @@ impl App {
self.finish_required_stream_reflow(tui)?;
} else {
self.transcript_cells.push(consolidated.clone());
self.owned_screen_push_cell(consolidated.clone());
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(consolidated.clone());
tui.frame_requester().schedule_frame();
+27 -2
View File
@@ -15,6 +15,12 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
self.owned_screen_push_cell(cell.clone());
if self.has_owned_screen() {
self.chat_widget.request_pending_usage_output_insertion();
tui.frame_requester().schedule_frame();
return;
}
if self.initial_history_replay_buffer.as_ref().is_some() {
self.insert_history_cell_lines_with_initial_replay_buffer(
tui,
@@ -96,6 +102,13 @@ impl App {
width: u16,
version: &'static str,
) -> Vec<Line<'static>> {
self.clear_ui_header_cell(version).display_lines(width)
}
fn clear_ui_header_cell(
&self,
version: &'static str,
) -> history_cell::SessionHeaderHistoryCell {
history_cell::SessionHeaderHistoryCell::new(
self.chat_widget.current_model().to_string(),
self.chat_widget.current_reasoning_effort(),
@@ -107,7 +120,6 @@ impl App {
version,
)
.with_yolo_mode(history_cell::is_yolo_mode(&self.config))
.display_lines(width)
}
pub(super) fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
@@ -115,6 +127,13 @@ impl App {
}
pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) {
if self.has_owned_screen() {
let cell: Arc<dyn HistoryCell> = Arc::new(self.clear_ui_header_cell(CODEX_CLI_VERSION));
self.transcript_cells.push(cell.clone());
self.owned_screen_push_cell(cell);
tui.frame_requester().schedule_frame();
return;
}
let width = self
.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width);
@@ -158,13 +177,19 @@ impl App {
Ok(())
}
pub(super) fn reset_app_ui_state_after_clear(&mut self) {
pub(super) fn reset_app_ui_state_after_clear(&mut self, tui: &mut tui::Tui) {
if self.overlay.is_some()
&& let Err(err) = tui.leave_alt_screen()
{
tracing::warn!(error = %err, "failed to release transcript overlay while clearing UI");
}
self.reset_transcript_state_after_clear();
}
pub(super) fn reset_transcript_state_after_clear(&mut self) {
self.overlay = None;
self.transcript_cells.clear();
self.sync_owned_screen_cells();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.transcript_reflow.clear();
+6 -1
View File
@@ -81,6 +81,11 @@ impl App {
} else {
self.chat_widget.set_raw_output_mode(enabled);
}
self.sync_owned_screen_render_mode();
if self.has_owned_screen() {
tui.frame_requester().schedule_frame();
return;
}
if let Err(err) = self.reflow_transcript_now(tui) {
tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle");
self.chat_widget
@@ -218,7 +223,7 @@ impl App {
self.chat_widget
.add_error_message(format!("Failed to clear terminal UI: {err}"));
} else {
self.reset_app_ui_state_after_clear();
self.reset_app_ui_state_after_clear(tui);
self.queue_clear_ui_header(tui);
tui.frame_requester().schedule_frame();
}
+150
View File
@@ -0,0 +1,150 @@
//! Application-owned alternate-screen rendering.
//!
//! The owned mode keeps committed conversation cells in a retained viewport and reserves the
//! bottom of every frame for the composer. Inline mode continues to use terminal scrollback.
use crossterm::cursor::SetCursorStyle;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Clear;
use ratatui::widgets::Widget;
use super::*;
use crate::AltScreenBehavior;
pub(super) struct OwnedScreen {
viewport: ConversationViewport,
}
struct RenderedOwnedScreen {
cursor: Option<(u16, u16)>,
cursor_style: SetCursorStyle,
}
impl OwnedScreen {
fn new(chat_widget: &ChatWidget, keymap: crate::keymap::PagerKeymap) -> Self {
Self {
viewport: ConversationViewport::new(
Vec::new(),
chat_widget.history_render_mode(),
keymap,
),
}
}
fn render(
&mut self,
chat_widget: &ChatWidget,
area: Rect,
buffer: &mut Buffer,
) -> RenderedOwnedScreen {
Clear.render(area, buffer);
let bottom_pane = chat_widget.bottom_pane_renderable();
let bottom_height = bottom_pane.desired_height(area.width).min(area.height);
let conversation_height = area.height.saturating_sub(bottom_height);
let conversation_area = Rect::new(
area.x,
area.y,
chat_widget.history_wrap_width(area.width),
conversation_height,
);
let bottom_area = Rect::new(
area.x,
area.y.saturating_add(conversation_height),
area.width,
bottom_height,
);
self.viewport
.set_render_mode(chat_widget.history_render_mode());
let active_key = chat_widget.active_cell_render_key();
self.viewport
.sync_live_tail(conversation_area.width, active_key, |width| {
chat_widget.active_cell_display_hyperlink_lines(width)
});
self.viewport.render(conversation_area, buffer);
bottom_pane.render(bottom_area, buffer);
RenderedOwnedScreen {
cursor: bottom_pane.cursor_pos(bottom_area),
cursor_style: bottom_pane.cursor_style(bottom_area),
}
}
}
impl App {
pub(super) fn owned_screen_for_behavior(
alt_screen_behavior: AltScreenBehavior,
chat_widget: &ChatWidget,
keymap: crate::keymap::PagerKeymap,
) -> Option<OwnedScreen> {
match alt_screen_behavior {
AltScreenBehavior::Disabled | AltScreenBehavior::OverlayOnly => None,
AltScreenBehavior::Owned => Some(OwnedScreen::new(chat_widget, keymap)),
}
}
pub(super) fn has_owned_screen(&self) -> bool {
self.owned_screen.is_some()
}
pub(super) fn owned_screen_push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
if let Some(screen) = &mut self.owned_screen {
screen.viewport.push_cell(cell);
}
}
pub(crate) fn sync_owned_screen_cells(&mut self) {
if let Some(screen) = &mut self.owned_screen {
screen.viewport.replace_cells(self.transcript_cells.clone());
}
}
pub(super) fn sync_owned_screen_render_mode(&mut self) {
if let Some(screen) = &mut self.owned_screen {
screen
.viewport
.set_render_mode(self.chat_widget.history_render_mode());
}
}
pub(super) fn handle_owned_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<bool> {
if self.owned_screen.is_none() {
return Ok(false);
}
let size = tui.terminal.size()?;
if size.width != tui.terminal.last_known_screen_size.width {
self.chat_widget.on_terminal_resize(size.width);
}
if size != tui.terminal.last_known_screen_size {
self.refresh_status_line();
}
self.transcript_reflow.clear();
tui.clear_pending_history_lines();
Ok(true)
}
pub(super) fn render_owned_screen_frame(&mut self, tui: &mut tui::Tui) -> Result<Option<Rect>> {
let Some(screen) = &mut self.owned_screen else {
return Ok(None);
};
self.chat_widget
.update_owned_screen_width(tui.terminal.size()?.width);
let chat_widget = &self.chat_widget;
let mut rendered_area = Rect::default();
tui.draw(/*height*/ u16::MAX, |frame| {
rendered_area = frame.area();
let rendered = screen.render(chat_widget, rendered_area, frame.buffer);
if let Some((x, y)) = rendered.cursor {
frame.set_cursor_style(rendered.cursor_style);
frame.set_cursor_position((x, y));
}
})?;
Ok(Some(rendered_area))
}
}
#[cfg(test)]
#[path = "owned_screen_tests.rs"]
mod tests;
@@ -0,0 +1,70 @@
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Line;
use super::*;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
#[derive(Debug)]
struct TestCell(&'static str);
impl HistoryCell for TestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![self.0.into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.0.into()]
}
}
#[tokio::test]
async fn renders_committed_conversation_above_fixed_composer() {
let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
chat_widget.set_composer_text("draft sentinel".to_string(), Vec::new(), Vec::new());
let mut screen = OwnedScreen::new(&chat_widget, crate::keymap::RuntimeKeymap::defaults().pager);
screen
.viewport
.push_cell(Arc::new(TestCell("committed response")));
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 50, /*height*/ 10)).expect("create terminal");
terminal
.draw(|frame| {
screen.render(&chat_widget, frame.area(), frame.buffer_mut());
})
.expect("render owned screen");
assert_snapshot!(terminal.backend(), @r###"
"committed response "
" "
" "
" "
" "
" "
" "
"› draft sentinel "
" "
" gpt-5.5 default · /tmp/project "
"###);
}
#[tokio::test]
async fn committed_cell_updates_viewport_without_queuing_terminal_history() {
let mut app = super::super::test_support::make_test_app().await;
app.owned_screen = App::owned_screen_for_behavior(
AltScreenBehavior::Owned,
&app.chat_widget,
app.keymap.pager.clone(),
);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
app.insert_history_cell(&mut tui, Box::new(TestCell("retained")));
let screen = app.owned_screen.as_ref().expect("owned screen");
assert_eq!(screen.viewport.committed_cell_count(), 1);
assert_eq!(app.transcript_cells.len(), 1);
assert!(!app.has_emitted_history_lines);
assert!(!tui.has_pending_history_lines());
}
+44
View File
@@ -94,6 +94,9 @@ impl App {
cell: &dyn HistoryCell,
width: u16,
) {
if self.has_owned_screen() {
return;
}
let display = self.display_lines_for_history_insert(cell, width);
if display.is_empty() {
return;
@@ -115,6 +118,9 @@ impl App {
/// Starting this buffer while an overlay owns rendering would split transcript ownership, so
/// overlay replay continues through the normal deferred-history path.
pub(super) fn begin_initial_history_replay_buffer(&mut self) {
if self.has_owned_screen() {
return;
}
if self.overlay.is_none() {
self.initial_history_replay_buffer = Some(Default::default());
}
@@ -126,6 +132,9 @@ impl App {
/// defer terminal writes until the replay is complete and reuse the resize-reflow tail renderer
/// so only the rows the terminal would retain are formatted and inserted.
pub(super) fn begin_thread_switch_history_replay_buffer(&mut self) {
if self.has_owned_screen() {
return;
}
if self.resize_reflow_max_rows().is_some() && self.overlay.is_none() {
self.initial_history_replay_buffer = Some(InitialHistoryReplayBuffer {
retained_lines: VecDeque::new(),
@@ -140,6 +149,10 @@ impl App {
/// This mirrors terminal scrollback behavior and avoids making startup replay cheaper or more
/// expensive than a later resize rebuild of the same transcript.
pub(super) fn finish_initial_history_replay_buffer(&mut self, tui: &mut tui::Tui) {
if self.has_owned_screen() {
self.initial_history_replay_buffer = None;
return;
}
let Some(buffer) = self.initial_history_replay_buffer.take() else {
return;
};
@@ -171,6 +184,9 @@ impl App {
cell: &dyn HistoryCell,
width: u16,
) {
if self.has_owned_screen() {
return;
}
if self
.initial_history_replay_buffer
.as_ref()
@@ -254,6 +270,11 @@ impl App {
/// 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.has_owned_screen() {
self.transcript_reflow.clear();
tui.frame_requester().schedule_frame();
return Ok(());
}
if self.transcript_reflow.take_stream_finish_reflow_needed() {
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
@@ -274,6 +295,11 @@ impl App {
/// 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.has_owned_screen() {
self.transcript_reflow.clear();
tui.frame_requester().schedule_frame();
return Ok(());
}
self.schedule_immediate_resize_reflow(tui);
self.maybe_run_resize_reflow(tui)?;
if !self.transcript_reflow.has_pending_reflow() {
@@ -333,6 +359,9 @@ impl App {
}
pub(super) fn handle_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<()> {
if self.handle_owned_draw_pre_render(tui)? {
return Ok(());
}
let size = tui.terminal.size()?;
let should_rebuild_transcript = self.handle_draw_size_change(
size,
@@ -356,6 +385,10 @@ impl App {
/// 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.has_owned_screen() {
self.transcript_reflow.clear();
return Ok(());
}
let Some(deadline) = self.transcript_reflow.pending_until() else {
return Ok(());
};
@@ -397,6 +430,11 @@ impl App {
pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
let terminal_width = tui.terminal.size()?.width;
if self.has_owned_screen() {
self.transcript_reflow.clear();
tui.clear_pending_history_lines();
return Ok(terminal_width);
}
let width = self.chat_widget.history_wrap_width(terminal_width);
if self.transcript_cells.is_empty() {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
@@ -429,6 +467,12 @@ impl App {
/// the cancelled user prompt stays visible in scrollback despite being removed from the source
/// transcript.
pub(super) fn rebuild_transcript_after_backtrack(&mut self, tui: &mut tui::Tui) -> Result<()> {
if self.has_owned_screen() {
self.sync_owned_screen_cells();
self.transcript_reflow.clear();
tui.clear_pending_history_lines();
return Ok(());
}
let terminal_width = tui.terminal.size()?.width;
let width = self.chat_widget.history_wrap_width(terminal_width);
let reflowed_lines = if self.transcript_cells.is_empty() {
+4 -2
View File
@@ -451,9 +451,11 @@ impl App {
}
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.reset_transcript_state_after_clear();
self.reset_app_ui_state_after_clear(tui);
tui.clear_pending_history_lines();
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
if !self.has_owned_screen() {
Self::clear_terminal_for_thread_switch(&mut tui.terminal)?;
}
Ok(())
}
+1
View File
@@ -32,6 +32,7 @@ pub(super) async fn make_test_app() -> App {
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
owned_screen: None,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
+4 -2
View File
@@ -4069,6 +4069,7 @@ async fn make_test_app() -> App {
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
owned_screen: None,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -4134,6 +4135,7 @@ async fn make_test_app_with_channels() -> (
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
owned_screen: None,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -6144,7 +6146,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
app.backtrack.nth_user_message = 0;
app.backtrack_render_pending = true;
app.reset_app_ui_state_after_clear();
app.reset_transcript_state_after_clear();
assert!(app.overlay.is_none());
assert!(app.transcript_cells.is_empty());
@@ -6177,7 +6179,7 @@ async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() {
Vec::<SkillErrorInfo>::new()
);
app.reset_app_ui_state_after_clear();
app.reset_transcript_state_after_clear();
assert_eq!(
app.skill_load_warnings
+1
View File
@@ -611,6 +611,7 @@ impl App {
/// 3. Drop deferred transcript lines buffered while overlay was open to avoid flushing lines
/// for cells that were just removed by the trim.
fn sync_overlay_after_transcript_trim(&mut self) {
self.sync_owned_screen_cells();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.replace_cells(self.transcript_cells.clone());
}
+11 -1
View File
@@ -1704,6 +1704,17 @@ impl ChatWidget {
}
}
/// Record the width used by an application-owned full-frame render.
///
/// The regular `Renderable` implementation records this as a render side effect. Owned mode
/// renders only the bottom pane from `ChatWidget`, so it must explicitly keep stream wrapping
/// in sync with the width of the retained conversation viewport.
pub(crate) fn update_owned_screen_width(&mut self, width: u16) {
if self.last_rendered_width.get() != Some(width as usize) {
self.on_terminal_resize(width);
}
}
/// 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()
@@ -1986,7 +1997,6 @@ impl ChatWidget {
}
/// Returns the active cells' main-viewport lines for a given terminal width.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn active_cell_display_hyperlink_lines(
&self,
width: u16,
@@ -151,10 +151,12 @@ impl ConversationViewport {
}
}
#[cfg(test)]
pub(crate) fn is_following_bottom(&self) -> bool {
self.content.is_following_bottom()
}
#[cfg(test)]
pub(crate) fn committed_cell_count(&self) -> usize {
self.cells.len()
}
+56 -27
View File
@@ -105,8 +105,6 @@ mod clipboard_paste;
mod collaboration_modes;
mod color;
mod config_update;
// Staged renderer primitive; the owned-screen app path is wired in a follow-up change.
#[cfg_attr(not(test), allow(dead_code))]
mod conversation_viewport;
pub(crate) mod custom_terminal;
mod pets;
@@ -1724,8 +1722,11 @@ async fn run_ratatui_app(
} = cli;
let images = shared.into_inner().images;
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
let alt_screen_behavior =
determine_alt_screen_behavior(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(
/*enabled*/ alt_screen_behavior != AltScreenBehavior::Disabled,
);
let mut app_server = match app_server {
Some(app_server) => app_server,
None => match start_app_server(
@@ -1785,8 +1786,12 @@ async fn run_ratatui_app(
StartupHooksReviewOutcome::OpenHooksBrowser(data) => Some(data),
};
if alt_screen_behavior == AltScreenBehavior::Owned {
tui.enter_alt_screen()?;
}
let app_result = App::run(
&mut tui,
alt_screen_behavior,
app_server,
config,
cli_kv_overrides.clone(),
@@ -1808,6 +1813,21 @@ async fn run_ratatui_app(
)
.await;
let finish_alt_screen_result = if alt_screen_behavior == AltScreenBehavior::Owned {
tui.finish_alt_screen()
} else {
Ok(())
};
let app_result = match (app_result, finish_alt_screen_result) {
(Ok(exit_info), Ok(())) => Ok(exit_info),
(Err(err), Ok(())) => Err(err),
(Ok(_), Err(err)) => Err(err.into()),
(Err(app_err), Err(finish_err)) => {
tracing::warn!(error = %finish_err, "failed to leave owned alternate screen");
Err(app_err)
}
};
terminal_restore_guard.restore_silently();
// Mark the end of the recorded session.
session_log::log_session_end();
@@ -1859,19 +1879,33 @@ impl Drop for TerminalRestoreGuard {
}
}
/// Determine whether to use the terminal's alternate screen buffer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AltScreenBehavior {
Disabled,
OverlayOnly,
Owned,
}
/// Determine how the application uses the terminal's alternate screen buffer.
///
/// - If `--no-alt-screen` is explicitly passed, always disable alternate screen
/// - Otherwise, respect the `tui.alternate_screen` config setting:
/// - `always`: Use alternate screen
/// - `always`: Own the alternate screen for the full application run
/// - `never`: Inline mode only, preserves scrollback
/// - `auto` (default): Use alternate screen
fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool {
/// - `auto` (default): Enter alternate screen only for overlays
fn determine_alt_screen_behavior(
no_alt_screen: bool,
tui_alternate_screen: AltScreenMode,
) -> AltScreenBehavior {
if no_alt_screen {
return false;
return AltScreenBehavior::Disabled;
}
tui_alternate_screen != AltScreenMode::Never
match tui_alternate_screen {
AltScreenMode::Always => AltScreenBehavior::Owned,
AltScreenMode::Auto => AltScreenBehavior::OverlayOnly,
AltScreenMode::Never => AltScreenBehavior::Disabled,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2149,23 +2183,18 @@ mod tests {
}
#[test]
fn alternate_screen_auto_uses_alt_screen() {
assert!(determine_alt_screen_mode(
/*no_alt_screen*/ false,
AltScreenMode::Auto,
));
assert!(determine_alt_screen_mode(
/*no_alt_screen*/ false,
AltScreenMode::Always,
));
assert!(!determine_alt_screen_mode(
/*no_alt_screen*/ false,
AltScreenMode::Never,
));
assert!(!determine_alt_screen_mode(
/*no_alt_screen*/ true,
AltScreenMode::Auto,
));
fn alternate_screen_mode_maps_to_explicit_behavior() {
let cases = [
(false, AltScreenMode::Auto, AltScreenBehavior::OverlayOnly),
(false, AltScreenMode::Always, AltScreenBehavior::Owned),
(false, AltScreenMode::Never, AltScreenBehavior::Disabled),
(true, AltScreenMode::Auto, AltScreenBehavior::Disabled),
(true, AltScreenMode::Always, AltScreenBehavior::Disabled),
(true, AltScreenMode::Never, AltScreenBehavior::Disabled),
];
for (no_alt_screen, mode, expected) in cases {
assert_eq!(determine_alt_screen_behavior(no_alt_screen, mode), expected,);
}
}
#[test]
+13
View File
@@ -302,6 +302,9 @@ pub(super) fn reapply_raw_mode_after_resume() -> Result<()> {
pub fn restore_after_exit() -> Result<()> {
let mut first_error =
restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit).err();
if let Err(err) = screen_session::restore_physical_alt_screen(&mut stdout()) {
first_error.get_or_insert(err);
}
if let Err(err) = terminal_stderr::finish() {
first_error.get_or_insert(err);
}
@@ -736,6 +739,11 @@ impl Tui {
self.screen_session.leave(&mut self.terminal)
}
/// Leave alternate screen regardless of any nested overlay owners.
pub fn finish_alt_screen(&mut self) -> Result<()> {
self.screen_session.finish(&mut self.terminal)
}
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.insert_history_lines_with_wrap_policy(lines, HistoryLineWrapPolicy::PreWrap);
}
@@ -774,6 +782,11 @@ impl Tui {
self.pending_history_lines.clear();
}
#[cfg(test)]
pub(crate) fn has_pending_history_lines(&self) -> bool {
!self.pending_history_lines.is_empty()
}
/// Resize the inline viewport for the resize-reflow path.
///
/// Unlike the legacy draw path, this path does not scroll rows above the viewport when the
+89 -4
View File
@@ -6,6 +6,8 @@ use std::num::NonZeroUsize;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
@@ -17,6 +19,40 @@ use super::EnableAlternateScroll;
use super::Terminal;
const INITIAL_OWNER_COUNT: usize = 1;
static PHYSICAL_ALT_SCREEN: PhysicalAltScreenTracker = PhysicalAltScreenTracker::new();
struct PhysicalAltScreenTracker {
active: AtomicBool,
}
impl PhysicalAltScreenTracker {
const fn new() -> Self {
Self {
active: AtomicBool::new(false),
}
}
fn mark_entered(&self) {
self.active.store(true, Ordering::Release);
}
fn mark_left(&self) {
self.active.store(false, Ordering::Release);
}
fn restore(&self, writer: &mut impl Write) -> io::Result<()> {
if !self.active.load(Ordering::Acquire) {
return Ok(());
}
let disable_result = execute!(writer, DisableAlternateScroll);
let leave_result = execute!(writer, LeaveAlternateScreen);
if leave_result.is_ok() {
self.mark_left();
}
merge_results(disable_result, leave_result)
}
}
#[derive(Clone)]
pub(super) struct ScreenSession {
@@ -119,6 +155,17 @@ impl ScreenSession {
result
}
pub(super) fn finish(&self, terminal: &mut Terminal) -> io::Result<()> {
let saved_viewport = self.saved_viewport();
let result = self.finish_commands(terminal);
if !self.is_active()
&& let Some(saved_viewport) = saved_viewport
{
terminal.set_viewport_area(saved_viewport);
}
result
}
pub(super) fn suspend(&self, terminal: &mut Terminal) -> io::Result<()> {
let was_suspended = self.is_suspended();
let result = self.suspend_commands(terminal);
@@ -211,6 +258,24 @@ impl ScreenSession {
merge_results(disable_result, leave_result)
}
fn finish_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
let mut inner = self.lock();
let ScreenOwnership::Alternate { physical, .. } = inner.ownership else {
return Ok(());
};
if physical == PhysicalAltScreen::Suspended {
inner.ownership = ScreenOwnership::Inline;
return Ok(());
}
let disable_result = commands.disable_alternate_scroll();
let leave_result = commands.leave_alternate_screen();
if leave_result.is_ok() {
inner.ownership = ScreenOwnership::Inline;
}
merge_results(disable_result, leave_result)
}
fn suspend_commands(&self, commands: &mut impl ScreenCommands) -> io::Result<()> {
let mut inner = self.lock();
let ScreenOwnership::Alternate { physical, .. } = &mut inner.ownership else {
@@ -264,13 +329,25 @@ trait ScreenCommands {
fn disable_alternate_scroll(&mut self) -> io::Result<()>;
}
pub(super) fn restore_physical_alt_screen(writer: &mut impl Write) -> io::Result<()> {
PHYSICAL_ALT_SCREEN.restore(writer)
}
impl ScreenCommands for Terminal {
fn enter_alternate_screen(&mut self) -> io::Result<()> {
execute!(self.backend_mut(), EnterAlternateScreen)
let result = execute!(self.backend_mut(), EnterAlternateScreen);
if result.is_ok() {
PHYSICAL_ALT_SCREEN.mark_entered();
}
result
}
fn leave_alternate_screen(&mut self) -> io::Result<()> {
execute!(self.backend_mut(), LeaveAlternateScreen)
let result = execute!(self.backend_mut(), LeaveAlternateScreen);
if result.is_ok() {
PHYSICAL_ALT_SCREEN.mark_left();
}
result
}
fn enable_alternate_scroll(&mut self) -> io::Result<()> {
@@ -286,11 +363,19 @@ struct WriterCommands<'a, W>(&'a mut W);
impl<W: Write> ScreenCommands for WriterCommands<'_, W> {
fn enter_alternate_screen(&mut self) -> io::Result<()> {
execute!(self.0, EnterAlternateScreen)
let result = execute!(self.0, EnterAlternateScreen);
if result.is_ok() {
PHYSICAL_ALT_SCREEN.mark_entered();
}
result
}
fn leave_alternate_screen(&mut self) -> io::Result<()> {
execute!(self.0, LeaveAlternateScreen)
let result = execute!(self.0, LeaveAlternateScreen);
if result.is_ok() {
PHYSICAL_ALT_SCREEN.mark_left();
}
result
}
fn enable_alternate_scroll(&mut self) -> io::Result<()> {
@@ -3,6 +3,7 @@ use std::io;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use super::PhysicalAltScreenTracker;
use super::ScreenCommands;
use super::ScreenSession;
@@ -98,6 +99,53 @@ fn nested_owners_only_transition_at_outer_boundaries() {
assert_effects(&commands, ENTER_AND_LEAVE);
}
#[test]
fn finish_clears_all_nested_owners_with_one_physical_transition() {
let session = ScreenSession::new();
let mut commands = FakeCommands::default();
acquire(&session, &mut commands);
acquire(&session, &mut commands);
commands.effects.clear();
session.finish_commands(&mut commands).expect("finish");
session
.finish_commands(&mut commands)
.expect("repeat finish");
assert!(!session.is_active());
assert_effects(&commands, LEAVE);
}
#[test]
fn emergency_restore_only_leaves_a_physically_owned_screen() {
let tracker = PhysicalAltScreenTracker::new();
let mut inactive_output = Vec::new();
tracker
.restore(&mut inactive_output)
.expect("restore inactive screen");
assert_eq!(inactive_output, Vec::<u8>::new());
tracker.mark_entered();
let mut restore_output = Vec::new();
tracker
.restore(&mut restore_output)
.expect("restore active screen");
let mut expected_output = Vec::new();
ratatui::crossterm::execute!(
expected_output,
super::DisableAlternateScroll,
crossterm::terminal::LeaveAlternateScreen
)
.expect("write expected restore sequence");
assert_eq!(restore_output, expected_output);
tracker
.restore(&mut restore_output)
.expect("repeat restore");
assert_eq!(restore_output, expected_output);
}
#[test]
fn temporary_suspend_preserves_nested_ownership_until_resume() {
let session = ScreenSession::new();