Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Embiricos
10e6bbd7a9 fix: tui launch latency by disabling osc probe
- Codex CLI startup slowed to ~1.5 s in Terminal and Ghostty.
- This was because because Tui::new requested the terminal’s
  256-color palette via OSC 4. Trace logging showed the constructor
  waiting for 1.5s at query_terminal_palette before timing out,
  since many modern GPU terminals ignore OSC 4/10/11.
- This is a minimal change that disables those probes, but keeps it
  easy to bring the back for specific terminals that support it
  like iTerm or xterm, or with different more performant approach.
2025-10-07 19:07:26 -07:00
rakesh-oai
abd517091f remove experimental prefix (#4907)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.
2025-10-07 17:27:27 -07:00
Jeremy Rose
b8b04514bc feat(tui): switch to tree-sitter-highlight bash highlighting (#4666)
use tree-sitter-highlight instead of custom logic over the tree-sitter
tree to highlight bash.
2025-10-07 16:20:12 -07:00
Jeremy Rose
0e5d72cc57 tui: bring the transcript closer to display mode (#4848)
before
<img width="1161" height="836" alt="Screenshot 2025-10-06 at 3 06 52 PM"
src="https://github.com/user-attachments/assets/7622fd6b-9d37-402f-8651-61c2c55dcbc6"
/>

after
<img width="1161" height="858" alt="Screenshot 2025-10-06 at 3 07 02 PM"
src="https://github.com/user-attachments/assets/1498f327-1d1a-4630-951f-7ca371ab0139"
/>
2025-10-07 16:18:48 -07:00
19 changed files with 474 additions and 311 deletions

18
codex-rs/Cargo.lock generated
View File

@@ -1424,6 +1424,8 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter-bash",
"tree-sitter-highlight",
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
@@ -6261,9 +6263,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.25.9"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa"
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
dependencies = [
"cc",
"regex",
@@ -6283,6 +6285,18 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-highlight"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3"
dependencies = [
"regex",
"streaming-iterator",
"thiserror 2.0.16",
"tree-sitter",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.5"

View File

@@ -175,8 +175,9 @@ tracing = "0.1.41"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.20"
tracing-test = "0.2.5"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
tree-sitter-highlight = "0.25.10"
ts-rs = "11"
unicode-segmentation = "1.12.0"
unicode-width = "0.2"

View File

@@ -157,9 +157,7 @@ struct LoginCommand {
)]
api_key: Option<String>,
/// EXPERIMENTAL: Use device code flow (not yet supported)
/// This feature is experimental and may changed in future releases.
#[arg(long = "experimental_use-device-code", hide = true)]
#[arg(long = "use-device-code")]
use_device_code: bool,
/// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced)

View File

@@ -68,6 +68,8 @@ strum_macros = { workspace = true }
supports-color = { workspace = true }
tempfile = { workspace = true }
textwrap = { workspace = true }
tree-sitter-highlight = { workspace = true }
tree-sitter-bash = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",

View File

@@ -134,8 +134,9 @@ impl App {
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
for cell in &self.transcript_cells {
tui.insert_history_lines(cell.transcript_lines());
tui.insert_history_lines(cell.display_lines(width));
}
}
}

View File

@@ -38,7 +38,6 @@ use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style;
use crate::terminal_palette;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
@@ -1533,7 +1532,7 @@ impl WidgetRef for ChatComposer {
}
}
}
let style = user_message_style(terminal_palette::default_bg());
let style = user_message_style();
let mut block_rect = composer_rect;
block_rect.y = composer_rect.y.saturating_sub(1);
block_rect.height = composer_rect.height.saturating_add(1);

View File

@@ -20,7 +20,6 @@ use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_palette;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -350,7 +349,7 @@ impl Renderable for ListSelectionView {
.areas(area);
Block::default()
.style(user_message_style(terminal_palette::default_bg()))
.style(user_message_style())
.render(content_area, buf);
let header_height = self

View File

@@ -120,6 +120,8 @@ where
/// Last known position of the cursor. Used to find the new area when the viewport is inlined
/// and the terminal resized.
pub last_known_cursor_pos: Position,
use_custom_flush: bool,
}
impl<B> Drop for Terminal<B>
@@ -158,6 +160,7 @@ where
viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
last_known_screen_size: screen_size,
last_known_cursor_pos: cursor_pos,
use_custom_flush: true,
})
}
@@ -190,15 +193,24 @@ 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 = 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 };
if self.use_custom_flush {
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 };
}
draw(&mut self.backend, updates.into_iter())
} else {
let updates = previous_buffer.diff(current_buffer);
if let Some((x, y, _)) = updates.last() {
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.
@@ -408,11 +420,13 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec<DrawCommand<'a>> {
let x = row
.iter()
.rposition(|cell| cell.symbol() != " " || cell.bg != bg)
.rposition(|cell| {
cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty()
})
.unwrap_or(0);
last_nonblank_column[y as usize] = x as u16;
let (x_abs, y_abs) = a.pos_of(row_start + x + 1);
if x < (a.area.width as usize).saturating_sub(1) {
let (x_abs, y_abs) = a.pos_of(row_start + x + 1);
updates.push(DrawCommand::ClearToEnd {
x: x_abs,
y: y_abs,

View File

@@ -67,7 +67,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(InsetRenderable::new(
Box::new(row.change),
row.change,
Insets::tlbr(0, 2, 0, 0),
)));
}

View File

@@ -11,6 +11,7 @@ use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_protocol::parse_command::ParsedCommand;
@@ -138,17 +139,25 @@ impl HistoryCell for ExecCell {
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
fn desired_transcript_height(&self, width: u16) -> u16 {
self.transcript_lines(width).len() as u16
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
for call in self.iter_calls() {
let cmd_display = strip_bash_lc_and_escape(&call.command);
for (i, part) in cmd_display.lines().enumerate() {
if i == 0 {
lines.push(vec!["$ ".magenta(), part.to_string().into()].into());
} else {
lines.push(vec![" ".into(), part.to_string().into()].into());
}
for (i, call) in self.iter_calls().enumerate() {
if i > 0 {
lines.push("".into());
}
let script = strip_bash_lc_and_escape(&call.command);
let highlighted_script = highlight_bash_to_lines(&script);
let cmd_display = word_wrap_lines(
&highlighted_script,
RtOptions::new(width as usize)
.initial_indent("$ ".magenta().into())
.subsequent_indent(" ".into()),
);
lines.extend(cmd_display);
if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
@@ -167,7 +176,6 @@ impl HistoryCell for ExecCell {
result.push_span(format!("{duration}").dim());
lines.push(result);
}
lines.push("".into());
}
lines
}

View File

@@ -11,7 +11,6 @@ use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
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;
@@ -56,10 +55,6 @@ use unicode_width::UnicodeWidthStr;
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.display_lines(u16::MAX)
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines(width)))
.wrap(Wrap { trim: false })
@@ -68,6 +63,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
.unwrap_or(0)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.display_lines(width)
}
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = self.transcript_lines(width);
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
if let [line] = &lines[..]
&& line
.spans
.iter()
.all(|s| s.content.chars().all(char::is_whitespace))
{
return 1;
}
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
fn is_stream_continuation(&self) -> bool {
false
}
@@ -92,12 +110,10 @@ impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
// 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 wrap_width = width.saturating_sub(LIVE_PREFIX_COLS);
let style = user_message_style(default_bg());
let style = user_message_style();
// Use our ratatui wrapping helpers for correct styling and lifetimes.
let wrapped = word_wrap_lines(
&self
.message
@@ -113,13 +129,6 @@ impl HistoryCell for UserHistoryCell {
lines.push(Line::from("").style(style));
lines
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push("user".cyan().bold().into());
lines.extend(self.message.lines().map(|l| l.to_string().into()));
lines
}
}
#[derive(Debug)]
@@ -127,6 +136,7 @@ pub(crate) struct ReasoningSummaryCell {
_header: String,
content: String,
citation_context: MarkdownCitationContext,
transcript_only: bool,
}
impl ReasoningSummaryCell {
@@ -134,17 +144,17 @@ impl ReasoningSummaryCell {
header: String,
content: String,
citation_context: MarkdownCitationContext,
transcript_only: bool,
) -> Self {
Self {
_header: header,
content,
citation_context,
transcript_only,
}
}
}
impl HistoryCell for ReasoningSummaryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
@@ -152,7 +162,7 @@ impl HistoryCell for ReasoningSummaryCell {
&mut lines,
self.citation_context.clone(),
);
let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
let summary_style = Style::default().dim().italic();
let summary_lines = lines
.into_iter()
.map(|mut line| {
@@ -172,19 +182,31 @@ impl HistoryCell for ReasoningSummaryCell {
.subsequent_indent(" ".into()),
)
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push("thinking".magenta().bold().into());
let mut lines = Vec::new();
append_markdown(
&self.content,
None,
&mut lines,
self.citation_context.clone(),
);
out.extend(lines);
out
impl HistoryCell for ReasoningSummaryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.transcript_only {
Vec::new()
} else {
self.lines(width)
}
}
fn desired_height(&self, width: u16) -> u16 {
if self.transcript_only {
0
} else {
self.lines(width).len() as u16
}
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines(width)
}
fn desired_transcript_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
}
@@ -217,15 +239,6 @@ impl HistoryCell for AgentMessageCell {
)
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
if self.is_first_line {
out.push("codex".magenta().bold().into());
}
out.extend(self.lines.clone());
out
}
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
@@ -248,21 +261,6 @@ impl HistoryCell for PlainHistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct TranscriptOnlyHistoryCell {
lines: Vec<Line<'static>>,
}
impl HistoryCell for TranscriptOnlyHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
Vec::new()
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
}
/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {
@@ -1050,16 +1048,6 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, None, &mut lines, config);
TranscriptOnlyHistoryCell { lines }
}
pub(crate) fn new_reasoning_summary_block(
full_reasoning_buffer: String,
config: &Config,
@@ -1085,12 +1073,18 @@ pub(crate) fn new_reasoning_summary_block(
header_buffer,
summary_buffer,
config.into(),
false,
));
}
}
}
}
Box::new(new_reasoning_block(full_reasoning_buffer, config))
Box::new(ReasoningSummaryCell::new(
"".to_string(),
full_reasoning_buffer,
config.into(),
true,
))
}
#[derive(Debug)]
@@ -1121,10 +1115,6 @@ impl HistoryCell for FinalMessageSeparator {
vec![Line::from_iter(["".repeat(width as usize).dim()])]
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
vec![]
}
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
@@ -1188,7 +1178,14 @@ mod tests {
}
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
render_lines(&cell.transcript_lines())
render_lines(&cell.transcript_lines(u16::MAX))
}
#[test]
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], false);
assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]);
assert_eq!(cell.desired_transcript_height(80), 1);
}
#[test]
@@ -1883,10 +1880,7 @@ mod tests {
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(
rendered_transcript,
vec!["thinking", "Detailed reasoning goes here."]
);
assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
}
#[test]
@@ -1898,7 +1892,7 @@ mod tests {
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]);
assert_eq!(rendered, vec!["Detailed reasoning goes here."]);
}
#[test]
@@ -1912,10 +1906,7 @@ mod tests {
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(
rendered,
vec!["thinking", "**High level reasoning without closing"]
);
assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
}
#[test]
@@ -1929,10 +1920,7 @@ mod tests {
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(
rendered,
vec!["thinking", "High level reasoning without closing"]
);
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
let cell = new_reasoning_summary_block(
"**High level reasoning without closing**\n\n ".to_string(),
@@ -1940,10 +1928,7 @@ mod tests {
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(
rendered,
vec!["thinking", "High level reasoning without closing"]
);
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
}
#[test]
@@ -1960,9 +1945,6 @@ mod tests {
assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(
rendered_transcript,
vec!["thinking", "We should fix the bug next."]
);
assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
}
}

View File

@@ -3,9 +3,13 @@ use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
@@ -13,6 +17,7 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -21,7 +26,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
pub(crate) enum Overlay {
Transcript(TranscriptOverlay),
@@ -317,29 +321,30 @@ impl PagerView {
}
}
struct CachedParagraph {
paragraph: Paragraph<'static>,
/// A renderable that caches its desired height.
struct CachedRenderable {
renderable: Box<dyn Renderable>,
height: std::cell::Cell<Option<u16>>,
last_width: std::cell::Cell<Option<u16>>,
}
impl CachedParagraph {
fn new(paragraph: Paragraph<'static>) -> Self {
impl CachedRenderable {
fn new(renderable: Box<dyn Renderable>) -> Self {
Self {
paragraph,
renderable,
height: std::cell::Cell::new(None),
last_width: std::cell::Cell::new(None),
}
}
}
impl Renderable for CachedParagraph {
impl Renderable for CachedRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.paragraph.render_ref(area, buf);
self.renderable.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
if self.last_width.get() != Some(width) {
let height = self.paragraph.line_count(width) as u16;
let height = self.renderable.desired_height(width);
self.height.set(Some(height));
self.last_width.set(Some(width));
}
@@ -347,6 +352,23 @@ impl Renderable for CachedParagraph {
}
}
struct CellRenderable {
cell: Arc<dyn HistoryCell>,
style: Style,
}
impl Renderable for CellRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let p =
Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style);
p.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.cell.desired_transcript_height(width)
}
}
pub(crate) struct TranscriptOverlay {
view: PagerView,
cells: Vec<Arc<dyn HistoryCell>>,
@@ -358,7 +380,7 @@ impl TranscriptOverlay {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self {
view: PagerView::new(
Self::render_cells_to_texts(&transcript_cells, None),
Self::render_cells(&transcript_cells, None),
"T R A N S C R I P T".to_string(),
usize::MAX,
),
@@ -368,46 +390,46 @@ impl TranscriptOverlay {
}
}
fn render_cells_to_texts(
fn render_cells(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
) -> Vec<Box<dyn Renderable>> {
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
let mut first = true;
for (idx, cell) in cells.iter().enumerate() {
let mut lines: Vec<Line<'static>> = Vec::new();
if !cell.is_stream_continuation() && !first {
lines.push(Line::from(""));
}
let cell_lines = if Some(idx) == highlight_cell {
cell.transcript_lines()
.into_iter()
.map(Stylize::reversed)
.collect()
} else {
cell.transcript_lines()
};
lines.extend(cell_lines);
texts.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
first = false;
}
texts
cells
.iter()
.enumerate()
.flat_map(|(i, c)| {
let mut v: Vec<Box<dyn Renderable>> = Vec::new();
let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
Box::new(CachedRenderable::new(Box::new(CellRenderable {
cell: c.clone(),
style: if highlight_cell == Some(i) {
user_message_style().reversed()
} else {
user_message_style()
},
}))) as Box<dyn Renderable>
} else {
Box::new(CachedRenderable::new(Box::new(CellRenderable {
cell: c.clone(),
style: Style::default(),
}))) as Box<dyn Renderable>
};
if !c.is_stream_continuation() && i > 0 {
cell_renderable = Box::new(InsetRenderable::new(
cell_renderable,
Insets::tlbr(1, 0, 0, 0),
));
}
v.push(cell_renderable);
v
})
.collect()
}
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
// Append as a new Text chunk (with a separating blank if needed)
let mut lines: Vec<Line<'static>> = Vec::new();
if !cell.is_stream_continuation() && !self.cells.is_empty() {
lines.push(Line::from(""));
}
lines.extend(cell.transcript_lines());
self.view.renderables.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
self.cells.push(cell);
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
@@ -415,7 +437,7 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
@@ -475,8 +497,8 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::with_renderables(
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
lines,
vec![Box::new(CachedRenderable::new(Box::new(Paragraph::new(
Text::from(lines),
))))],
title,
)
@@ -585,7 +607,7 @@ mod tests {
self.lines.clone()
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
}

View File

@@ -1,81 +1,146 @@
use codex_core::bash::try_parse_bash;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use std::sync::OnceLock;
use tree_sitter_highlight::Highlight;
use tree_sitter_highlight::HighlightConfiguration;
use tree_sitter_highlight::HighlightEvent;
use tree_sitter_highlight::Highlighter;
/// Convert the full bash script into per-line styled content by first
/// computing operator-dimmed spans across the entire script, then splitting
/// by newlines and dimming heredoc body lines. Performs a single parse and
/// reuses it for both highlighting and heredoc detection.
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
// Parse once; use the tree for both highlighting and heredoc body detection.
let spans: Vec<Span<'static>> = if let Some(tree) = try_parse_bash(script) {
// Single walk: collect operator ranges and heredoc rows.
let root = tree.root_node();
let mut cursor = root.walk();
let mut stack = vec![root];
let mut ranges: Vec<(usize, usize)> = Vec::new();
while let Some(node) = stack.pop() {
if !node.is_named() && !node.is_extra() {
let kind = node.kind();
let is_quote = matches!(kind, "\"" | "'" | "`");
let is_whitespace = kind.trim().is_empty();
if !is_quote && !is_whitespace {
ranges.push((node.start_byte(), node.end_byte()));
}
} else if node.kind() == "heredoc_body" {
ranges.push((node.start_byte(), node.end_byte()));
}
for child in node.children(&mut cursor) {
stack.push(child);
}
}
if ranges.is_empty() {
ranges.push((script.len(), script.len()));
}
ranges.sort_by_key(|(st, _)| *st);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut i = 0usize;
for (start, end) in ranges.into_iter() {
let dim_start = start.max(i);
let dim_end = end;
if dim_start < dim_end {
if dim_start > i {
spans.push(script[i..dim_start].to_string().into());
}
spans.push(script[dim_start..dim_end].to_string().dim());
i = dim_end;
}
}
if i < script.len() {
spans.push(script[i..].to_string().into());
}
spans
} else {
vec![script.to_string().into()]
};
// Split spans into lines preserving style boundaries and highlights across newlines.
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
for sp in spans {
let style = sp.style;
let text = sp.content.into_owned();
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
if part.is_empty() {
continue;
}
let span = Span {
style,
content: std::borrow::Cow::Owned(part.to_string()),
};
if let Some(last) = lines.last_mut() {
last.spans.push(span);
}
// Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm
#[derive(Copy, Clone)]
enum BashHighlight {
Comment,
Constant,
Embedded,
Function,
Keyword,
Number,
Operator,
Property,
String,
}
impl BashHighlight {
const ALL: [Self; 9] = [
Self::Comment,
Self::Constant,
Self::Embedded,
Self::Function,
Self::Keyword,
Self::Number,
Self::Operator,
Self::Property,
Self::String,
];
const fn as_str(self) -> &'static str {
match self {
Self::Comment => "comment",
Self::Constant => "constant",
Self::Embedded => "embedded",
Self::Function => "function",
Self::Keyword => "keyword",
Self::Number => "number",
Self::Operator => "operator",
Self::Property => "property",
Self::String => "string",
}
}
lines
fn style(self) -> Style {
match self {
Self::Comment | Self::Operator | Self::String => Style::default().dim(),
_ => Style::default(),
}
}
}
static HIGHLIGHT_CONFIG: OnceLock<HighlightConfiguration> = OnceLock::new();
fn highlight_names() -> &'static [&'static str] {
static NAMES: OnceLock<[&'static str; BashHighlight::ALL.len()]> = OnceLock::new();
NAMES
.get_or_init(|| BashHighlight::ALL.map(BashHighlight::as_str))
.as_slice()
}
fn highlight_config() -> &'static HighlightConfiguration {
HIGHLIGHT_CONFIG.get_or_init(|| {
let language = tree_sitter_bash::LANGUAGE.into();
#[expect(clippy::expect_used)]
let mut config = HighlightConfiguration::new(
language,
"bash",
tree_sitter_bash::HIGHLIGHT_QUERY,
"",
"",
)
.expect("load bash highlight query");
config.configure(highlight_names());
config
})
}
fn highlight_for(highlight: Highlight) -> BashHighlight {
BashHighlight::ALL[highlight.0]
}
fn push_segment(lines: &mut Vec<Line<'static>>, segment: &str, style: Option<Style>) {
for (i, part) in segment.split('\n').enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
if part.is_empty() {
continue;
}
let span = match style {
Some(style) => Span::styled(part.to_string(), style),
None => part.to_string().into(),
};
if let Some(last) = lines.last_mut() {
last.spans.push(span);
}
}
}
/// Convert a bash script into per-line styled content using tree-sitter's
/// bash highlight query. The highlighter is streamed so multi-line content is
/// split into `Line`s while preserving style boundaries.
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
let mut highlighter = Highlighter::new();
let iterator =
match highlighter.highlight(highlight_config(), script.as_bytes(), None, |_| None) {
Ok(iter) => iter,
Err(_) => return vec![script.to_string().into()],
};
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
let mut highlight_stack: Vec<Highlight> = Vec::new();
for event in iterator {
match event {
Ok(HighlightEvent::HighlightStart(highlight)) => highlight_stack.push(highlight),
Ok(HighlightEvent::HighlightEnd) => {
highlight_stack.pop();
}
Ok(HighlightEvent::Source { start, end }) => {
if start == end {
continue;
}
let style = highlight_stack.last().map(|h| highlight_for(*h).style());
push_segment(&mut lines, &script[start..end], style);
}
Err(_) => return vec![script.to_string().into()],
}
}
if lines.is_empty() {
vec![Line::from("")]
} else {
lines
}
}
#[cfg(test)]
@@ -84,62 +149,88 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
fn reconstructed(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn dimmed_tokens(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| sp.style.add_modifier.contains(Modifier::DIM))
.map(|sp| sp.content.clone().into_owned())
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.collect()
}
#[test]
fn dims_expected_bash_operators() {
let s = "echo foo && bar || baz | qux & (echo hi)";
let lines = highlight_bash_to_lines(s);
let reconstructed: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(reconstructed, s);
assert_eq!(reconstructed(&lines), s);
fn is_dim(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::DIM)
}
let dimmed: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| is_dim(sp))
.map(|sp| sp.content.clone().into_owned())
.collect();
assert_eq!(dimmed, vec!["&&", "||", "|", "&", "(", ")"]);
let dimmed = dimmed_tokens(&lines);
assert!(dimmed.contains(&"&&".to_string()));
assert!(dimmed.contains(&"|".to_string()));
assert!(!dimmed.contains(&"echo".to_string()));
}
#[test]
fn does_not_dim_quotes_but_dims_other_punct() {
fn dims_redirects_and_strings() {
let s = "echo \"hi\" > out.txt; echo 'ok'";
let lines = highlight_bash_to_lines(s);
let reconstructed: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(reconstructed, s);
assert_eq!(reconstructed(&lines), s);
fn is_dim(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::DIM)
}
let dimmed: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| is_dim(sp))
.map(|sp| sp.content.clone().into_owned())
.collect();
let dimmed = dimmed_tokens(&lines);
assert!(dimmed.contains(&">".to_string()));
assert!(dimmed.contains(&";".to_string()));
assert!(!dimmed.contains(&"\"".to_string()));
assert!(!dimmed.contains(&"'".to_string()));
assert!(dimmed.contains(&"\"hi\"".to_string()));
assert!(dimmed.contains(&"'ok'".to_string()));
}
#[test]
fn highlights_command_and_strings() {
let s = "echo \"hi\"";
let lines = highlight_bash_to_lines(s);
let mut echo_style = None;
let mut string_style = None;
for span in &lines[0].spans {
let text = span.content.as_ref();
if text == "echo" {
echo_style = Some(span.style);
}
if text == "\"hi\"" {
string_style = Some(span.style);
}
}
let echo_style = echo_style.expect("echo span missing");
let string_style = string_style.expect("string span missing");
assert!(echo_style.fg.is_none());
assert!(!echo_style.add_modifier.contains(Modifier::DIM));
assert!(string_style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn highlights_heredoc_body_as_string() {
let s = "cat <<EOF\nheredoc body\nEOF";
let lines = highlight_bash_to_lines(s);
let body_line = &lines[1];
let mut body_style = None;
for span in &body_line.spans {
if span.content.as_ref() == "heredoc body" {
body_style = Some(span.style);
}
}
let body_style = body_style.expect("missing heredoc span");
assert!(body_style.add_modifier.contains(Modifier::DIM));
}
}

View File

@@ -38,11 +38,13 @@ pub trait RectExt {
impl RectExt for Rect {
fn inset(&self, insets: Insets) -> Rect {
let horizontal = insets.left.saturating_add(insets.right);
let vertical = insets.top.saturating_add(insets.bottom);
Rect {
x: self.x + insets.left,
y: self.y + insets.top,
width: self.width - insets.left - insets.right,
height: self.height - insets.top - insets.bottom,
x: self.x.saturating_add(insets.left),
y: self.y.saturating_add(insets.top),
width: self.width.saturating_sub(horizontal),
height: self.height.saturating_sub(vertical),
}
}
}

View File

@@ -1,3 +1,5 @@
use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
@@ -12,6 +14,12 @@ pub trait Renderable {
fn desired_height(&self, width: u16) -> u16;
}
impl<R: Renderable + 'static> From<R> for Box<dyn Renderable> {
fn from(value: R) -> Self {
Box::new(value)
}
}
impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
@@ -71,6 +79,15 @@ impl<R: Renderable> Renderable for Option<R> {
}
}
impl<R: Renderable> Renderable for Arc<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_ref().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
}
pub struct ColumnRenderable {
children: Vec<Box<dyn Renderable>>,
}
@@ -122,7 +139,10 @@ impl Renderable for InsetRenderable {
}
impl InsetRenderable {
pub fn new(child: Box<dyn Renderable>, insets: Insets) -> Self {
Self { child, insets }
pub fn new(child: impl Into<Box<dyn Renderable>>, insets: Insets) -> Self {
Self {
child: child.into(),
insets,
}
}
}

View File

@@ -141,7 +141,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history_cell",
"lines": cell.transcript_lines().len(),
"lines": cell.transcript_lines(u16::MAX).len(),
});
LOGGER.write_json_line(value);
}

