update composer + user message styling (#4240)

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 | <img width="810" height="641" alt="Screenshot
2025-09-25 at 11 44 52 AM"
src="https://github.com/user-attachments/assets/1317e579-64a9-4785-93e6-98b0258f5d92"
/> |
| iTerm with light bg | <img width="845" height="540" alt="Screenshot
2025-09-25 at 11 46 29 AM"
src="https://github.com/user-attachments/assets/e671d490-c747-4460-af0b-3f8d7f7a6b8e"
/> |
| iTerm with color bg | <img width="825" height="564" alt="Screenshot
2025-09-25 at 11 47 12 AM"
src="https://github.com/user-attachments/assets/141cda1b-1164-41d5-87da-3be11e6a3063"
/> |
| Terminal.app with dark bg | <img width="577" height="367"
alt="Screenshot 2025-09-25 at 11 45 22 AM"
src="https://github.com/user-attachments/assets/93fc4781-99f7-4ee7-9c8e-3db3cd854fe5"
/> |
| Terminal.app with light bg | <img width="577" height="367"
alt="Screenshot 2025-09-25 at 11 46 04 AM"
src="https://github.com/user-attachments/assets/19bf6a3c-91e0-447b-9667-b8033f512219"
/> |
| Terminal.app with color bg | <img width="577" height="367"
alt="Screenshot 2025-09-25 at 11 45 50 AM"
src="https://github.com/user-attachments/assets/dd7c4b5b-342e-4028-8140-f4e65752bd0b"
/> |
This commit is contained in:
Jeremy Rose
2025-09-26 16:35:56 -07:00
committed by GitHub
parent cc1b21e47f
commit 43b63ccae8
50 changed files with 1181 additions and 623 deletions

View File

@@ -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

View File

@@ -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 }

View File

@@ -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()

View File

@@ -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);

View File

@@ -209,8 +209,8 @@ impl WidgetRef for CommandPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
false,
"no matches",
false,
);
}
}

View File

@@ -144,8 +144,8 @@ impl WidgetRef for &FileSearchPopup {
&rows_all,
&self.state,
MAX_POPUP_ROWS,
false,
empty_message,
false,
);
}
}

View File

@@ -454,8 +454,8 @@ impl BottomPaneView for ListSelectionView {
&rows,
&self.state,
MAX_POPUP_ROWS,
true,
"no matches",
true,
);
}

View File

@@ -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(' '));

View File

@@ -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.

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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 "

View File

@@ -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,

View File

@@ -1,8 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
expression: visible_after
expression: "lines[start_idx..].join(\"\\n\")"
---
> Im 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!
• Im going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then Ill 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.
> Heres whats driving size in this workspaces binaries.
Heres whats driving size in this workspaces binaries.
Main Causes

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Ask Codex to do anything "
" Ask Codex to do anything "

View File

@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" "
" Ask Codex to do anything "

View File

@@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" Ask Codex to do anything "
" "

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Ask Codex to do anything "
" Ask Codex to do anything "

View File

@@ -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 "

View File

@@ -3,5 +3,5 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Ask Codex to do anything "
" Ask Codex to do anything "
" "

View File

@@ -1,8 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
expression: visual
expression: term.backend().vt100().screen().contents()
---
> Im going to search the repo for where “Change Approved” is rendered to update
Im 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

View File

@@ -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';

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
> Here is the result.
Here is the result.

View File

@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
> Here is the result.
Here is the result.

View File

@@ -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 "
" "

View File

@@ -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<u8> = 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<String> = 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<String> = 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::<Vec<_>>().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<u8> = 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<String> = (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<u8> = 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<String> = (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());
}

75
codex-rs/tui/src/color.rs Normal file
View File

@@ -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()
}

View File

