Skip to content

Commit d862706

Browse files
streamline ui (#1733)
Simplify and improve many UI elements. * Remove all-around borders in most places. These interact badly with terminal resizing and look heavy. Prefer left-side-only borders. * Make the viewport adjust to the size of its contents. * <kbd>/</kbd> and <kbd>@</kbd> autocomplete boxes appear below the prompt, instead of above it. * Restyle the keyboard shortcut hints & move them to the left. * Restyle the approval dialog. * Use synchronized rendering to avoid flashing during rerenders. https://github.com/user-attachments/assets/96f044af-283b-411c-b7fc-5e6b8a433c20 <img width="1117" height="858" alt="Screenshot 2025-07-30 at 5 29 20 PM" src="https://github.com/user-attachments/assets/0cc0af77-8396-429b-b6ee-9feaaccdbee7" />
1 parent defeafb commit d862706

18 files changed

+230
-171
lines changed

codex-rs/tui/src/app.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ use crate::slash_command::SlashCommand;
99
use crate::tui;
1010
use codex_core::config::Config;
1111
use codex_core::protocol::Event;
12+
use codex_core::protocol::EventMsg;
13+
use codex_core::protocol::ExecApprovalRequestEvent;
1214
use color_eyre::eyre::Result;
15+
use crossterm::SynchronizedUpdate;
1316
use crossterm::event::KeyCode;
1417
use crossterm::event::KeyEvent;
1518
use ratatui::layout::Offset;
@@ -201,7 +204,7 @@ impl App<'_> {
201204
self.schedule_redraw();
202205
}
203206
AppEvent::Redraw => {
204-
self.draw_next_frame(terminal)?;
207+
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
205208
}
206209
AppEvent::KeyEvent(key_event) => {
207210
match key_event {
@@ -297,6 +300,18 @@ impl App<'_> {
297300
widget.add_diff_output(text);
298301
}
299302
}
303+
#[cfg(debug_assertions)]
304+
SlashCommand::TestApproval => {
305+
self.app_event_tx.send(AppEvent::CodexEvent(Event {
306+
id: "1".to_string(),
307+
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
308+
call_id: "1".to_string(),
309+
command: vec!["git".into(), "apply".into()],
310+
cwd: self.config.cwd.clone(),
311+
reason: Some("test".to_string()),
312+
}),
313+
}));
314+
}
300315
},
301316
AppEvent::StartFileSearch(query) => {
302317
self.file_search.on_user_query(query);
@@ -321,8 +336,6 @@ impl App<'_> {
321336
}
322337

323338
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
324-
// TODO: add a throttle to avoid redrawing too often
325-
326339
let screen_size = terminal.size()?;
327340
let last_known_screen_size = terminal.last_known_screen_size;
328341
if screen_size != last_known_screen_size {
@@ -345,7 +358,7 @@ impl App<'_> {
345358

346359
let size = terminal.size()?;
347360
let desired_height = match &self.app_state {
348-
AppState::Chat { widget } => widget.desired_height(),
361+
AppState::Chat { widget } => widget.desired_height(size.width),
349362
AppState::GitWarning { .. } => 10,
350363
};
351364
let mut area = terminal.viewport_area;

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
5757
self.current.is_complete() && self.queue.is_empty()
5858
}
5959

60+
fn desired_height(&self, width: u16) -> u16 {
61+
self.current.desired_height(width)
62+
}
63+
6064
fn render(&self, area: Rect, buf: &mut Buffer) {
6165
(&self.current).render_ref(area, buf);
6266
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ pub(crate) trait BottomPaneView<'a> {
2828
CancellationEvent::Ignored
2929
}
3030

31+
/// Return the desired height of the view.
32+
fn desired_height(&self, width: u16) -> u16;
33+
3134
/// Render the view: this will be displayed in place of the composer.
3235
fn render(&self, area: Rect, buf: &mut Buffer);
3336

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

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use codex_core::protocol::TokenUsage;
22
use crossterm::event::KeyEvent;
33
use ratatui::buffer::Buffer;
4-
use ratatui::layout::Alignment;
54
use ratatui::layout::Rect;
5+
use ratatui::style::Color;
66
use ratatui::style::Style;
7+
use ratatui::style::Styled;
78
use ratatui::style::Stylize;
89
use ratatui::text::Line;
10+
use ratatui::text::Span;
911
use ratatui::widgets::BorderType;
1012
use ratatui::widgets::Borders;
1113
use ratatui::widgets::Widget;
@@ -22,7 +24,7 @@ use crate::app_event::AppEvent;
2224
use crate::app_event_sender::AppEventSender;
2325
use codex_file_search::FileMatch;
2426

25-
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
27+
const BASE_PLACEHOLDER_TEXT: &str = "...";
2628
/// If the pasted content exceeds this number of characters, replace it with a
2729
/// placeholder in the UI.
2830
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -72,9 +74,9 @@ impl ChatComposer<'_> {
7274
}
7375

