From 43b63ccae89cf911c1d3fecc4577a98b898d6048 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Fri, 26 Sep 2025 16:35:56 -0700
Subject: [PATCH] update composer + user message styling (#4240)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changes:
- the composer and user messages now have a colored background that
stretches the entire width of the terminal.
- the prompt character was changed from a cyan `▌` to a bold `›`.
- the "working" shimmer now follows the "dark gray" color of the
terminal, better matching the terminal's color scheme
| Terminal + Background | Screenshot |
|------------------------------|------------|
| iTerm with dark bg |
|
| iTerm with light bg |
|
| iTerm with color bg |
|
| Terminal.app with dark bg |
|
| Terminal.app with light bg |
|
| Terminal.app with color bg |
|
---
codex-rs/core/tests/suite/client.rs | 6 +
codex-rs/tui/Cargo.toml | 17 +-
codex-rs/tui/src/app_backtrack.rs | 4 +-
codex-rs/tui/src/bottom_pane/chat_composer.rs | 94 ++---
codex-rs/tui/src/bottom_pane/command_popup.rs | 2 +-
.../tui/src/bottom_pane/file_search_popup.rs | 2 +-
.../src/bottom_pane/list_selection_view.rs | 2 +-
codex-rs/tui/src/bottom_pane/mod.rs | 6 +-
.../src/bottom_pane/selection_popup_common.rs | 23 +-
...mposer__tests__backspace_after_pastes.snap | 18 +-
...tom_pane__chat_composer__tests__empty.snap | 18 +-
...tom_pane__chat_composer__tests__large.snap | 18 +-
...chat_composer__tests__multiple_pastes.snap | 18 +-
..._chat_composer__tests__slash_popup_mo.snap | 8 +-
...tom_pane__chat_composer__tests__small.snap | 18 +-
codex-rs/tui/src/chatwidget.rs | 9 +-
...et__tests__binary_size_ideal_response.snap | 13 +-
...chatwidget__tests__chat_small_idle_h1.snap | 2 +-
...chatwidget__tests__chat_small_idle_h2.snap | 2 +-
...chatwidget__tests__chat_small_idle_h3.snap | 2 +-
...twidget__tests__chat_small_running_h1.snap | 2 +-
...twidget__tests__chat_small_running_h2.snap | 2 +-
...twidget__tests__chat_small_running_h3.snap | 2 +-
...exec_and_status_layout_vt100_snapshot.snap | 9 +-
...t_markdown_code_blocks_vt100_snapshot.snap | 2 +-
...e_final_message_are_rendered_snapshot.snap | 2 +-
...n_message_without_deltas_are_rendered.snap | 2 +-
...atwidget__tests__status_widget_active.snap | 5 +-
codex-rs/tui/src/chatwidget/tests.rs | 142 +------
codex-rs/tui/src/color.rs | 75 ++++
codex-rs/tui/src/custom_terminal.rs | 217 +++++++++-
codex-rs/tui/src/exec_cell/render.rs | 2 +-
codex-rs/tui/src/history_cell.rs | 48 ++-
codex-rs/tui/src/insert_history.rs | 112 ++---
codex-rs/tui/src/key_hint.rs | 4 +-
codex-rs/tui/src/lib.rs | 6 +
codex-rs/tui/src/render/line_utils.rs | 2 +-
codex-rs/tui/src/shimmer.rs | 28 +-
...wraps_and_prefixes_each_line_snapshot.snap | 8 +-
codex-rs/tui/src/style.rs | 44 ++
codex-rs/tui/src/terminal_palette.rs | 398 ++++++++++++++++++
codex-rs/tui/src/test_backend.rs | 123 ++++++
codex-rs/tui/src/tui.rs | 8 +
codex-rs/tui/src/wrapping.rs | 4 +-
codex-rs/tui/tests/all.rs | 3 +
codex-rs/tui/tests/suite/mod.rs | 1 -
codex-rs/tui/tests/suite/vt100_history.rs | 130 +-----
codex-rs/tui/tests/suite/vt100_live_commit.rs | 61 +--
.../tui/tests/suite/vt100_streaming_no_dup.rs | 76 ----
codex-rs/tui/tests/test_backend.rs | 4 +
50 files changed, 1181 insertions(+), 623 deletions(-)
create mode 100644 codex-rs/tui/src/color.rs
create mode 100644 codex-rs/tui/src/style.rs
create mode 100644 codex-rs/tui/src/terminal_palette.rs
create mode 100644 codex-rs/tui/src/test_backend.rs
delete mode 100644 codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs
create mode 100644 codex-rs/tui/tests/test_backend.rs
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index a9b0bb1b..cf15e02d 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -361,6 +361,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_base_instructions_override_in_request() {
+ skip_if_no_network!();
// Mock server
let server = MockServer::start().await;
@@ -558,6 +559,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_user_instructions_message_in_request() {
+ skip_if_no_network!();
let server = MockServer::start().await;
let first = ResponseTemplate::new(200)
@@ -755,6 +757,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_count_includes_rate_limits_snapshot() {
+ skip_if_no_network!();
let server = MockServer::start().await;
let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]);
@@ -899,6 +902,7 @@ async fn token_count_includes_rate_limits_snapshot() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
+ skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let response = ResponseTemplate::new(429)
@@ -978,6 +982,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn azure_overrides_assign_properties_used_for_responses_url() {
+ skip_if_no_network!();
let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
// Mock server
@@ -1054,6 +1059,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn env_var_overrides_loaded_auth() {
+ skip_if_no_network!();
let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
// Mock server
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index ffc49fd3..51f5c235 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -40,23 +40,20 @@ codex-login = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
color-eyre = { workspace = true }
-crossterm = { workspace = true, features = [
- "bracketed-paste",
- "event-stream",
-] }
-dirs = { workspace = true }
+crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
diffy = { workspace = true }
-image = { workspace = true, features = [
- "jpeg",
- "png",
-] }
+dirs = { workspace = true }
+image = { workspace = true, features = ["jpeg", "png"] }
itertools = { workspace = true }
lazy_static = { workspace = true }
mcp-types = { workspace = true }
path-clean = { workspace = true }
+pathdiff = { workspace = true }
+pulldown-cmark = { workspace = true }
rand = { workspace = true }
ratatui = { workspace = true, features = [
"scrolling-regions",
+ "unstable-backend-writer",
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
@@ -80,11 +77,9 @@ tokio-stream = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
-pulldown-cmark = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
-pathdiff = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs
index cbea2c02..cf03784d 100644
--- a/codex-rs/tui/src/app_backtrack.rs
+++ b/codex-rs/tui/src/app_backtrack.rs
@@ -447,7 +447,7 @@ mod tests {
.iter()
.map(|span| span.content.as_ref())
.collect();
- assert_eq!(intro_text, "> intro");
+ assert_eq!(intro_text, "• intro");
}
#[test]
@@ -479,7 +479,7 @@ mod tests {
.iter()
.map(|span| span.content.as_ref())
.collect();
- assert_eq!(intro_text, "> intro");
+ assert_eq!(intro_text, "• intro");
let user_first = cells[1]
.as_any()
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 55762979..b3c1eea6 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -8,15 +8,10 @@ use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
-use ratatui::style::Color;
-use ratatui::style::Modifier;
-use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
-use ratatui::widgets::BorderType;
-use ratatui::widgets::Borders;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
@@ -30,6 +25,8 @@ use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::slash_command::SlashCommand;
+use crate::style::user_message_style;
+use crate::terminal_palette;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::app_event::AppEvent;
@@ -97,8 +94,6 @@ enum ActivePopup {
}
const FOOTER_HINT_HEIGHT: u16 = 1;
-const FOOTER_SPACING_HEIGHT: u16 = 1;
-const FOOTER_HEIGHT_WITH_HINT: u16 = FOOTER_HINT_HEIGHT + FOOTER_SPACING_HEIGHT;
impl ChatComposer {
pub fn new(
@@ -137,30 +132,40 @@ impl ChatComposer {
}
pub fn desired_height(&self, width: u16) -> u16 {
- // Leave 1 column for the left border and 1 column for left padding
self.textarea
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
+ + 2
+ match &self.active_popup {
- ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
+ ActivePopup::None => FOOTER_HINT_HEIGHT,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
- pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
+ fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
- ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
+ ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT),
};
- let [textarea_rect, _] =
+ let mut area = area;
+ // Leave an empty row at the top, unless there isn't room.
+ if area.height > 1 {
+ area.height -= 1;
+ area.y += 1;
+ }
+ let [composer_rect, popup_rect] =
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
- let mut textarea_rect = textarea_rect;
- // Leave 1 for border and 1 for padding
+ let mut textarea_rect = composer_rect;
textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS);
textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
+ [composer_rect, textarea_rect, popup_rect]
+ }
+
+ pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
+ let [_, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
@@ -1232,19 +1237,13 @@ impl ChatComposer {
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
- let (popup_constraint, hint_spacing) = match &self.active_popup {
- ActivePopup::Command(popup) => (
- Constraint::Max(popup.calculate_required_height(area.width)),
- 0,
- ),
- ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
- ActivePopup::None => (
- Constraint::Length(FOOTER_HEIGHT_WITH_HINT),
- FOOTER_SPACING_HEIGHT,
- ),
- };
- let [textarea_rect, popup_rect] =
- Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
+ let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
+ if !matches!(self.active_popup, ActivePopup::None) {
+ buf.set_style(
+ popup_rect,
+ user_message_style(terminal_palette::default_bg()),
+ );
+ }
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
@@ -1253,16 +1252,9 @@ impl WidgetRef for ChatComposer {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
- let hint_rect = if hint_spacing > 0 {
- let [_, hint_rect] = Layout::vertical([
- Constraint::Length(hint_spacing),
- Constraint::Length(FOOTER_HINT_HEIGHT),
- ])
- .areas(popup_rect);
- hint_rect
- } else {
- popup_rect
- };
+ let mut hint_rect = popup_rect;
+ hint_rect.x += 2;
+ hint_rect.width = hint_rect.width.saturating_sub(2);
render_footer(
hint_rect,
buf,
@@ -1276,23 +1268,17 @@ impl WidgetRef for ChatComposer {
);
}
}
- let border_style = if self.has_focus {
- Style::default().fg(Color::Cyan)
- } else {
- Style::default().add_modifier(Modifier::DIM)
- };
- Block::default()
- .borders(Borders::LEFT)
- .border_type(BorderType::QuadrantOutside)
- .border_style(border_style)
- .render_ref(
- Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height),
- buf,
- );
- let mut textarea_rect = textarea_rect;
- // Leave 1 for border and 1 for padding
- textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS);
- textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
+ let style = user_message_style(terminal_palette::default_bg());
+ let mut block_rect = composer_rect;
+ block_rect.y = composer_rect.y.saturating_sub(1);
+ block_rect.height = composer_rect.height.saturating_add(1);
+ Block::default().style(style).render_ref(block_rect, buf);
+ buf.set_span(
+ composer_rect.x,
+ composer_rect.y,
+ &"›".bold(),
+ composer_rect.width,
+ );
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
index a0933f4a..32216f07 100644
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
@@ -209,8 +209,8 @@ impl WidgetRef for CommandPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
- false,
"no matches",
+ false,
);
}
}
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 65dfe693..e017b504 100644
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
@@ -144,8 +144,8 @@ impl WidgetRef for &FileSearchPopup {
&rows_all,
&self.state,
MAX_POPUP_ROWS,
- false,
empty_message,
+ false,
);
}
}
diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs
index c95f1ee0..b20d7476 100644
--- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs
+++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs
@@ -454,8 +454,8 @@ impl BottomPaneView for ListSelectionView {
&rows,
&self.state,
MAX_POPUP_ROWS,
- true,
"no matches",
+ true,
);
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 062ccdbb..42af749b 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -150,7 +150,9 @@ impl BottomPane {
let status_height = self
.status
.as_ref()
- .map_or(0, |status| status.desired_height(area.width));
+ .map_or(0, |status| status.desired_height(area.width))
+ .min(area.height.saturating_sub(1));
+
Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area)
}
}
@@ -616,7 +618,7 @@ mod tests {
// Composer placeholder should be visible somewhere below.
let mut found_composer = false;
- for y in 1..area.height.saturating_sub(2) {
+ for y in 1..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
index 38eaab74..f3b23801 100644
--- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
+++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
@@ -8,9 +8,6 @@ use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
-use ratatui::widgets::Block;
-use ratatui::widgets::BorderType;
-use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
@@ -119,15 +116,21 @@ pub(crate) fn render_rows(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
- _dim_non_selected: bool,
empty_message: &str,
+ include_border: bool,
) {
- // Always draw a dim left border to match other popups.
- let block = Block::default()
- .borders(Borders::LEFT)
- .border_type(BorderType::QuadrantOutside)
- .border_style(Style::default().add_modifier(Modifier::DIM));
- block.render(area, buf);
+ if include_border {
+ use ratatui::widgets::Block;
+ use ratatui::widgets::BorderType;
+ use ratatui::widgets::Borders;
+
+ // Always draw a dim left border to match other popups.
+ let block = Block::default()
+ .borders(Borders::LEFT)
+ .border_type(BorderType::QuadrantOutside)
+ .border_style(Style::default().add_modifier(Modifier::DIM));
+ block.render(area, buf);
+ }
// Content renders to the right of the border with the same live prefix
// padding used by the composer so the popup aligns with the input text.
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 c9f5cb97..3f57cab0 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] "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› [Pasted Content 1002 chars][Pasted Content 1004 chars] "
+" "
+" "
+" "
+" "
+" "
+" "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃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 823a41cf..beccbaf7 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()
---
-"▌ Ask Codex to do anything "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› Ask Codex to do anything "
+" "
+" "
+" "
+" "
+" "
+" "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃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 d89c4033..263c7cc5 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] "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› [Pasted Content 1005 chars] "
+" "
+" "
+" "
+" "
+" "
+" "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃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 636d5c22..beb2d8e4 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 "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste "
+" "
+" "
+" "
+" "
+" "
+" "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap
index 9c667c0d..42532620 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap
@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
-"▌ /mo "
-"▌ "
-"▌ /model choose what model and reasoning effort to use "
-"▌ /mention mention a file "
+" "
+"› /mo "
+" /model choose what model and reasoning effort to use "
+" /mention mention a file "
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 111e45b6..41f0f03d 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 "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
-"▌ "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› short "
+" "
+" "
+" "
+" "
+" "
+" "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index a373338f..0bf77f99 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -59,6 +59,7 @@ use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
+use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
@@ -79,13 +80,11 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PatchEventType;
-use crate::history_cell::RateLimitSnapshotDisplay;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
+use crate::status::RateLimitSnapshotDisplay;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
-// streaming internals are provided by crate::streaming and crate::markdown_stream
-use crate::bottom_pane::ApprovalRequest;
mod interrupts;
use self::interrupts::InterruptManager;
mod agent;
@@ -417,7 +416,7 @@ impl ChatWidget {
.and_then(|window| window.window_minutes),
);
- let display = history_cell::rate_limit_snapshot_display(&snapshot, Local::now());
+ let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now());
self.rate_limit_snapshot = Some(display);
if !warnings.is_empty() {
@@ -1542,7 +1541,7 @@ impl ChatWidget {
default_usage = TokenUsage::default();
&default_usage
};
- self.add_to_history(history_cell::new_status_output(
+ self.add_to_history(crate::status::new_status_output(
&self.config,
usage_ref,
&self.conversation_id,
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
index bca67dfe..97709b61 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
@@ -1,8 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
-expression: visible_after
+expression: "lines[start_idx..].join(\"\\n\")"
---
-> I’m going to scan the workspace and Cargo manifests to see build profiles and
+• I need to check the codex-rs repository to explain why the project's binaries
+ are large. The user is likely seeking specifics about the setup: are Rust
+ builds static, what features are enabled, and is debug information included?
+ It could be due to static linking, included OpenSSL, or how panic handling
+ is set up. I should look into the Cargo.toml file to confirm features and
+ profiles without needing to edit any code. Let's get started on this!
+
+• I’m going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then I’ll summarize the main causes.
• Explored
@@ -103,7 +110,7 @@ expression: visible_after
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question.
-> Here’s what’s driving size in this workspace’s binaries.
+• Here’s what’s driving size in this workspace’s binaries.
Main Causes
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap
index 3a6e6532..51f7901d 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
-"▌ Ask Codex to do anything "
+"› Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
index 48e6154e..fb07268d 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap
@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
-"▌ Ask Codex to do anything "
" "
+"› Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
index c77477a0..60ef7938 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap
@@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
-"▌ Ask Codex to do anything "
+"› Ask Codex to do anything "
" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap
index 3a6e6532..51f7901d 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
-"▌ Ask Codex to do anything "
+"› Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
index 917f0126..5164cd45 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Thinking (0s • Esc to interrupt) "
-"▌ Ask Codex to do anything "
+"› Ask Codex to do anything "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
index c77477a0..60ef7938 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap
@@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
-"▌ Ask Codex to do anything "
+"› Ask Codex to do anything "
" "
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap
index 9a285904..248a57f8 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap
@@ -1,8 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
-expression: visual
+expression: term.backend().vt100().screen().contents()
---
-> I’m going to search the repo for where “Change Approved” is rendered to update
+• I’m going to search the repo for where “Change Approved” is rendered to update
that view.
• Explored
@@ -11,6 +11,7 @@ expression: visual
Investigating rendering code (0s • Esc to interrupt)
-▌ Summarize recent commits
-⏎ send ⌃J newline ⌃T transcript ⌃C quit
+› Summarize recent commits
+
+ ⏎ send ⌃J newline ⌃T transcript ⌃C quit
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap
index ca4e72a9..1ed73b5f 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap
@@ -2,7 +2,7 @@
source: tui/src/chatwidget/tests.rs
expression: visual
---
-> -- Indented code block (4 spaces)
+• -- Indented code block (4 spaces)
SELECT *
FROM "users"
WHERE "email" LIKE '%@example.com';
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap
index ae86ddc8..60620871 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
-> Here is the result.
+• Here is the result.
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap
index ae86ddc8..60620871 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
-> Here is the result.
+• Here is the result.
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
index e28659e2..9c94206a 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap
@@ -5,7 +5,8 @@ expression: terminal.backend()
" "
" Analyzing (0s • Esc to interrupt) "
" "
-"▌ Ask Codex to do anything "
" "
-"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
+"› Ask Codex to do anything "
+" "
+" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
" "
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 53ad6097..0c9ebc73 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -1,6 +1,7 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
+use crate::test_backend::VT100Backend;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
@@ -81,6 +82,7 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json
payload
}
+/*
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -138,6 +140,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
"expected final answer text to be flushed to history"
);
}
+*/
#[test]
fn resumed_initial_messages_render_history() {
@@ -1001,7 +1004,7 @@ async fn binary_size_transcript_snapshot() {
let width: u16 = 80;
let height: u16 = 2000;
let viewport = Rect::new(0, height - 1, width, 1);
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
@@ -1010,7 +1013,6 @@ async fn binary_size_transcript_snapshot() {
let file = open_fixture("binary-size-log.jsonl");
let reader = BufReader::new(file);
let mut transcript = String::new();
- let mut ansi: Vec = Vec::new();
let mut has_emitted_history = false;
for line in reader.lines() {
@@ -1071,11 +1073,7 @@ async fn binary_size_transcript_snapshot() {
}
has_emitted_history = true;
transcript.push_str(&lines_to_single_string(&lines));
- crate::insert_history::insert_history_lines_to_writer(
- &mut terminal,
- &mut ansi,
- lines,
- );
+ crate::insert_history::insert_history_lines(&mut terminal, lines);
}
}
}
@@ -1096,11 +1094,7 @@ async fn binary_size_transcript_snapshot() {
}
has_emitted_history = true;
transcript.push_str(&lines_to_single_string(&lines));
- crate::insert_history::insert_history_lines_to_writer(
- &mut terminal,
- &mut ansi,
- lines,
- );
+ crate::insert_history::insert_history_lines(&mut terminal, lines);
}
}
}
@@ -1111,13 +1105,12 @@ async fn binary_size_transcript_snapshot() {
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
- let mut parser = vt100::Parser::new(height, width, 0);
- parser.process(&ansi);
+ let screen = terminal.backend().vt100().screen();
let mut lines: Vec = Vec::with_capacity(height as usize);
for row in 0..height {
let mut s = String::with_capacity(width as usize);
for col in 0..width {
- if let Some(cell) = parser.screen().cell(row, col) {
+ if let Some(cell) = screen.cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
s.push(ch);
} else {
@@ -1144,7 +1137,7 @@ async fn binary_size_transcript_snapshot() {
// Prefer the first assistant content line (blockquote '>' prefix) after the marker;
// fallback to the first non-empty, non-'thinking' line.
let start_idx = (last_marker_line_idx + 1..lines.len())
- .find(|&idx| lines[idx].trim_start().starts_with('>'))
+ .find(|&idx| lines[idx].trim_start().starts_with('•'))
.unwrap_or_else(|| {
(last_marker_line_idx + 1..lines.len())
.find(|&idx| {
@@ -1154,28 +1147,8 @@ async fn binary_size_transcript_snapshot() {
.expect("no content line found after marker")
});
- let mut compare_lines: Vec = Vec::new();
- // Ensure the first line is trimmed-left to match the fixture shape.
- compare_lines.push(lines[start_idx].trim_start().to_string());
- compare_lines.extend(lines[(start_idx + 1)..].iter().cloned());
- let visible_after = compare_lines.join("\n");
-
- // Normalize: drop a leading 'thinking' line if present to avoid coupling
- // to whether the reasoning header is rendered in history.
- fn drop_leading_thinking(s: &str) -> String {
- let mut it = s.lines();
- let first = it.next();
- let rest = it.collect::>().join("\n");
- if first.is_some_and(|l| l.trim() == "thinking") {
- rest
- } else {
- s.to_string()
- }
- }
- let visible_after = drop_leading_thinking(&visible_after);
-
// Snapshot the normalized visible transcript following the banner.
- assert_snapshot!("binary_size_ideal_response", visible_after);
+ assert_snapshot!("binary_size_ideal_response", lines[start_idx..].join("\n"));
}
//
@@ -2104,66 +2077,25 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
chat.bottom_pane
.set_composer_text("Summarize recent commits".to_string());
- // Dimensions
let width: u16 = 80;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 40;
- let viewport = Rect::new(0, vt_height - ui_height, width, ui_height);
+ let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
- // Use TestBackend for the terminal (no real ANSI emitted by drawing),
- // but capture VT100 escape stream for history insertion with a separate writer.
- let backend = ratatui::backend::TestBackend::new(width, vt_height);
+ let backend = VT100Backend::new(width, vt_height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(viewport);
- // 1) Apply any pending history insertions by emitting ANSI to a buffer via insert_history_lines_to_writer
- let mut ansi: Vec = Vec::new();
for lines in drain_insert_history(&mut rx) {
- crate::insert_history::insert_history_lines_to_writer(&mut term, &mut ansi, lines);
+ crate::insert_history::insert_history_lines(&mut term, lines);
}
- // 2) Render the ChatWidget UI into an off-screen buffer using WidgetRef directly
- let mut ui_buf = Buffer::empty(viewport);
- (&chat).render_ref(viewport, &mut ui_buf);
+ term.draw(|f| {
+ (&chat).render_ref(f.area(), f.buffer_mut());
+ })
+ .unwrap();
- // 3) Build VT100 visual from the captured ANSI
- let mut parser = vt100::Parser::new(vt_height, width, 0);
- parser.process(&ansi);
- let mut vt_lines: Vec = (0..vt_height)
- .map(|row| {
- let mut s = String::with_capacity(width as usize);
- for col in 0..width {
- if let Some(cell) = parser.screen().cell(row, col) {
- if let Some(ch) = cell.contents().chars().next() {
- s.push(ch);
- } else {
- s.push(' ');
- }
- } else {
- s.push(' ');
- }
- }
- s.trim_end().to_string()
- })
- .collect();
-
- // 4) Overlay UI buffer content into the viewport region of the VT output
- for rel_y in 0..viewport.height {
- let y = viewport.y + rel_y;
- let mut line = String::with_capacity(width as usize);
- for x in 0..viewport.width {
- let ch = ui_buf[(viewport.x + x, viewport.y + rel_y)]
- .symbol()
- .chars()
- .next()
- .unwrap_or(' ');
- line.push(ch);
- }
- vt_lines[y as usize] = line.trim_end().to_string();
- }
-
- let visual = vt_lines.join("\n");
- assert_snapshot!(visual);
+ assert_snapshot!(term.backend().vt100().screen().contents());
}
// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks
@@ -2182,13 +2114,11 @@ fn chatwidget_markdown_code_blocks_vt100_snapshot() {
// Build a vt100 visual from the history insertions only (no UI overlay)
let width: u16 = 80;
let height: u16 = 50;
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
// Place viewport at the last line so that history lines insert above it
term.set_viewport_area(Rect::new(0, height - 1, width, 1));
- let mut ansi: Vec = Vec::new();
-
// Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage).
let source: &str = r#"
@@ -2234,9 +2164,7 @@ printf 'fenced within fenced\n'
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = app_ev {
let lines = cell.display_lines(width);
- crate::insert_history::insert_history_lines_to_writer(
- &mut term, &mut ansi, lines,
- );
+ crate::insert_history::insert_history_lines(&mut term, lines);
inserted_any = true;
}
}
@@ -2254,34 +2182,8 @@ printf 'fenced within fenced\n'
}),
});
for lines in drain_insert_history(&mut rx) {
- crate::insert_history::insert_history_lines_to_writer(&mut term, &mut ansi, lines);
+ crate::insert_history::insert_history_lines(&mut term, lines);
}
- let mut parser = vt100::Parser::new(height, width, 0);
- parser.process(&ansi);
-
- let mut vt_lines: Vec = (0..height)
- .map(|row| {
- let mut s = String::with_capacity(width as usize);
- for col in 0..width {
- if let Some(cell) = parser.screen().cell(row, col) {
- if let Some(ch) = cell.contents().chars().next() {
- s.push(ch);
- } else {
- s.push(' ');
- }
- } else {
- s.push(' ');
- }
- }
- s.trim_end().to_string()
- })
- .collect();
-
- // Compact trailing blank rows for a stable snapshot
- while matches!(vt_lines.last(), Some(l) if l.trim().is_empty()) {
- vt_lines.pop();
- }
- let visual = vt_lines.join("\n");
- assert_snapshot!(visual);
+ assert_snapshot!(term.backend().vt100().screen().contents());
}
diff --git a/codex-rs/tui/src/color.rs b/codex-rs/tui/src/color.rs
new file mode 100644
index 00000000..f5121a1f
--- /dev/null
+++ b/codex-rs/tui/src/color.rs
@@ -0,0 +1,75 @@
+pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool {
+ let (r, g, b) = bg;
+ let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
+ y > 128.0
+}
+
+pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
+ let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8;
+ let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8;
+ let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8;
+ (r, g, b)
+}
+
+/// Returns the perceptual color distance between two RGB colors.
+/// Uses the CIE76 formula (Euclidean distance in Lab space approximation).
+pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 {
+ // Convert sRGB to linear RGB
+ fn srgb_to_linear(c: u8) -> f32 {
+ let c = c as f32 / 255.0;
+ if c <= 0.04045 {
+ c / 12.92
+ } else {
+ ((c + 0.055) / 1.055).powf(2.4)
+ }
+ }
+
+ // Convert RGB to XYZ
+ fn rgb_to_xyz(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
+ let r = srgb_to_linear(r);
+ let g = srgb_to_linear(g);
+ let b = srgb_to_linear(b);
+
+ let x = r * 0.4124 + g * 0.3576 + b * 0.1805;
+ let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
+ let z = r * 0.0193 + g * 0.1192 + b * 0.9505;
+ (x, y, z)
+ }
+
+ // Convert XYZ to Lab
+ fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) {
+ // D65 reference white
+ let xr = x / 0.95047;
+ let yr = y / 1.00000;
+ let zr = z / 1.08883;
+
+ fn f(t: f32) -> f32 {
+ if t > 0.008856 {
+ t.powf(1.0 / 3.0)
+ } else {
+ 7.787 * t + 16.0 / 116.0
+ }
+ }
+
+ let fx = f(xr);
+ let fy = f(yr);
+ let fz = f(zr);
+
+ let l = 116.0 * fy - 16.0;
+ let a = 500.0 * (fx - fy);
+ let b = 200.0 * (fy - fz);
+ (l, a, b)
+ }
+
+ let (x1, y1, z1) = rgb_to_xyz(a.0, a.1, a.2);
+ let (x2, y2, z2) = rgb_to_xyz(b.0, b.1, b.2);
+
+ let (l1, a1, b1) = xyz_to_lab(x1, y1, z1);
+ let (l2, a2, b2) = xyz_to_lab(x2, y2, z2);
+
+ let dl = l1 - l2;
+ let da = a1 - a2;
+ let db = b1 - b2;
+
+ (dl * dl + da * da + db * db).sqrt()
+}
diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs
index 9de17412..3eddbec5 100644
--- a/codex-rs/tui/src/custom_terminal.rs
+++ b/codex-rs/tui/src/custom_terminal.rs
@@ -22,13 +22,25 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use std::io;
+use std::io::Write;
+use crossterm::cursor::MoveTo;
+use crossterm::queue;
+use crossterm::style::Colors;
+use crossterm::style::Print;
+use crossterm::style::SetAttribute;
+use crossterm::style::SetBackgroundColor;
+use crossterm::style::SetColors;
+use crossterm::style::SetForegroundColor;
+use crossterm::terminal::Clear;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::buffer::Buffer;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::layout::Size;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
#[derive(Debug, Hash)]
@@ -90,7 +102,7 @@ impl Frame<'_> {
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct Terminal
where
- B: Backend,
+ B: Backend + Write,
{
/// The backend used to interface with the terminal
backend: B,
@@ -113,6 +125,7 @@ where
impl Drop for Terminal
where
B: Backend,
+ B: Write,
{
#[allow(clippy::print_stderr)]
fn drop(&mut self) {
@@ -128,6 +141,7 @@ where
impl Terminal
where
B: Backend,
+ B: Write,
{
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
pub fn with_options(mut backend: B) -> io::Result {
@@ -176,11 +190,15 @@ where
pub fn flush(&mut self) -> io::Result<()> {
let previous_buffer = &self.buffers[1 - self.current];
let current_buffer = &self.buffers[self.current];
- let updates = previous_buffer.diff(current_buffer);
- if let Some((col, row, _)) = updates.last() {
- self.last_known_cursor_pos = Position { x: *col, y: *row };
+ let updates = diff_buffers(previous_buffer, current_buffer);
+ if let Some(DrawCommand::Put { x, y, .. }) = updates
+ .iter()
+ .rev()
+ .find(|cmd| matches!(cmd, DrawCommand::Put { .. }))
+ {
+ self.last_known_cursor_pos = Position { x: *x, y: *y };
}
- self.backend.draw(updates.into_iter())
+ draw(&mut self.backend, updates.into_iter())
}
/// Updates the Terminal so that internal buffers match the requested area.
@@ -307,8 +325,7 @@ where
self.swap_buffers();
- // Flush
- self.backend.flush()?;
+ ratatui::backend::Backend::flush(&mut self.backend)?;
Ok(())
}
@@ -367,3 +384,189 @@ where
self.backend.size()
}
}
+
+use ratatui::buffer::Cell;
+use unicode_width::UnicodeWidthStr;
+
+#[derive(Debug)]
+enum DrawCommand<'a> {
+ Put { x: u16, y: u16, cell: &'a Cell },
+ ClearToEnd { x: u16, y: u16, bg: Color },
+}
+
+fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> {
+ let previous_buffer = &a.content;
+ let next_buffer = &b.content;
+
+ let mut updates = vec![];
+ let mut last_nonblank_column = vec![0; a.area.height as usize];
+ for y in 0..a.area.height {
+ let row_start = y as usize * a.area.width as usize;
+ let row_end = row_start + a.area.width as usize;
+ let row = &next_buffer[row_start..row_end];
+ let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset);
+
+ let x = row
+ .iter()
+ .rposition(|cell| cell.symbol() != " " || cell.bg != bg)
+ .unwrap_or(0);
+ last_nonblank_column[y as usize] = x as u16;
+ let (x_abs, y_abs) = a.pos_of(row_start + x + 1);
+ updates.push(DrawCommand::ClearToEnd {
+ x: x_abs,
+ y: y_abs,
+ bg,
+ });
+ }
+
+ // Cells invalidated by drawing/replacing preceding multi-width characters:
+ let mut invalidated: usize = 0;
+ // Cells from the current buffer to skip due to preceding multi-width characters taking
+ // their place (the skipped cells should be blank anyway), or due to per-cell-skipping:
+ let mut to_skip: usize = 0;
+ for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
+ if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
+ let (x, y) = a.pos_of(i);
+ let row = i / a.area.width as usize;
+ if x <= last_nonblank_column[row] {
+ updates.push(DrawCommand::Put {
+ x,
+ y,
+ cell: &next_buffer[i],
+ });
+ }
+ }
+
+ to_skip = current.symbol().width().saturating_sub(1);
+
+ let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width());
+ invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ updates
+}
+
+fn draw<'a, I>(writer: &mut impl Write, commands: I) -> io::Result<()>
+where
+ I: Iterator- >,
+{
+ let mut fg = Color::Reset;
+ let mut bg = Color::Reset;
+ let mut modifier = Modifier::empty();
+ let mut last_pos: Option = None;
+ for command in commands {
+ let (x, y) = match command {
+ DrawCommand::Put { x, y, .. } => (x, y),
+ DrawCommand::ClearToEnd { x, y, .. } => (x, y),
+ };
+ // Move the cursor if the previous location was not (x - 1, y)
+ if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) {
+ queue!(writer, MoveTo(x, y))?;
+ }
+ last_pos = Some(Position { x, y });
+ match command {
+ DrawCommand::Put { cell, .. } => {
+ if cell.modifier != modifier {
+ let diff = ModifierDiff {
+ from: modifier,
+ to: cell.modifier,
+ };
+ diff.queue(writer)?;
+ modifier = cell.modifier;
+ }
+ if cell.fg != fg || cell.bg != bg {
+ queue!(
+ writer,
+ SetColors(Colors::new(cell.fg.into(), cell.bg.into()))
+ )?;
+ fg = cell.fg;
+ bg = cell.bg;
+ }
+
+ queue!(writer, Print(cell.symbol()))?;
+ }
+ DrawCommand::ClearToEnd { bg: clear_bg, .. } => {
+ queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?;
+ modifier = Modifier::empty();
+ queue!(writer, SetBackgroundColor(clear_bg.into()))?;
+ bg = clear_bg;
+ queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?;
+ }
+ }
+ }
+
+ queue!(
+ writer,
+ SetForegroundColor(crossterm::style::Color::Reset),
+ SetBackgroundColor(crossterm::style::Color::Reset),
+ SetAttribute(crossterm::style::Attribute::Reset),
+ )?;
+
+ Ok(())
+}
+
+/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier`
+/// values. This is useful when updating the terminal display, as it allows for more
+/// efficient updates by only sending the necessary changes.
+struct ModifierDiff {
+ pub from: Modifier,
+ pub to: Modifier,
+}
+
+impl ModifierDiff {
+ fn queue(self, w: &mut W) -> io::Result<()> {
+ use crossterm::style::Attribute as CAttribute;
+ let removed = self.from - self.to;
+ if removed.contains(Modifier::REVERSED) {
+ queue!(w, SetAttribute(CAttribute::NoReverse))?;
+ }
+ if removed.contains(Modifier::BOLD) {
+ queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
+ if self.to.contains(Modifier::DIM) {
+ queue!(w, SetAttribute(CAttribute::Dim))?;
+ }
+ }
+ if removed.contains(Modifier::ITALIC) {
+ queue!(w, SetAttribute(CAttribute::NoItalic))?;
+ }
+ if removed.contains(Modifier::UNDERLINED) {
+ queue!(w, SetAttribute(CAttribute::NoUnderline))?;
+ }
+ if removed.contains(Modifier::DIM) {
+ queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
+ }
+ if removed.contains(Modifier::CROSSED_OUT) {
+ queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
+ }
+ if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
+ queue!(w, SetAttribute(CAttribute::NoBlink))?;
+ }
+
+ let added = self.to - self.from;
+ if added.contains(Modifier::REVERSED) {
+ queue!(w, SetAttribute(CAttribute::Reverse))?;
+ }
+ if added.contains(Modifier::BOLD) {
+ queue!(w, SetAttribute(CAttribute::Bold))?;
+ }
+ if added.contains(Modifier::ITALIC) {
+ queue!(w, SetAttribute(CAttribute::Italic))?;
+ }
+ if added.contains(Modifier::UNDERLINED) {
+ queue!(w, SetAttribute(CAttribute::Underlined))?;
+ }
+ if added.contains(Modifier::DIM) {
+ queue!(w, SetAttribute(CAttribute::Dim))?;
+ }
+ if added.contains(Modifier::CROSSED_OUT) {
+ queue!(w, SetAttribute(CAttribute::CrossedOut))?;
+ }
+ if added.contains(Modifier::SLOW_BLINK) {
+ queue!(w, SetAttribute(CAttribute::SlowBlink))?;
+ }
+ if added.contains(Modifier::RAPID_BLINK) {
+ queue!(w, SetAttribute(CAttribute::RapidBlink))?;
+ }
+
+ Ok(())
+ }
+}
diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs
index a096dbe5..27cb53d7 100644
--- a/codex-rs/tui/src/exec_cell/render.rs
+++ b/codex-rs/tui/src/exec_cell/render.rs
@@ -200,7 +200,7 @@ impl ExecCell {
if self.is_active() {
spinner(self.active_start_time())
} else {
- "•".bold()
+ "•".into()
},
" ".into(),
if self.is_active() {
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index d95663d9..941a7fbd 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -9,9 +9,8 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
-pub(crate) use crate::status::RateLimitSnapshotDisplay;
-pub(crate) use crate::status::new_status_output;
-pub(crate) use crate::status::rate_limit_snapshot_display;
+use crate::style::user_message_style;
+use crate::terminal_palette::default_bg;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::wrapping::RtOptions;
@@ -97,17 +96,24 @@ impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec> {
let mut lines: Vec> = Vec::new();
- // Wrap the content first, then prefix each wrapped line with the marker.
+ // Use ratatui-aware word wrapping and prefixing to avoid lifetime issues.
let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space
- let wrapped = textwrap::wrap(
- &self.message,
- textwrap::Options::new(wrap_width as usize)
- .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), // Match textarea wrap
+
+ let style = user_message_style(default_bg());
+
+ // Use our ratatui wrapping helpers for correct styling and lifetimes.
+ let wrapped = word_wrap_lines(
+ &self
+ .message
+ .lines()
+ .map(|l| Line::from(l).style(style))
+ .collect::>(),
+ RtOptions::new(wrap_width as usize),
);
- for line in wrapped {
- lines.push(vec!["▌ ".cyan().dim(), line.to_string().dim()].into());
- }
+ lines.push(Line::from("").style(style));
+ lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into()));
+ lines.push(Line::from("").style(style));
lines
}
@@ -139,7 +145,21 @@ impl HistoryCell for ReasoningSummaryCell {
let summary_lines = self
.content
.iter()
- .map(|l| l.clone().dim().italic())
+ .map(|line| {
+ Line::from(
+ line.spans
+ .iter()
+ .map(|span| {
+ Span::styled(
+ span.content.clone().into_owned(),
+ span.style
+ .add_modifier(Modifier::ITALIC)
+ .add_modifier(Modifier::DIM),
+ )
+ })
+ .collect::>(),
+ )
+ })
.collect::>();
word_wrap_lines(
@@ -179,7 +199,7 @@ impl HistoryCell for AgentMessageCell {
&self.lines,
RtOptions::new(width as usize)
.initial_indent(if self.is_first_line {
- "> ".into()
+ "• ".into()
} else {
" ".into()
})
@@ -866,7 +886,7 @@ pub(crate) fn new_mcp_tools_output(
}
pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell {
- let mut line = vec!["> ".into(), message.into()];
+ let mut line = vec!["• ".into(), message.into()];
if let Some(hint) = hint {
line.push(" ".into());
line.push(hint.dark_gray());
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
index 75731c7f..8f4bfa10 100644
--- a/codex-rs/tui/src/insert_history.rs
+++ b/codex-rs/tui/src/insert_history.rs
@@ -2,7 +2,6 @@ use std::fmt;
use std::io;
use std::io::Write;
-use crate::tui;
use crate::wrapping::word_wrap_lines_borrowed;
use crossterm::Command;
use crossterm::cursor::MoveTo;
@@ -14,7 +13,10 @@ use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
+use crossterm::terminal::Clear;
+use crossterm::terminal::ClearType;
use ratatui::layout::Size;
+use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
@@ -22,24 +24,16 @@ use ratatui::text::Span;
/// Insert `lines` above the viewport using the terminal's backend writer
/// (avoids direct stdout references).
-pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) {
- let mut out = std::io::stdout();
- insert_history_lines_to_writer(terminal, &mut out, lines);
-}
-
-/// Like `insert_history_lines`, but writes ANSI to the provided writer. This
-/// is intended for testing where a capture buffer is used instead of stdout.
-pub fn insert_history_lines_to_writer(
- terminal: &mut crate::custom_terminal::Terminal,
- writer: &mut W,
- lines: Vec,
-) where
- B: ratatui::backend::Backend,
- W: Write,
+pub fn insert_history_lines(terminal: &mut crate::custom_terminal::Terminal, lines: Vec)
+where
+ B: Backend + Write,
{
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let mut area = terminal.viewport_area;
+ let mut should_update_area = false;
+ let last_cursor_pos = terminal.last_known_cursor_pos;
+ let writer = terminal.backend_mut();
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
@@ -67,7 +61,7 @@ pub fn insert_history_lines_to_writer(
let cursor_top = area.top().saturating_sub(1);
area.y += scroll_amount;
- terminal.set_viewport_area(area);
+ should_update_area = true;
cursor_top
} else {
area.top().saturating_sub(1)
@@ -97,6 +91,21 @@ pub fn insert_history_lines_to_writer(
for line in wrapped {
queue!(writer, Print("\r\n")).ok();
+ queue!(
+ writer,
+ SetColors(Colors::new(
+ line.style
+ .fg
+ .map(std::convert::Into::into)
+ .unwrap_or(CColor::Reset),
+ line.style
+ .bg
+ .map(std::convert::Into::into)
+ .unwrap_or(CColor::Reset)
+ ))
+ )
+ .ok();
+ queue!(writer, Clear(ClearType::UntilNewLine)).ok();
// Merge line-level style into each span so that ANSI colors reflect
// line styles (e.g., blockquotes with green fg).
let merged_spans: Vec = line
@@ -113,14 +122,12 @@ pub fn insert_history_lines_to_writer(
queue!(writer, ResetScrollRegion).ok();
// Restore the cursor position to where it was before we started.
- queue!(
- writer,
- MoveTo(
- terminal.last_known_cursor_pos.x,
- terminal.last_known_cursor_pos.y
- )
- )
- .ok();
+ queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y)).ok();
+
+ let _ = writer;
+ if should_update_area {
+ terminal.set_viewport_area(area);
+ }
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -275,9 +282,9 @@ where
mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
+ use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Color;
- use vt100::Parser;
#[test]
fn writes_bold_then_regular_spans() {
@@ -312,7 +319,7 @@ mod tests {
// Set up a small off-screen terminal
let width: u16 = 40;
let height: u16 = 10;
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
// Place viewport on the last line so history inserts scroll upward
let viewport = Rect::new(0, height - 1, width, 1);
@@ -321,17 +328,12 @@ mod tests {
// Build a blockquote-like line: apply line-level green style and prefix "> "
let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]);
line = line.style(Color::Green);
- let mut ansi: Vec = Vec::new();
- insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]);
-
- // Parse ANSI using vt100 and assert at least one non-default fg color appears
- let mut parser = Parser::new(height, width, 0);
- parser.process(&ansi);
+ insert_history_lines(&mut term, vec![line]);
let mut saw_colored = false;
'outer: for row in 0..height {
for col in 0..width {
- if let Some(cell) = parser.screen().cell(row, col)
+ if let Some(cell) = term.backend().vt100().screen().cell(row, col)
&& cell.has_contents()
&& cell.fgcolor() != vt100::Color::Default
{
@@ -351,7 +353,7 @@ mod tests {
// Force wrapping by using a narrow viewport width and a long blockquote line.
let width: u16 = 20;
let height: u16 = 8;
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
// Viewport is the last line so history goes directly above it.
let viewport = Rect::new(0, height - 1, width, 1);
@@ -364,13 +366,10 @@ mod tests {
]);
line = line.style(Color::Green);
- let mut ansi: Vec = Vec::new();
- insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]);
+ insert_history_lines(&mut term, vec![line]);
// Parse and inspect the final screen buffer.
- let mut parser = Parser::new(height, width, 0);
- parser.process(&ansi);
- let screen = parser.screen();
+ let screen = term.backend().vt100().screen();
// Collect rows that are non-empty; these should correspond to our wrapped lines.
let mut non_empty_rows: Vec = Vec::new();
@@ -418,7 +417,7 @@ mod tests {
fn vt100_colored_prefix_then_plain_text_resets_color() {
let width: u16 = 40;
let height: u16 = 6;
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(0, height - 1, width, 1);
term.set_viewport_area(viewport);
@@ -429,12 +428,9 @@ mod tests {
Span::raw("Hello world"),
]);
- let mut ansi: Vec = Vec::new();
- insert_history_lines_to_writer(&mut term, &mut ansi, vec![line]);
+ insert_history_lines(&mut term, vec![line]);
- let mut parser = Parser::new(height, width, 0);
- parser.process(&ansi);
- let screen = parser.screen();
+ let screen = term.backend().vt100().screen();
// Find the first non-empty row; verify first three cells are colored, following cells default.
'rows: for row in 0..height {
@@ -483,35 +479,17 @@ mod tests {
let width: u16 = 60;
let height: u16 = 12;
- let backend = ratatui::backend::TestBackend::new(width, height);
+ let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
term.set_viewport_area(viewport);
- let mut ansi: Vec = Vec::new();
- insert_history_lines_to_writer(&mut term, &mut ansi, lines);
+ insert_history_lines(&mut term, lines);
- let mut parser = Parser::new(height, width, 0);
- parser.process(&ansi);
- let screen = parser.screen();
+ let screen = term.backend().vt100().screen();
// Reconstruct screen rows as strings to locate the 3rd level line.
- let mut rows: Vec = Vec::with_capacity(height as usize);
- for row in 0..height {
- let mut s = String::with_capacity(width as usize);
- for col in 0..width {
- if let Some(cell) = screen.cell(row, col) {
- if let Some(ch) = cell.contents().chars().next() {
- s.push(ch);
- } else {
- s.push(' ');
- }
- } else {
- s.push(' ');
- }
- }
- rows.push(s.trim_end().to_string());
- }
+ let rows: Vec = screen.rows(0, width).collect();
let needle = "1. Third level (ordered)";
let row_idx = rows
diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs
index deeee6ae..80f91d50 100644
--- a/codex-rs/tui/src/key_hint.rs
+++ b/codex-rs/tui/src/key_hint.rs
@@ -1,5 +1,5 @@
-use ratatui::style::Color;
use ratatui::style::Style;
+use ratatui::style::Stylize;
use ratatui::text::Span;
use std::fmt::Display;
@@ -25,7 +25,7 @@ const SHIFT_PREFIX: &str = "⇧";
const SHIFT_PREFIX: &str = "Shift+";
fn key_hint_style() -> Style {
- Style::default().fg(Color::Cyan)
+ Style::default().bold()
}
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 25eec9d8..4c5e7ee9 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -39,6 +39,7 @@ mod chatwidget;
mod citation_regex;
mod cli;
mod clipboard_paste;
+mod color;
pub mod custom_terminal;
mod diff_render;
mod exec_cell;
@@ -64,12 +65,17 @@ mod slash_command;
mod status;
mod status_indicator_widget;
mod streaming;
+mod style;
+mod terminal_palette;
mod text_formatting;
mod tui;
mod ui_consts;
mod version;
mod wrapping;
+#[cfg(test)]
+pub mod test_backend;
+
#[cfg(not(debug_assertions))]
mod updates;
diff --git a/codex-rs/tui/src/render/line_utils.rs b/codex-rs/tui/src/render/line_utils.rs
index 5a8e0906..175b79b2 100644
--- a/codex-rs/tui/src/render/line_utils.rs
+++ b/codex-rs/tui/src/render/line_utils.rs
@@ -53,7 +53,7 @@ pub fn prefix_lines(
subsequent_prefix.clone()
});
spans.extend(l.spans);
- Line::from(spans)
+ Line::from(spans).style(l.style)
})
.collect()
}
diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs
index b5752fc3..a6a16f2c 100644
--- a/codex-rs/tui/src/shimmer.rs
+++ b/codex-rs/tui/src/shimmer.rs
@@ -7,6 +7,12 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
+use crate::color::blend;
+use crate::terminal_palette::default_fg;
+use crate::terminal_palette::terminal_palette;
+
+const FALLBACK_DARK_GRAY: (u8, u8, u8) = (103, 103, 103);
+
static PROCESS_START: OnceLock = OnceLock::new();
fn elapsed_since_start() -> Duration {
@@ -32,6 +38,8 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> {
let band_half_width = 3.0;
let mut spans: Vec> = Vec::with_capacity(chars.len());
+ let default_fg = default_fg();
+ let palette_dark_gray = terminal_palette().map(|palette| palette[8]);
for (i, ch) in chars.iter().enumerate() {
let i_pos = i as isize + padding as isize;
let pos = pos as isize;
@@ -43,31 +51,33 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> {
} else {
0.0
};
- let brightness = 0.4 + 0.6 * t;
- let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
let style = if has_true_color {
+ let base = palette_dark_gray
+ .or(default_fg)
+ .unwrap_or(FALLBACK_DARK_GRAY);
+ let highlight = t.clamp(0.0, 1.0);
+ let (r, g, b) = blend((255, 255, 255), base, highlight);
// Allow custom RGB colors, as the implementation is thoughtfully
// adjusting the level of the default foreground color.
#[allow(clippy::disallowed_methods)]
{
Style::default()
- .fg(Color::Rgb(level, level, level))
+ .fg(Color::Rgb(r, g, b))
.add_modifier(Modifier::BOLD)
}
} else {
- color_for_level(level)
+ color_for_level(t)
};
spans.push(Span::styled(ch.to_string(), style));
}
spans
}
-fn color_for_level(level: u8) -> Style {
- // Tune thresholds so the edges of the shimmer band appear dim
- // in fallback mode (no true color support).
- if level < 160 {
+fn color_for_level(intensity: f32) -> Style {
+ // Tune fallback styling so the shimmer band reads even without RGB support.
+ if intensity < 0.2 {
Style::default().add_modifier(Modifier::DIM)
- } else if level < 224 {
+ } else if intensity < 0.6 {
Style::default()
} else {
Style::default().add_modifier(Modifier::BOLD)
diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap
index ef0ceabc..3fd59095 100644
--- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap
+++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap
@@ -2,7 +2,7 @@
source: tui/src/history_cell.rs
expression: rendered
---
-▌ one two
-▌ three four
-▌ five six
-▌ seven
+› one two
+ three four
+ five six
+ seven
diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs
new file mode 100644
index 00000000..90a8e7a8
--- /dev/null
+++ b/codex-rs/tui/src/style.rs
@@ -0,0 +1,44 @@
+use crate::color::blend;
+use crate::color::is_light;
+use crate::color::perceptual_distance;
+use crate::terminal_palette::terminal_palette;
+use ratatui::style::Color;
+use ratatui::style::Style;
+
+/// Returns the style for a user-authored message using the provided terminal background.
+pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style {
+ match terminal_bg {
+ Some(bg) => Style::default().bg(user_message_bg(bg)),
+ None => Style::default(),
+ }
+}
+
+#[allow(clippy::disallowed_methods)]
+pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
+ let top = if is_light(terminal_bg) {
+ (0, 0, 0)
+ } else {
+ (255, 255, 255)
+ };
+ let bottom = terminal_bg;
+ let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else {
+ return Color::default();
+ };
+
+ let target = blend(top, bottom, 0.1);
+ if color_level.has_16m {
+ let (r, g, b) = target;
+ Color::Rgb(r, g, b)
+ } else if color_level.has_256
+ && let Some(palette) = terminal_palette()
+ && let Some((i, _)) = palette.into_iter().enumerate().min_by(|(_, a), (_, b)| {
+ perceptual_distance(*a, target)
+ .partial_cmp(&perceptual_distance(*b, target))
+ .unwrap_or(std::cmp::Ordering::Equal)
+ })
+ {
+ Color::Indexed(i as u8)
+ } else {
+ Color::default()
+ }
+}
diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs
new file mode 100644
index 00000000..499fe3e0
--- /dev/null
+++ b/codex-rs/tui/src/terminal_palette.rs
@@ -0,0 +1,398 @@
+pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
+ imp::terminal_palette()
+}
+
+#[derive(Clone, Copy)]
+pub struct DefaultColors {
+ #[allow(dead_code)]
+ fg: (u8, u8, u8),
+ bg: (u8, u8, u8),
+}
+
+pub fn default_colors() -> Option<&'static DefaultColors> {
+ imp::default_colors()
+}
+
+#[allow(dead_code)]
+pub fn default_fg() -> Option<(u8, u8, u8)> {
+ default_colors().map(|c| c.fg)
+}
+
+pub fn default_bg() -> Option<(u8, u8, u8)> {
+ default_colors().map(|c| c.bg)
+}
+
+#[cfg(all(unix, not(test)))]
+mod imp {
+ use super::DefaultColors;
+ use std::mem::MaybeUninit;
+ use std::os::fd::RawFd;
+ use std::sync::OnceLock;
+
+ pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
+ static CACHE: OnceLock