diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3c8cb4c3b4..6b6c0e6f0d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5275,6 +5275,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "two-face", + "unicode-linebreak", "unicode-segmentation", "unicode-width 0.2.1", "url", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3bedc0fc44..4390ffa4f1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -558,6 +558,7 @@ tree-sitter-powershell = "=0.26.4" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" +unicode-linebreak = "0.1.5" unicode-normalization = "0.1.25" unicode-segmentation = "1.12.0" unicode-width = "0.2" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 06dd250f7d..913ee8f8cb 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -127,6 +127,7 @@ tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } syntect = { version = "5", default-features = false, features = ["parsing", "plist-load", "regex-onig"] } two-face = { version = "0.5", default-features = false, features = ["syntect-onig"] } +unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } vtparse = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 49be9b4971..30543e24ed 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -26,10 +26,10 @@ fn composer() -> ChatComposer { fn extension_status_line_clips_links_to_footer_layout() { let mut composer = composer(); let tree = r#"{ - "type": "row", "props": {"gap": 0}, "children": [ + "type": "row", "props": {"gap": 1}, "children": [ "plain", - { "type": "row", "props": {"gap": 0}, "children": [ - " · ", + { "type": "row", "props": {"gap": 1}, "children": [ + "·", { "type": "link", "props": { "href": "https://example.com/pr/42" }, "children": [ "PR #42" ] } diff --git a/codex-rs/tui/src/bottom_pane/status_line_content.rs b/codex-rs/tui/src/bottom_pane/status_line_content.rs index 5095708551..1c16876eae 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_content.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_content.rs @@ -38,7 +38,9 @@ impl StatusLineRenderer { let mut children = Vec::new(); for (item, text) in segments { if !children.is_empty() { - children.push(Some(UiNode::String(STATUS_LINE_SEPARATOR.to_owned()))); + children.push(Some(UiNode::String( + STATUS_LINE_SEPARATOR.trim().to_owned(), + ))); } let text = UiNode::String(text); let node = if item == StatusLineItem::PullRequestNumber @@ -53,10 +55,9 @@ impl StatusLineRenderer { }; children.push(Some(node)); } - let tree = UiNode::Row { - props: RowProps::default(), - children, - }; + let mut props = RowProps::default(); + props.gap = 1; + let tree = UiNode::Row { props, children }; Some(FooterContent::Extension { content: UiTree(Some(tree)), }) diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index d069474279..697e52291c 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -1,5 +1,9 @@ //! Render the visible prefix of a horizontal display tree into one terminal line. +mod text; + +use self::text::InlineText; +use self::text::Paint; use super::MAX_TEXT_BYTES; use super::TextProps; use super::TextWeight; @@ -13,6 +17,7 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Span; use std::borrow::Cow; +use std::ops::Range; use syntect::highlighting::Theme; use vtparse::CollectingVTActor; use vtparse::VTAction; @@ -25,8 +30,15 @@ impl UiTree { let Some(root) = self.0.as_ref().filter(|_| width > 0) else { return HyperlinkLine::default(); }; - let mut line = node_line(root, width, Style::default(), &RenderTheme::new(theme)); - if line.width() > width { + let output = LineBuilder::from_node( + root, + width, + Style::default(), + /*href*/ None, + &RenderTheme::new(theme), + ); + let mut line = output.line; + if output.used > width { line.line = truncate_line_with_ellipsis(line.line, width); line.clip_hyperlinks_to_width(line.width() - 1); } @@ -34,35 +46,122 @@ impl UiTree { } } -fn node_line(node: &UiNode, width: usize, style: Style, theme: &RenderTheme<'_>) -> HyperlinkLine { - let (children, gap, style) = match node { - UiNode::String(raw) => { - let text = sanitize_leaf(raw); - let first = text.split('\n').next().unwrap_or_default(); - let visible = first.replace('\t', " "); - return HyperlinkLine::new(Span::styled(visible, style).into()); - } - UiNode::Link { props, children } => { - let mut line = row_line(children, /*gap*/ 0, width, style.underlined(), theme); - let width = line.width(); - if width > 0 { - line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())]; +struct LineBuilder<'a> { + inline: InlineText<'a>, + line: HyperlinkLine, + used: usize, + width: usize, + saw_row: bool, +} + +impl<'a> LineBuilder<'a> { + fn from_node( + node: &'a UiNode, + width: usize, + style: Style, + href: Option<&'a str>, + theme: &RenderTheme<'_>, + ) -> Self { + let mut output = Self { + inline: InlineText::default(), + line: HyperlinkLine::default(), + used: 0, + width, + saw_row: false, + }; + output.visit(node, style, href, theme); + output.flush_runs(); + output + } + + fn visit( + &mut self, + node: &'a UiNode, + mut style: Style, + mut href: Option<&'a str>, + theme: &RenderTheme<'_>, + ) { + let children = match node { + UiNode::String(raw) => { + self.inline.push_leaf(raw, Paint { style, href }); + return; } - return line; + UiNode::Row { props, children } => { + self.saw_row = true; + self.flush_runs(); + if let Some(color) = &props.background { + style = style.bg(color.resolve(theme)); + } + let start = self.used; + for child in children.iter().flatten() { + if self.used > self.width { + break; + } + let child = Self::from_node(child, self.width - self.used, style, href, theme); + if child.used == 0 { + continue; + } + if self.used > start && props.gap > 0 { + let gap = props.gap.min((self.width + 1 - self.used) as u64) as usize; + self.line + .line + .push_span(Span::styled(" ".repeat(gap), style)); + if let Some(url) = href { + push_link(&mut self.line, self.used..self.used + gap, url); + } + self.used += gap; + } + self.line.line.spans.extend(child.line.line.spans); + for mut link in child.line.hyperlinks { + link.columns = self.used + link.columns.start..self.used + link.columns.end; + push_link(&mut self.line, link.columns, &link.destination); + } + self.used += child.used; + } + return; + } + UiNode::Text { props, children } => { + style = props.style(style, theme); + children + } + UiNode::Link { props, children } => { + href = Some(&props.href.0); + style = style.underlined(); + children + } + UiNode::Bold { children } => { + style = style.bold(); + children + } + UiNode::Italic { children } => { + style = style.italic(); + children + } + UiNode::Underline { children } => { + style = style.underlined(); + children + } + }; + for node in children.iter().flatten() { + if self.used > self.width { + break; + } + self.visit(node, style, href, theme); } - UiNode::Row { props, children } => { - let style = props - .background - .as_ref() - .map_or(style, |color| style.bg(color.resolve(theme))); - (children, props.gap, style) + } + + fn flush_runs(&mut self) { + let start = self.used; + let first_span = self.line.line.spans.len(); + let text = std::mem::take(&mut self.inline).finish(); + if let Some(first) = text.lines.first() { + self.used += text::paint_chunks(first, &mut self.line, self.used); } - UiNode::Text { props, children } => (children, 0, props.style(style, theme)), - UiNode::Bold { children } => (children, 0, style.bold()), - UiNode::Italic { children } => (children, 0, style.italic()), - UiNode::Underline { children } => (children, 0, style.underlined()), - }; - row_line(children, gap, width, style, theme) + // A zero-cell fragment between row children must not style a later ellipsis. + if self.saw_row && self.used == start { + self.line.line.spans.truncate(first_span); + } + } } fn sanitize_leaf(raw: &str) -> Cow<'_, str> { @@ -86,43 +185,16 @@ fn sanitize_leaf(raw: &str) -> Cow<'_, str> { .collect() } -fn row_line( - children: &[Option], - gap: u64, - width: usize, - style: Style, - theme: &RenderTheme<'_>, -) -> HyperlinkLine { - let mut result = HyperlinkLine::default(); - let mut used = 0; - for child in children.iter().flatten() { - let child = node_line(child, width, style, theme); - let child_width = child.width(); - if child_width == 0 { - continue; - } - if used > 0 && gap > 0 { - // One column past the viewport is sufficient to detect overflow. - let spacing = gap.min((width + 1 - used) as u64) as usize; - result - .line - .push_span(Span::styled(" ".repeat(spacing), style)); - used += spacing; - } - let offset = used; - result.line.spans.extend(child.line.spans); - result - .hyperlinks - .extend(child.hyperlinks.into_iter().map(|mut link| { - link.columns = link.columns.start + offset..link.columns.end + offset; - link - })); - used += child_width; - if used > width { - break; - } +fn push_link(line: &mut HyperlinkLine, columns: Range, href: &str) { + if let Some(last) = line.hyperlinks.last_mut() + && last.columns.end == columns.start + && last.destination == href + { + last.columns.end = columns.end; + } else { + line.hyperlinks + .push(TerminalHyperlink::web(columns, href.to_owned())); } - result } impl TextProps { diff --git a/codex-rs/tui/src/ui_tree/render/text.rs b/codex-rs/tui/src/ui_tree/render/text.rs new file mode 100644 index 0000000000..ce4d0d9e6b --- /dev/null +++ b/codex-rs/tui/src/ui_tree/render/text.rs @@ -0,0 +1,281 @@ +//! Prepared inline text retains styled chunks and their legal line breaks across leaves. + +use super::push_link; +use super::sanitize_leaf; +use crate::line_truncation::span_width; +use crate::terminal_hyperlinks::HyperlinkLine; +use ratatui::style::Style; +use ratatui::text::Span; +use std::borrow::Cow; +use std::ops::Range; +use unicode_linebreak::linebreaks; +use unicode_linebreak::split_at_safe; +use unicode_segmentation::GraphemeCursor; +use unicode_segmentation::GraphemeIncomplete; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Paint<'a> { + pub(super) style: Style, + pub(super) href: Option<&'a str>, +} + +#[derive(Debug)] +struct Run<'a> { + text: Cow<'a, str>, + paint: Paint<'a>, +} + +fn slice<'a>(text: &Cow<'a, str>, range: Range) -> Cow<'a, str> { + match text { + Cow::Borrowed(raw) => Cow::Borrowed(&raw[range]), + Cow::Owned(raw) => Cow::Owned(raw[range].to_owned()), + } +} + +#[derive(Debug, Default)] +pub(super) struct Chunk<'a> { + runs: Vec>, + pub(super) width_cells: usize, + pub(super) trailing_space: Option>, +} + +#[derive(Default)] +pub(super) struct PreparedText<'a> { + pub(super) lines: Vec>>, +} + +#[derive(Default)] +pub(super) struct InlineText<'a> { + text: PreparedText<'a>, + line: Vec>, + space: Option>, +} + +impl<'a> InlineText<'a> { + pub(super) fn push_leaf(&mut self, raw: &'a str, paint: Paint<'a>) { + let text = sanitize_leaf(raw); + let mut start = 0; + for (end, ch) in text + .match_indices([' ', '\t', '\n']) + .chain(std::iter::once((text.len(), ""))) + { + if start < end { + if let Some(paint) = self.space.take() { + self.line.push(Run { + text: " ".into(), + paint, + }); + } + self.line.push(Run { + text: slice(&text, start..end), + paint, + }); + } + if ch == "\n" { + self.text.lines.push(chunks(std::mem::take(&mut self.line))); + self.space = None; + } else if !ch.is_empty() && !self.line.is_empty() { + self.space.get_or_insert(paint); + } + start = end + ch.len(); + } + } + + pub(super) fn finish(mut self) -> PreparedText<'a> { + if !self.line.is_empty() { + self.text.lines.push(chunks(self.line)); + } + self.text + } +} + +// Most seams already fall between graphemes. Only a cluster that actually crosses a +// seam needs contiguous storage, including when it spans more than two leaves. +fn join_graphemes(runs: Vec>) -> Vec> { + let mut runs = runs.into_iter().peekable(); + let mut joined = Vec::new(); + while let Some(run) = runs.next() { + let Some(next) = runs.peek() else { + joined.push(run); + break; + }; + if run.text.as_bytes().last().is_some_and(u8::is_ascii) + && next.text.as_bytes()[0].is_ascii() + { + joined.push(run); + continue; + } + // Reverse segmentation in unicode-segmentation 1.12 disagrees on Indic conjuncts. + #[expect(clippy::double_ended_iterator_last)] + let Some(tail) = run.text.graphemes(true).last() else { + continue; + }; + let split = tail.len(); + let mut cursor = GraphemeCursor::new(split, usize::MAX, true); + while let Err(GraphemeIncomplete::PreContext(_)) = cursor.is_boundary(&next.text, split) { + cursor.provide_context(tail, 0); + } + if cursor.is_boundary(&next.text, split) == Ok(true) { + joined.push(run); + continue; + } + + let mut cluster = tail.to_owned(); + let mut paint = run.paint; + let prefix = run.text.len() - split; + if prefix > 0 { + joined.push(Run { + text: slice(&run.text, 0..prefix), + paint, + }); + } + // Resume in the growing cluster so even a long chain of combining marks + // advances once per character and Unicode lookbehind stays available. + let mut cursor = GraphemeCursor::new(0, usize::MAX, true); + let _ = cursor.next_boundary(&cluster, 0); + while let Some(next) = runs.peek_mut() { + let mut consumed = 0; + for ch in next.text.chars() { + cluster.push(ch); + if cursor.next_boundary(&cluster, 0).is_ok() { + cluster.pop(); + break; + } + consumed += ch.len_utf8(); + } + if consumed > 0 && paint.href != next.paint.href { + paint.href = None; + } + if consumed < next.text.len() { + if consumed > 0 { + next.text = slice(&next.text, consumed..next.text.len()); + } + break; + } + runs.next(); + } + joined.push(Run { + text: Cow::Owned(cluster), + paint, + }); + } + joined +} + +// Keep the context needed for breaks across style boundaries. +fn break_offsets<'a>(runs: impl Iterator) -> Vec { + let mut runs = runs.peekable(); + let mut breaks = Vec::new(); + let mut scratch = String::new(); + let mut base = 0; + while let Some(&run) = runs.peek() { + let scanned = scratch.len(); + let window = if scanned == 0 { + runs.next(); + run + } else { + // Match the retained context with fresh bytes to keep rescanning linear. + // Bound each read so a short prefix never pulls a large ASCII run into scratch. + while scratch.len() - scanned < scanned + && let Some(next) = runs.peek_mut() + { + let (part, rest) = next.split_at(next.floor_char_boundary(scanned.max(128))); + scratch.push_str(part); + *next = rest; + runs.next_if(|s| s.is_empty()); + } + scratch.as_str() + }; + breaks.extend( + linebreaks(window) + .filter_map(|(i, _)| (i >= scanned && i < window.len()).then_some(base + i)), + ); + if runs.peek().is_none() { + break; + } + let discard = split_at_safe(window).0.len(); + base += discard; + if scanned == 0 { + scratch.push_str(&run[discard..]); + } else { + scratch.drain(..discard); + } + } + breaks +} + +fn chunks(runs: Vec>) -> Vec> { + let runs = join_graphemes(runs); + let mut breaks = break_offsets(runs.iter().map(|run| run.text.as_ref())) + .into_iter() + .peekable(); + let mut out = Vec::new(); + let mut chunk = Chunk::default(); + let mut offset = 0; + for run in runs { + let mut start = 0; + for (i, _) in run.text.grapheme_indices(true) { + while breaks.next_if(|b| *b < offset + i).is_some() {} + if breaks.next_if_eq(&(offset + i)).is_none() { + continue; + } + chunk.push(&run, start..i); + if chunk.runs.last().is_some_and(|r| r.text == " ") { + chunk.trailing_space = chunk.runs.pop().map(|r| r.paint); + chunk.width_cells -= 1; + } + if !chunk.runs.is_empty() { + out.push(std::mem::take(&mut chunk)); + } + start = i; + } + chunk.push(&run, start..run.text.len()); + offset += run.text.len(); + } + if !chunk.runs.is_empty() { + out.push(chunk); + } + out +} + +impl<'a> Chunk<'a> { + fn push(&mut self, run: &Run<'a>, range: Range) { + if !range.is_empty() { + let text = slice(&run.text, range); + self.width_cells += span_width(&Span::raw(text.as_ref())); + self.runs.push(Run { text, ..*run }); + } + } +} + +pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start: usize) -> usize { + let mut col = start; + for (index, chunk) in chunks.iter().enumerate() { + for run in &chunk.runs { + let span = Span::styled(run.text.to_string(), run.paint.style); + let end = col + span_width(&span); + if let Some(url) = run.paint.href + && end > col + { + push_link(line, col..end, url); + } + line.line.push_span(span); + col = end; + } + if index + 1 < chunks.len() + && let Some(paint) = chunk.trailing_space + { + line.line.push_span(Span::styled(" ", paint.style)); + if let Some(url) = paint.href { + push_link(line, col..col + 1, url); + } + col += 1; + } + } + col - start +} + +#[cfg(test)] +#[path = "text_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/ui_tree/render/text_tests.rs b/codex-rs/tui/src/ui_tree/render/text_tests.rs new file mode 100644 index 0000000000..c1ffff0924 --- /dev/null +++ b/codex-rs/tui/src/ui_tree/render/text_tests.rs @@ -0,0 +1,16 @@ +#![cfg(test)] + +use pretty_assertions::assert_eq; +use unicode_linebreak::linebreaks; + +#[test] +fn styled_runs_have_the_same_breaks_as_contiguous_text() { + let long = "中日".repeat(200); + let runs = ["bu", "ild (", " ready)", "— ", "—"] // codespell:ignore bu + .into_iter() + .chain(std::iter::repeat_n("🇨", 79)) + .chain([long.as_str(), " done"]); + let text: String = runs.clone().collect(); + let expected: Vec<_> = linebreaks(&text).map(|(i, _)| i).collect(); + assert_eq!(super::break_offsets(runs), expected[..expected.len() - 1]); +} diff --git a/codex-rs/tui/src/ui_tree_tests.rs b/codex-rs/tui/src/ui_tree_tests.rs index 29061ea8ad..3cf7473ace 100644 --- a/codex-rs/tui/src/ui_tree_tests.rs +++ b/codex-rs/tui/src/ui_tree_tests.rs @@ -167,6 +167,24 @@ fn ellipsis_counts_toward_width_only_when_there_is_overflow() { ); } +#[test] +fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() { + let row = |text| json!({"type":"row","props":{"gap":0},"children":[text]}); + let mark = json!({"type":"bold","children":["\u{301}"]}); + for (mut root, width) in [ + (json!({"type":"text"}), 2), + (json!({"type":"row","props":{"gap":1}}), 4), + ] { + root["children"] = json!([row("x"), mark, row("yz")]); + let node = tree(root.clone()); + root["children"] = json!([row("x"), row("yz")]); + assert_eq!( + node.render_line(width, &Theme::default()), + tree(root).render_line(width, &Theme::default()) + ); + } +} + #[test] fn first_line_skips_hidden_text_before_rendering_its_sibling() { let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(1024)); @@ -406,3 +424,68 @@ fn terminal_commands_are_sanitized_within_each_string() { ); insta::assert_snapshot!(line.line.to_string(), @"red label Ready"); } + +#[test] +fn split_graphemes_use_the_first_style_and_require_unanimous_links() { + use crate::terminal_hyperlinks::TerminalHyperlink; + use ratatui::style::Stylize; + use ratatui::text::Span; + + let url = "https://example.com/a"; + let link = |href, text| json!({"type":"link","props":{"href":href},"children":[text]}); + let node = tree(json!({"type":"text","children":[ + {"type":"bold","children":[{"type":"row","props":{"gap":0},"children":["R"]},"e"]}, {"type":"italic","children":[]}, "\u{301}", + link(url, "👩"), link(url, "\u{200d}💻"), + link(url, "👩"), link("https://example.com/b", "\u{200d}💻"), + {"type":"bold","children":["\u{600}"]}, " x", + link(url, "तक्क"), "\u{301}" + ]})); + let line = node.render_line(/*width*/ 20, &Theme::default()); + assert_eq!( + line.line.spans, + [ + Span::raw("R").bold(), + Span::raw("e\u{301}").bold(), + Span::raw("👩‍💻").underlined(), + Span::raw("👩‍💻").underlined(), + Span::raw("\u{600} ").bold(), + Span::raw("x"), + Span::raw("त").underlined(), + Span::raw("क्क\u{301}").underlined() + ] + ); + assert_eq!( + line.hyperlinks, + [ + TerminalHyperlink::web(2..4, url.to_owned()), + TerminalHyperlink::web(9..10, url.to_owned()) + ] + ); +} + +#[test] +fn collapsed_space_keeps_the_first_whitespace_owners_link() { + use crate::terminal_hyperlinks::TerminalHyperlink; + + let href = "https://example.com/build"; + let node = tree(json!({"type":"text","children":[ + "a", {"type":"link","props":{"href":href},"children":["\t"]}, + "\u{1b}[31m", " b", {"type":"link","props":{"href":href},"children":[" "]}, + {"type":"row","props":{"gap":0},"children":[" c "]}, " d" + ]})); + let line = node.render_line(/*width*/ 10, &Theme::default()); + assert_eq!(line.line.to_string(), "a bcd"); + assert_eq!( + line.hyperlinks, + [TerminalHyperlink::web(1..2, href.to_owned())] + ); +} + +#[test] +fn whitespace_collapses_across_styled_leaves() { + let node = tree(json!({"type":"text","children":[ + " build\t", {"type":"bold","children":[" ready "]}, "\nnext" + ]})); + let line = node.render_line(/*width*/ 20, &Theme::default()); + insta::assert_snapshot!(line.line.to_string(), @"build ready"); +}