@@ -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<B>
where
B: Backend,
B: Backend + Write,
{
/// The backend used to interface with the terminal
backend: B,
@@ -113,6 +125,7 @@ where
impl<B> Drop for Terminal<B>
where
B: Backend,
B: Write,
{
#[allow(clippy::print_stderr)]
fn drop(&mut self) {
@@ -128,6 +141,7 @@ where
impl<B> Terminal<B>
where
B: Backend,
B: Write,
{
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
pub fn with_options(mut backend: B) -> io::Result<Self> {
@@ -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<DrawCommand<'a>> {
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<Item = DrawCommand<'a>>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut modifier = Modifier::empty();
let mut last_pos: Option<Position> = 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<W: io::Write>(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(())
}
}

View File

@@ -200,7 +200,7 @@ impl ExecCell {
if self.is_active() {
spinner(self.active_start_time())
} else {
"".bold()
"".into()
},
" ".into(),
if self.is_active() {

View File

@@ -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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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::<Vec<_>>(),
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::<Vec<_>>(),
)
})
.collect::<Vec<_>>();
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<String>) -> 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());

View File

@@ -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<Line>) {
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<B, W>(
terminal: &mut crate::custom_terminal::Terminal<B>,
writer: &mut W,
lines: Vec<Line>,
) where
B: ratatui::backend::Backend,
W: Write,
pub fn insert_history_lines<B>(terminal: &mut crate::custom_terminal::Terminal<B>, lines: Vec<Line>)
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<B, W>(
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<B, W>(
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<Span> = line
@@ -113,14 +122,12 @@ pub fn insert_history_lines_to_writer<B, W>(
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<u8> = 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<u8> = 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<u16> = 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<u8> = 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<u8> = 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<String> = 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<String> = screen.rows(0, width).collect();
let needle = "1. Third level (ordered)";
let row_idx = rows

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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<Instant> = OnceLock::new();
fn elapsed_since_start() -> Duration {
@@ -32,6 +38,8 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
let band_half_width = 3.0;
let mut spans: Vec<Span<'static>> = 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<Span<'static>> {
} 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)

View File

@@ -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

44
codex-rs/tui/src/style.rs Normal file
View File

@@ -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()
}
}

View File

@@ -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<Option<[(u8, u8, u8); 256]>> = OnceLock::new();
*CACHE.get_or_init(|| match query_terminal_palette() {
Ok(Some(palette)) => Some(palette),
_ => None,
})
}
pub(super) fn default_colors() -> Option<&'static DefaultColors> {
static CACHE: OnceLock<Option<DefaultColors>> = OnceLock::new();
CACHE
.get_or_init(|| query_default_colors().unwrap_or_default())
.as_ref()
}
#[allow(dead_code)]
fn query_terminal_palette() -> std::io::Result<Option<[(u8, u8, u8); 256]>> {
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::io::IsTerminal;
use std::io::Read;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::time::Duration;
use std::time::Instant;
if !std::io::stdout().is_terminal() {
return Ok(None);
}
let mut tty = match OpenOptions::new().read(true).write(true).open("/dev/tty") {
Ok(file) => file,
Err(_) => return Ok(None),
};
for index in 0..256 {
write!(tty, "\x1b]4;{index};?\x07")?;
}
tty.flush()?;
let fd = tty.as_raw_fd();
let _termios_guard = unsafe { suppress_echo(fd) };
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
let mut palette: [Option<(u8, u8, u8)>; 256] = [None; 256];
let mut buffer = Vec::new();
let mut remaining = palette.len();
let read_deadline = Instant::now() + Duration::from_millis(1500);
while remaining > 0 && Instant::now() < read_deadline {
let mut chunk = [0u8; 512];
match tty.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
buffer.extend_from_slice(&chunk[..read]);
let newly = apply_palette_responses(&mut buffer, &mut palette);
if newly > 0 {
remaining = remaining.saturating_sub(newly);
}
}
Err(err) if err.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(_) => return Ok(None),
}
}
remaining = remaining.saturating_sub(apply_palette_responses(&mut buffer, &mut palette));
remaining = remaining.saturating_sub(drain_remaining(&mut tty, &mut buffer, &mut palette));
if remaining > 0 {
return Ok(None);
}
let mut colors = [(0, 0, 0); 256];
for (slot, value) in colors.iter_mut().zip(palette.into_iter()) {
if let Some(rgb) = value {
*slot = rgb;
} else {
return Ok(None);
}
}
Ok(Some(colors))
}
#[allow(dead_code)]
fn query_default_colors() -> std::io::Result<Option<DefaultColors>> {
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::io::IsTerminal;
use std::io::Read;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::time::Duration;
use std::time::Instant;
let mut stdout_handle = std::io::stdout();
if !stdout_handle.is_terminal() {
return Ok(None);
}
stdout_handle.write_all(b"\x1b]10;?\x07\x1b]11;?\x07")?;
stdout_handle.flush()?;
let mut tty = match OpenOptions::new().read(true).open("/dev/tty") {
Ok(file) => file,
Err(_) => return Ok(None),
};
let fd = tty.as_raw_fd();
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
let deadline = Instant::now() + Duration::from_millis(200);
let mut buffer = Vec::new();
let mut fg = None;
let mut bg = None;
while Instant::now() < deadline {
let mut chunk = [0u8; 128];
match tty.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buffer.extend_from_slice(&chunk[..n]);
if fg.is_none() {
fg = parse_osc_color(&buffer, 10);
}
if bg.is_none() {
bg = parse_osc_color(&buffer, 11);
}
if let (Some(fg), Some(bg)) = (fg, bg) {
return Ok(Some(DefaultColors { fg, bg }));
}
}
Err(err) if err.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
if fg.is_none() {
fg = parse_osc_color(&buffer, 10);
}
if bg.is_none() {
bg = parse_osc_color(&buffer, 11);
}
Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg }))
}
fn drain_remaining(
tty: &mut std::fs::File,
buffer: &mut Vec<u8>,
palette: &mut [Option<(u8, u8, u8)>; 256],
) -> usize {
use std::io::ErrorKind;
use std::io::Read;
use std::time::Duration;
use std::time::Instant;
let mut chunk = [0u8; 512];
let mut idle_deadline = Instant::now() + Duration::from_millis(50);
let mut newly_filled = 0usize;
loop {
match tty.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
buffer.extend_from_slice(&chunk[..read]);
newly_filled += apply_palette_responses(buffer, palette);
idle_deadline = Instant::now() + Duration::from_millis(50);
}
Err(err) if err.kind() == ErrorKind::WouldBlock => {
if Instant::now() >= idle_deadline {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
buffer.clear();
newly_filled
}
struct TermiosGuard {
fd: RawFd,
original: libc::termios,
}
impl Drop for TermiosGuard {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
unsafe fn suppress_echo(fd: RawFd) -> Option<TermiosGuard> {
let mut termios = MaybeUninit::<libc::termios>::uninit();
if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } != 0 {
return None;
}
let termios = unsafe { termios.assume_init() };
let mut modified = termios;
modified.c_lflag &= !(libc::ECHO | libc::ECHONL);
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &modified) } != 0 {
return None;
}
Some(TermiosGuard {
fd,
original: termios,
})
}
fn apply_palette_responses(
buffer: &mut Vec<u8>,
palette: &mut [Option<(u8, u8, u8)>; 256],
) -> usize {
let mut newly_filled = 0;
while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) {
if start > 0 {
buffer.drain(..start);
continue;
}
let mut index = 2; // skip ESC ]
let mut terminator_len = None;
while index < buffer.len() {
match buffer[index] {
0x07 => {
terminator_len = Some(1);
break;
}
0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => {
terminator_len = Some(2);
break;
}
_ => index += 1,
}
}
let Some(terminator_len) = terminator_len else {
break;
};
let end = index;
let parsed = std::str::from_utf8(&buffer[2..end])
.ok()
.and_then(parse_palette_message);
let processed = end + terminator_len;
buffer.drain(..processed);
if let Some((slot, color)) = parsed
&& palette[slot].is_none()
{
palette[slot] = Some(color);
newly_filled += 1;
}
}
newly_filled
}
fn parse_palette_message(message: &str) -> Option<(usize, (u8, u8, u8))> {
let mut parts = message.splitn(3, ';');
if parts.next()? != "4" {
return None;
}
let index: usize = parts.next()?.trim().parse().ok()?;
if index >= 256 {
return None;
}
let payload = parts.next()?;
let (model, values) = payload.split_once(':')?;
if model != "rgb" && model != "rgba" {
return None;
}
let mut components = values.split('/');
let r = parse_component(components.next()?)?;
let g = parse_component(components.next()?)?;
let b = parse_component(components.next()?)?;
Some((index, (r, g, b)))
}
fn parse_component(component: &str) -> Option<u8> {
let trimmed = component.trim();
if trimmed.is_empty() {
return None;
}
let bits = trimmed.len().checked_mul(4)?;
if bits == 0 || bits > 64 {
return None;
}
let max = if bits == 64 {
u64::MAX
} else {
(1u64 << bits) - 1
};
let value = u64::from_str_radix(trimmed, 16).ok()?;
Some(((value * 255 + max / 2) / max) as u8)
}
fn parse_osc_color(buffer: &[u8], code: u8) -> Option<(u8, u8, u8)> {
let text = std::str::from_utf8(buffer).ok()?;
let prefix = match code {
10 => "\u{1b}]10;",
11 => "\u{1b}]11;",
_ => return None,
};
let start = text.rfind(prefix)?;
let after_prefix = &text[start + prefix.len()..];
let end_bel = after_prefix.find('\u{7}');
let end_st = after_prefix.find("\u{1b}\\");
let end_idx = match (end_bel, end_st) {
(Some(bel), Some(st)) => bel.min(st),
(Some(bel), None) => bel,
(None, Some(st)) => st,
(None, None) => return None,
};
let payload = after_prefix[..end_idx].trim();
parse_color_payload(payload)
}
fn parse_color_payload(payload: &str) -> Option<(u8, u8, u8)> {
if payload.is_empty() || payload == "?" {
return None;
}
let (model, values) = payload.split_once(':')?;
if model != "rgb" && model != "rgba" {
return None;
}
let mut parts = values.split('/');
let r = parse_component(parts.next()?)?;
let g = parse_component(parts.next()?)?;
let b = parse_component(parts.next()?)?;
Some((r, g, b))
}
}
#[cfg(not(all(unix, not(test))))]
mod imp {
use super::DefaultColors;
pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
None
}
pub(super) fn default_colors() -> Option<&'static DefaultColors> {
None
}
}