View File

@@ -90,6 +90,7 @@ mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use pretty_assertions::assert_eq;
async fn test_config() -> Config {
let overrides = ConfigOverrides {
@@ -195,7 +196,7 @@ mod tests {
for d in deltas.iter() {
ctrl.push(d);
while let (Some(cell), idle) = ctrl.on_commit_tick() {
lines.extend(cell.transcript_lines());
lines.extend(cell.transcript_lines(u16::MAX));
if idle {
break;
}
@@ -203,21 +204,14 @@ mod tests {
}
// Finalize and flush remaining lines now.
if let Some(cell) = ctrl.finalize() {
lines.extend(cell.transcript_lines());
lines.extend(cell.transcript_lines(u16::MAX));
}
let mut flat = lines;
// Drop leading blank and header line if present.
if !flat.is_empty() && lines_to_plain_strings(&[flat[0].clone()])[0].is_empty() {
flat.remove(0);
}
if !flat.is_empty() {
let s0 = lines_to_plain_strings(&[flat[0].clone()])[0].clone();
if s0 == "codex" {
flat.remove(0);
}
}
let streamed = lines_to_plain_strings(&flat);
let streamed: Vec<_> = lines_to_plain_strings(&lines)
.into_iter()
// skip • and 2-space indentation
.map(|s| s.chars().skip(2).collect::<String>())
.collect();
// Full render of the same source
let source: String = deltas.iter().copied().collect();

View File

@@ -1,12 +1,17 @@
use crate::color::blend;
use crate::color::is_light;
use crate::color::perceptual_distance;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::terminal_palette;
use ratatui::style::Color;
use ratatui::style::Style;
pub fn user_message_style() -> Style {
user_message_style_for(default_bg())
}
/// 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 {
pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
match terminal_bg {
Some(bg) => Style::default().bg(user_message_bg(bg)),
None => Style::default(),

View File

@@ -34,6 +34,8 @@ mod imp {
use std::sync::Mutex;
use std::sync::OnceLock;
const TERMINAL_QUERIES_ENABLED: bool = false;
struct Cache<T> {
attempted: bool,
value: Option<T>,
@@ -70,6 +72,9 @@ mod imp {
}
pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
if !TERMINAL_QUERIES_ENABLED {
return None;
}
static CACHE: OnceLock<Option<[(u8, u8, u8); 256]>> = OnceLock::new();
*CACHE.get_or_init(|| match query_terminal_palette() {
Ok(Some(palette)) => Some(palette),
@@ -78,12 +83,18 @@ mod imp {
}
pub(super) fn default_colors() -> Option<DefaultColors> {
if !TERMINAL_QUERIES_ENABLED {
return None;
}
let cache = default_colors_cache();
let mut cache = cache.lock().ok()?;
cache.get_or_init_with(|| query_default_colors().unwrap_or_default())
}
pub(super) fn requery_default_colors() {
if !TERMINAL_QUERIES_ENABLED {
return;
}
if let Ok(mut cache) = default_colors_cache().lock() {
cache.refresh_with(|| query_default_colors().unwrap_or_default());
}