mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
feat(tui): own alternate screen in always mode
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user