View File

@@ -0,0 +1,123 @@
use std::fmt::{self};
use std::io::Write;
use std::io::{self};
use ratatui::prelude::CrosstermBackend;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::WindowSize;
use ratatui::buffer::Cell;
use ratatui::layout::Position;
use ratatui::layout::Size;
/// This wraps a CrosstermBackend and a vt100::Parser to mock
/// a "real" terminal.
///
/// Importantly, this wrapper avoids calling any crossterm methods
/// which write to stdout regardless of the writer. This includes:
/// - getting the terminal size
/// - getting the cursor position
pub struct VT100Backend {
crossterm_backend: CrosstermBackend<vt100::Parser>,
}
impl VT100Backend {
/// Creates a new `TestBackend` with the specified width and height.
pub fn new(width: u16, height: u16) -> Self {
Self {
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
}
}
pub fn vt100(&self) -> &vt100::Parser {
self.crossterm_backend.writer()
}
}
impl Write for VT100Backend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.crossterm_backend.writer_mut().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.crossterm_backend.writer_mut().flush()
}
}
impl fmt::Display for VT100Backend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.crossterm_backend.writer().screen().contents())
}
}
impl Backend for VT100Backend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.crossterm_backend.draw(content)?;
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
self.crossterm_backend.hide_cursor()?;
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
self.crossterm_backend.show_cursor()?;
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(self.vt100().screen().cursor_position().into())
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
self.crossterm_backend.set_cursor_position(position)
}
fn clear(&mut self) -> io::Result<()> {
self.crossterm_backend.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
self.crossterm_backend.clear_region(clear_type)
}
fn append_lines(&mut self, line_count: u16) -> io::Result<()> {
self.crossterm_backend.append_lines(line_count)
}
fn size(&self) -> io::Result<Size> {
Ok(self.vt100().screen().size().into())
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: self.vt100().screen().size().into(),
// Arbitrary size, we don't rely on this in testing.
pixels: Size {
width: 640,
height: 480,
},
})
}
fn flush(&mut self) -> io::Result<()> {
self.crossterm_backend.writer_mut().flush()
}
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, scroll_by: u16) -> io::Result<()> {
self.crossterm_backend.scroll_region_up(region, scroll_by)
}
fn scroll_region_down(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
self.crossterm_backend.scroll_region_down(region, scroll_by)
}
}