7476
pub fn desired_height(&self) -> u16 {
75-
2 + self.textarea.lines().len() as u16
77+
self.textarea.lines().len().max(1) as u16
7678
+ match &self.active_popup {
77-
ActivePopup::None => 0u16,
79+
ActivePopup::None => 1u16,
7880
ActivePopup::Command(c) => c.calculate_required_height(),
7981
ActivePopup::File(c) => c.calculate_required_height(),
8082
}
@@ -635,37 +637,17 @@ impl ChatComposer<'_> {
635637
}
636638

637639
fn update_border(&mut self, has_focus: bool) {
638-
struct BlockState {
639-
right_title: Line<'static>,
640-
border_style: Style,
641-
}
642-
643-
let bs = if has_focus {
644-
if self.ctrl_c_quit_hint {
645-
BlockState {
646-
right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right),
647-
border_style: Style::default(),
648-
}
649-
} else {
650-
BlockState {
651-
right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
652-
.alignment(Alignment::Right),
653-
border_style: Style::default(),
654-
}
655-
}
640+
let border_style = if has_focus {
641+
Style::default().fg(Color::Cyan)
656642
} else {
657-
BlockState {
658-
right_title: Line::from(""),
659-
border_style: Style::default().dim(),
660-
}
643+
Style::default().dim()
661644
};
662645

663646
self.textarea.set_block(
664647
ratatui::widgets::Block::default()
665-
.title_bottom(bs.right_title)
666-
.borders(Borders::ALL)
667-
.border_type(BorderType::Rounded)
668-
.border_style(bs.border_style),
648+
.borders(Borders::LEFT)
649+
.border_type(BorderType::QuadrantOutside)
650+
.border_style(border_style),
669651
);
670652
}
671653
}
@@ -677,19 +659,19 @@ impl WidgetRef for &ChatComposer<'_> {
677659
let popup_height = popup.calculate_required_height();
678660

