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

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