diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index aa50df3dce..c1df1987b4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -299,8 +299,6 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::ShortcutHint; use crate::key_hint::has_ctrl_or_alt; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::ui_consts::FOOTER_INDENT_COLS; use codex_message_history::HistoryBatchCursor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -371,6 +369,7 @@ use super::skill_popup::SkillPopup; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use super::voice_strip::VoiceStrip; +use crate::bottom_pane::footer_content::FooterContent; use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; @@ -379,12 +378,12 @@ use crate::keymap::KeymapContextSet; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::user_bindings; -use crate::onboarding::mark_underlined_hyperlink; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; +use crate::ui_consts::FOOTER_INDENT_COLS; use codex_protocol::ThreadId; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -626,7 +625,8 @@ pub(crate) struct ChatComposer { app_event_tx: AppEventSender, history: ChatComposerHistory, pub(super) command_center_enabled: bool, - footer: FooterState, + // Keep the footer payload out of the composer's stack frame during session navigation. + footer: Box, has_focus: bool, frame_requester: Option, sparkle: sparkle::Sparkle, @@ -760,7 +760,7 @@ impl ChatComposer { app_event_tx, history: ChatComposerHistory::new(), command_center_enabled: false, - footer: FooterState { + footer: Box::new(FooterState { quit_shortcut_expires_at: None, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), esc_backtrack_hint: false, @@ -775,7 +775,6 @@ impl ChatComposer { goal_status_indicator: None, ide_context_active: false, status_line_value: None, - status_line_hyperlink_url: None, status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, @@ -806,7 +805,7 @@ impl ChatComposer { reasoning_up_key: default_keymap .primary_hint(KeymapContext::Chat, "increase_reasoning_effort"), toggle_voice_key: default_keymap.primary_hint(KeymapContext::Chat, "toggle_voice"), - }, + }), has_focus: has_input_focus, frame_requester: None, sparkle: sparkle::Sparkle::default(), @@ -891,8 +890,10 @@ impl ChatComposer { if self.footer.status_line_enabled && let Some(previous) = passive_footer_status_line(&self.footer_props()) { - self.effort_status_line_transition = - Some(EffortStatusLineTransition::new(tier, previous)); + self.effort_status_line_transition = Some(EffortStatusLineTransition::new( + tier, + previous.into_hyperlink_line(), + )); } if let Some(frame_requester) = &self.frame_requester { frame_requester.schedule_frame(); @@ -4389,7 +4390,7 @@ impl ChatComposer { } } - pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { + pub(crate) fn set_status_line(&mut self, status_line: Option) -> bool { if self.footer.status_line_value == status_line { return false; } @@ -4397,14 +4398,6 @@ impl ChatComposer { true } - pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { - if self.footer.status_line_hyperlink_url == url { - return false; - } - self.footer.status_line_hyperlink_url = url; - true - } - pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { if self.footer.status_line_enabled == enabled { return false; @@ -4695,40 +4688,33 @@ impl ChatComposer { } else if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); } else { - let available_width = - hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let mut status_line_width = + hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16); let status_line_active = uses_passive_footer_status_layout(&footer_props); let combined_status_line = if status_line_active { passive_footer_status_line(&footer_props) } else { None }; - let transition_visible = status_line_active + let status_line_visible = status_line_active && !self.footer.flash_visible() && self.footer.hint_override.is_none(); - let transition_active = transition_visible + let transition_active = status_line_visible && self .effort_status_line_transition .as_ref() .is_some_and(|transition| !transition.is_finished()); - let combined_status_line = if transition_visible + let combined_status_line = if status_line_visible && let Some(transition) = &self.effort_status_line_transition && !transition.is_finished() { - transition.render_line( - combined_status_line.as_ref(), - hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16), - ) + let current = combined_status_line.map(FooterContent::into_hyperlink_line); + transition + .render_line(current, status_line_width) + .map(FooterContent::HyperlinkLine) } else { combined_status_line }; - let mut truncated_status_line = if status_line_active { - combined_status_line.as_ref().map(|line| { - truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) - }) - } else { - None - }; let left_mode_indicator = if status_line_active || self.status_surface_height(options) > 0 { None @@ -4745,10 +4731,9 @@ impl ChatComposer { } else if let Some(items) = active_footer_hint_override { footer_hint_items_width(items) } else if status_line_active { - truncated_status_line + combined_status_line .as_ref() - .map(|line| line.width() as u16) - .unwrap_or(0) + .map_or(/*default*/ 0, FooterContent::width) } else { footer_line_width( &footer_props, @@ -4778,15 +4763,11 @@ impl ChatComposer { Some(self.right_footer_line_with_context()) }; let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); - if status_line_active + if status_line_visible && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) - && left_width > max_left - && let Some(line) = combined_status_line.as_ref().map(|line| { - truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) - }) { - left_width = line.width() as u16; - truncated_status_line = Some(line); + status_line_width = max_left; + left_width = left_width.min(max_left); } let can_show_left_and_context = can_show_left_with_context(hint_rect, left_width, right_width); @@ -4835,31 +4816,15 @@ impl ChatComposer { if let Some((summary_left, _)) = single_line_layout { match summary_left { SummaryLeft::Default => { - if status_line_active { - if let Some(line) = truncated_status_line.clone() { - render_footer_line(hint_rect, buf, line); - } else { - render_footer_from_props( - hint_rect, - buf, - &footer_props, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); - } - } else { - render_footer_from_props( - hint_rect, - buf, - &footer_props, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); - } + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); } SummaryLeft::Custom(line) => { render_footer_line(hint_rect, buf, line); @@ -4873,8 +4838,14 @@ impl ChatComposer { } else if let Some(items) = active_footer_hint_override { render_footer_hint_items(hint_rect, buf, items); } else if status_line_active { - if let Some(line) = truncated_status_line { - render_footer_line(hint_rect, buf, line); + if let Some(content) = combined_status_line { + content.render( + Rect { + width: status_line_width, + ..inset_footer_hint_area(hint_rect) + }, + buf, + ); } } else { render_footer_from_props( @@ -4890,12 +4861,7 @@ impl ChatComposer { if show_right && let Some(line) = &right_line { render_context_right(hint_rect, buf, line); } - if status_line_active - && let Some(url) = self.footer.status_line_hyperlink_url.as_deref() - { - mark_underlined_hyperlink(buf, hint_rect, url); - } - if transition_visible + if status_line_visible && let Some(transition) = &self.effort_status_line_transition && !transition.is_finished() && let Some(frame_requester) = &self.frame_requester @@ -5468,9 +5434,9 @@ mod tests { /*enhanced_keys_supported*/ true, |composer| { composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from( - "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", - ))); + composer.set_status_line(Some( + Line::from("gpt-5.4 high fast · ~/code/codex-1 · Context 0% used").into(), + )); composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); }, ); @@ -5480,9 +5446,9 @@ mod tests { /*enhanced_keys_supported*/ true, |composer| { composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from( - "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", - ))); + composer.set_status_line(Some( + Line::from("gpt-5.4 high fast · ~/code/codex-1 · Context 0% used").into(), + )); composer.set_text_content("!".to_string(), Vec::new(), Vec::new()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); }, @@ -5523,9 +5489,9 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from( - "gpt-5.4 high fast · ~/code/codex-1 · Context 0% used", - ))); + composer.set_status_line(Some( + Line::from("gpt-5.4 high fast · ~/code/codex-1 · Context 0% used").into(), + )); composer.set_text_content("!git status".to_string(), Vec::new(), Vec::new()); let area = Rect::new(0, 0, 100, 9); @@ -5698,11 +5664,18 @@ mod tests { ); let url = "https://github.com/openai/codex/pull/20252"; composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from(Span::styled( - "PR #20252", - Style::default().cyan().underlined(), - )))); - composer.set_status_line_hyperlink(Some(url.to_string())); + composer.set_status_line( + crate::bottom_pane::status_line_from_segments( + [( + crate::bottom_pane::status_line_setup::StatusLineItem::PullRequestNumber, + "PR #20252".to_string(), + )], + /*use_theme_colors*/ false, + /*thread_id*/ None, + Some(url), + ) + .map(Into::into), + ); let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); @@ -5712,10 +5685,7 @@ mod tests { .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y))) .filter(|&(x, y)| buf[(x, y)].symbol().contains(url)) .count(); - assert_eq!( - marked_cells, - "PR #20252".chars().filter(|ch| !ch.is_whitespace()).count() - ); + assert_eq!(marked_cells, "PR #20252".chars().count()); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs index 66fa88f292..dd27767480 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs @@ -121,7 +121,7 @@ fn agents_navigation_hint_snapshots() { } "agents_navigation_status" => { composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("model · project"))); + composer.set_status_line(Some(Line::from("model · project").into())); } _ => {} } 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 bcc92a3ed5..2cd33dc94d 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 @@ -20,6 +20,7 @@ use super::super::footer::shows_passive_footer_line; use super::ActivePopup; use crate::bottom_pane::footer::FooterKeyHints; use crate::bottom_pane::footer::FooterProps; +use crate::bottom_pane::footer_content::FooterContent; use crate::key_hint; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; @@ -358,8 +359,7 @@ pub(super) struct FooterState { pub(super) collaboration_mode_indicator: Option, pub(super) goal_status_indicator: Option, pub(super) ide_context_active: bool, - pub(super) status_line_value: Option>, - pub(super) status_line_hyperlink_url: Option, + pub(super) status_line_value: Option, pub(super) status_line_enabled: bool, pub(super) side_conversation_context_label: Option, pub(super) active_agent_label: Option, @@ -401,12 +401,12 @@ impl FooterState { #[cfg(test)] pub(super) fn status_line_text(&self) -> Option { - self.status_line_value.as_ref().map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + self.status_line_value + .as_ref() + .map(|content| match content { + FooterContent::Line(line) => line.to_string(), + FooterContent::HyperlinkLine(line) => line.line.to_string(), + }) } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs index 5db883b7e6..e3d556c464 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs @@ -18,7 +18,7 @@ fn composer() -> ChatComposer { /*disable_paste_burst*/ true, ); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("MODEL STATUS"))); + composer.set_status_line(Some(Line::from("MODEL STATUS").into())); composer } 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 60ee2e0fa9..d05f8ad2e0 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 @@ -43,9 +43,7 @@ impl ChatComposer { props.is_task_running = false; let line = passive_footer_status_line(&props); let full = self.mode_indicator_line(show_cycle_hint); - let left_width = line - .as_ref() - .map_or(/*default*/ 0, |line| line.width() as u16); + let left_width = line.as_ref().map_or(/*default*/ 0, FooterContent::width); let full_width = full .as_ref() .map_or(/*default*/ 0, |line| line.width() as u16); @@ -65,24 +63,26 @@ impl ChatComposer { .effort_status_line_transition .as_ref() .filter(|transition| !transition.is_finished()); - let line = if let Some(transition) = transition { - transition.render_line(line.as_ref(), width) + let content = if let Some(transition) = transition { + let current = line.map(FooterContent::into_hyperlink_line); + transition + .render_line(current, width) + .map(FooterContent::HyperlinkLine) } else { line }; - if let Some(line) = line { - render_footer_line( - area, + if let Some(content) = content { + content.render( + Rect { + width, + ..inset_footer_hint_area(area) + }, buf, - truncate_line_with_ellipsis_if_overflow(line, usize::from(width)), ); } if let Some(right) = right { render_context_right(area, buf, &right); } - if let Some(url) = self.footer.status_line_hyperlink_url.as_deref() { - mark_underlined_hyperlink(buf, area, url); - } if transition.is_some() && let Some(frame_requester) = &self.frame_requester { 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 03e761f4f0..273f7bc2b6 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 @@ -1,6 +1,8 @@ //! Persistent status survives transient footer modes and suggestion overlays. use super::*; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::strip_osc8; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -14,7 +16,9 @@ fn composer() -> ChatComposer { /*disable_paste_burst*/ true, ); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("MODEL · ~/project · Context 20% used"))); + composer.set_status_line(Some( + Line::from("MODEL · ~/project · Context 20% used").into(), + )); composer } @@ -101,9 +105,14 @@ fn fullscreen_plan_indicator_keeps_the_cycle_hint_when_it_fits() { ); insta::assert_snapshot!("fullscreen_plan_cycle_hint", wide); - let (narrow, _) = render(&composer, /*width*/ 44, /*footer*/ None); - assert!(narrow.contains("Plan mode"), "{narrow}"); - assert!(!narrow.contains("⇧tab"), "{narrow}"); + let mut status = HyperlinkLine::from("project · "); + status.push_span("PR #42".underlined(), Some("https://example.com/pull/42")); + composer.set_status_line(Some(status.into())); + composer.set_active_agent_label(Some("reviewer".to_string())); + let (narrow, _) = render(&composer, /*width*/ 37, /*footer*/ None); + let row = narrow.lines().rev().nth(1).expect("persistent status row"); + insta::assert_snapshot!(strip_osc8(row).trim(), @"project · PR #42 · rev… Plan mode"); + composer.set_active_agent_label(/*active_agent_label*/ None); composer.set_task_running(/*running*/ true); let (running, _) = render(&composer, /*width*/ 100, /*footer*/ None); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs index 84de74e38e..f131f6e08e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice.rs @@ -3,6 +3,7 @@ //! Only the count and warning label receive an amber accent; surrounding hints stay muted. use super::*; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; impl ChatComposer { pub(crate) fn warning_notice_contains(&self, position: ratatui::layout::Position) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice_tests.rs index 2e11e17236..f3845a79f7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/warning_notice_tests.rs @@ -16,7 +16,7 @@ fn composer() -> ChatComposer { // Keep layout fixtures independent of platform-specific default shortcuts. composer.footer.show_warnings_key = Some(crate::key_hint::plain(KeyCode::F(2)).into()); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("MODEL · ~/project"))); + composer.set_status_line(Some(Line::from("MODEL · ~/project").into())); composer } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs index aaae8c2d4f..04947a3c23 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs @@ -7,7 +7,7 @@ use pretty_assertions::assert_eq; fn effort_composer_baseline_repeat_and_lowering_do_not_replay() { let (mut composer, _rx) = new_test_composer(); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main").into())); assert!(composer.set_active_reasoning_effort( Some(&ReasoningEffort::Ultra), /*animations_enabled*/ true, @@ -43,7 +43,7 @@ fn effort_composer_baseline_repeat_and_lowering_do_not_replay() { fn effort_transition_does_not_queue_a_missing_outgoing_status_line() { let (mut composer, _rx) = new_test_composer(); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main").into())); composer.set_task_running(/*running*/ true); composer.set_text_content("queued draft".to_string(), Vec::new(), Vec::new()); composer.set_active_reasoning_effort( @@ -63,7 +63,7 @@ fn effort_transition_does_not_queue_a_missing_outgoing_status_line() { fn effort_composer_restored_baseline_and_reduced_motion_do_not_start() { let (mut composer, _rx) = new_test_composer(); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main").into())); composer.set_active_reasoning_effort( Some(&ReasoningEffort::High), /*animations_enabled*/ false, @@ -99,7 +99,7 @@ fn effort_composer_restored_baseline_and_reduced_motion_do_not_start() { fn effort_transition_never_replaces_a_footer_flash() { let (mut composer, _rx) = new_test_composer(); composer.set_status_line_enabled(/*enabled*/ true); - composer.set_status_line(Some(Line::from("gpt-5.4 high · main"))); + composer.set_status_line(Some(Line::from("gpt-5.4 high · main").into())); composer.set_active_reasoning_effort( Some(&ReasoningEffort::High), /*animations_enabled*/ true, @@ -108,7 +108,7 @@ fn effort_transition_never_replaces_a_footer_flash() { Some(&ReasoningEffort::Ultra), /*animations_enabled*/ true, ); - composer.set_status_line(Some(Line::from("gpt-5.4 ultra · main"))); + composer.set_status_line(Some(Line::from("gpt-5.4 ultra · main").into())); composer .footer .show_flash(Line::from("saved"), Duration::from_secs(/*secs*/ 1)); @@ -132,7 +132,7 @@ fn effort_transition_keeps_the_full_footer_row() { composer.set_status_line_enabled(/*enabled*/ true); composer.set_collaboration_modes_enabled(/*enabled*/ true); composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan)); - composer.set_status_line(Some(Line::from("gpt-5.4 high · feature-branch"))); + composer.set_status_line(Some(Line::from("gpt-5.4 high · feature-branch").into())); composer.set_active_reasoning_effort( Some(&ReasoningEffort::High), /*animations_enabled*/ true, 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 d47605d3c7..7415c70c38 100644 --- a/codex-rs/tui/src/bottom_pane/effort_status_line.rs +++ b/codex-rs/tui/src/bottom_pane/effort_status_line.rs @@ -24,6 +24,7 @@ use ratatui::text::Span; use crate::color::blend; use crate::color::is_light; use crate::line_truncation::truncate_line_to_width; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::terminal_palette::default_fg; @@ -61,12 +62,12 @@ impl EffortTier { pub(crate) struct EffortStatusLineTransition { tier: EffortTier, - previous: Line<'static>, + previous: HyperlinkLine, started_at: Cell>, } impl EffortStatusLineTransition { - pub(crate) fn new(tier: EffortTier, previous: Line<'static>) -> Self { + pub(crate) fn new(tier: EffortTier, previous: HyperlinkLine) -> Self { Self { tier, previous, @@ -82,9 +83,9 @@ impl EffortStatusLineTransition { pub(crate) fn render_line( &self, - current: Option<&Line<'static>>, + current: Option, width: u16, - ) -> Option> { + ) -> Option { let elapsed = match self.started_at.get() { Some(started) => started.elapsed(), None => { @@ -92,17 +93,17 @@ impl EffortStatusLineTransition { Duration::ZERO } }; - transition_line_at(self.tier, Some(&self.previous), current, elapsed, width) + transition_line_at(self.tier, &self.previous, current, elapsed, width) } } fn transition_line_at( tier: EffortTier, - previous: Option<&Line<'static>>, - current: Option<&Line<'static>>, + previous: &HyperlinkLine, + current: Option, elapsed: Duration, width: u16, -) -> Option> { +) -> Option { let width = usize::from(width); if width == 0 { return None; @@ -111,17 +112,22 @@ fn transition_line_at( if elapsed < SCROLL_OUT { let progress = elapsed.as_secs_f32() / SCROLL_OUT.as_secs_f32(); let offset = (width as f32 * ease_in_cubic(progress)).round() as usize; - let mut line = previous.cloned()?; + let mut frame = previous.clone(); let tint = 0.12 + 0.88 * progress; style_line( - &mut line, + &mut frame.line, tier, /*opacity*/ 1.0 - progress * progress, tint, ); - line.spans + frame + .line + .spans .insert(/*index*/ 0, Span::raw(" ".repeat(offset))); - return Some(truncate_line_to_width(line, width)); + for link in &mut frame.hyperlinks { + link.columns = link.columns.start + offset..link.columns.end + offset; + } + return Some(clip_frame(frame, width)); } let after_scroll = elapsed.saturating_sub(SCROLL_OUT); @@ -138,14 +144,23 @@ fn transition_line_at( let fade = after_scroll.saturating_sub(LABEL_ASSEMBLE.saturating_add(LABEL_HOLD)); (1.0, 1.0 - fade.as_secs_f32() / LABEL_FADE_OUT.as_secs_f32()) }; - return Some(tier_label_line(tier, width, assemble, opacity)); + return Some(tier_label_line(tier, width, assemble, opacity).into()); } let fade_elapsed = after_scroll.saturating_sub(label_duration); let opacity = (fade_elapsed.as_secs_f32() / STATUS_FADE_IN.as_secs_f32()).clamp(0.0, 1.0); - let mut line = current.cloned()?; - style_line(&mut line, tier, opacity, /*tint*/ 0.0); - Some(truncate_line_to_width(line, width)) + let mut frame = current?; + style_line(&mut frame.line, tier, opacity, /*tint*/ 0.0); + Some(clip_frame(frame, width)) +} + +fn clip_frame(mut frame: HyperlinkLine, width: usize) -> HyperlinkLine { + frame.line = truncate_line_to_width(frame.line, width); + if !frame.hyperlinks.is_empty() { + frame.clip_hyperlinks_to_width(frame.width()); + } + frame.source = None; + frame } fn ease_in_cubic(progress: f32) -> f32 { 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 03991e2244..a17f5b8891 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 @@ -1,7 +1,10 @@ use super::*; +use crate::terminal_hyperlinks::osc8_hyperlink; use crate::terminal_palette::indexed_color; use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; fn text(line: &Line<'static>) -> String { @@ -14,45 +17,45 @@ fn text(line: &Line<'static>) -> String { #[test] fn clock_waits_for_the_first_visible_frame() { let transition = - EffortStatusLineTransition::new(EffortTier::Ultra, Line::from("gpt-5.4 high · main")); + EffortStatusLineTransition::new(EffortTier::Ultra, "gpt-5.4 high · main".into()); assert!(!transition.is_finished()); assert_eq!(transition.started_at.get(), None); - let current = Line::from("gpt-5.4 ultra · main"); + let current = HyperlinkLine::from("gpt-5.4 ultra · main"); let line = transition - .render_line(Some(¤t), /*width*/ 40) + .render_line(Some(current), /*width*/ 40) .expect("first frame should render the outgoing line"); - assert_eq!(text(&line), "gpt-5.4 high · main"); + assert_eq!(text(&line.line), "gpt-5.4 high · main"); assert!(transition.started_at.get().is_some()); } #[test] fn outgoing_status_line_moves_right_and_clips_at_the_edge() { - let previous = Line::from("gpt-5.4 high · main"); - let current = Line::from("gpt-5.4 ultra · main"); + let previous = HyperlinkLine::from("gpt-5.4 high · main"); + let current = HyperlinkLine::from("gpt-5.4 ultra · main"); let first = transition_line_at( EffortTier::Ultra, - Some(&previous), - Some(¤t), + &previous, + Some(current.clone()), Duration::ZERO, /*width*/ 28, ) .expect("outgoing line should be visible"); let middle = transition_line_at( EffortTier::Ultra, - Some(&previous), - Some(¤t), + &previous, + Some(current), Duration::from_millis(300), /*width*/ 28, ) .expect("outgoing line should be visible"); - assert_eq!(text(&first), "gpt-5.4 high · main"); - let first_offset = text(&first) + assert_eq!(text(&first.line), "gpt-5.4 high · main"); + let first_offset = text(&first.line) .find("gpt-5.4") .expect("first frame should contain the outgoing model"); - let middle_offset = text(&middle) + let middle_offset = text(&middle.line) .find("gpt-5.4") .expect("middle frame should contain the outgoing model"); assert!(middle_offset > first_offset); @@ -61,34 +64,37 @@ fn outgoing_status_line_moves_right_and_clips_at_the_edge() { #[test] fn label_and_refreshed_line_appear_in_order() { - let previous = Line::from("gpt-5.4 high · main"); - let current = Line::from("gpt-5.4 max · main"); + let previous = HyperlinkLine::from("gpt-5.4 high · main"); + let current = HyperlinkLine::from("gpt-5.4 max · main"); let label = transition_line_at( EffortTier::Max, - Some(&previous), - Some(¤t), + &previous, + Some(current.clone()), Duration::from_millis(1500), /*width*/ 32, ) .expect("label should be visible"); let refreshed = transition_line_at( EffortTier::Max, - Some(&previous), - Some(¤t), + &previous, + Some(current), Duration::from_millis(2200), /*width*/ 32, ) .expect("refreshed status line should be visible"); - assert_eq!(text(&label), " M A X"); - assert_eq!(text(&refreshed), "gpt-5.4 max · main"); + assert_eq!(text(&label.line), " M A X"); + assert_eq!(text(&refreshed.line), "gpt-5.4 max · main"); } #[test] fn unicode_and_span_boundaries_are_safe_on_narrow_rows() { - let previous = Line::from(vec!["模型 ".cyan(), "👩‍💻 main".underlined()]); - let current = Line::from(vec!["模型 ".cyan(), "ultra · main".underlined()]); + let previous = HyperlinkLine::from(Line::from(vec!["模型 ".cyan(), "👩‍💻 main".underlined()])); + let current = HyperlinkLine::from(Line::from(vec![ + "模型 ".cyan(), + "ultra · main".underlined(), + ])); for width in 0..=12 { for elapsed in [ @@ -101,8 +107,8 @@ fn unicode_and_span_boundaries_are_safe_on_narrow_rows() { ] { let line = transition_line_at( EffortTier::Ultra, - Some(&previous), - Some(¤t), + &previous, + Some(current.clone()), elapsed, width, ); @@ -111,6 +117,46 @@ fn unicode_and_span_boundaries_are_safe_on_narrow_rows() { } } +#[test] +fn links_follow_the_visible_text_through_the_effort_transition() { + let old_url = "https://example.com/pr/42"; + let new_url = "https://example.com/pr/43"; + let mut previous = HyperlinkLine::from("a "); + previous.push_span("PR42界".into(), Some(old_url)); + let mut current = HyperlinkLine::from("b "); + current.push_span("PR43".into(), Some(new_url)); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 1, + ); + for (elapsed_ms, visible, linked_columns, url) in [ + // One cell of slide offset. The wide trailing glyph cannot fit the last cell. + (310, " a PR42 ", 3..7, old_url), + (1500, " M A X ", 0..0, old_url), + (2200, "b PR43 ", 2..6, new_url), + ] { + let frame = transition_line_at( + EffortTier::Max, + &previous, + Some(current.clone()), + Duration::from_millis(elapsed_ms), + area.width, + ) + .expect("visible frame"); + let mut buf = Buffer::empty(area); + frame.render_fitted(area, &mut buf); + for (x, ch) in (0..area.width).zip(visible.chars()) { + let symbol = ch.to_string(); + let expected = if linked_columns.contains(&x) { + osc8_hyperlink(url, &symbol) + } else { + symbol + }; + assert_eq!(buf[(x, 0)].symbol(), expected, "{elapsed_ms}ms, column {x}"); + } + } +} + #[test] fn palette_status_line_colors_survive_the_fade() { for color in [ @@ -162,9 +208,9 @@ fn ultra_letters_start_wide_at_the_edges_and_converge_to_the_center() { #[test] fn transition_frames_snapshot() { - let previous = Line::from(vec!["gpt-5.4 high".cyan(), " · main".dim()]); - let max = Line::from(vec!["gpt-5.4 max".cyan(), " · main".dim()]); - let ultra = Line::from(vec!["gpt-5.4 ultra".cyan(), " · main".dim()]); + let previous = HyperlinkLine::from(Line::from(vec!["gpt-5.4 high".cyan(), " · main".dim()])); + let max = HyperlinkLine::from(Line::from(vec!["gpt-5.4 max".cyan(), " · main".dim()])); + let ultra = HyperlinkLine::from(Line::from(vec!["gpt-5.4 ultra".cyan(), " · main".dim()])); let mut frames = Vec::new(); for (tier, current) in [(EffortTier::Max, &max), (EffortTier::Ultra, &ultra)] { @@ -173,14 +219,14 @@ fn transition_frames_snapshot() { ] { let line = transition_line_at( tier, - Some(&previous), - Some(current), + &previous, + Some(current.clone()), Duration::from_millis(millis), /*width*/ 32, ) .expect("frame should render"); let label = tier.label(); - frames.push(format!("{label:5} {millis:4}ms │{:<32}│", text(&line))); + frames.push(format!("{label:5} {millis:4}ms │{:<32}│", text(&line.line))); } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index f0b809ce50..74dcc68471 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,6 +1,6 @@ //! The bottom-pane footer renders transient hints and context indicators. //! -//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state. +//! The footer is pure rendering: it measures and paints selected content without mutating state. //! It intentionally does not decide *which* footer content should be shown; that is owned by the //! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like //! `ChatWidget` (which decides when quit/interrupt is allowed). @@ -37,20 +37,19 @@ //! context to avoid too many state transitions in quick succession. //! - Finally, try a mode-only line (with and without context), and fall //! back to no left-side footer if nothing can fit. -//! 3. When collapse chooses a specific line, callers render it via -//! `render_footer_line`. Otherwise, callers render the straightforward -//! mode-to-text mapping via `render_footer_from_props`. -//! -//! In short: `single_line_footer_layout` chooses *what* best fits, and the two -//! render helpers choose whether to draw the chosen line or the default -//! `FooterProps` mapping. +//! 3. The composer renders the chosen line, the default `FooterProps` mapping, or no left content. +pub(crate) mod footer_content; + +use self::footer_content::FooterContent; use super::shortcut_overlay; +use super::status_line_style::STATUS_LINE_SEPARATOR; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::ShortcutHint; use crate::render::line_utils::prefix_lines; use crate::status::format_tokens_compact; use crate::style::secondary_text_style; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; @@ -65,10 +64,9 @@ use ratatui::widgets::Widget; /// The rendering inputs for the footer area under the composer. /// /// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`, -/// `BottomPane`, and `ChatWidget`) and pass it to the footer render helpers -/// (`render_footer_from_props` or the single-line collapse logic). The footer -/// treats these values as authoritative and does not attempt to infer missing -/// state (for example, it does not query whether a task is running). +/// `BottomPane`, and `ChatWidget`) and pass it to the footer content and layout helpers. The +/// footer treats these values as authoritative and does not attempt to infer missing state +/// (for example, it does not query whether a task is running). #[derive(Clone, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, @@ -81,7 +79,7 @@ pub(crate) struct FooterProps { /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. pub(crate) quit_shortcut_key: KeyBinding, - pub(crate) status_line_value: Option>, + pub(crate) status_line_value: Option, pub(crate) status_line_enabled: bool, pub(crate) key_hints: FooterKeyHints, /// Active thread label shown when the footer is rendering contextual information instead of an @@ -237,34 +235,17 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } pub(crate) fn footer_height(props: &FooterProps, width: u16) -> u16 { - if props.mode == FooterMode::ShortcutOverlay { - return shortcut_overlay::lines(props, width.saturating_sub(FOOTER_INDENT_COLS as u16)).len() - as u16; - } - let show_shortcuts_hint = match props.mode { - FooterMode::ComposerEmpty => true, - FooterMode::ComposerHasDraft => false, - FooterMode::HistorySearch - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - | FooterMode::EscHint => false, - }; - let show_queue_hint = match props.mode { - FooterMode::ComposerHasDraft => props.is_task_running, - FooterMode::QuitShortcutReminder + match props.mode { + FooterMode::ShortcutOverlay => { + shortcut_overlay::lines(props, width.saturating_sub(FOOTER_INDENT_COLS as u16)).len() + as u16 + } + FooterMode::ComposerEmpty + | FooterMode::ComposerHasDraft | FooterMode::HistorySearch - | FooterMode::ComposerEmpty - | FooterMode::ShortcutOverlay - | FooterMode::EscHint => false, - }; - footer_from_props_lines( - props, - /*collaboration_mode_indicator*/ None, - /*show_cycle_hint*/ false, - show_shortcuts_hint, - show_queue_hint, - ) - .len() as u16 + | FooterMode::QuitShortcutReminder + | FooterMode::EscHint => 1, + } } /// Render a single precomputed footer line. @@ -709,8 +690,10 @@ pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_wi if left_width == 0 { return true; } - let left_extent = FOOTER_INDENT_COLS as u16 + left_width + FOOTER_CONTEXT_GAP_COLS; - left_extent <= context_x.saturating_sub(area.x) + left_width + <= context_x + .saturating_sub(area.x) + .saturating_sub(FOOTER_INDENT_COLS as u16 + FOOTER_CONTEXT_GAP_COLS) } pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'static>) { @@ -772,10 +755,10 @@ fn footer_from_props_lines( show_queue_hint: bool, ) -> Vec> { let key_hints = props.key_hints; - // Passive footer context can come from the configurable status line, the - // active agent label, or both combined. - if let Some(status_line) = passive_footer_status_line(props) { - return vec![status_line]; + // The configurable status line has its own layout. An agent label by itself still belongs + // to the ordinary footer flow. + if shows_passive_footer_line(props) && props.active_agent_label.is_some() { + return vec![passive_footer_suffix(props, /*has_status*/ false)]; } match props.mode { FooterMode::QuitShortcutReminder => { @@ -826,37 +809,47 @@ fn footer_from_props_lines( /// The returned line may contain the configured status line, the currently viewed agent label, or /// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue /// prompts deliberately return `None` so those call-to-action hints stay visible. -pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option> { +pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option { if !shows_passive_footer_line(props) { return None; } - let mut line = if props.status_line_enabled { - props.status_line_value.clone() - } else { - None - }; - - if let Some(active_agent_label) = props.active_agent_label.as_ref() { - if let Some(existing) = line.as_mut() { - existing.spans.push(" · ".set_style(secondary_text_style())); - existing - .spans - .push(active_agent_label.clone().set_style(secondary_text_style())); - } else { - line = Some(Line::from(active_agent_label.clone()).set_style(secondary_text_style())); - } + let status = props + .status_line_value + .as_ref() + .filter(|_| props.status_line_enabled); + let suffix = passive_footer_suffix(props, status.is_some()); + if status.is_none() && suffix.spans.is_empty() { + return None; } + let mut line = match status { + Some(FooterContent::Line(line)) => HyperlinkLine::from(line.clone()), + Some(FooterContent::HyperlinkLine(line)) => line.clone(), + None => HyperlinkLine::default(), + }; + if line.width() > 0 && suffix.width() > 0 { + line.line + .push_span(STATUS_LINE_SEPARATOR.set_style(secondary_text_style())); + } + line.line.spans.extend(suffix.spans); + Some(FooterContent::HyperlinkLine(line)) +} +fn passive_footer_suffix(props: &FooterProps, has_status: bool) -> Line<'static> { + let mut line = Line::default(); + if let Some(active_agent_label) = &props.active_agent_label { + line.push_span(active_agent_label.clone().set_style(secondary_text_style())); + } if props.mode == FooterMode::ComposerEmpty && let Some(key) = props.key_hints.agents - && let Some(line) = line.as_mut() + && (has_status || !line.spans.is_empty()) { - line.push_span(" · ".set_style(secondary_text_style())); + if !line.spans.is_empty() { + line.push_span(" · ".set_style(secondary_text_style())); + } line.extend(key.spans()); line.push_span(" for agents".set_style(secondary_text_style())); } - line } @@ -1071,6 +1064,7 @@ mod tests { let status_line_active = uses_passive_footer_status_layout(props); let passive_status_line = if status_line_active { passive_footer_status_line(props) + .map(|content| content.into_hyperlink_line().line) } else { None }; @@ -1521,7 +1515,7 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - status_line_value: Some(Line::from("Status line content".to_string())), + status_line_value: Some(Line::from("Status line content".to_string()).into()), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, @@ -1537,7 +1531,7 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - status_line_value: Some(Line::from("Status line content".to_string())), + status_line_value: Some(Line::from("Status line content".to_string()).into()), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, @@ -1553,7 +1547,7 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - status_line_value: Some(Line::from("Status line content".to_string())), + status_line_value: Some(Line::from("Status line content".to_string()).into()), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, @@ -1644,9 +1638,13 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - status_line_value: Some(Line::from( - "Status line content that should truncate before the mode indicator".to_string(), - )), + status_line_value: Some( + Line::from( + "Status line content that should truncate before the mode indicator" + .to_string(), + ) + .into(), + ), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, @@ -1684,7 +1682,7 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - status_line_value: Some(Line::from("Status line content".to_string())), + status_line_value: Some(Line::from("Status line content".to_string()).into()), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: Some("Robie [explorer]".to_string()), @@ -1706,7 +1704,7 @@ mod tests { status_line_value: Some(Line::from( "Status line content that is definitely too long to fit alongside the mode label" .to_string(), - )), + ).into()), status_line_enabled: true, key_hints: FooterKeyHints::default_bindings(), active_agent_label: None, diff --git a/codex-rs/tui/src/bottom_pane/footer/footer_content.rs b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs new file mode 100644 index 0000000000..81563a11e5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content.rs @@ -0,0 +1,78 @@ +//! The composer measures the natural width before allocating the left column, then renders +//! the selected content in the allotted rectangle. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::terminal_hyperlinks::HyperlinkLine; + +/// Owned content for the footer's negotiated left-hand area. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FooterContent { + Line(Line<'static>), + HyperlinkLine(HyperlinkLine), +} + +impl FooterContent { + /// Natural display width, before the footer allocates space for the right indicator. + pub(crate) fn width(&self) -> u16 { + let width = match self { + Self::Line(line) => line.width(), + Self::HyperlinkLine(line) => line.width(), + }; + u16::try_from(width).unwrap_or(u16::MAX) + } + + /// Paint only inside the final area supplied by the parent. + pub(crate) fn render(self, area: Rect, buf: &mut Buffer) { + match self { + Self::Line(line) => Widget::render(line, area, buf), + Self::HyperlinkLine(mut 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_fitted(area, buf); + } + } + } + + /// Keep link destinations when handing the status line to the effort transition. + pub(crate) fn into_hyperlink_line(self) -> HyperlinkLine { + match self { + Self::Line(line) => line.into(), + Self::HyperlinkLine(line) => line, + } + } +} + +impl From for FooterContent { + fn from(line: HyperlinkLine) -> Self { + Self::HyperlinkLine(line) + } +} + +impl From> for FooterContent { + fn from(line: Line<'static>) -> Self { + Self::Line(line) + } +} + +impl From<&'static str> for FooterContent { + fn from(text: &'static str) -> Self { + Self::Line(Line::from(text)) + } +} + +#[cfg(test)] +#[path = "footer_content_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs b/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs new file mode 100644 index 0000000000..e809c5d0c0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/footer/footer_content_tests.rs @@ -0,0 +1,56 @@ +#![cfg(test)] + +use super::FooterContent; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::osc8_hyperlink; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +#[test] +fn clipped_links_keep_their_cells_after_a_halfwidth_katakana_grapheme() { + let project_url = "https://example.com/project"; + let pr_url = "https://example.com/pr/42"; + let mut line = HyperlinkLine::default(); + line.push_span("ガ".into(), Some(project_url)); + line.push_span(" · ".into(), /*destination*/ None); + line.push_span("PR #42".into(), Some(pr_url)); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + FooterContent::HyperlinkLine(line).render(area, &mut buf); + assert_eq!(buf[(0, 0)].symbol(), osc8_hyperlink(project_url, "ガ")); + for (x, ch) in (2..5).zip(" · ".chars()) { + assert_eq!(buf[(x, 0)].symbol(), ch.to_string()); + } + for (x, ch) in (5..9).zip("PR #".chars()) { + assert_eq!( + buf[(x, 0)].symbol(), + osc8_hyperlink(pr_url, &ch.to_string()) + ); + } + assert_eq!(buf[(9, 0)].symbol(), "…"); +} + +#[test] +fn arabic_prefix_keeps_link_on_the_painted_cells_at_exact_fit_and_overflow() { + let url = "https://example.com/pr/42"; + let mut line = HyperlinkLine::from("لا · "); + line.push_span("PR #42".into(), Some(url)); + assert_eq!(line.width(), 11); + + for (width, visible_link) in [(11, "PR #42"), (10, "PR #")] { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); + let mut buf = Buffer::empty(area); + FooterContent::HyperlinkLine(line.clone()).render(area, &mut buf); + assert_eq!(buf[(4, 0)].symbol(), " "); + for (x, ch) in (5..width).zip(visible_link.chars()) { + assert_eq!(buf[(x, 0)].symbol(), osc8_hyperlink(url, &ch.to_string())); + } + if width < 11 { + assert_eq!(buf[(width - 1, 0)].symbol(), "…"); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5e4dee0ff5..5c19ba286d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -116,6 +116,7 @@ pub(crate) use approval_overlay::format_requested_permissions_rule; pub(crate) use async_questions::AsyncQuestions; pub(crate) use async_questions::QuestionState; pub(crate) use async_questions::QuestionSubmission; +pub(crate) use footer::footer_content; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; @@ -2585,7 +2586,7 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_status_line(&mut self, status_line: Option>) { + pub(crate) fn set_status_line(&mut self, status_line: Option) { if self.composer.set_status_line(status_line) { self.request_redraw(); } @@ -2597,12 +2598,6 @@ impl BottomPane { } } - pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { - if self.composer.set_status_line_hyperlink(url) { - self.request_redraw(); - } - } - pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { if self.composer.set_status_line_enabled(enabled) { self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 6041a24105..571530d1c4 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -3,19 +3,19 @@ use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Styled; -use ratatui::text::Line; use ratatui::text::Span; use super::status_line_setup::StatusLineItem; use crate::render::highlight::foreground_style_for_scopes; use crate::style::readable_color_on; use crate::style::secondary_text_style; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_palette::StdoutColorLevel; use crate::terminal_palette::effective_stdout_color_level; use crate::thread_color::thread_color; use codex_protocol::ThreadId; -const STATUS_LINE_SEPARATOR: &str = " · "; +pub(crate) const STATUS_LINE_SEPARATOR: &str = " · "; const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85; const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100; @@ -98,29 +98,38 @@ pub(crate) fn status_line_from_segments( segments: I, use_theme_colors: bool, thread_id: Option, -) -> Option> + pull_request_url: Option<&str>, +) -> Option where I: IntoIterator, { - status_line_from_segments_with_resolver(segments, use_theme_colors, thread_id, |accent| { - foreground_style_for_scopes(accent.scopes()) - }) + status_line_from_segments_with_resolver( + segments, + use_theme_colors, + thread_id, + pull_request_url, + |accent| foreground_style_for_scopes(accent.scopes()), + ) } fn status_line_from_segments_with_resolver( segments: I, use_theme_colors: bool, thread_id: Option, + pull_request_url: Option<&str>, theme_style_for_accent: F, -) -> Option> +) -> Option where I: IntoIterator, F: Fn(StatusLineAccent) -> Option