Files
codex/prs/bolinfest/PR-1732.md
2025-09-02 15:17:45 -07:00

931 lines
36 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #1732: resizable viewport
- URL: https://github.com/openai/codex/pull/1732
- Author: nornagon-openai
- Created: 2025-07-29 23:02:05 UTC
- Updated: 2025-07-31 00:07:02 UTC
- Changes: +668/-23, Files changed: 11, Commits: 11
## Description
Proof of concept for a resizable viewport.
The general approach here is to duplicate the `Terminal` struct from ratatui, but with our own logic. This is a "light fork" in that we are still using all the base ratatui functions (`Buffer`, `Widget` and so on), but we're doing our own bookkeeping at the top level to determine where to draw everything.
This approach could use improvement—e.g, when the window is resized to a smaller size, if the UI wraps, we don't correctly clear out the artifacts from wrapping. This is possible with a little work (i.e. tracking what parts of our UI would have been wrapped), but this behavior is at least at par with the existing behavior.
https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d
cc @joshka who might have Thoughts™
## Full Diff
```diff
diff --git a/NOTICE b/NOTICE
index ad09ca421e..2805899d56 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,2 +1,6 @@
OpenAI Codex
Copyright 2025 OpenAI
+
+This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
+Copyright (c) 2016-2022 Florian Dehau
+Copyright (c) 2023-2025 The Ratatui Developers
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 6823a83a50..13ceabd7aa 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -12,6 +12,8 @@ use codex_core::protocol::Event;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
+use ratatui::layout::Offset;
+use ratatui::prelude::Backend;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -321,6 +323,44 @@ impl App<'_> {
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// TODO: add a throttle to avoid redrawing too often
+ let screen_size = terminal.size()?;
+ let last_known_screen_size = terminal.last_known_screen_size;
+ if screen_size != last_known_screen_size {
+ let cursor_pos = terminal.get_cursor_position()?;
+ let last_known_cursor_pos = terminal.last_known_cursor_pos;
+ if cursor_pos.y != last_known_cursor_pos.y {
+ // The terminal was resized. The only point of reference we have for where our viewport
+ // was moved is the cursor position.
+ // NB this assumes that the cursor was not wrapped as part of the resize.
+ let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
+
+ let new_viewport_area = terminal.viewport_area.offset(Offset {
+ x: 0,
+ y: cursor_delta,
+ });
+ terminal.set_viewport_area(new_viewport_area);
+ terminal.clear()?;
+ }
+ }
+
+ let size = terminal.size()?;
+ let desired_height = match &self.app_state {
+ AppState::Chat { widget } => widget.desired_height(),
+ AppState::GitWarning { .. } => 10,
+ };
+ let mut area = terminal.viewport_area;
+ area.height = desired_height;
+ area.width = size.width;
+ if area.bottom() > size.height {
+ terminal
+ .backend_mut()
+ .scroll_region_up(0..area.top(), area.bottom() - size.height)?;
+ area.y = size.height - area.height;
+ }
+ if area != terminal.viewport_area {
+ terminal.clear()?;
+ terminal.set_viewport_area(area);
+ }
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index b15d81f8f5..4d313f14a5 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -71,6 +71,15 @@ impl ChatComposer<'_> {
this
}
+ pub fn desired_height(&self) -> u16 {
+ 2 + self.textarea.lines().len() as u16
+ + match &self.active_popup {
+ ActivePopup::None => 0u16,
+ ActivePopup::Command(c) => c.calculate_required_height(),
+ ActivePopup::File(c) => c.calculate_required_height(),
+ }
+ }
+
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -651,7 +660,7 @@ impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match &self.active_popup {
ActivePopup::Command(popup) => {
- let popup_height = popup.calculate_required_height(&area);
+ let popup_height = popup.calculate_required_height();
// Split the provided rect so that the popup is rendered at the
// *top* and the textarea occupies the remaining space below.
@@ -673,7 +682,7 @@ impl WidgetRef for &ChatComposer<'_> {
self.textarea.render(textarea_rect, buf);
}
ActivePopup::File(popup) => {
- let popup_height = popup.calculate_required_height(&area);
+ let popup_height = popup.calculate_required_height();
let popup_rect = Rect {
x: area.x,
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
index fd865047ef..da3b3a8253 100644
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
@@ -71,7 +71,7 @@ impl CommandPopup {
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
- pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
+ pub(crate) fn calculate_required_height(&self) -> u16 {
let matches = self.filtered_commands();
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
// Account for the border added by the Block that wraps the table.
diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
index 34eb59e4b2..e15f8690ae 100644
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
@@ -109,7 +109,7 @@ impl FileSearchPopup {
}
/// Preferred height (rows) including border.
- pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
+ pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
// row so the popup is still visible. When matches are present we show
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 4ec1ba4b3e..2ca858d8ce 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -64,6 +64,10 @@ impl BottomPane<'_> {
}
}
+ pub fn desired_height(&self) -> u16 {
+ self.composer.desired_height()
+ }
+
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if let Some(mut view) = self.active_view.take() {
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index fde6978634..33e3ee11e4 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -143,6 +143,10 @@ impl ChatWidget<'_> {
}
}
+ pub fn desired_height(&self) -> u16 {
+ self.bottom_pane.desired_height()
+ }
+
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
self.bottom_pane.clear_ctrl_c_quit_hint();
diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs
new file mode 100644
index 0000000000..1ada679fc1
--- /dev/null
+++ b/codex-rs/tui/src/custom_terminal.rs
@@ -0,0 +1,588 @@
+// This is derived from `ratatui::Terminal`, which is licensed under the following terms:
+//
+// The MIT License (MIT)
+// Copyright (c) 2016-2022 Florian Dehau
+// Copyright (c) 2023-2025 The Ratatui Developers
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+use std::io;
+
+use ratatui::backend::Backend;
+use ratatui::backend::ClearType;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Position;
+use ratatui::layout::Rect;
+use ratatui::layout::Size;
+use ratatui::widgets::StatefulWidget;
+use ratatui::widgets::StatefulWidgetRef;
+use ratatui::widgets::Widget;
+use ratatui::widgets::WidgetRef;
+
+#[derive(Debug, Hash)]
+pub struct Frame<'a> {
+ /// Where should the cursor be after drawing this frame?
+ ///
+ /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x,
+ /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
+ pub(crate) cursor_position: Option<Position>,
+
+ /// The area of the viewport
+ pub(crate) viewport_area: Rect,
+
+ /// The buffer that is used to draw the current frame
+ pub(crate) buffer: &'a mut Buffer,
+
+ /// The frame count indicating the sequence number of this frame.
+ pub(crate) count: usize,
+}
+
+#[allow(dead_code)]
+impl Frame<'_> {
+ /// The area of the current frame
+ ///
+ /// This is guaranteed not to change during rendering, so may be called multiple times.
+ ///
+ /// If your app listens for a resize event from the backend, it should ignore the values from
+ /// the event for any calculations that are used to render the current frame and use this value
+ /// instead as this is the area of the buffer that is used to render the current frame.
+ pub const fn area(&self) -> Rect {
+ self.viewport_area
+ }
+
+ /// Render a [`Widget`] to the current buffer using [`Widget::render`].
+ ///
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
+ /// frame (which can be obtained using [`Layout`] to split the total area).
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// # use ratatui::{backend::TestBackend, Terminal};
+ /// # let backend = TestBackend::new(5, 5);
+ /// # let mut terminal = Terminal::new(backend).unwrap();
+ /// # let mut frame = terminal.get_frame();
+ /// use ratatui::{layout::Rect, widgets::Block};
+ ///
+ /// let block = Block::new();
+ /// let area = Rect::new(0, 0, 5, 5);
+ /// frame.render_widget(block, area);
+ /// ```
+ ///
+ /// [`Layout`]: crate::layout::Layout
+ pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
+ widget.render(area, self.buffer);
+ }
+
+ /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`].
+ ///
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
+ /// frame (which can be obtained using [`Layout`] to split the total area).
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// # #[cfg(feature = "unstable-widget-ref")] {
+ /// # use ratatui::{backend::TestBackend, Terminal};
+ /// # let backend = TestBackend::new(5, 5);
+ /// # let mut terminal = Terminal::new(backend).unwrap();
+ /// # let mut frame = terminal.get_frame();
+ /// use ratatui::{layout::Rect, widgets::Block};
+ ///
+ /// let block = Block::new();
+ /// let area = Rect::new(0, 0, 5, 5);
+ /// frame.render_widget_ref(block, area);
+ /// # }
+ /// ```
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn render_widget_ref<W: WidgetRef>(&mut self, widget: W, area: Rect) {
+ widget.render_ref(area, self.buffer);
+ }
+
+ /// Render a [`StatefulWidget`] to the current buffer using [`StatefulWidget::render`].
+ ///
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
+ /// frame (which can be obtained using [`Layout`] to split the total area).
+ ///
+ /// The last argument should be an instance of the [`StatefulWidget::State`] associated to the
+ /// given [`StatefulWidget`].
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// # use ratatui::{backend::TestBackend, Terminal};
+ /// # let backend = TestBackend::new(5, 5);
+ /// # let mut terminal = Terminal::new(backend).unwrap();
+ /// # let mut frame = terminal.get_frame();
+ /// use ratatui::{
+ /// layout::Rect,
+ /// widgets::{List, ListItem, ListState},
+ /// };
+ ///
+ /// let mut state = ListState::default().with_selected(Some(1));
+ /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
+ /// let area = Rect::new(0, 0, 5, 5);
+ /// frame.render_stateful_widget(list, area, &mut state);
+ /// ```
+ ///
+ /// [`Layout`]: crate::layout::Layout
+ pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
+ where
+ W: StatefulWidget,
+ {
+ widget.render(area, self.buffer, state);
+ }
+
+ /// Render a [`StatefulWidgetRef`] to the current buffer using
+ /// [`StatefulWidgetRef::render_ref`].
+ ///
+ /// Usually the area argument is the size of the current frame or a sub-area of the current
+ /// frame (which can be obtained using [`Layout`] to split the total area).
+ ///
+ /// The last argument should be an instance of the [`StatefulWidgetRef::State`] associated to
+ /// the given [`StatefulWidgetRef`].
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// # #[cfg(feature = "unstable-widget-ref")] {
+ /// # use ratatui::{backend::TestBackend, Terminal};
+ /// # let backend = TestBackend::new(5, 5);
+ /// # let mut terminal = Terminal::new(backend).unwrap();
+ /// # let mut frame = terminal.get_frame();
+ /// use ratatui::{
+ /// layout::Rect,
+ /// widgets::{List, ListItem, ListState},
+ /// };
+ ///
+ /// let mut state = ListState::default().with_selected(Some(1));
+ /// let list = List::new(vec![ListItem::new("Item 1"), ListItem::new("Item 2")]);
+ /// let area = Rect::new(0, 0, 5, 5);
+ /// frame.render_stateful_widget_ref(list, area, &mut state);
+ /// # }
+ /// ```
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn render_stateful_widget_ref<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
+ where
+ W: StatefulWidgetRef,
+ {
+ widget.render_ref(area, self.buffer, state);
+ }
+
+ /// After drawing this frame, make the cursor visible and put it at the specified (x, y)
+ /// coordinates. If this method is not called, the cursor will be hidden.
+ ///
+ /// Note that this will interfere with calls to [`Terminal::hide_cursor`],
+ /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and
+ /// stick with it.
+ ///
+ /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor
+ /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor
+ /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position
+ pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) {
+ self.cursor_position = Some(position.into());
+ }
+
+ /// Gets the buffer that this `Frame` draws into as a mutable reference.
+ pub fn buffer_mut(&mut self) -> &mut Buffer {
+ self.buffer
+ }
+
+ /// Returns the current frame count.
+ ///
+ /// This method provides access to the frame count, which is a sequence number indicating
+ /// how many frames have been rendered up to (but not including) this one. It can be used
+ /// for purposes such as animation, performance tracking, or debugging.
+ ///
+ /// Each time a frame has been rendered, this count is incremented,
+ /// providing a consistent way to reference the order and number of frames processed by the
+ /// terminal. When count reaches its maximum value (`usize::MAX`), it wraps around to zero.
+ ///
+ /// This count is particularly useful when dealing with dynamic content or animations where the
+ /// state of the display changes over time. By tracking the frame count, developers can
+ /// synchronize updates or changes to the content with the rendering process.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// # use ratatui::{backend::TestBackend, Terminal};
+ /// # let backend = TestBackend::new(5, 5);
+ /// # let mut terminal = Terminal::new(backend).unwrap();
+ /// # let mut frame = terminal.get_frame();
+ /// let current_count = frame.count();
+ /// println!("Current frame count: {}", current_count);
+ /// ```
+ pub const fn count(&self) -> usize {
+ self.count
+ }
+}
+
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
+pub struct Terminal<B>
+where
+ B: Backend,
+{
+ /// The backend used to interface with the terminal
+ backend: B,
+ /// Holds the results of the current and previous draw calls. The two are compared at the end
+ /// of each draw pass to output the necessary updates to the terminal
+ buffers: [Buffer; 2],
+ /// Index of the current buffer in the previous array
+ current: usize,
+ /// Whether the cursor is currently hidden
+ hidden_cursor: bool,
+ /// Area of the viewport
+ pub viewport_area: Rect,
+ /// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
+ pub last_known_screen_size: Size,
+ /// Last known position of the cursor. Used to find the new area when the viewport is inlined
+ /// and the terminal resized.
+ pub last_known_cursor_pos: Position,
+ /// Number of frames rendered up until current time.
+ frame_count: usize,
+}
+
+impl<B> Drop for Terminal<B>
+where
+ B: Backend,
+{
+ #[allow(clippy::print_stderr)]
+ fn drop(&mut self) {
+ // Attempt to restore the cursor state
+ if self.hidden_cursor {
+ if let Err(err) = self.show_cursor() {
+ eprintln!("Failed to show the cursor: {err}");
+ }
+ }
+ }
+}
+
+impl<B> Terminal<B>
+where
+ B: Backend,
+{
+ /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// use std::io::stdout;
+ ///
+ /// use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport};
+ ///
+ /// let backend = CrosstermBackend::new(stdout());
+ /// let viewport = Viewport::Fixed(Rect::new(0, 0, 10, 10));
+ /// let terminal = Terminal::with_options(backend, TerminalOptions { viewport })?;
+ /// # std::io::Result::Ok(())
+ /// ```
+ pub fn with_options(mut backend: B) -> io::Result<Self> {
+ let screen_size = backend.size()?;
+ let cursor_pos = backend.get_cursor_position()?;
+ Ok(Self {
+ backend,
+ buffers: [
+ Buffer::empty(Rect::new(0, 0, 0, 0)),
+ Buffer::empty(Rect::new(0, 0, 0, 0)),
+ ],
+ current: 0,
+ hidden_cursor: false,
+ viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
+ last_known_screen_size: screen_size,
+ last_known_cursor_pos: cursor_pos,
+ frame_count: 0,
+ })
+ }
+
+ /// Get a Frame object which provides a consistent view into the terminal state for rendering.
+ pub fn get_frame(&mut self) -> Frame {
+ let count = self.frame_count;
+ Frame {
+ cursor_position: None,
+ viewport_area: self.viewport_area,
+ buffer: self.current_buffer_mut(),
+ count,
+ }
+ }
+
+ /// Gets the current buffer as a mutable reference.
+ pub fn current_buffer_mut(&mut self) -> &mut Buffer {
+ &mut self.buffers[self.current]
+ }
+
+ /// Gets the backend
+ pub const fn backend(&self) -> &B {
+ &self.backend
+ }
+
+ /// Gets the backend as a mutable reference
+ pub fn backend_mut(&mut self) -> &mut B {
+ &mut self.backend
+ }
+
+ /// Obtains a difference between the previous and the current buffer and passes it to the
+ /// current backend for drawing.
+ pub fn flush(&mut self) -> io::Result<()> {
+ let previous_buffer = &self.buffers[1 - self.current];
+ let current_buffer = &self.buffers[self.current];
+ let updates = previous_buffer.diff(current_buffer);
+ if let Some((col, row, _)) = updates.last() {
+ self.last_known_cursor_pos = Position { x: *col, y: *row };
+ }
+ self.backend.draw(updates.into_iter())
+ }
+
+ /// Updates the Terminal so that internal buffers match the requested area.
+ ///
+ /// Requested area will be saved to remain consistent when rendering. This leads to a full clear
+ /// of the screen.
+ pub fn resize(&mut self, screen_size: Size) -> io::Result<()> {
+ self.last_known_screen_size = screen_size;
+ Ok(())
+ }
+
+ /// Sets the viewport area.
+ pub fn set_viewport_area(&mut self, area: Rect) {
+ self.buffers[self.current].resize(area);
+ self.buffers[1 - self.current].resize(area);
+ self.viewport_area = area;
+ }
+
+ /// Queries the backend for size and resizes if it doesn't match the previous size.
+ pub fn autoresize(&mut self) -> io::Result<()> {
+ let screen_size = self.size()?;
+ if screen_size != self.last_known_screen_size {
+ self.resize(screen_size)?;
+ }
+ Ok(())
+ }
+
+ /// Draws a single frame to the terminal.
+ ///
+ /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`].
+ ///
+ /// If the render callback passed to this method can fail, use [`try_draw`] instead.
+ ///
+ /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the
+ /// terminal. These methods are the main entry points for drawing to the terminal.
+ ///
+ /// [`try_draw`]: Terminal::try_draw
+ ///
+ /// This method will:
+ ///
+ /// - autoresize the terminal if necessary
+ /// - call the render callback, passing it a [`Frame`] reference to render to
+ /// - flush the current internal state by copying the current buffer to the backend
+ /// - move the cursor to the last known position if it was set during the rendering closure
+ ///
+ /// The render callback should fully render the entire frame when called, including areas that
+ /// are unchanged from the previous frame. This is because each frame is compared to the
+ /// previous frame to determine what has changed, and only the changes are written to the
+ /// terminal. If the render callback does not fully render the frame, the terminal will not be
+ /// in a consistent state.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # let backend = ratatui::backend::TestBackend::new(10, 10);
+ /// # let mut terminal = ratatui::Terminal::new(backend)?;
+ /// use ratatui::{layout::Position, widgets::Paragraph};
+ ///
+ /// // with a closure
+ /// terminal.draw(|frame| {
+ /// let area = frame.area();
+ /// frame.render_widget(Paragraph::new("Hello World!"), area);
+ /// frame.set_cursor_position(Position { x: 0, y: 0 });
+ /// })?;
+ ///
+ /// // or with a function
+ /// terminal.draw(render)?;
+ ///
+ /// fn render(frame: &mut ratatui::Frame) {
+ /// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
+ /// }
+ /// # std::io::Result::Ok(())
+ /// ```
+ pub fn draw<F>(&mut self, render_callback: F) -> io::Result<()>
+ where
+ F: FnOnce(&mut Frame),
+ {
+ self.try_draw(|frame| {
+ render_callback(frame);
+ io::Result::Ok(())
+ })
+ }
+
+ /// Tries to draw a single frame to the terminal.
+ ///
+ /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise
+ /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure.
+ ///
+ /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or
+ /// closure that returns a `Result` instead of nothing.
+ ///
+ /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the
+ /// terminal. These methods are the main entry points for drawing to the terminal.
+ ///
+ /// [`draw`]: Terminal::draw
+ ///
+ /// This method will:
+ ///
+ /// - autoresize the terminal if necessary
+ /// - call the render callback, passing it a [`Frame`] reference to render to
+ /// - flush the current internal state by copying the current buffer to the backend
+ /// - move the cursor to the last known position if it was set during the rendering closure
+ /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal
+ ///
+ /// The render callback passed to `try_draw` can return any [`Result`] with an error type that
+ /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible
+ /// to use the `?` operator to propagate errors that occur during rendering. If the render
+ /// callback returns an error, the error will be returned from `try_draw` as an
+ /// [`std::io::Error`] and the terminal will not be updated.
+ ///
+ /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing
+ /// purposes, but it is often not used in regular applicationss.
+ ///
+ /// The render callback should fully render the entire frame when called, including areas that
+ /// are unchanged from the previous frame. This is because each frame is compared to the
+ /// previous frame to determine what has changed, and only the changes are written to the
+ /// terminal. If the render function does not fully render the frame, the terminal will not be
+ /// in a consistent state.
+ ///
+ /// # Examples
+ ///
+ /// ```should_panic
+ /// # use ratatui::layout::Position;;
+ /// # let backend = ratatui::backend::TestBackend::new(10, 10);
+ /// # let mut terminal = ratatui::Terminal::new(backend)?;
+ /// use std::io;
+ ///
+ /// use ratatui::widgets::Paragraph;
+ ///
+ /// // with a closure
+ /// terminal.try_draw(|frame| {
+ /// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
+ /// let area = frame.area();
+ /// frame.render_widget(Paragraph::new("Hello World!"), area);
+ /// frame.set_cursor_position(Position { x: 0, y: 0 });
+ /// io::Result::Ok(())
+ /// })?;
+ ///
+ /// // or with a function
+ /// terminal.try_draw(render)?;
+ ///
+ /// fn render(frame: &mut ratatui::Frame) -> io::Result<()> {
+ /// let value: u8 = "not a number".parse().map_err(io::Error::other)?;
+ /// frame.render_widget(Paragraph::new("Hello World!"), frame.area());
+ /// Ok(())
+ /// }
+ /// # io::Result::Ok(())
+ /// ```
+ pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<()>
+ where
+ F: FnOnce(&mut Frame) -> Result<(), E>,
+ E: Into<io::Error>,
+ {
+ // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets
+ // and the terminal (if growing), which may OOB.
+ self.autoresize()?;
+
+ let mut frame = self.get_frame();
+
+ render_callback(&mut frame).map_err(Into::into)?;
+
+ // We can't change the cursor position right away because we have to flush the frame to
+ // stdout first. But we also can't keep the frame around, since it holds a &mut to
+ // Buffer. Thus, we're taking the important data out of the Frame and dropping it.
+ let cursor_position = frame.cursor_position;
+
+ // Draw to stdout
+ self.flush()?;
+
+ match cursor_position {
+ None => self.hide_cursor()?,
+ Some(position) => {
+ self.show_cursor()?;
+ self.set_cursor_position(position)?;
+ }
+ }
+
+ self.swap_buffers();
+
+ // Flush
+ self.backend.flush()?;
+
+ // increment frame count before returning from draw
+ self.frame_count = self.frame_count.wrapping_add(1);
+
+ Ok(())
+ }
+
+ /// Hides the cursor.
+ pub fn hide_cursor(&mut self) -> io::Result<()> {
+ self.backend.hide_cursor()?;
+ self.hidden_cursor = true;
+ Ok(())
+ }
+
+ /// Shows the cursor.
+ pub fn show_cursor(&mut self) -> io::Result<()> {
+ self.backend.show_cursor()?;
+ self.hidden_cursor = false;
+ Ok(())
+ }
+
+ /// Gets the current cursor position.
+ ///
+ /// This is the position of the cursor after the last draw call.
+ #[allow(dead_code)]
+ pub fn get_cursor_position(&mut self) -> io::Result<Position> {
+ self.backend.get_cursor_position()
+ }
+
+ /// Sets the cursor position.
+ pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
+ let position = position.into();
+ self.backend.set_cursor_position(position)?;
+ self.last_known_cursor_pos = position;
+ Ok(())
+ }
+
+ /// Clear the terminal and force a full redraw on the next draw call.
+ pub fn clear(&mut self) -> io::Result<()> {
+ if self.viewport_area.is_empty() {
+ return Ok(());
+ }
+ self.backend
+ .set_cursor_position(self.viewport_area.as_position())?;
+ self.backend.clear_region(ClearType::AfterCursor)?;
+ // Reset the back buffer to make sure the next update will redraw everything.
+ self.buffers[1 - self.current].reset();
+ Ok(())
+ }
+
+ /// Clears the inactive buffer and swaps it with the current buffer
+ pub fn swap_buffers(&mut self) {
+ self.buffers[1 - self.current].reset();
+ self.current = 1 - self.current;
+ }
+
+ /// Queries the real size of the backend.
+ pub fn size(&self) -> io::Result<Size> {
+ self.backend.size()
+ }
+}
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
index 32d0b4b297..54faf4beb8 100644
--- a/codex-rs/tui/src/insert_history.rs
+++ b/codex-rs/tui/src/insert_history.rs
@@ -4,6 +4,7 @@ use std::io::Write;
use crate::tui;
use crossterm::Command;
+use crossterm::cursor::MoveTo;
use crossterm::queue;
use crossterm::style::Color as CColor;
use crossterm::style::Colors;
@@ -12,7 +13,6 @@ use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
-use ratatui::layout::Position;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
@@ -23,6 +23,7 @@ use ratatui::text::Span;
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
+ let cursor_pos = terminal.get_cursor_position().ok();
let mut area = terminal.get_frame().area();
@@ -60,9 +61,10 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
- terminal
- .set_cursor_position(Position::new(0, cursor_top))
- .ok();
+ // 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!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
for line in lines {
queue!(std::io::stdout(), Print("\r\n")).ok();
@@ -70,6 +72,11 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
}
queue!(std::io::stdout(), ResetScrollRegion).ok();
+
+ // Restore the cursor position to where it was before we started.
+ if let Some(cursor_pos) = cursor_pos {
+ queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok();
+ }
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 424b5ac2fc..351fab4df8 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -25,6 +25,7 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
+mod custom_terminal;
mod exec_command;
mod file_search;
mod get_git_diff;
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
index 66ae1cfb96..1b215961ab 100644
--- a/codex-rs/tui/src/tui.rs
+++ b/codex-rs/tui/src/tui.rs
@@ -5,14 +5,13 @@ use std::io::stdout;
use codex_core::config::Config;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::EnableBracketedPaste;
-use ratatui::Terminal;
-use ratatui::TerminalOptions;
-use ratatui::Viewport;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
+use crate::custom_terminal::Terminal;
+
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
@@ -23,19 +22,8 @@ pub fn init(_config: &Config) -> Result<Tui> {
enable_raw_mode()?;
set_panic_hook();
- // Reserve a fixed number of lines for the interactive viewport (composer,
- // status, popups). History is injected above using `insert_before`. This
- // is an initial step of the refactor later the height can become
- // dynamic. For now a conservative default keeps enough room for the
- // multiline composer while not occupying the whole screen.
- const BOTTOM_VIEWPORT_HEIGHT: u16 = 8;
let backend = CrosstermBackend::new(stdout());
- let tui = Terminal::with_options(
- backend,
- TerminalOptions {
- viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT),
- },
- )?;
+ let tui = Terminal::with_options(backend)?;
Ok(tui)
}
```
## Review Comments
### codex-rs/tui/src/insert_history.rs
- Created: 2025-07-30 23:51:07 UTC | Link: https://github.com/openai/codex/pull/1732#discussion_r2244070783
```diff
@@ -60,16 +62,17 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
- terminal
- .set_cursor_position(Position::new(0, cursor_top))
- .ok();
+ queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
```
> Should you memorialize this as a comment in the code?
- Created: 2025-07-30 23:51:22 UTC | Link: https://github.com/openai/codex/pull/1732#discussion_r2244071035
```diff
@@ -60,16 +61,17 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
- terminal
- .set_cursor_position(Position::new(0, cursor_top))
- .ok();
+ queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
for line in lines {
queue!(std::io::stdout(), Print("\r\n")).ok();
write_spans(&mut std::io::stdout(), line.iter()).ok();
}
queue!(std::io::stdout(), ResetScrollRegion).ok();
+ if let Some(cursor_pos) = cursor_pos {
```
> Does this also merit a comment?