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:
@@ -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;
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
mod status_indicator;
|
||||
mod vt100_history;
|
||||
mod vt100_live_commit;
|
||||
mod vt100_streaming_no_dup;
|
||||
|
||||
@@ -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. Mira’s 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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
4
codex-rs/tui/tests/test_backend.rs
Normal file
4
codex-rs/tui/tests/test_backend.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
#[path = "../src/test_backend.rs"]
|
||||
mod inner;
|
||||
|
||||
pub use inner::VT100Backend;
|
||||
Reference in New Issue
Block a user