diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index f990145d31..6ce1f30645 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -503,6 +503,8 @@ async fn owned_drag_stops_when_focus_or_input_ownership_is_lost() -> Result<()> async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; attach_thread(&mut app, ThreadId::new()); + app.chat_widget + .set_status_line(Some("gpt-test default · /tmp/project".into())); app.transcript_cells = vec![user_cell("First prompt"), user_cell("Second prompt")]; app.chat_widget .apply_external_edit("preserved draft".to_string()); @@ -647,8 +649,7 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> { app.transcript_view.link_at(column, row).as_deref(), Some("https://example.com/docs") ); - let copied_cursor = tui.terminal.last_known_cursor_pos; - assert_eq!((copied_cursor.x, copied_cursor.y + 1), (caret.x, caret.y)); + assert_eq!(tui.terminal.last_known_cursor_pos, caret); let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); for pos in Rect::from(outer) .positions() diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap index 41dac123fd..f9839b1e29 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__follow_tests__running_copy_feedback_spacing.snap @@ -33,7 +33,6 @@ transcript row 39 tab to queue mes 6 columns, 18 rows -ript row 38 transc ript @@ -49,7 +48,8 @@ row 39 her e - GPT… + GPT- + 5.6… tab 80 columns, 6 rows diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap index 3dae559699..44849f438f 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap @@ -4,7 +4,6 @@ expression: normalize_snapshot_paths(screen) --- ···································· ···································· -····· ····· ·····› Second prompt ····· ····· ····· ····· ····· @@ -17,6 +16,7 @@ expression: normalize_snapshot_paths(screen) ····· ····· ·····› preserved draft ····· ····· ····· -····· gpt-test default · /tmp…····· +····· gpt-test default · /tmp/····· +····· project ····· ···································· ···································· diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap index d45000ee78..d39259c65b 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_frame_tests__startup_tip_in_transcript.snap @@ -40,8 +40,8 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots. - › Ask Codex to do anything - GPT-5.6-Sol default · /tmp/pr… + GPT-5.6-Sol default · /tmp/ + project ? for shortcuts diff --git a/codex-rs/tui/src/app/tests/startup_frame_tests.rs b/codex-rs/tui/src/app/tests/startup_frame_tests.rs index ad24c7231d..9e484afda9 100644 --- a/codex-rs/tui/src/app/tests/startup_frame_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_frame_tests.rs @@ -41,6 +41,8 @@ async fn owned_startup_hides_tip_in_transcript() -> Result<()> { ]; let mut tui = crate::tui::test_support::make_test_tui()?; tui.set_owned_screen(/*owned*/ true)?; + app.chat_widget + .set_status_line(Some("GPT-5.6-Sol default · /tmp/project".into())); let mut snapshots = Vec::new(); for width in [80, 32] { let size = Size::new(width, /*height*/ 20); diff --git a/codex-rs/tui/src/app/turn_tips_tests.rs b/codex-rs/tui/src/app/turn_tips_tests.rs index d46dbf284a..e14395e89a 100644 --- a/codex-rs/tui/src/app/turn_tips_tests.rs +++ b/codex-rs/tui/src/app/turn_tips_tests.rs @@ -248,6 +248,8 @@ async fn turn_tip_placements_and_completion_barrier() -> Result<()> { } } assert_eq!(saw_barrier, !working); + app.chat_widget + .set_status_line(Some("gpt-test default · /tmp/project".into())); let frames = tui.frame_requester(); let mut painted = Default::default(); tui.draw(height, |frame| { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 912488b910..ca88946067 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -414,6 +414,7 @@ mod paste_input; mod popup_state; mod reconnect; pub(crate) use reconnect::RestrictedInputMode; +mod native_status; mod slash_input; mod sparkle; mod status_surface; @@ -796,6 +797,7 @@ impl ChatComposer { ide_context_active: false, status_line_value: None, measured_status_line: Default::default(), + wrapped_native_status: Default::default(), status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index f439175828..9951d9a29c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -413,6 +413,8 @@ pub(super) struct FooterState { pub(super) status_line_value: Option, // Measurement bounds and occupied height, shared by layout and cursor placement. pub(super) measured_status_line: std::cell::Cell>, + pub(super) wrapped_native_status: + std::cell::RefCell>, pub(super) status_line_enabled: bool, pub(super) side_conversation_context_label: Option, pub(super) active_agent_label: Option, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs b/codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs new file mode 100644 index 0000000000..80cb11f6fd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/native_status.rs @@ -0,0 +1,133 @@ +//! Wrap the built-in status line using its painted grapheme widths and native hyperlink ranges. +//! Measurement and paint share the visible rows at the current width. + +use super::ChatComposer; +use super::FooterContent; +use super::composer_layout::MAX_FOOTER_ROWS; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::TerminalHyperlink; +use crate::terminal_hyperlinks::project_hyperlink_columns; +use crate::width::display_width; +use crate::wrapping::adaptive_wrap_line_to_width; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::text::Span; +use std::cell::Ref; +use unicode_segmentation::UnicodeSegmentation; + +pub(super) struct WrappedNativeStatus { + width: u16, + suffix: Vec>, + rows: Vec, +} + +impl WrappedNativeStatus { + pub(super) fn height(&self) -> u16 { + self.rows.len().max(1) as u16 + } + + pub(super) fn render(&self, area: Rect, buf: &mut Buffer) { + let height = usize::from(area.height); + for (index, line) in self.rows.iter().take(height).enumerate() { + let row = Rect::new(area.x, area.y + index as u16, area.width, 1); + if (index + 1 == height && self.rows.len() > height) + || line.width() > usize::from(area.width) + { + line.clone().render_with_ellipsis(row, buf); + } else { + line.render_fitted(row, buf); + } + } + } +} + +impl ChatComposer { + pub(super) fn wrapped_native_status( + &self, + content: &FooterContent, + width: u16, + ) -> Option> { + let (line, links) = content.native_parts()?; + if width == 0 { + return None; + } + // The setter invalidates configured spans. The styled agent suffix can change independently. + let configured = self + .footer + .status_line_value + .as_ref() + .and_then(FooterContent::native_parts) + .map_or(0, |(line, _)| line.spans.len()); + let suffix = &line.spans[configured..]; + let cache = &self.footer.wrapped_native_status; + if cache + .borrow() + .as_ref() + .is_none_or(|entry| entry.width != width || entry.suffix != suffix) + { + if content.width() <= width { + return None; + } + cache.replace(Some(WrappedNativeStatus { + width, + suffix: suffix.to_vec(), + rows: wrap_rows(line, links, width), + })); + } + Ref::filter_map(cache.borrow(), Option::as_ref).ok() + } +} + +fn wrap_rows(line: &Line<'_>, links: &[TerminalHyperlink], width: u16) -> Vec { + // One row of lookahead settles the last visible break. Whitespace discarded by + // wrapping cannot count toward that budget or it could hide a later word. + let limit = usize::from(MAX_FOOTER_ROWS) + 1; + let mut remaining = usize::from(width) * limit; + let mut prefix = Line::default().style(line.style); + for span in &line.spans { + let mut end = 0; + for grapheme in span.content.graphemes(true) { + end += grapheme.len(); + if !grapheme.chars().all(char::is_whitespace) { + remaining = + remaining.saturating_sub(display_width(grapheme).min(usize::from(width))); + if remaining == 0 { + break; + } + } + } + prefix.push_span(Span::styled(&span.content[..end], span.style)); + if remaining == 0 { + break; + } + } + // Native link columns are counted per grapheme within each painted span. + let mut graphemes = prefix + .spans + .iter() + .flat_map(|span| span.content.graphemes(true)); + let (mut byte, mut column) = (0, 0); + let mut column_at = |target| { + while byte < target + && let Some(grapheme) = graphemes.next() + { + byte += grapheme.len(); + column += display_width(grapheme); + } + column + }; + adaptive_wrap_line_to_width(&prefix, usize::from(width).into()) + .into_iter() + .take(limit) + .map(|row| { + let source = column_at(row.range.start)..column_at(row.range.end); + HyperlinkLine { + line: line_to_static(&row.line), + hyperlinks: project_hyperlink_columns(links, source, /*prefix*/ 0), + source: None, + } + }) + .collect() +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__native_status_wraps_links_within_the_host_budget.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__native_status_wraps_links_within_the_host_budget.snap new file mode 100644 index 0000000000..7069863a9c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__native_status_wraps_links_within_the_host_budget.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: "frames.join(\"\\n---\\n\")" +--- +لا PR #42 build +green deploy +ready all unit +tests passed… +--- +لا PR #42 build +green deploy +ready all unit… +--- +لا PR #42 build +green deploy… +--- +لا PR #42 build… diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs index 80ed155772..f7baf4a125 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs @@ -15,6 +15,7 @@ impl ChatComposer { self.effort_status_line_transition = None; } self.footer.measured_status_line.set(None); + self.footer.wrapped_native_status.take(); self.footer.status_line_value = status_line; true } @@ -66,6 +67,12 @@ impl ChatComposer { cache.set(Some((bounds, height))); height } + Some(native) if bounds.height > 1 && self.active_status_transition().is_none() => self + .wrapped_native_status(native, bounds.width) + .map_or_else( + || u16::from(native.width() > 0), + |rows| rows.height().min(bounds.height), + ), Some(line) => u16::from(line.width() > 0), None => 0, } @@ -98,6 +105,12 @@ impl ChatComposer { passive_footer_status_line(&props).map(Cow::Owned) } + fn active_status_transition(&self) -> Option<&EffortStatusLineTransition> { + self.effort_status_line_transition + .as_ref() + .filter(|transition| !transition.is_finished()) + } + pub(super) fn render_status_surface( &self, area: Rect, @@ -119,11 +132,7 @@ impl ChatComposer { } let area = inset_footer_hint_area(area); let line = self.status_surface_content(); - if let Some(transition) = self - .effort_status_line_transition - .as_ref() - .filter(|transition| !transition.is_finished()) - { + if let Some(transition) = self.active_status_transition() { if let Some(frame) = transition.render_line( line.map(|line| line.into_owned().into_hyperlink_line()), area.width, @@ -134,7 +143,13 @@ impl ChatComposer { requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK); } } else if let Some(line) = line { - line.into_owned().render(area, buf); + if area.height > 1 + && let Some(wrapped) = self.wrapped_native_status(&line, area.width) + { + wrapped.render(area, buf); + } else { + line.into_owned().render(area, buf); + } } } } 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 8c639d6c7e..2e5dbfc9cd 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 @@ -474,6 +474,129 @@ fn extension_status_keeps_its_rows_when_effort_changes() { } } +#[test] +fn native_status_wraps_links_within_the_host_budget() { + let mut composer = composer(); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + let url = "https://example.com/pr/42"; + let mut status = HyperlinkLine::from("لا "); + status.push_span( + "PR #42 build green deploy ready all unit tests passed release queued".underlined(), + Some(url), + ); + composer.set_status_line(Some(status.into())); + let mut frames = Vec::new(); + composer.footer.toggle_shortcuts_key = None; + for (fullscreen, mode, status_height) in [ + (true, None, 4), + (true, Some(CollaborationModeIndicator::Plan), 3), + (false, None, 2), + (false, Some(CollaborationModeIndicator::Plan), 1), + ] { + composer.set_collaboration_mode_indicator(mode); + let (_, rows) = render_status( + &composer, + Rect::new(0, 0, 18, 20), + ComposerRenderOptions { + separate_status_line: fullscreen, + ..Default::default() + }, + ); + assert_eq!(rows.len(), status_height); + for row in &rows { + let text = strip_osc8(row); + let (prefix, rest) = text + .strip_prefix("لا ") + .map_or(("", text.as_str()), |rest| ("لا ", rest)); + let (link, end) = rest + .strip_suffix('…') + .map_or((rest, ""), |link| (link, "…")); + let visible_link: String = link + .chars() + .map(|ch| osc8_hyperlink(url, &ch.to_string())) + .collect(); + assert_eq!(*row, format!("{prefix}{visible_link}{end}")); + } + frames.push( + rows.iter() + .map(|row| strip_osc8(row)) + .collect::>() + .join("\n"), + ); + } + insta::assert_snapshot!(frames.join("\n---\n")); +} + +#[test] +fn native_wrap_tracks_agent_label_and_content_changes_at_the_same_width() { + let mut composer = composer(); + composer.set_status_line(Some(Line::from("build ready").into())); + let area = Rect::new(0, 0, 16, 14); + composer.footer.toggle_shortcuts_key = None; + for agent in ["frontend", "backend"] { + composer.set_active_agent_label(Some(agent.into())); + assert_eq!( + render_status(&composer, area, Default::default()).1, + ["build ready ·", agent] + ); + } + composer.set_status_line(Some(Line::from("tests green").into())); + assert_eq!( + render_status(&composer, area, Default::default()).1, + ["tests green ·", "backend"] + ); + let wide = Rect { width: 36, ..area }; + assert_eq!( + render_status(&composer, wide, Default::default()).1, + ["tests green · backend"] + ); + assert_eq!( + render_status(&composer, area, Default::default()).1, + ["tests green ·", "backend"] + ); +} + +#[tokio::test(start_paused = true)] +async fn native_effort_transition_restores_wrapping_after_its_final_frame() { + let mut composer = composer(); + composer.set_status_line(Some( + Line::from("build ready tests passed deploy queued").into(), + )); + composer.set_active_reasoning_effort_baseline(/*effort*/ None); + composer.set_active_reasoning_effort( + Some(&ReasoningEffort::Max), + /*animations_enabled*/ true, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 14, /*height*/ 20, + ); + for separate_status_line in [false, true] { + let options = ComposerRenderOptions { + separate_status_line, + ..Default::default() + }; + assert_eq!(render_status(&composer, area, options).1.len(), 1); + } + + let options = ComposerRenderOptions { + separate_status_line: true, + ..Default::default() + }; + let status = composer.layout_with_options(area, options).status; + assert_eq!(status.height, 1); + let (requester, mut frames) = FrameRequester::test_channel(); + composer.set_frame_requester(requester); + tokio::time::advance(Duration::from_secs(/*secs*/ 10)).await; + composer.render_status_surface(status, &mut Buffer::empty(area), options); + frames.try_recv().expect("final paint requests a redraw"); + let wrapped = composer.layout_with_options(area, options).status; + let mut buf = Buffer::empty(area); + composer.render_status_surface(wrapped, &mut buf, options); + assert!(wrapped.height > 1); + assert!(frame(&buf).contains("tests")); + assert!(frames.try_recv().is_err()); +} + fn render( composer: &ChatComposer, width: u16, diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line.rs b/codex-rs/tui/src/bottom_pane/effort_status_line.rs index 7415c70c38..ad121adc7b 100644 --- a/codex-rs/tui/src/bottom_pane/effort_status_line.rs +++ b/codex-rs/tui/src/bottom_pane/effort_status_line.rs @@ -13,13 +13,13 @@ use std::cell::Cell; use std::time::Duration; -use std::time::Instant; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; +use tokio::time::Instant; use crate::color::blend; use crate::color::is_light; @@ -63,7 +63,14 @@ impl EffortTier { pub(crate) struct EffortStatusLineTransition { tier: EffortTier, previous: HyperlinkLine, - started_at: Cell>, + phase: Cell, +} + +#[derive(Clone, Copy)] +enum TransitionPhase { + Pending, + Running(Instant), + Finished, } impl EffortStatusLineTransition { @@ -71,14 +78,12 @@ impl EffortStatusLineTransition { Self { tier, previous, - started_at: Cell::new(None), + phase: Cell::new(TransitionPhase::Pending), } } pub(crate) fn is_finished(&self) -> bool { - self.started_at - .get() - .is_some_and(|started| started.elapsed() >= TOTAL) + matches!(self.phase.get(), TransitionPhase::Finished) } pub(crate) fn render_line( @@ -86,13 +91,18 @@ impl EffortStatusLineTransition { current: Option, width: u16, ) -> Option { - let elapsed = match self.started_at.get() { - Some(started) => started.elapsed(), - None => { - self.started_at.set(Some(Instant::now())); + let elapsed = match self.phase.get() { + TransitionPhase::Pending => { + self.phase.set(TransitionPhase::Running(Instant::now())); Duration::ZERO } + TransitionPhase::Running(started) => started.elapsed(), + TransitionPhase::Finished => return current, }; + // Paint the last single-row frame before the next layout restores wrapping. + if elapsed >= TOTAL { + self.phase.set(TransitionPhase::Finished); + } transition_line_at(self.tier, &self.previous, current, elapsed, width) } } diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs index a17f5b8891..197f7bf338 100644 --- a/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs +++ b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs @@ -19,14 +19,17 @@ fn clock_waits_for_the_first_visible_frame() { let transition = EffortStatusLineTransition::new(EffortTier::Ultra, "gpt-5.4 high · main".into()); assert!(!transition.is_finished()); - assert_eq!(transition.started_at.get(), None); + assert!(matches!(transition.phase.get(), TransitionPhase::Pending)); let current = HyperlinkLine::from("gpt-5.4 ultra · main"); let line = transition .render_line(Some(current), /*width*/ 40) .expect("first frame should render the outgoing line"); assert_eq!(text(&line.line), "gpt-5.4 high · main"); - assert!(transition.started_at.get().is_some()); + assert!(matches!( + transition.phase.get(), + TransitionPhase::Running(_) + )); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs index e74199951a..a3db27dab2 100644 --- a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs @@ -6,9 +6,9 @@ use ratatui::layout::Rect; use ratatui::text::Line; use std::sync::Arc; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::highlight::with_syntax_theme; use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::TerminalHyperlink; use crate::ui_tree::PreparedUiTree; use crate::ui_tree::UiTree; @@ -29,6 +29,14 @@ impl From for FooterContent { } impl FooterContent { + pub(crate) fn native_parts(&self) -> Option<(&Line<'static>, &[TerminalHyperlink])> { + match self { + Self::Line(line) => Some((line, &[])), + Self::HyperlinkLine(line) => Some((&line.line, &line.hyperlinks)), + Self::Extension { .. } => None, + } + } + /// Natural display width in terminal cells. pub(crate) fn width(&self) -> u16 { let width = match self { @@ -46,18 +54,17 @@ impl FooterContent { with_syntax_theme(|theme| content.render(area, theme, buf)); } native => { - let mut line = native.into_hyperlink_line(); + let line = native.into_hyperlink_line(); let area = Rect { height: area.height.min(1), ..area }; let width = usize::from(area.width); if line.width() > width { - line.line = truncate_line_with_ellipsis_if_overflow(line.line, width); - // The ellipsis is never part of the link. - line.clip_hyperlinks_to_width(line.width().saturating_sub(1)); + line.render_with_ellipsis(area, buf); + } else { + line.render_fitted(area, buf); } - line.render_fitted(area, buf); } } } diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index cde3304ae3..9d4ef7c0ab 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -482,19 +482,8 @@ pub(crate) fn remap_source_wrapped_line( let end = start + display_width(&text[range.clone()]); source_byte = range.end; source_column = end; - let hyperlinks = source - .hyperlinks - .iter() - .filter_map(|link| { - let first = link.columns.start.max(start); - let last = link.columns.end.min(end); - (first < last).then(|| { - link.with_columns( - prefix_columns + first - start..prefix_columns + last - start, - ) - }) - }) - .collect(); + let hyperlinks = + project_hyperlink_columns(&source.hyperlinks, start..end, prefix_columns); HyperlinkLine { line, hyperlinks, @@ -504,6 +493,24 @@ pub(crate) fn remap_source_wrapped_line( .collect() } +/// Translate links intersecting a source fragment into columns on the displayed row. +pub(crate) fn project_hyperlink_columns( + links: &[TerminalHyperlink], + source: Range, + prefix: usize, +) -> Vec { + links + .iter() + .filter_map(|link| { + let first = link.columns.start.max(source.start); + let last = link.columns.end.min(source.end); + (first < last).then(|| { + link.with_columns(prefix + first - source.start..prefix + last - source.start) + }) + }) + .collect() +} + /// Re-attach source hyperlink ranges after visible-text wrapping has split a line. /// /// Link text is matched in display order so a URL split across table rows retains the complete diff --git a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs index 748a3a192c..30b81fd636 100644 --- a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs +++ b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs @@ -113,6 +113,17 @@ impl HyperlinkLine { self.mark_cells(area, buf); } + /// Reserve the last visible grapheme for an unlinked overflow marker. + pub(crate) fn render_with_ellipsis(mut self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + self.line = + crate::line_truncation::truncate_line_with_ellipsis(self.line, usize::from(area.width)); + self.clip_hyperlinks_to_width(self.width().saturating_sub(1)); + self.render_fitted(area, buf); + } + fn mark_cells(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return;