mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Preserve status-line hyperlinks through footer layout and animations (#57883)
## Why Footer hyperlinks must follow their text when the status line is truncated or animated. Inferring link targets from underlined cells loses that association, and Unicode width differences can shift links onto the wrong cells. ## What changed - Carry explicit hyperlink ranges with status-line content through footer layout and reasoning-effort transitions. - Fit content to the space allocated beside right-hand indicators, keeping the truncation ellipsis outside links. - Measure and truncate rendered graphemes consistently while preserving source columns for transcript tab expansion. - Box the composer's footer state to reduce its stack footprint during session navigation. ## Testing Add regression tests for clipped links after halfwidth katakana and Arabic prefixes, hyperlink positions during effort transitions, and narrow status rows beside the Plan mode indicator. GitOrigin-RevId: ede62690e191daefc85f38e637f9bcc9796268b7
This commit is contained in:
@@ -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<FooterState>,
|
||||
has_focus: bool,
|
||||
frame_requester: Option<FrameRequester>,
|
||||
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<Line<'static>>) -> bool {
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) -> 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<String>) -> 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]
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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<CollaborationModeIndicator>,
|
||||
pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
pub(super) ide_context_active: bool,
|
||||
pub(super) status_line_value: Option<Line<'static>>,
|
||||
pub(super) status_line_hyperlink_url: Option<String>,
|
||||
pub(super) status_line_value: Option<FooterContent>,
|
||||
pub(super) status_line_enabled: bool,
|
||||
pub(super) side_conversation_context_label: Option<String>,
|
||||
pub(super) active_agent_label: Option<String>,
|
||||
@@ -401,12 +401,12 @@ impl FooterState {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn status_line_text(&self) -> Option<String> {
|
||||
self.status_line_value.as_ref().map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
self.status_line_value
|
||||
.as_ref()
|
||||
.map(|content| match content {
|
||||
FooterContent::Line(line) => line.to_string(),
|
||||
FooterContent::HyperlinkLine(line) => line.line.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<Instant>>,
|
||||
}
|
||||
|
||||
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<HyperlinkLine>,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
) -> Option<HyperlinkLine> {
|
||||
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<HyperlinkLine>,
|
||||
elapsed: Duration,
|
||||
width: u16,
|
||||
) -> Option<Line<'static>> {
|
||||
) -> Option<HyperlinkLine> {
|
||||
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 {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Line<'static>>,
|
||||
pub(crate) status_line_value: Option<FooterContent>,
|
||||
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<Line<'static>> {
|
||||
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<Line<'static>> {
|
||||
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterContent> {
|
||||
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,
|
||||
|
||||
@@ -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<HyperlinkLine> for FooterContent {
|
||||
fn from(line: HyperlinkLine) -> Self {
|
||||
Self::HyperlinkLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Line<'static>> 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;
|
||||
@@ -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(), "…");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Line<'static>>) {
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<footer_content::FooterContent>) {
|
||||
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<String>) {
|
||||
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();
|
||||
|
||||
@@ -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<I>(
|
||||
segments: I,
|
||||
use_theme_colors: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
) -> Option<Line<'static>>
|
||||
pull_request_url: Option<&str>,
|
||||
) -> Option<HyperlinkLine>
|
||||
where
|
||||
I: IntoIterator<Item = (StatusLineItem, String)>,
|
||||
{
|
||||
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<I, F>(
|
||||
segments: I,
|
||||
use_theme_colors: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
pull_request_url: Option<&str>,
|
||||
theme_style_for_accent: F,
|
||||
) -> Option<Line<'static>>
|
||||
) -> Option<HyperlinkLine>
|
||||
where
|
||||
I: IntoIterator<Item = (StatusLineItem, String)>,
|
||||
F: Fn(StatusLineAccent) -> Option<Style>,
|
||||
{
|
||||
let mut spans = Vec::new();
|
||||
for (item, text) in segments {
|
||||
if !spans.is_empty() {
|
||||
spans.push(STATUS_LINE_SEPARATOR.set_style(secondary_text_style()));
|
||||
let mut line = HyperlinkLine::default();
|
||||
for (item, mut text) in segments {
|
||||
// Paragraph skips control characters, so they must not enter this single row's link offsets.
|
||||
text.retain(|ch| !ch.is_control());
|
||||
if !line.line.spans.is_empty() {
|
||||
line.line
|
||||
.push_span(STATUS_LINE_SEPARATOR.set_style(secondary_text_style()));
|
||||
}
|
||||
let style = if use_theme_colors
|
||||
&& matches!(
|
||||
@@ -150,15 +159,14 @@ where
|
||||
} else {
|
||||
style
|
||||
};
|
||||
let style = if item == StatusLineItem::PullRequestNumber {
|
||||
style.underlined()
|
||||
if item == StatusLineItem::PullRequestNumber {
|
||||
line.push_span(Span::styled(text, style.underlined()), pull_request_url);
|
||||
} else {
|
||||
style
|
||||
};
|
||||
spans.push(Span::styled(text, style));
|
||||
line.line.push_span(Span::styled(text, style));
|
||||
}
|
||||
}
|
||||
|
||||
(!spans.is_empty()).then(|| Line::from(spans))
|
||||
(!line.line.spans.is_empty()).then_some(line)
|
||||
}
|
||||
|
||||
fn soften_status_line_style(mut style: Style) -> Style {
|
||||
@@ -219,6 +227,7 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
|
||||
fn line_text(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
@@ -237,9 +246,11 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| None,
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(line_text(&line), "gpt-5 · /repo · main");
|
||||
assert_eq!(line.spans[0].style.fg, Some(Color::Cyan));
|
||||
@@ -259,12 +270,14 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|accent| match accent {
|
||||
StatusLineAccent::Model => Some(Style::default().red()),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(line.spans[0].style.fg, Some(Color::Red));
|
||||
assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));
|
||||
@@ -282,9 +295,11 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| None,
|
||||
)
|
||||
.expect("thread usage status line");
|
||||
.expect("thread usage status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(line_text(&line), "5.2 credits · ~$0.21");
|
||||
assert_eq!(line.spans[0].style, line.spans[2].style);
|
||||
@@ -303,9 +318,11 @@ mod tests {
|
||||
[(StatusLineItem::ModelName, "gpt-5".to_string())],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| Some(Style::default().fg(Color::Rgb(255, 0, 0))),
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(
|
||||
line.spans[0].style.fg,
|
||||
@@ -327,9 +344,11 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ false,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| Some(Style::default().red()),
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(line_text(&line), "gpt-5 · Context 12% used");
|
||||
assert_eq!(line.spans[0].style, secondary_text_style());
|
||||
@@ -343,9 +362,11 @@ mod tests {
|
||||
[(StatusLineItem::PullRequestNumber, "PR #20252".to_string())],
|
||||
/*use_theme_colors*/ false,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| None,
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
|
||||
assert_eq!(line.spans[0].style, secondary_text_style().underlined());
|
||||
}
|
||||
@@ -357,6 +378,7 @@ mod tests {
|
||||
Vec::<(StatusLineItem, String)>::new(),
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|_| None,
|
||||
),
|
||||
None
|
||||
@@ -385,6 +407,7 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
|accent| {
|
||||
let rgb = match accent {
|
||||
StatusLineAccent::Model => (240, 210, 160),
|
||||
@@ -394,7 +417,8 @@ mod tests {
|
||||
Some(Style::default().fg(crate::terminal_palette::rgb_color(rgb)))
|
||||
},
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 42, /*height*/ 1,
|
||||
);
|
||||
@@ -445,8 +469,10 @@ mod tests {
|
||||
],
|
||||
/*use_theme_colors*/ true,
|
||||
/*thread_id*/ None,
|
||||
/*pull_request_url*/ None,
|
||||
)
|
||||
.expect("status line");
|
||||
.expect("status line")
|
||||
.line;
|
||||
if level == "0" {
|
||||
assert_eq!(
|
||||
line.spans
|
||||
|
||||
@@ -254,7 +254,13 @@ impl StatusSurfacePreviewData {
|
||||
self.value_for(item.preview_item())
|
||||
.map(|value| (item, value.to_string()))
|
||||
});
|
||||
status_line_from_segments(segments, use_theme_colors, self.thread_id)
|
||||
status_line_from_segments(
|
||||
segments,
|
||||
use_theme_colors,
|
||||
self.thread_id,
|
||||
/*pull_request_url*/ None,
|
||||
)
|
||||
.map(|line| line.line)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,13 +83,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
/// Sets the currently rendered footer status-line value.
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
|
||||
self.bottom_pane.set_status_line(status_line);
|
||||
}
|
||||
|
||||
/// Sets the terminal hyperlink target for the currently rendered footer status line.
|
||||
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
|
||||
self.bottom_pane.set_status_line_hyperlink(url);
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<HyperlinkLine>) {
|
||||
self.bottom_pane
|
||||
.set_status_line(status_line.map(Into::into));
|
||||
}
|
||||
|
||||
/// Forwards the contextual active-agent label into the bottom-pane footer pipeline.
|
||||
|
||||
@@ -206,7 +206,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_status_line_enabled(enabled);
|
||||
if !enabled {
|
||||
self.set_status_line(/*status_line*/ None);
|
||||
self.set_status_line_hyperlink(/*url*/ None);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,13 +220,8 @@ impl ChatWidget {
|
||||
segments,
|
||||
self.local_settings.tui.status_line_use_colors,
|
||||
self.thread_id,
|
||||
self.status_line_pull_request_url(),
|
||||
));
|
||||
let hyperlink_url = selections
|
||||
.status_line_items
|
||||
.contains(&StatusLineItem::PullRequestNumber)
|
||||
.then(|| self.status_line_pull_request_url())
|
||||
.flatten();
|
||||
self.set_status_line_hyperlink(hyperlink_url);
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
@@ -852,11 +846,11 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_line_pull_request_url(&self) -> Option<String> {
|
||||
fn status_line_pull_request_url(&self) -> Option<&str> {
|
||||
self.status_line_git_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.pull_request.as_ref())
|
||||
.map(|pull_request| pull_request.url.clone())
|
||||
.map(|pull_request| pull_request.url.as_str())
|
||||
}
|
||||
|
||||
pub(super) fn status_surface_preview_value_for_item(
|
||||
|
||||
@@ -35,9 +35,10 @@ async fn thread_color_preview_matches_footer() {
|
||||
.map(|item| (item, chat.status_line_value_for_item(item).unwrap())),
|
||||
use_colors,
|
||||
Some(id),
|
||||
/*pull_request_url*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(line, footer);
|
||||
assert_eq!(line, footer.line);
|
||||
snapshot.push(format!("colors={use_colors}: {line:?}"));
|
||||
}
|
||||
insta::assert_snapshot!(snapshot.join("\n"));
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use ratatui::buffer::CellWidth;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::width::display_width;
|
||||
/// Paragraph paints each visible grapheme separately, even when the whole string
|
||||
/// has a narrower Unicode width (for example, Arabic lam followed by alef).
|
||||
pub(crate) fn span_width(span: &Span<'_>) -> usize {
|
||||
span.styled_graphemes(Style::default())
|
||||
.map(|g| usize::from(g.symbol.cell_width()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn line_width(line: &Line<'_>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| display_width(span.content.as_ref()))
|
||||
.sum()
|
||||
line.iter().map(span_width).sum()
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
@@ -24,7 +29,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
|
||||
for span in spans {
|
||||
let span_width = display_width(span.content.as_ref());
|
||||
let span_width = span_width(&span);
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(span);
|
||||
@@ -41,20 +46,18 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
let mut end_idx = 0usize;
|
||||
for (idx, grapheme) in text.grapheme_indices(/*is_extended*/ true) {
|
||||
let grapheme_width = display_width(grapheme);
|
||||
let mut prefix = String::new();
|
||||
for grapheme in span.styled_graphemes(Style::default()) {
|
||||
let grapheme_width = usize::from(grapheme.symbol.cell_width());
|
||||
if used + grapheme_width > max_width {
|
||||
break;
|
||||
}
|
||||
end_idx = idx + grapheme.len();
|
||||
prefix.push_str(grapheme.symbol);
|
||||
used += grapheme_width;
|
||||
}
|
||||
|
||||
if end_idx > 0 {
|
||||
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
|
||||
if !prefix.is_empty() {
|
||||
spans_out.push(Span::styled(prefix, span.style));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -71,11 +71,6 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
crate::terminal_hyperlinks::mark_url_hyperlink(buf, area, url);
|
||||
}
|
||||
|
||||
/// Marks any underlined buffer cells as an OSC 8 hyperlink.
|
||||
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, url);
|
||||
}
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
mod headless_chatgpt_login;
|
||||
|
||||
@@ -6,7 +6,6 @@ mod keys;
|
||||
pub(crate) mod onboarding_screen;
|
||||
mod provider_setup;
|
||||
mod trust_directory;
|
||||
pub(crate) use auth::mark_underlined_hyperlink;
|
||||
pub(crate) use auth::mark_url_hyperlink;
|
||||
pub(crate) use onboarding_screen::DirectoryTrustOptions;
|
||||
pub(crate) use trust_directory::TrustCancelAction;
|
||||
|
||||
@@ -176,6 +176,13 @@ pub(crate) fn lines_with_sources_eq(left: &[HyperlinkLine], right: &[HyperlinkLi
|
||||
.eq(right.iter().map(|line| &line.source))
|
||||
}
|
||||
|
||||
fn source_span_width(span: &Span<'_>) -> usize {
|
||||
span.content
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.map(display_width)
|
||||
.sum()
|
||||
}
|
||||
|
||||
impl HyperlinkLine {
|
||||
pub(crate) fn new(line: Line<'static>) -> Self {
|
||||
Self {
|
||||
@@ -189,10 +196,15 @@ impl HyperlinkLine {
|
||||
line_width(&self.line)
|
||||
}
|
||||
|
||||
/// The transcript expands tabs after wrapping. Keep their source columns until then.
|
||||
pub(crate) fn source_width(&self) -> usize {
|
||||
self.line.iter().map(source_span_width).sum()
|
||||
}
|
||||
|
||||
pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
|
||||
self.source = None;
|
||||
let start = self.width();
|
||||
let end = start + display_width(span.content.as_ref());
|
||||
let start = self.source_width();
|
||||
let end = start + source_span_width(&span);
|
||||
self.line.push_span(span);
|
||||
if end > start
|
||||
&& let Some(destination) = destination.and_then(web_destination)
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::HyperlinkLine;
|
||||
use super::annotate_web_urls;
|
||||
use super::mark_buffer_hyperlinks;
|
||||
use super::visible_lines_ref;
|
||||
use crate::render::line_utils::line_to_borrowed;
|
||||
use crate::render::renderable::Renderable;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::CellWidth;
|
||||
@@ -101,6 +102,18 @@ impl Renderable for HyperlinkRows {
|
||||
impl Widget for &HyperlinkLine {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
self.line.clone().render(area, buf);
|
||||
self.mark_cells(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl HyperlinkLine {
|
||||
/// Paint a fitted, left-aligned row using the terminal cell widths of its graphemes.
|
||||
pub(crate) fn render_fitted(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(line_to_borrowed(&self.line)).render(area, buf);
|
||||
self.mark_cells(area, buf);
|
||||
}
|
||||
|
||||
fn mark_cells(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ impl LogicalLine {
|
||||
.styled_range(self.origin.range.end..source.range.start)
|
||||
.spans,
|
||||
);
|
||||
let column = self.line.width();
|
||||
let column = self.line.source_width();
|
||||
let body = slice_line(
|
||||
&line.line,
|
||||
source.prefix_bytes..source.prefix_bytes + source.range.len(),
|
||||
|
||||
Reference in New Issue
Block a user