mirror of
https://github.com/openai/codex.git
synced 2026-04-29 10:53:24 +03:00
931 lines
36 KiB
Markdown
931 lines
36 KiB
Markdown
# 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
|
||
- // multi‑line 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? |