View File

@@ -1,3 +1,4 @@
use std::io::IsTerminal;
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
@@ -123,6 +124,9 @@ pub fn restore() -> Result<()> {
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
pub fn init() -> Result<Terminal> {
if !stdout().is_terminal() {
return Err(std::io::Error::other("stdout is not a terminal"));
}
set_modes()?;
set_panic_hook();
@@ -271,6 +275,10 @@ impl Tui {
// Detect keyboard enhancement support before any EventStream is created so the
// crossterm poller can acquire its lock without contention.
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
// Cache this to avoid contention with the event reader.
supports_color::on_cached(supports_color::Stream::Stdout);
let _ = crate::terminal_palette::terminal_palette();
let _ = crate::terminal_palette::default_colors();
Self {
frame_schedule_tx,

View File

@@ -186,7 +186,7 @@ where
};
// Build first wrapped line with initial indent.
let mut first_line = rt_opts.initial_indent.clone();
let mut first_line = rt_opts.initial_indent.clone().style(line.style);
{
let sliced = slice_line_spans(line, &span_bounds, first_line_range);
let mut spans = first_line.spans;
@@ -214,7 +214,7 @@ where
if r.is_empty() {
continue;
}
let mut subsequent_line = rt_opts.subsequent_indent.clone();
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, &span_bounds, &offset_range);
let mut spans = subsequent_line.spans;

View File

@@ -1,3 +1,6 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
#[cfg(feature = "vt100-tests")]
mod test_backend;
mod suite;

View File

@@ -2,4 +2,3 @@
mod status_indicator;
mod vt100_history;
mod vt100_live_commit;
mod vt100_streaming_no_dup;

View File

@@ -1,7 +1,7 @@
#![cfg(feature = "vt100-tests")]
#![expect(clippy::expect_used)]
use ratatui::backend::TestBackend;
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
@@ -23,52 +23,20 @@ macro_rules! assert_contains {
}
struct TestScenario {
width: u16,
height: u16,
term: codex_tui::custom_terminal::Terminal<TestBackend>,
term: codex_tui::custom_terminal::Terminal<VT100Backend>,
}
impl TestScenario {
fn new(width: u16, height: u16, viewport: Rect) -> Self {
let backend = TestBackend::new(width, height);
let backend = VT100Backend::new(width, height);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
term.set_viewport_area(viewport);
Self {
width,
height,
term,
}
Self { term }
}
fn run_insert(&mut self, lines: Vec<Line<'static>>) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines);
buf
}
fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec<String> {
let mut parser = vt100::Parser::new(self.height, self.width, 0);
parser.process(bytes);
let screen = parser.screen();
let mut rows: Vec<String> = Vec::with_capacity(self.height as usize);
for row in 0..self.height {
let mut s = String::with_capacity(self.width as usize);
for col in 0..self.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());
}
rows
fn run_insert(&mut self, lines: Vec<Line<'static>>) {
codex_tui::insert_history::insert_history_lines(&mut self.term, lines);
}
}
@@ -79,19 +47,10 @@ fn basic_insertion_no_wrap() {
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec!["first".into(), "second".into()];
let buf = scenario.run_insert(lines);
let rows = scenario.screen_rows_from_bytes(&buf);
scenario.run_insert(lines);
let rows = scenario.term.backend().vt100().screen().contents();
assert_contains!(rows, String::from("first"));
assert_contains!(rows, String::from("second"));
let first_idx = rows
.iter()
.position(|r| r == "first")
.expect("expected 'first' row to be present");
let second_idx = rows
.iter()
.position(|r| r == "second")
.expect("expected 'second' row to be present");
assert_eq!(second_idx, first_idx + 1, "rows should be adjacent");
}
#[test]
@@ -101,10 +60,8 @@ fn long_token_wraps() {
let long = "A".repeat(45); // > 2 lines at width 20
let lines = vec![long.clone().into()];
let buf = scenario.run_insert(lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
scenario.run_insert(lines);
let screen = scenario.term.backend().vt100().screen();
// Count total A's on the screen
let mut count_a = 0usize;
@@ -133,12 +90,11 @@ fn emoji_and_cjk() {
let text = String::from("😀😀😀😀😀 你好世界");
let lines = vec![text.clone().into()];
let buf = scenario.run_insert(lines);
let rows = scenario.screen_rows_from_bytes(&buf);
let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect();
scenario.run_insert(lines);
let rows = scenario.term.backend().vt100().screen().contents();
for ch in text.chars().filter(|c| !c.is_whitespace()) {
assert!(
reconstructed.contains(ch),
rows.contains(ch),
"missing character {ch:?} in reconstructed screen"
);
}
@@ -150,8 +106,8 @@ fn mixed_ansi_spans() {
let mut scenario = TestScenario::new(20, 6, area);
let line = vec!["red".red(), "+plain".into()].into();
let buf = scenario.run_insert(vec![line]);
let rows = scenario.screen_rows_from_bytes(&buf);
scenario.run_insert(vec![line]);
let rows = scenario.term.backend().vt100().screen().contents();
assert_contains!(rows, String::from("red+plain"));
}
@@ -161,18 +117,8 @@ fn cursor_restoration() {
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec!["x".into()];
let buf = scenario.run_insert(lines);
let s = String::from_utf8_lossy(&buf);
// CUP to 1;1 (ANSI: ESC[1;1H)
assert!(
s.contains("\u{1b}[1;1H"),
"expected final CUP to 1;1 in output, got: {s:?}"
);
// Reset scroll region
assert!(
s.contains("\u{1b}[r"),
"expected reset scroll region in output, got: {s:?}"
);
scenario.run_insert(lines);
assert_eq!(scenario.term.last_known_cursor_pos, (0, 0).into());
}
#[test]
@@ -182,9 +128,8 @@ fn word_wrap_no_mid_word_split() {
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Years passed, and Willowmere thrived in peace and friendship. Miras herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
let buf = scenario.run_insert(vec![sample.into()]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
scenario.run_insert(vec![sample.into()]);
let joined = scenario.term.backend().vt100().screen().contents();
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
@@ -198,43 +143,10 @@ fn em_dash_and_space_word_wrap() {
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
let buf = scenario.run_insert(vec![sample.into()]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
scenario.run_insert(vec![sample.into()]);
let joined = scenario.term.backend().vt100().screen().contents();
assert!(
!joined.contains("insi\nde"),
"word 'inside' should not be split across lines:\n{joined}"
);
}
#[test]
fn pre_scroll_region_down() {
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec!["first".into(), "second".into()];
let buf = scenario.run_insert(lines);
let s = String::from_utf8_lossy(&buf);
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
assert!(
s.contains("\u{1b}[4;6r"),
"expected pre-scroll SetScrollRegion 4..6, got: {s:?}"
);
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
assert!(
s.contains("\u{1b}[4;1H"),
"expected cursor at top of pre-scroll region, got: {s:?}"
);
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
let ri_count = s.matches("\u{1b}M").count();
assert!(
ri_count >= 1,
"expected at least one RI (ESC M), got: {s:?}"
);
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
assert!(
s.contains("\u{1b}[1;5r"),
"expected insertion SetScrollRegion 1..5, got: {s:?}"
);
}

View File

@@ -1,12 +1,12 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::text::Line;
#[test]
fn live_001_commit_on_overflow() {
let backend = TestBackend::new(20, 6);
let backend = VT100Backend::new(20, 6);
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
@@ -26,27 +26,12 @@ fn live_001_commit_on_overflow() {
let commit_rows = rb.drain_commit_ready(3);
let lines: Vec<Line<'static>> = commit_rows.into_iter().map(|r| r.text.into()).collect();
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
codex_tui::insert_history::insert_history_lines(&mut term, lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
let screen = term.backend().vt100().screen();
// The words "one" and "two" should appear above the viewport.
let mut joined = String::new();
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
joined.push(ch);
} else {
joined.push(' ');
}
}
}
joined.push('\n');
}
let joined = screen.contents();
assert!(
joined.contains("one"),
"expected committed 'one' to be visible\n{joined}"
@@ -57,39 +42,3 @@ fn live_001_commit_on_overflow() {
);
// The last three (three,four,five) remain in the live ring, not committed here.
}
#[test]
fn live_002_pre_scroll_and_commit() {
let backend = TestBackend::new(20, 6);
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
// Viewport not at bottom: y=3
let area = Rect::new(0, 3, 20, 1);
term.set_viewport_area(area);
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
rb.push_fragment("alpha\n");
rb.push_fragment("beta\n");
rb.push_fragment("gamma\n");
rb.push_fragment("delta\n");
// Keep 3, commit 1.
let commit_rows = rb.drain_commit_ready(3);
let lines: Vec<Line<'static>> = commit_rows.into_iter().map(|r| r.text.into()).collect();
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let s = String::from_utf8_lossy(&buf);
// Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region.
assert!(
s.contains("\u{1b}[4;6r"),
"expected pre-scroll region 4..6, got: {s:?}"
);
assert!(
s.contains("\u{1b}[4;1H"),
"expected cursor CUP 4;1H, got: {s:?}"
);
}

View File

@@ -1,76 +0,0 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal<TestBackend> {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.unwrap_or_else(|e| panic!("failed to construct terminal: {e}"));
term.set_viewport_area(viewport);
term
}
#[test]
fn stream_commit_trickle_no_duplication() {
// Viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Step 1: commit first row
let mut out1 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out1,
vec!["one".into()],
);
// Step 2: later commit next row
let mut out2 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out2,
vec!["two".into()],
);
let combined = [out1, out2].concat();
let s = String::from_utf8_lossy(&combined);
assert_eq!(
s.matches("one").count(),
1,
"history line duplicated: {s:?}"
);
assert_eq!(
s.matches("two").count(),
1,
"history line duplicated: {s:?}"
);
assert!(
!s.contains("three"),
"live-only content leaked into history: {s:?}"
);
}
#[test]
fn live_ring_rows_not_inserted_into_history() {
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Commit two rows to history.
let mut buf = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut buf,
vec!["one".into(), "two".into()],
);
// The live ring might display tail+head rows like ["two", "three"],
// but only committed rows should be present in the history ANSI stream.
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("one"));
assert!(s.contains("two"));
assert!(
!s.contains("three"),
"uncommitted live-ring content should not be inserted into history: {s:?}"
);
}

View File

@@ -0,0 +1,4 @@
#[path = "../src/test_backend.rs"]
mod inner;
pub use inner::VT100Backend;