Skip to content

Commit f2134f6

Browse files
resizable viewport (#1732)
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™
1 parent 221ebfc commit f2134f6

File tree

11 files changed

+668
-23
lines changed

11 files changed

+668
-23
lines changed

NOTICE

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
OpenAI Codex
22
Copyright 2025 OpenAI
3+
4+
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
5+
Copyright (c) 2016-2022 Florian Dehau
6+
Copyright (c) 2023-2025 The Ratatui Developers

codex-rs/tui/src/app.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use codex_core::protocol::Event;
1212
use color_eyre::eyre::Result;
1313
use crossterm::event::KeyCode;
1414
use crossterm::event::KeyEvent;
15+
use ratatui::layout::Offset;
16+
use ratatui::prelude::Backend;
1517
use std::path::PathBuf;
1618
use std::sync::Arc;
1719
use std::sync::atomic::AtomicBool;
@@ -321,6 +323,44 @@ impl App<'_> {
321323
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
322324
// TODO: add a throttle to avoid redrawing too often
323325

326+
let screen_size = terminal.size()?;
327+
let last_known_screen_size = terminal.last_known_screen_size;
328+
if screen_size != last_known_screen_size {
329+
let cursor_pos = terminal.get_cursor_position()?;
330+
let last_known_cursor_pos = terminal.last_known_cursor_pos;
331+
if cursor_pos.y != last_known_cursor_pos.y {
332+
// The terminal was resized. The only point of reference we have for where our viewport
333+
// was moved is the cursor position.
334+
// NB this assumes that the cursor was not wrapped as part of the resize.
335+
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
336+
337+
let new_viewport_area = terminal.viewport_area.offset(Offset {
338+
x: 0,
339+
y: cursor_delta,
340+
});
341+
terminal.set_viewport_area(new_viewport_area);
342+
terminal.clear()?;
343+
}
344+
}
345+
346+
let size = terminal.size()?;
347+
let desired_height = match &self.app_state {
348+
AppState::Chat { widget } => widget.desired_height(),
349+
AppState::GitWarning { .. } => 10,
350+
};
351+
let mut area = terminal.viewport_area;
352+
area.height = desired_height;
353+
area.width = size.width;
354+
if area.bottom() > size.height {
355+
terminal
356+
.backend_mut()
357+
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
358+
area.y = size.height - area.height;
359+
}
360+
if area != terminal.viewport_area {
361+
terminal.clear()?;
362+
terminal.set_viewport_area(area);
363+
}
324364
match &mut self.app_state {
325365
AppState::Chat { widget } => {
326366
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ impl ChatComposer<'_> {
7171
this
7272
}
7373

74+
pub fn desired_height(&self) -> u16 {
75+
2 + self.textarea.lines().len() as u16
76+
+ match &self.active_popup {
77+
ActivePopup::None => 0u16,
78+
ActivePopup::Command(c) => c.calculate_required_height(),
79+
ActivePopup::File(c) => c.calculate_required_height(),
80+
}
81+
}
82+
7483
/// Returns true if the composer currently contains no user input.
7584
pub(crate) fn is_empty(&self) -> bool {
7685
self.textarea.is_empty()
@@ -651,7 +660,7 @@ impl WidgetRef for &ChatComposer<'_> {
651660
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
652661
match &self.active_popup {
653662
ActivePopup::Command(popup) => {
654-
let popup_height = popup.calculate_required_height(&area);
663+
let popup_height = popup.calculate_required_height();
655664

656665
// Split the provided rect so that the popup is rendered at the
657666
// *top* and the textarea occupies the remaining space below.
@@ -673,7 +682,7 @@ impl WidgetRef for &ChatComposer<'_> {
673682
self.textarea.render(textarea_rect, buf);
674683
}
675684
ActivePopup::File(popup) => {
676-
let popup_height = popup.calculate_required_height(&area);
685+
let popup_height = popup.calculate_required_height();
677686

678687
let popup_rect = Rect {
679688
x: area.x,

codex-rs/tui/src/bottom_pane/command_popup.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ impl CommandPopup {
7171
/// Determine the preferred height of the popup. This is the number of
7272
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
7373
/// table/border overhead (one line at the top and one at the bottom).
74-
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
74+
pub(crate) fn calculate_required_height(&self) -> u16 {
7575
let matches = self.filtered_commands();
7676
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
7777
// Account for the border added by the Block that wraps the table.

codex-rs/tui/src/bottom_pane/file_search_popup.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ impl FileSearchPopup {
109109
}
110110

111111
/// Preferred height (rows) including border.
112-
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
112+
pub(crate) fn calculate_required_height(&self) -> u16 {
113113
// Row count depends on whether we already have matches. If no matches
114114
// yet (e.g. initial search or query with no results) reserve a single
115115
// row so the popup is still visible. When matches are present we show

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ impl BottomPane<'_> {
6464
}
6565
}
6666

67+
pub fn desired_height(&self) -> u16 {
68+
self.composer.desired_height()
69+
}
70+
6771
/// Forward a key event to the active view or the composer.
6872
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
6973
if let Some(mut view) = self.active_view.take() {

codex-rs/tui/src/chatwidget.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ impl ChatWidget<'_> {
143143
}
144144
}
145145

146+
pub fn desired_height(&self) -> u16 {
147+
self.bottom_pane.desired_height()
148+
}
149+
146150
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
147151
self.bottom_pane.clear_ctrl_c_quit_hint();
148152

0 commit comments

Comments
 (0)