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" />
This commit is contained in:
@@ -9,7 +9,10 @@ use crate::slash_command::SlashCommand;
|
|||||||
use crate::tui;
|
use crate::tui;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
|
use codex_core::protocol::EventMsg;
|
||||||
|
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::Result;
|
||||||
|
use crossterm::SynchronizedUpdate;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::layout::Offset;
|
use ratatui::layout::Offset;
|
||||||
@@ -201,7 +204,7 @@ impl App<'_> {
|
|||||||
self.schedule_redraw();
|
self.schedule_redraw();
|
||||||
}
|
}
|
||||||
AppEvent::Redraw => {
|
AppEvent::Redraw => {
|
||||||
self.draw_next_frame(terminal)?;
|
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
|
||||||
}
|
}
|
||||||
AppEvent::KeyEvent(key_event) => {
|
AppEvent::KeyEvent(key_event) => {
|
||||||
match key_event {
|
match key_event {
|
||||||
@@ -297,6 +300,18 @@ impl App<'_> {
|
|||||||
widget.add_diff_output(text);
|
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) => {
|
AppEvent::StartFileSearch(query) => {
|
||||||
self.file_search.on_user_query(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<()> {
|
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 screen_size = terminal.size()?;
|
||||||
let last_known_screen_size = terminal.last_known_screen_size;
|
let last_known_screen_size = terminal.last_known_screen_size;
|
||||||
if screen_size != last_known_screen_size {
|
if screen_size != last_known_screen_size {
|
||||||
@@ -345,7 +358,7 @@ impl App<'_> {
|
|||||||
|
|
||||||
let size = terminal.size()?;
|
let size = terminal.size()?;
|
||||||
let desired_height = match &self.app_state {
|
let desired_height = match &self.app_state {
|
||||||
AppState::Chat { widget } => widget.desired_height(),
|
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||||
AppState::GitWarning { .. } => 10,
|
AppState::GitWarning { .. } => 10,
|
||||||
};
|
};
|
||||||
let mut area = terminal.viewport_area;
|
let mut area = terminal.viewport_area;
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
|||||||
self.current.is_complete() && self.queue.is_empty()
|
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) {
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
(&self.current).render_ref(area, buf);
|
(&self.current).render_ref(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub(crate) trait BottomPaneView<'a> {
|
|||||||
CancellationEvent::Ignored
|
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.
|
/// Render the view: this will be displayed in place of the composer.
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Alignment;
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
|
use ratatui::style::Styled;
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::BorderType;
|
use ratatui::widgets::BorderType;
|
||||||
use ratatui::widgets::Borders;
|
use ratatui::widgets::Borders;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
@@ -22,7 +24,7 @@ use crate::app_event::AppEvent;
|
|||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use codex_file_search::FileMatch;
|
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
|
/// If the pasted content exceeds this number of characters, replace it with a
|
||||||
/// placeholder in the UI.
|
/// placeholder in the UI.
|
||||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||||
@@ -72,9 +74,9 @@ impl ChatComposer<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn desired_height(&self) -> u16 {
|
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 {
|
+ match &self.active_popup {
|
||||||
ActivePopup::None => 0u16,
|
ActivePopup::None => 1u16,
|
||||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||||
ActivePopup::File(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) {
|
fn update_border(&mut self, has_focus: bool) {
|
||||||
struct BlockState {
|
let border_style = if has_focus {
|
||||||
right_title: Line<'static>,
|
Style::default().fg(Color::Cyan)
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
BlockState {
|
Style::default().dim()
|
||||||
right_title: Line::from(""),
|
|
||||||
border_style: Style::default().dim(),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
self.textarea.set_block(
|
self.textarea.set_block(
|
||||||
ratatui::widgets::Block::default()
|
ratatui::widgets::Block::default()
|
||||||
.title_bottom(bs.right_title)
|
.borders(Borders::LEFT)
|
||||||
.borders(Borders::ALL)
|
.border_type(BorderType::QuadrantOutside)
|
||||||
.border_type(BorderType::Rounded)
|
.border_style(border_style),
|
||||||
.border_style(bs.border_style),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,19 +659,19 @@ impl WidgetRef for &ChatComposer<'_> {
|
|||||||
let popup_height = popup.calculate_required_height();
|
let popup_height = popup.calculate_required_height();
|
||||||
|
|
||||||
// Split the provided rect so that the popup is rendered at the
|
// Split the provided rect so that the popup is rendered at the
|
||||||
// *top* and the textarea occupies the remaining space below.
|
// **bottom** and the textarea occupies the remaining space above.
|
||||||
let popup_rect = Rect {
|
let popup_height = popup_height.min(area.height);
|
||||||
|
let textarea_rect = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: area.width,
|
width: area.width,
|
||||||
height: popup_height.min(area.height),
|
height: area.height.saturating_sub(popup_height),
|
||||||
};
|
};
|
||||||
|
let popup_rect = Rect {
|
||||||
let textarea_rect = Rect {
|
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: area.y + popup_rect.height,
|
y: area.y + textarea_rect.height,
|
||||||
width: area.width,
|
width: area.width,
|
||||||
height: area.height.saturating_sub(popup_rect.height),
|
height: popup_height,
|
||||||
};
|
};
|
||||||
|
|
||||||
popup.render(popup_rect, buf);
|
popup.render(popup_rect, buf);
|
||||||
@@ -698,25 +680,51 @@ impl WidgetRef for &ChatComposer<'_> {
|
|||||||
ActivePopup::File(popup) => {
|
ActivePopup::File(popup) => {
|
||||||
let popup_height = popup.calculate_required_height();
|
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,
|
x: area.x,
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width: area.width,
|
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),
|
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);
|
popup.render(popup_rect, buf);
|
||||||
self.textarea.render(textarea_rect, buf);
|
self.textarea.render(textarea_rect, buf);
|
||||||
}
|
}
|
||||||
ActivePopup::None => {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use ratatui::layout::Rect;
|
|||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::widgets::Block;
|
use ratatui::symbols::border::QUADRANT_LEFT_HALF;
|
||||||
use ratatui::widgets::BorderType;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::Borders;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Cell;
|
use ratatui::widgets::Cell;
|
||||||
use ratatui::widgets::Row;
|
use ratatui::widgets::Row;
|
||||||
use ratatui::widgets::Table;
|
use ratatui::widgets::Table;
|
||||||
@@ -72,11 +72,7 @@ impl CommandPopup {
|
|||||||
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
|
/// 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).
|
/// table/border overhead (one line at the top and one at the bottom).
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
let matches = self.filtered_commands();
|
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the list of commands that match the current filter. Matching is
|
/// 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 default_style = Style::default();
|
||||||
let command_style = Style::default().fg(Color::LightBlue);
|
let command_style = Style::default().fg(Color::LightBlue);
|
||||||
for (idx, cmd) in visible_matches.iter().enumerate() {
|
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![
|
rows.push(Row::new(vec![
|
||||||
Cell::from(format!("/{}", cmd.command())).style(cmd_style),
|
Cell::from(Line::from(vec![
|
||||||
Cell::from(cmd.description().to_string()).style(desc_style),
|
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,
|
rows,
|
||||||
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
|
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
|
||||||
)
|
)
|
||||||
.column_spacing(0)
|
.column_spacing(0);
|
||||||
.block(
|
// .block(
|
||||||
Block::default()
|
// Block::default()
|
||||||
.borders(Borders::ALL)
|
// .borders(Borders::LEFT)
|
||||||
.border_type(BorderType::Rounded),
|
// .border_type(BorderType::QuadrantOutside)
|
||||||
);
|
// .border_style(Style::default().fg(Color::DarkGray)),
|
||||||
|
// );
|
||||||
|
|
||||||
table.render(area, buf);
|
table.render(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,8 @@ impl FileSearchPopup {
|
|||||||
// row so the popup is still visible. When matches are present we show
|
// 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
|
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||||
// remains stable while a newer search is in-flight.
|
// remains stable while a newer search is in-flight.
|
||||||
let rows = if self.matches.is_empty() {
|
|
||||||
1
|
self.matches.len().clamp(1, MAX_RESULTS) as u16
|
||||||
} else {
|
|
||||||
self.matches.len().clamp(1, MAX_RESULTS)
|
|
||||||
} as u16;
|
|
||||||
rows + 2 // border
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +124,14 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
// Prepare rows.
|
// Prepare rows.
|
||||||
let rows: Vec<Row> = if self.matches.is_empty() {
|
let rows: Vec<Row> = 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 {
|
} else {
|
||||||
self.matches
|
self.matches
|
||||||
.iter()
|
.iter()
|
||||||
@@ -169,17 +172,12 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut title = format!(" @{} ", self.pending_query);
|
|
||||||
if self.waiting {
|
|
||||||
title.push_str(" (searching …)");
|
|
||||||
}
|
|
||||||
|
|
||||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::LEFT)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::QuadrantOutside)
|
||||||
.title(title),
|
.border_style(Style::default().fg(Color::DarkGray)),
|
||||||
)
|
)
|
||||||
.widths([Constraint::Percentage(100)]);
|
.widths([Constraint::Percentage(100)]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ impl BottomPane<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn desired_height(&self) -> u16 {
|
pub fn desired_height(&self, width: u16) -> u16 {
|
||||||
self.composer.desired_height()
|
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.
|
/// Forward a key event to the active view or the composer.
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
source: tui/src/bottom_pane/chat_composer.rs
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
"▌[Pasted Content 1002 chars][Pasted Content 1004 chars] "
|
||||||
"│[Pasted Content 1002 chars][Pasted Content 1004 chars] │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
" ⏎ send Shift+⏎ newline Ctrl+C quit "
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
source: tui/src/bottom_pane/chat_composer.rs
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
"▌ ... "
|
||||||
"│ send a message │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
" ⏎ send Shift+⏎ newline Ctrl+C quit "
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
source: tui/src/bottom_pane/chat_composer.rs
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
"▌[Pasted Content 1005 chars] "
|
||||||
"│[Pasted Content 1005 chars] │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
" ⏎ send Shift+⏎ newline Ctrl+C quit "
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
source: tui/src/bottom_pane/chat_composer.rs
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
"▌[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste "
|
||||||
"│[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
" ⏎ send Shift+⏎ newline Ctrl+C quit "
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
source: tui/src/bottom_pane/chat_composer.rs
|
source: tui/src/bottom_pane/chat_composer.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
"▌short "
|
||||||
"│short │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"│ │"
|
"▌ "
|
||||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
" ⏎ send Shift+⏎ newline Ctrl+C quit "
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
self.view.desired_height(width)
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
|
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
|
||||||
self.view.render_ref(area, buf);
|
self.view.render_ref(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -44,6 +45,12 @@ use crate::history_cell::PatchEventType;
|
|||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::user_approval_widget::ApprovalRequest;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
|
struct RunningCommand {
|
||||||
|
command: Vec<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
cwd: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct ChatWidget<'a> {
|
pub(crate) struct ChatWidget<'a> {
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
codex_op_tx: UnboundedSender<Op>,
|
codex_op_tx: UnboundedSender<Op>,
|
||||||
@@ -56,6 +63,7 @@ pub(crate) struct ChatWidget<'a> {
|
|||||||
// We wait for the final AgentMessage event and then emit the full text
|
// We wait for the final AgentMessage event and then emit the full text
|
||||||
// at once into scrollback so the history contains a single message.
|
// at once into scrollback so the history contains a single message.
|
||||||
answer_buffer: String,
|
answer_buffer: String,
|
||||||
|
running_commands: HashMap<String, RunningCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserMessage {
|
struct UserMessage {
|
||||||
@@ -140,11 +148,12 @@ impl ChatWidget<'_> {
|
|||||||
token_usage: TokenUsage::default(),
|
token_usage: TokenUsage::default(),
|
||||||
reasoning_buffer: String::new(),
|
reasoning_buffer: String::new(),
|
||||||
answer_buffer: String::new(),
|
answer_buffer: String::new(),
|
||||||
|
running_commands: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn desired_height(&self) -> u16 {
|
pub fn desired_height(&self, width: u16) -> u16 {
|
||||||
self.bottom_pane.desired_height()
|
self.bottom_pane.desired_height(width)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||||
@@ -343,12 +352,18 @@ impl ChatWidget<'_> {
|
|||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||||
call_id: _,
|
call_id,
|
||||||
command,
|
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.add_to_history(HistoryCell::new_active_exec_command(command));
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||||
call_id: _,
|
call_id: _,
|
||||||
@@ -361,7 +376,6 @@ impl ChatWidget<'_> {
|
|||||||
PatchEventType::ApplyBegin { auto_approved },
|
PatchEventType::ApplyBegin { auto_approved },
|
||||||
changes,
|
changes,
|
||||||
));
|
));
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||||
call_id,
|
call_id,
|
||||||
@@ -369,8 +383,9 @@ impl ChatWidget<'_> {
|
|||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
}) => {
|
}) => {
|
||||||
|
let cmd = self.running_commands.remove(&call_id);
|
||||||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||||||
call_id,
|
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||||||
CommandOutput {
|
CommandOutput {
|
||||||
exit_code,
|
exit_code,
|
||||||
stdout,
|
stdout,
|
||||||
@@ -384,7 +399,6 @@ impl ChatWidget<'_> {
|
|||||||
invocation,
|
invocation,
|
||||||
}) => {
|
}) => {
|
||||||
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
|
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||||
call_id: _,
|
call_id: _,
|
||||||
@@ -419,7 +433,6 @@ impl ChatWidget<'_> {
|
|||||||
}
|
}
|
||||||
event => {
|
event => {
|
||||||
self.add_to_history(HistoryCell::new_background_event(format!("{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) {
|
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||||
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
|
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward file-search results to the bottom pane.
|
/// Forward file-search results to the bottom pane.
|
||||||
|
|||||||
@@ -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::markdown::append_markdown;
|
||||||
use crate::text_block::TextBlock;
|
use crate::text_block::TextBlock;
|
||||||
use crate::text_formatting::format_and_truncate_tool_result;
|
use crate::text_formatting::format_and_truncate_tool_result;
|
||||||
@@ -246,7 +246,7 @@ impl HistoryCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
|
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
|
||||||
let command_escaped = escape_command(&command);
|
let command_escaped = strip_bash_lc_and_escape(&command);
|
||||||
|
|
||||||
let lines: Vec<Line<'static>> = vec![
|
let lines: Vec<Line<'static>> = vec![
|
||||||
Line::from(vec!["command".magenta(), " running...".dim()]),
|
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<String>, output: CommandOutput) -> Self {
|
||||||
let CommandOutput {
|
let CommandOutput {
|
||||||
exit_code,
|
exit_code,
|
||||||
stdout,
|
stdout,
|
||||||
@@ -283,7 +283,8 @@ impl HistoryCell {
|
|||||||
|
|
||||||
let src = if exit_code == 0 { stdout } else { stderr };
|
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();
|
let mut lines_iter = src.lines();
|
||||||
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||||
lines.push(ansi_escape_line(raw).dim());
|
lines.push(ansi_escape_line(raw).dim());
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub enum SlashCommand {
|
|||||||
New,
|
New,
|
||||||
Diff,
|
Diff,
|
||||||
Quit,
|
Quit,
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
TestApproval,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlashCommand {
|
impl SlashCommand {
|
||||||
@@ -26,6 +28,8 @@ impl SlashCommand {
|
|||||||
SlashCommand::Diff => {
|
SlashCommand::Diff => {
|
||||||
"Show git diff of the working directory (including untracked files)"
|
"Show git diff of the working directory (including untracked files)"
|
||||||
}
|
}
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
SlashCommand::TestApproval => "Test approval request",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ impl StatusIndicatorWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn desired_height(&self, _width: u16) -> u16 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the line that is displayed in the widget.
|
/// Update the line that is displayed in the widget.
|
||||||
pub(crate) fn update_text(&mut self, text: String) {
|
pub(crate) fn update_text(&mut self, text: String) {
|
||||||
self.text = text.replace(['\n', '\r'], " ");
|
self.text = text.replace(['\n', '\r'], " ");
|
||||||
@@ -91,8 +95,8 @@ impl WidgetRef for StatusIndicatorWidget {
|
|||||||
let widget_style = Style::default();
|
let widget_style = Style::default();
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.padding(Padding::new(1, 0, 0, 0))
|
.padding(Padding::new(1, 0, 0, 0))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::LEFT)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::QuadrantOutside)
|
||||||
.border_style(widget_style.dim());
|
.border_style(widget_style.dim());
|
||||||
// Animated 3‑dot pattern inside brackets. The *active* dot is bold
|
// Animated 3‑dot pattern inside brackets. The *active* dot is bold
|
||||||
// white, the others are dim.
|
// white, the others are dim.
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ use ratatui::layout::Rect;
|
|||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Block;
|
|
||||||
use ratatui::widgets::BorderType;
|
|
||||||
use ratatui::widgets::Borders;
|
|
||||||
use ratatui::widgets::List;
|
use ratatui::widgets::List;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
use tui_input::Input;
|
use tui_input::Input;
|
||||||
use tui_input::backend::crossterm::EventHandler;
|
use tui_input::backend::crossterm::EventHandler;
|
||||||
|
|
||||||
@@ -134,10 +132,9 @@ impl UserApprovalWidget<'_> {
|
|||||||
None => cwd.display().to_string(),
|
None => cwd.display().to_string(),
|
||||||
};
|
};
|
||||||
let mut contents: Vec<Line> = vec![
|
let mut contents: Vec<Line> = vec![
|
||||||
Line::from("Shell Command".bold()),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
format!("{cwd_str}$").dim(),
|
Span::from(cwd_str).dim(),
|
||||||
|
Span::from("$"),
|
||||||
Span::from(format!(" {cmd}")),
|
Span::from(format!(" {cmd}")),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
@@ -147,7 +144,7 @@ impl UserApprovalWidget<'_> {
|
|||||||
contents.push(Line::from(""));
|
contents.push(Line::from(""));
|
||||||
}
|
}
|
||||||
contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
|
contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
|
||||||
Paragraph::new(contents)
|
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||||||
}
|
}
|
||||||
ApprovalRequest::ApplyPatch {
|
ApprovalRequest::ApplyPatch {
|
||||||
reason, grant_root, ..
|
reason, grant_root, ..
|
||||||
@@ -313,21 +310,21 @@ impl UserApprovalWidget<'_> {
|
|||||||
pub(crate) fn is_complete(&self) -> bool {
|
pub(crate) fn is_complete(&self) -> bool {
|
||||||
self.done
|
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 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<'_> {
|
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
// Take the area, wrap it in a block with a border, and divide up the
|
// 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
|
// remaining area into two chunks: one for the confirmation prompt and
|
||||||
// one for the response.
|
// one for the response.
|
||||||
let outer = Block::default()
|
let inner = area.inner(Margin::new(0, 2));
|
||||||
.title("Review")
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded);
|
|
||||||
let inner = outer.inner(area);
|
|
||||||
|
|
||||||
// Determine how many rows we can allocate for the static confirmation
|
// Determine how many rows we can allocate for the static confirmation
|
||||||
// prompt while *always* keeping enough space for the interactive
|
// 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);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user