tui: commit pending changes

Files: codex-rs/tui/src/app.rs, codex-rs/tui/src/bottom_pane/chat_composer.rs, codex-rs/tui/src/bottom_pane/mod.rs, codex-rs/tui/src/history_cell.rs, codex-rs/tui/src/tui.rs
This commit is contained in:
James Peter
2025-08-24 09:45:53 +10:00
parent 17bbc71946
commit 85089e13fe
5 changed files with 163 additions and 22 deletions
+4 -13
View File
@@ -134,7 +134,8 @@ impl App<'_> {
// needs to acquire the event lock, and so will fail if it
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
// Keep the timeout small to minimize input-to-echo latency.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(5)) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
@@ -160,7 +161,8 @@ impl App<'_> {
}
}
} else {
// Timeout expired, no `Event` is available
// Timeout expired, no `Event` is available; yield cooperatively
std::thread::yield_now();
}
}
});
@@ -768,17 +770,6 @@ impl App<'_> {
let _screen_size = terminal.size()?;
terminal.draw(|frame| {
// Fill entire frame with theme background
let bg_style = ratatui::style::Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
let area = frame.area();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
frame.buffer_mut()[(x, y)].set_style(bg_style);
}
}
match &mut self.app_state {
AppState::Chat { widget } => {
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
@@ -1375,7 +1375,10 @@ impl WidgetRef for &ChatComposer {
// Draw border around input area with optional "Coding" title when task is running
let mut input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()));
.border_style(Style::default().fg(crate::colors::border()))
// Fill input block with theme background so underlying content
// never shows through when the composer grows/shrinks.
.style(Style::default().bg(crate::colors::background()));
if self.is_task_running {
use std::time::{SystemTime, UNIX_EPOCH};
+26
View File
@@ -487,6 +487,18 @@ impl BottomPane<'_> {
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Base clear: fill the entire bottom pane with the theme background so
// newly exposed rows (e.g., when the composer grows on paste) do not
// show stale pixels from history.
let base_style = ratatui::style::Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(base_style);
}
}
let mut y_offset = 0u16;
if let Some(ring) = &self.live_ring {
let live_h = ring.desired_height(area.width).min(area.height);
@@ -520,6 +532,13 @@ impl WidgetRef for &BottomPane<'_> {
width: area.width.saturating_sub(horizontal_padding * 2),
height: avail - pad,
};
// Ensure view background is painted under its content
let view_bg = ratatui::style::Style::default().bg(crate::colors::background());
for y in view_rect.y..view_rect.y.saturating_add(view_rect.height) {
for x in view_rect.x..view_rect.x.saturating_add(view_rect.width) {
buf[(x, y)].set_style(view_bg);
}
}
view.render(view_rect, buf);
}
} else if y_offset < area.height {
@@ -538,6 +557,13 @@ impl WidgetRef for &BottomPane<'_> {
height: (area.height - y_offset)
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
};
// Paint the composer area background before rendering widgets
let comp_bg = ratatui::style::Style::default().bg(crate::colors::background());
for y in composer_rect.y..composer_rect.y.saturating_add(composer_rect.height) {
for x in composer_rect.x..composer_rect.x.saturating_add(composer_rect.width) {
buf[(x, y)].set_style(comp_bg);
}
}
(&self.composer).render_ref(composer_rect, buf);
}
}
+125 -6
View File
@@ -361,14 +361,29 @@ impl HistoryCell for ExecCell {
fn has_custom_render(&self) -> bool { true }
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Render command header/content above and stdout/stderr preview inside a left-bordered block.
let (pre_lines, out_lines) = self.exec_render_parts();
let (pre_lines_raw, out_lines) = self.exec_render_parts();
// For the preamble, implement a hanging indent of 2 cols by
// stripping the leading visual prefixes ("└ " or two spaces) and
// rendering into a content area shifted by 2 columns.
let mut pre_lines: Vec<Line<'static>> = Vec::with_capacity(pre_lines_raw.len());
for mut line in pre_lines_raw {
if let Some(first) = line.spans.first() {
let s = first.content.clone();
if s == "└ " || s == " " {
// Drop the leading prefix span
line.spans.remove(0);
}
}
pre_lines.push(line);
}
// Prepare texts and total heights (after wrapping)
let pre_text = Text::from(trim_empty_lines(pre_lines));
let out_text = Text::from(trim_empty_lines(out_lines));
// IMPORTANT: measure with the same effective widths we will render with.
// Preamble renders without borders/padding, so width = area.width.
let pre_wrap_width = area.width;
// Preamble renders with a 2-col hanging indent, so reduce width.
let pre_wrap_width = area.width.saturating_sub(2);
// Output renders inside a Block with a LEFT border (1 col) and left padding of 1,
// so the inner text width is reduced accordingly.
let out_wrap_width = area.width.saturating_sub(2);
@@ -396,7 +411,14 @@ impl HistoryCell for ExecCell {
// Render preamble (scrolled) if any space
if pre_height > 0 {
let pre_area = Rect { x: area.x, y: area.y, width: area.width, height: pre_height };
let pre_area = Rect { x: area.x.saturating_add(2), y: area.y, width: area.width.saturating_sub(2), height: pre_height };
// Fill the 2-col hanging indent margin
let margin_style = Style::default().bg(crate::colors::background());
for y in area.y..area.y.saturating_add(pre_height) {
for x in area.x..area.x.saturating_add(2.min(area.width)) {
buf[(x, y)].set_style(margin_style);
}
}
let pre_block = Block::default().style(Style::default().bg(crate::colors::background()));
Paragraph::new(pre_text)
.block(pre_block)
@@ -452,6 +474,103 @@ impl ExecCell {
}
}
// ==================== DiffCell ====================
pub(crate) struct DiffCell {
pub(crate) lines: Vec<Line<'static>>,
}
impl HistoryCell for DiffCell {
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType { HistoryCellType::Diff }
fn display_lines(&self) -> Vec<Line<'static>> { self.lines.clone() }
fn has_custom_render(&self) -> bool { true }
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, mut skip_rows: u16) {
// Render a two-column diff with a 1-col marker gutter and 1-col padding
// so wrapped lines hang under their first content column.
let bg = Style::default().bg(crate::colors::background());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_style(bg);
}
}
let marker_col_x = area.x; // 1 column for marker
let content_x = area.x.saturating_add(2); // 1 for marker + 1 space
let content_w = area.width.saturating_sub(2);
let mut cur_y = area.y;
// Helper to classify a line and extract marker and content
let classify = |l: &Line<'_>| -> (Option<char>, Line<'static>, Style) {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect::<String>();
let default_style = Style::default().fg(crate::colors::text());
if text.starts_with("+") && !text.starts_with("+++") {
let content = text.chars().skip(1).collect::<String>();
(Some('+'), Line::from(content).style(Style::default().fg(crate::colors::success())), default_style)
} else if text.starts_with("-") && !text.starts_with("---") {
let content = text.chars().skip(1).collect::<String>();
(Some('-'), Line::from(content).style(Style::default().fg(crate::colors::error())), default_style)
} else if text.starts_with("@@") {
(None, Line::from(text).style(Style::default().fg(crate::colors::primary())), default_style)
} else {
(None, Line::from(text), default_style)
}
};
'outer: for line in &self.lines {
// Measure this line at wrapped width
let (_marker, content_line, _sty) = classify(line);
let content_text = Text::from(vec![content_line.clone()]);
let rows: u16 = Paragraph::new(content_text.clone())
.wrap(Wrap { trim: false })
.line_count(content_w)
.try_into()
.unwrap_or(0);
let mut local_skip = 0u16;
if skip_rows > 0 {
if skip_rows >= rows {
skip_rows -= rows;
continue 'outer;
} else {
local_skip = skip_rows;
skip_rows = 0;
}
}
// Remaining height available
if cur_y >= area.y.saturating_add(area.height) { break; }
let avail = area.y.saturating_add(area.height) - cur_y;
let draw_h = rows.saturating_sub(local_skip).min(avail);
if draw_h == 0 { continue; }
// Draw content with hanging indent (left margin = 2)
let content_area = Rect { x: content_x, y: cur_y, width: content_w, height: draw_h };
let block = Block::default().style(bg);
Paragraph::new(content_text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((local_skip, 0))
.style(bg)
.render(content_area, buf);
// Draw marker on the first visible visual row of this logical line
let (marker, _content_line2, _) = classify(line);
if let Some(m) = marker {
if local_skip == 0 && area.width >= 1 {
let color = if m == '+' { crate::colors::success() } else { crate::colors::error() };
let style = Style::default().fg(color).bg(crate::colors::background());
buf.set_string(marker_col_x, cur_y, m.to_string(), style);
}
}
cur_y = cur_y.saturating_add(draw_h);
if cur_y >= area.y.saturating_add(area.height) { break; }
}
}
}
fn exec_render_parts_generic(
command: &[String],
output: Option<&CommandOutput>,
@@ -2330,7 +2449,7 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
PlainHistoryCell { lines, kind: HistoryCellType::Error }
}
pub(crate) fn new_diff_output(diff_output: String) -> PlainHistoryCell {
pub(crate) fn new_diff_output(diff_output: String) -> DiffCell {
// Parse the diff output into lines
let mut lines = vec![Line::from("/diff".magenta())];
for line in diff_output.lines() {
@@ -2345,7 +2464,7 @@ pub(crate) fn new_diff_output(diff_output: String) -> PlainHistoryCell {
}
}
lines.push(Line::from(""));
PlainHistoryCell { lines, kind: HistoryCellType::Diff }
DiffCell { lines }
}
pub(crate) fn new_reasoning_output(reasoning_effort: &ReasoningEffort) -> PlainHistoryCell {
+4 -2
View File
@@ -1,6 +1,7 @@
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
use std::io::BufWriter;
use codex_core::config::Config;
use crossterm::cursor::MoveTo;
@@ -23,7 +24,7 @@ use ratatui::crossterm::terminal::enable_raw_mode;
use ratatui_image::picker::Picker;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
pub type Tui = Terminal<CrosstermBackend<BufWriter<Stdout>>>;
/// Terminal information queried at startup
#[derive(Clone)]
@@ -105,7 +106,8 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
execute!(stdout(), MoveTo(0, 0), ResetColor, SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())))?;
}
let backend = CrosstermBackend::new(stdout());
// Wrap stdout in a BufWriter to reduce syscalls during rendering.
let backend = CrosstermBackend::new(BufWriter::new(stdout()));
let tui = Terminal::new(backend)?;
Ok((tui, terminal_info))
}