From d86270696e7bda0c321c7e981edbc7562ad3d330 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Thu, 31 Jul 2025 00:43:21 -0700
Subject: [PATCH] streamline ui (#1733)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
* / and @ 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
---
codex-rs/tui/src/app.rs | 21 +++-
.../src/bottom_pane/approval_modal_view.rs | 4 +
.../tui/src/bottom_pane/bottom_pane_view.rs | 3 +
codex-rs/tui/src/bottom_pane/chat_composer.rs | 100 ++++++++++--------
codex-rs/tui/src/bottom_pane/command_popup.rs | 48 ++++-----
.../tui/src/bottom_pane/file_search_popup.rs | 28 +++--
codex-rs/tui/src/bottom_pane/mod.rs | 7 +-
...mposer__tests__backspace_after_pastes.snap | 20 ++--
...tom_pane__chat_composer__tests__empty.snap | 20 ++--
...tom_pane__chat_composer__tests__large.snap | 20 ++--
...chat_composer__tests__multiple_pastes.snap | 20 ++--
...tom_pane__chat_composer__tests__small.snap | 20 ++--
.../src/bottom_pane/status_indicator_view.rs | 4 +
codex-rs/tui/src/chatwidget.rs | 32 ++++--
codex-rs/tui/src/history_cell.rs | 9 +-
codex-rs/tui/src/slash_command.rs | 4 +
codex-rs/tui/src/status_indicator_widget.rs | 8 +-
codex-rs/tui/src/user_approval_widget.rs | 37 ++++---
18 files changed, 232 insertions(+), 173 deletions(-)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 13ceabd7..ad4bc24f 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -9,7 +9,10 @@ use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::ExecApprovalRequestEvent;
use color_eyre::eyre::Result;
+use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::layout::Offset;
@@ -201,7 +204,7 @@ impl App<'_> {
self.schedule_redraw();
}
AppEvent::Redraw => {
- self.draw_next_frame(terminal)?;
+ std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::KeyEvent(key_event) => {
match key_event {
@@ -297,6 +300,18 @@ impl App<'_> {
widget.add_diff_output(text);
}
}
+ #[cfg(debug_assertions)]
+ SlashCommand::TestApproval => {
+ self.app_event_tx.send(AppEvent::CodexEvent(Event {
+ id: "1".to_string(),
+ msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
+ call_id: "1".to_string(),
+ command: vec!["git".into(), "apply".into()],
+ cwd: self.config.cwd.clone(),
+ reason: Some("test".to_string()),
+ }),
+ }));
+ }
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
@@ -321,8 +336,6 @@ 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 {
@@ -345,7 +358,7 @@ impl App<'_> {
let size = terminal.size()?;
let desired_height = match &self.app_state {
- AppState::Chat { widget } => widget.desired_height(),
+ AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10,
};
let mut area = terminal.viewport_area;
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
index 376135ef..4cd952f9 100644
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
@@ -57,6 +57,10 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
self.current.is_complete() && self.queue.is_empty()
}
+ fn desired_height(&self, width: u16) -> u16 {
+ self.current.desired_height(width)
+ }
+
fn render(&self, area: Rect, buf: &mut Buffer) {
(&self.current).render_ref(area, buf);
}
diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
index 96922d94..a5616371 100644
--- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
+++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
@@ -28,6 +28,9 @@ pub(crate) trait BottomPaneView<'a> {
CancellationEvent::Ignored
}
+ /// Return the desired height of the view.
+ fn desired_height(&self, width: u16) -> u16;
+
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 9c52057c..3bc573a0 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -1,11 +1,13 @@
use codex_core::protocol::TokenUsage;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
-use ratatui::layout::Alignment;
use ratatui::layout::Rect;
+use ratatui::style::Color;
use ratatui::style::Style;
+use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
+use ratatui::text::Span;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Widget;
@@ -22,7 +24,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_file_search::FileMatch;
-const BASE_PLACEHOLDER_TEXT: &str = "send a message";
+const BASE_PLACEHOLDER_TEXT: &str = "...";
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
@@ -72,9 +74,9 @@ impl ChatComposer<'_> {
}
pub fn desired_height(&self) -> u16 {
- 2 + self.textarea.lines().len() as u16
+ self.textarea.lines().len().max(1) as u16
+ match &self.active_popup {
- ActivePopup::None => 0u16,
+ ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
}
@@ -635,37 +637,17 @@ impl ChatComposer<'_> {
}
fn update_border(&mut self, has_focus: bool) {
- struct BlockState {
- right_title: Line<'static>,
- border_style: Style,
- }
-
- let bs = if has_focus {
- if self.ctrl_c_quit_hint {
- BlockState {
- right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right),
- border_style: Style::default(),
- }
- } else {
- BlockState {
- right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
- .alignment(Alignment::Right),
- border_style: Style::default(),
- }
- }
+ let border_style = if has_focus {
+ Style::default().fg(Color::Cyan)
} else {
- BlockState {
- right_title: Line::from(""),
- border_style: Style::default().dim(),
- }
+ Style::default().dim()
};
self.textarea.set_block(
ratatui::widgets::Block::default()
- .title_bottom(bs.right_title)
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded)
- .border_style(bs.border_style),
+ .borders(Borders::LEFT)
+ .border_type(BorderType::QuadrantOutside)
+ .border_style(border_style),
);
}
}
@@ -677,19 +659,19 @@ impl WidgetRef for &ChatComposer<'_> {
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.
- let popup_rect = Rect {
+ // **bottom** and the textarea occupies the remaining space above.
+ let popup_height = popup_height.min(area.height);
+ let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
- height: popup_height.min(area.height),
+ height: area.height.saturating_sub(popup_height),
};
-
- let textarea_rect = Rect {
+ let popup_rect = Rect {
x: area.x,
- y: area.y + popup_rect.height,
+ y: area.y + textarea_rect.height,
width: area.width,
- height: area.height.saturating_sub(popup_rect.height),
+ height: popup_height,
};
popup.render(popup_rect, buf);
@@ -698,25 +680,51 @@ impl WidgetRef for &ChatComposer<'_> {
ActivePopup::File(popup) => {
let popup_height = popup.calculate_required_height();
- let popup_rect = Rect {
+ let popup_height = popup_height.min(area.height);
+ let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
- height: popup_height.min(area.height),
- };
-
- let textarea_rect = Rect {
- x: area.x,
- y: area.y + popup_rect.height,
- width: area.width,
height: area.height.saturating_sub(popup_height),
};
+ let popup_rect = Rect {
+ x: area.x,
+ y: area.y + textarea_rect.height,
+ width: area.width,
+ height: popup_height,
+ };
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::None => {
- self.textarea.render(area, buf);
+ let mut textarea_rect = area;
+ textarea_rect.height = textarea_rect.height.saturating_sub(1);
+ self.textarea.render(textarea_rect, buf);
+ let mut bottom_line_rect = area;
+ bottom_line_rect.y += textarea_rect.height;
+ bottom_line_rect.height = 1;
+ let key_hint_style = Style::default().fg(Color::Cyan);
+ let hint = if self.ctrl_c_quit_hint {
+ vec![
+ Span::from(" "),
+ "Ctrl+C again".set_style(key_hint_style),
+ Span::from(" to quit"),
+ ]
+ } else {
+ vec![
+ Span::from(" "),
+ "⏎".set_style(key_hint_style),
+ Span::from(" send "),
+ "Shift+⏎".set_style(key_hint_style),
+ Span::from(" newline "),
+ "Ctrl+C".set_style(key_hint_style),
+ Span::from(" quit"),
+ ]
+ };
+ Line::from(hint)
+ .style(Style::default().dim())
+ .render_ref(bottom_line_rect, buf);
}
}
}
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
index da3b3a82..364a8472 100644
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
@@ -3,9 +3,9 @@ use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
-use ratatui::widgets::Block;
-use ratatui::widgets::BorderType;
-use ratatui::widgets::Borders;
+use ratatui::symbols::border::QUADRANT_LEFT_HALF;
+use ratatui::text::Line;
+use ratatui::text::Span;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
@@ -72,11 +72,7 @@ impl CommandPopup {
/// 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) -> 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.
- // 2 = one line at the top, one at the bottom.
- row_count + 2
+ self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
/// Return the list of commands that match the current filter. Matching is
@@ -158,18 +154,19 @@ impl WidgetRef for CommandPopup {
let default_style = Style::default();
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
- let (cmd_style, desc_style) = if Some(idx) == self.selected_idx {
- (
- command_style.bg(Color::DarkGray),
- default_style.bg(Color::DarkGray),
- )
- } else {
- (command_style, default_style)
- };
-
rows.push(Row::new(vec![
- Cell::from(format!("/{}", cmd.command())).style(cmd_style),
- Cell::from(cmd.description().to_string()).style(desc_style),
+ Cell::from(Line::from(vec![
+ if Some(idx) == self.selected_idx {
+ Span::styled(
+ "›",
+ Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
+ )
+ } else {
+ Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
+ },
+ Span::styled(format!("/{}", cmd.command()), command_style),
+ ])),
+ Cell::from(cmd.description().to_string()).style(default_style),
]));
}
}
@@ -180,12 +177,13 @@ impl WidgetRef for CommandPopup {
rows,
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
)
- .column_spacing(0)
- .block(
- Block::default()
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded),
- );
+ .column_spacing(0);
+ // .block(
+ // Block::default()
+ // .borders(Borders::LEFT)
+ // .border_type(BorderType::QuadrantOutside)
+ // .border_style(Style::default().fg(Color::DarkGray)),
+ // );
table.render(area, buf);
}
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 e15f8690..ac6c91cf 100644
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
@@ -115,12 +115,8 @@ impl FileSearchPopup {
// row so the popup is still visible. When matches are present we show
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
- let rows = if self.matches.is_empty() {
- 1
- } else {
- self.matches.len().clamp(1, MAX_RESULTS)
- } as u16;
- rows + 2 // border
+
+ self.matches.len().clamp(1, MAX_RESULTS) as u16
}
}
@@ -128,7 +124,14 @@ impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Prepare rows.
let rows: Vec = if self.matches.is_empty() {
- vec![Row::new(vec![Cell::from(" no matches ")])]
+ vec![Row::new(vec![
+ Cell::from(if self.waiting {
+ "(searching …)"
+ } else {
+ "no matches"
+ })
+ .style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
+ ])]
} else {
self.matches
.iter()
@@ -169,17 +172,12 @@ impl WidgetRef for &FileSearchPopup {
.collect()
};
- let mut title = format!(" @{} ", self.pending_query);
- if self.waiting {
- title.push_str(" (searching …)");
- }
-
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded)
- .title(title),
+ .borders(Borders::LEFT)
+ .border_type(BorderType::QuadrantOutside)
+ .border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 2ca858d8..2710a3e9 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -64,8 +64,11 @@ impl BottomPane<'_> {
}
}
- pub fn desired_height(&self) -> u16 {
- self.composer.desired_height()
+ pub fn desired_height(&self, width: u16) -> u16 {
+ self.active_view
+ .as_ref()
+ .map(|v| v.desired_height(width))
+ .unwrap_or(self.composer.desired_height())
}
/// Forward a key event to the active view or the composer.
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
index fa604c86..4f155dab 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
-"│[Pasted Content 1002 chars][Pasted Content 1004 chars] │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
+"▌[Pasted Content 1002 chars][Pasted Content 1004 chars] "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+" ⏎ send Shift+⏎ newline Ctrl+C quit "
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
index a89076d8..4e8371f1 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
-"│ send a message │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
+"▌ ... "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+" ⏎ send Shift+⏎ newline Ctrl+C quit "
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
index 39a62da4..80fea40d 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
-"│[Pasted Content 1005 chars] │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
+"▌[Pasted Content 1005 chars] "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+" ⏎ send Shift+⏎ newline Ctrl+C quit "
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
index cd940954..26e8d267 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
-"│[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
+"▌[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+" ⏎ send Shift+⏎ newline Ctrl+C quit "
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
index e6b55e36..0f1b9e64 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
@@ -2,13 +2,13 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
-"│short │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"│ │"
-"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
+"▌short "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+"▌ "
+" ⏎ send Shift+⏎ newline Ctrl+C quit "
diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
index f8c06ec5..a944271e 100644
--- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
+++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
@@ -33,6 +33,10 @@ impl BottomPaneView<'_> for StatusIndicatorView {
true
}
+ fn desired_height(&self, width: u16) -> u16 {
+ self.view.desired_height(width)
+ }
+
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
self.view.render_ref(area, buf);
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 33e3ee11..3ee724e6 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -44,6 +45,12 @@ use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
+struct RunningCommand {
+ command: Vec,
+ #[allow(dead_code)]
+ cwd: PathBuf,
+}
+
pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender,
@@ -56,6 +63,7 @@ pub(crate) struct ChatWidget<'a> {
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
answer_buffer: String,
+ running_commands: HashMap,
}
struct UserMessage {
@@ -140,11 +148,12 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
+ running_commands: HashMap::new(),
}
}
- pub fn desired_height(&self) -> u16 {
- self.bottom_pane.desired_height()
+ pub fn desired_height(&self, width: u16) -> u16 {
+ self.bottom_pane.desired_height(width)
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
@@ -343,12 +352,18 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
- call_id: _,
+ call_id,
command,
- cwd: _,
+ cwd,
}) => {
+ self.running_commands.insert(
+ call_id,
+ RunningCommand {
+ command: command.clone(),
+ cwd: cwd.clone(),
+ },
+ );
self.add_to_history(HistoryCell::new_active_exec_command(command));
- self.request_redraw();
}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: _,
@@ -361,7 +376,6 @@ impl ChatWidget<'_> {
PatchEventType::ApplyBegin { auto_approved },
changes,
));
- self.request_redraw();
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
@@ -369,8 +383,9 @@ impl ChatWidget<'_> {
stdout,
stderr,
}) => {
+ let cmd = self.running_commands.remove(&call_id);
self.add_to_history(HistoryCell::new_completed_exec_command(
- call_id,
+ cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
CommandOutput {
exit_code,
stdout,
@@ -384,7 +399,6 @@ impl ChatWidget<'_> {
invocation,
}) => {
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
- self.request_redraw();
}
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: _,
@@ -419,7 +433,6 @@ impl ChatWidget<'_> {
}
event => {
self.add_to_history(HistoryCell::new_background_event(format!("{event:?}")));
- self.request_redraw();
}
}
}
@@ -436,7 +449,6 @@ impl ChatWidget<'_> {
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
- self.request_redraw();
}
/// Forward file-search results to the bottom pane.
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 04279a01..956a0cc7 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -1,4 +1,4 @@
-use crate::exec_command::escape_command;
+use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::text_block::TextBlock;
use crate::text_formatting::format_and_truncate_tool_result;
@@ -246,7 +246,7 @@ impl HistoryCell {
}
pub(crate) fn new_active_exec_command(command: Vec) -> Self {
- let command_escaped = escape_command(&command);
+ let command_escaped = strip_bash_lc_and_escape(&command);
let lines: Vec> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
@@ -259,7 +259,7 @@ impl HistoryCell {
}
}
- pub(crate) fn new_completed_exec_command(command: String, output: CommandOutput) -> Self {
+ pub(crate) fn new_completed_exec_command(command: Vec, output: CommandOutput) -> Self {
let CommandOutput {
exit_code,
stdout,
@@ -283,7 +283,8 @@ impl HistoryCell {
let src = if exit_code == 0 { stdout } else { stderr };
- lines.push(Line::from(format!("$ {command}")));
+ let cmdline = strip_bash_lc_and_escape(&command);
+ lines.push(Line::from(format!("$ {cmdline}")));
let mut lines_iter = src.lines();
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(ansi_escape_line(raw).dim());
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
index 603eb721..7df1bcbd 100644
--- a/codex-rs/tui/src/slash_command.rs
+++ b/codex-rs/tui/src/slash_command.rs
@@ -15,6 +15,8 @@ pub enum SlashCommand {
New,
Diff,
Quit,
+ #[cfg(debug_assertions)]
+ TestApproval,
}
impl SlashCommand {
@@ -26,6 +28,8 @@ impl SlashCommand {
SlashCommand::Diff => {
"Show git diff of the working directory (including untracked files)"
}
+ #[cfg(debug_assertions)]
+ SlashCommand::TestApproval => "Test approval request",
}
}
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index 973ef098..7e6d2674 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -73,6 +73,10 @@ impl StatusIndicatorWidget {
}
}
+ pub fn desired_height(&self, _width: u16) -> u16 {
+ 1
+ }
+
/// Update the line that is displayed in the widget.
pub(crate) fn update_text(&mut self, text: String) {
self.text = text.replace(['\n', '\r'], " ");
@@ -91,8 +95,8 @@ impl WidgetRef for StatusIndicatorWidget {
let widget_style = Style::default();
let block = Block::default()
.padding(Padding::new(1, 0, 0, 0))
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded)
+ .borders(Borders::LEFT)
+ .border_type(BorderType::QuadrantOutside)
.border_style(widget_style.dim());
// Animated 3‑dot pattern inside brackets. The *active* dot is bold
// white, the others are dim.
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
index a161c2c3..855a7ea3 100644
--- a/codex-rs/tui/src/user_approval_widget.rs
+++ b/codex-rs/tui/src/user_approval_widget.rs
@@ -17,13 +17,11 @@ use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Line;
use ratatui::text::Span;
-use ratatui::widgets::Block;
-use ratatui::widgets::BorderType;
-use ratatui::widgets::Borders;
use ratatui::widgets::List;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
+use ratatui::widgets::Wrap;
use tui_input::Input;
use tui_input::backend::crossterm::EventHandler;
@@ -134,10 +132,9 @@ impl UserApprovalWidget<'_> {
None => cwd.display().to_string(),
};
let mut contents: Vec = vec![
- Line::from("Shell Command".bold()),
- Line::from(""),
Line::from(vec![
- format!("{cwd_str}$").dim(),
+ Span::from(cwd_str).dim(),
+ Span::from("$"),
Span::from(format!(" {cmd}")),
]),
Line::from(""),
@@ -147,7 +144,7 @@ impl UserApprovalWidget<'_> {
contents.push(Line::from(""));
}
contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
- Paragraph::new(contents)
+ Paragraph::new(contents).wrap(Wrap { trim: false })
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
@@ -313,21 +310,21 @@ impl UserApprovalWidget<'_> {
pub(crate) fn is_complete(&self) -> bool {
self.done
}
+
+ pub(crate) fn desired_height(&self, width: u16) -> u16 {
+ self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
+ }
}
const PLAIN: Style = Style::new();
-const BLUE_FG: Style = Style::new().fg(Color::Blue);
+const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
impl WidgetRef for &UserApprovalWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Take the area, wrap it in a block with a border, and divide up the
// remaining area into two chunks: one for the confirmation prompt and
// one for the response.
- let outer = Block::default()
- .title("Review")
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded);
- let inner = outer.inner(area);
+ let inner = area.inner(Margin::new(0, 2));
// Determine how many rows we can allocate for the static confirmation
// prompt while *always* keeping enough space for the interactive
@@ -384,8 +381,18 @@ impl WidgetRef for &UserApprovalWidget<'_> {
}
};
- outer.render(area, buf);
+ let border = ("◢◤")
+ .repeat((area.width / 2).into())
+ .fg(Color::LightYellow);
+
+ border.render_ref(area, buf);
+ Paragraph::new(" Execution Request ".bold().black().on_light_yellow())
+ .alignment(Alignment::Center)
+ .render_ref(area, buf);
+
self.confirmation_prompt.clone().render(prompt_chunk, buf);
- Widget::render(List::new(lines), response_chunk, buf);
+ List::new(lines).render_ref(response_chunk, buf);
+
+ border.render_ref(Rect::new(0, area.y + area.height - 1, area.width, 1), buf);
}
}