679661
// Split the provided rect so that the popup is rendered at the
680-
// *top* and the textarea occupies the remaining space below.
681-
let popup_rect = Rect {
662+
// **bottom** and the textarea occupies the remaining space above.
663+
let popup_height = popup_height.min(area.height);
664+
let textarea_rect = Rect {
682665
x: area.x,
683666
y: area.y,
684667
width: area.width,
685-
height: popup_height.min(area.height),
668+
height: area.height.saturating_sub(popup_height),
686669
};
687-
688-
let textarea_rect = Rect {
670+
let popup_rect = Rect {
689671
x: area.x,
690-
y: area.y + popup_rect.height,
672+
y: area.y + textarea_rect.height,
691673
width: area.width,
692-
height: area.height.saturating_sub(popup_rect.height),
674+
height: popup_height,
693675
};
694676

695677
popup.render(popup_rect, buf);
@@ -698,25 +680,51 @@ impl WidgetRef for &ChatComposer<'_> {
698680
ActivePopup::File(popup) => {
699681
let popup_height = popup.calculate_required_height();
700682

701-
let popup_rect = Rect {
683+
let popup_height = popup_height.min(area.height);
684+
let textarea_rect = Rect {
702685
x: area.x,
703686
y: area.y,
704687
width: area.width,
705-
height: popup_height.min(area.height),
688+
height: area.height.saturating_sub(popup_height),
706689
};
707-
708-
let textarea_rect = Rect {
690+
let popup_rect = Rect {
709691
x: area.x,
710-
y: area.y + popup_rect.height,
692+
y: area.y + textarea_rect.height,
711693
width: area.width,
712-
height: area.height.saturating_sub(popup_height),
694+
height: popup_height,
713695
};
714696

715697
popup.render(popup_rect, buf);
716698
self.textarea.render(textarea_rect, buf);
717699
}
718700
ActivePopup::None => {
719-
self.textarea.render(area, buf);
701+
let mut textarea_rect = area;
702+
textarea_rect.height = textarea_rect.height.saturating_sub(1);
703+
self.textarea.render(textarea_rect, buf);
704+
let mut bottom_line_rect = area;
705+
bottom_line_rect.y += textarea_rect.height;
706+
bottom_line_rect.height = 1;
707+
let key_hint_style = Style::default().fg(Color::Cyan);
708+
let hint = if self.ctrl_c_quit_hint {
709+
vec![
710+
Span::from(" "),
711+
"Ctrl+C again".set_style(key_hint_style),
712+
Span::from(" to quit"),
713+
]
714+
} else {
715+
vec![
716+
Span::from(" "),
717+
"⏎".set_style(key_hint_style),
718+
Span::from(" send "),
719+
"Shift+⏎".set_style(key_hint_style),
720+
Span::from(" newline "),
721+
"Ctrl+C".set_style(key_hint_style),
722+
Span::from(" quit"),
723+
]
724+
};
725+
Line::from(hint)
726+
.style(Style::default().dim())
727+
.render_ref(bottom_line_rect, buf);
720728
}
721729
}
722730
}

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

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ use ratatui::layout::Rect;
33
use ratatui::style::Color;
44
use ratatui::style::Style;
55
use ratatui::style::Stylize;
6-
use ratatui::widgets::Block;
7-
use ratatui::widgets::BorderType;
8-
use ratatui::widgets::Borders;
6+
use ratatui::symbols::border::QUADRANT_LEFT_HALF;
7+
use ratatui::text::Line;
8+
use ratatui::text::Span;
99
use ratatui::widgets::Cell;
1010
use ratatui::widgets::Row;
1111
use ratatui::widgets::Table;
@@ -72,11 +72,7 @@ impl CommandPopup {
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).
7474
pub(crate) fn calculate_required_height(&self) -> u16 {
75-
let matches = self.filtered_commands();
76-
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
77-
// Account for the border added by the Block that wraps the table.
78-
// 2 = one line at the top, one at the bottom.
79-
row_count + 2
75+
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
8076
}
8177

8278
/// Return the list of commands that match the current filter. Matching is
@@ -158,18 +154,19 @@ impl WidgetRef for CommandPopup {
158154
let default_style = Style::default();
159155
let command_style = Style::default().fg(Color::LightBlue);
160156
for (idx, cmd) in visible_matches.iter().enumerate() {
161-
let (cmd_style, desc_style) = if Some(idx) == self.selected_idx {
162-
(
163-
command_style.bg(Color::DarkGray),
164-
default_style.bg(Color::DarkGray),
165-
)
166-
} else {
167-
(command_style, default_style)
168-
};
169-
170157
rows.push(Row::new(vec![
171-
Cell::from(format!("/{}", cmd.command())).style(cmd_style),
172-
Cell::from(cmd.description().to_string()).style(desc_style),
158+
Cell::from(Line::from(vec![
159+
if Some(idx) == self.selected_idx {
160+
Span::styled(
161+
"›",
162+
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
163+
)
164+
} else {
165+
Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
166+
},
167+
Span::styled(format!("/{}", cmd.command()), command_style),
168+
])),
169+
Cell::from(cmd.description().to_string()).style(default_style),
173170
]));
174171
}
175172
}
@@ -180,12 +177,13 @@ impl WidgetRef for CommandPopup {
180177
rows,
181178
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
182179
)
183-
.column_spacing(0)
184-
.block(
185-
Block::default()
186-
.borders(Borders::ALL)
187-
.border_type(BorderType::Rounded),
188-
);
180+
.column_spacing(0);
181+
// .block(
182+
// Block::default()
183+
// .borders(Borders::LEFT)
184+
// .border_type(BorderType::QuadrantOutside)
185+
// .border_style(Style::default().fg(Color::DarkGray)),
186+
// );
189187

190188
table.render(area, buf);
191189
}

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,20 +115,23 @@ impl FileSearchPopup {
115115
// row so the popup is still visible. When matches are present we show
116116
// up to MAX_RESULTS regardless of the waiting flag so the list
117117
// remains stable while a newer search is in-flight.
118-
let rows = if self.matches.is_empty() {
119-
1
120-
} else {
121-
self.matches.len().clamp(1, MAX_RESULTS)
122-
} as u16;
123-
rows + 2 // border
118+
119+
self.matches.len().clamp(1, MAX_RESULTS) as u16
124120
}
125121
}
126122

127123
impl WidgetRef for &FileSearchPopup {
128124
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
129125
// Prepare rows.
130126
let rows: Vec<Row> = if self.matches.is_empty() {
131-
vec![Row::new(vec![Cell::from(" no matches ")])]
127+
vec![Row::new(vec![
128+
Cell::from(if self.waiting {
129+
"(searching …)"
130+
} else {
131+
"no matches"
132+
})
133+
.style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
134+
])]
132135
} else {
133136
self.matches
134137
.iter()
@@ -169,17 +172,12 @@ impl WidgetRef for &FileSearchPopup {
169172
.collect()
170173
};
171174

172-
let mut title = format!(" @{} ", self.pending_query);
173-
if self.waiting {
174-
title.push_str(" (searching …)");
175-
}
176-
177175
let table = Table::new(rows, vec![Constraint::Percentage(100)])
178176
.block(
179177
Block::default()
180-
.borders(Borders::ALL)
181-
.border_type(BorderType::Rounded)
182-
.title(title),
178+
.borders(Borders::LEFT)
179+
.border_type(BorderType::QuadrantOutside)
180+
.border_style(Style::default().fg(Color::DarkGray)),
183181
)
184182
.widths([Constraint::Percentage(100)]);
185183

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ impl BottomPane<'_> {
6464
}
6565
}
6666

67-
pub fn desired_height(&self) -> u16 {
68-
self.composer.desired_height()
67+
pub fn desired_height(&self, width: u16) -> u16 {
68+
self.active_view
69+
.as_ref()
70+
.map(|v| v.desired_height(width))
71+
.unwrap_or(self.composer.desired_height())
6972
}
7073

7174
/// Forward a key event to the active view or the composer.

0 commit comments

Comments
 (0)