From 02609184bed60ad566b9c0d4d523f03ef6c2e750 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 07:13:13 -0700 Subject: [PATCH] Refactor the footer logic to a new file (#4259) This will help us have more control over the footer --------- Co-authored-by: pakrym-oai --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 87 +--- codex-rs/tui/src/bottom_pane/footer.rs | 386 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + ...ooter__tests__footer_ctrl_c_quit_idle.snap | 7 + ...er__tests__footer_ctrl_c_quit_running.snap | 7 + ...oter__tests__footer_shortcuts_default.snap | 7 + ...tests__footer_shortcuts_shift_and_esc.snap | 7 + 7 files changed, 428 insertions(+), 74 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/footer.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7530794c..55762979 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,5 +1,4 @@ use codex_core::protocol::TokenUsageInfo; -use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -25,6 +24,8 @@ use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use super::footer::FooterProps; +use super::footer::render_footer; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; @@ -37,7 +38,6 @@ use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; -use crate::key_hint; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -1263,78 +1263,17 @@ impl WidgetRef for ChatComposer { } else { popup_rect }; - let mut hint: Vec> = if self.ctrl_c_quit_hint { - let ctrl_c_followup = if self.is_task_running { - " to interrupt" - } else { - " to quit" - }; - vec![ - " ".into(), - key_hint::ctrl('C'), - " again".into(), - ctrl_c_followup.into(), - ] - } else { - let newline_hint_key = if self.use_shift_enter_hint { - key_hint::shift('⏎') - } else { - key_hint::ctrl('J') - }; - vec![ - key_hint::plain('⏎'), - " send ".into(), - newline_hint_key, - " newline ".into(), - key_hint::ctrl('T'), - " transcript ".into(), - key_hint::ctrl('C'), - " quit".into(), - ] - }; - - if !self.ctrl_c_quit_hint && self.esc_backtrack_hint { - hint.push(" ".into()); - hint.push(key_hint::plain("Esc")); - hint.push(" edit prev".into()); - } - - // Append token/context usage info to the footer hints when available. - if let Some(token_usage_info) = &self.token_usage_info { - let token_usage = &token_usage_info.total_token_usage; - hint.push(" ".into()); - hint.push( - Span::from(format!( - "{} tokens used", - format_si_suffix(token_usage.blended_total()) - )) - .style(Style::default().add_modifier(Modifier::DIM)), - ); - let last_token_usage = &token_usage_info.last_token_usage; - if let Some(context_window) = token_usage_info.model_context_window { - let percent_remaining: u8 = if context_window > 0 { - last_token_usage.percent_of_context_window_remaining(context_window) - } else { - 100 - }; - let context_style = if percent_remaining < 20 { - Style::default().fg(Color::Yellow) - } else { - Style::default().add_modifier(Modifier::DIM) - }; - hint.push(" ".into()); - hint.push(Span::styled( - format!("{percent_remaining}% context left"), - context_style, - )); - } - } - - let hint = hint - .into_iter() - .map(|span| span.patch_style(Style::default().dim())) - .collect::>(); - Line::from(hint).render_ref(hint_rect, buf); + render_footer( + hint_rect, + buf, + FooterProps { + ctrl_c_quit_hint: self.ctrl_c_quit_hint, + is_task_running: self.is_task_running, + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + token_usage_info: self.token_usage_info.as_ref(), + }, + ); } } let border_style = if self.has_focus { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs new file mode 100644 index 00000000..77e2c106 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -0,0 +1,386 @@ +use codex_core::protocol::TokenUsageInfo; +use codex_protocol::num_format::format_si_suffix; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::WidgetRef; + +use crate::key_hint; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FooterProps<'a> { + pub(crate) ctrl_c_quit_hint: bool, + pub(crate) is_task_running: bool, + pub(crate) esc_backtrack_hint: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, +} + +#[derive(Clone, Copy, Debug)] +struct CtrlCReminderState { + pub(crate) is_task_running: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + pub(crate) use_shift_enter_hint: bool, + pub(crate) esc_backtrack_hint: bool, +} + +#[derive(Clone, Copy, Debug)] +enum FooterContent { + Shortcuts(ShortcutsState), + CtrlCReminder(CtrlCReminderState), +} + +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { + let content = if props.ctrl_c_quit_hint { + FooterContent::CtrlCReminder(CtrlCReminderState { + is_task_running: props.is_task_running, + }) + } else { + FooterContent::Shortcuts(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }) + }; + + let mut spans = footer_spans(content); + if let Some(token_usage_info) = props.token_usage_info { + append_token_usage_spans(&mut spans, token_usage_info); + } + + let spans = spans + .into_iter() + .map(|span| span.patch_style(Style::default().dim())) + .collect::>(); + Line::from(spans).render_ref(area, buf); +} + +fn footer_spans(content: FooterContent) -> Vec> { + match content { + FooterContent::Shortcuts(state) => shortcuts_spans(state), + FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), + } +} + +fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { + let token_usage = &token_usage_info.total_token_usage; + spans.push(" ".into()); + spans.push( + Span::from(format!( + "{} tokens used", + format_si_suffix(token_usage.blended_total()) + )) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + + let last_token_usage = &token_usage_info.last_token_usage; + if let Some(context_window) = token_usage_info.model_context_window { + let percent_remaining: u8 = if context_window > 0 { + last_token_usage.percent_of_context_window_remaining(context_window) + } else { + 100 + }; + + let context_style = if percent_remaining < 20 { + Style::default().fg(Color::Yellow) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + spans.push(" ".into()); + spans.push(Span::styled( + format!("{percent_remaining}% context left"), + context_style, + )); + } +} + +fn shortcuts_spans(state: ShortcutsState) -> Vec> { + let mut spans = Vec::new(); + for descriptor in SHORTCUTS { + if let Some(segment) = descriptor.footer_segment(state) { + if !segment.prefix.is_empty() { + spans.push(segment.prefix.into()); + } + spans.push(segment.binding.span()); + spans.push(segment.label.into()); + } + } + spans +} + +fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec> { + let followup = if state.is_task_running { + " to interrupt" + } else { + " to quit" + }; + vec![ + " ".into(), + key_hint::ctrl('C'), + " again".into(), + followup.into(), + ] +} + +#[derive(Clone, Copy, Debug)] +struct FooterSegment { + prefix: &'static str, + binding: ShortcutBinding, + label: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum ShortcutId { + Send, + InsertNewline, + ShowTranscript, + Quit, + EditPrevious, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ShortcutBinding { + code: KeyCode, + modifiers: KeyModifiers, + display: ShortcutDisplay, + condition: DisplayCondition, +} + +impl ShortcutBinding { + fn span(&self) -> Span<'static> { + self.display.into_span() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutDisplay { + Plain(&'static str), + Ctrl(char), + Shift(char), +} + +impl ShortcutDisplay { + fn into_span(self) -> Span<'static> { + match self { + ShortcutDisplay::Plain(text) => key_hint::plain(text), + ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), + ShortcutDisplay::Shift(ch) => key_hint::shift(ch), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayCondition { + Always, + WhenShiftEnterHint, + WhenNotShiftEnterHint, +} + +impl DisplayCondition { + fn matches(self, state: ShortcutsState) -> bool { + match self { + DisplayCondition::Always => true, + DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, + DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + } + } +} + +struct ShortcutDescriptor { + id: ShortcutId, + bindings: &'static [ShortcutBinding], + footer_label: &'static str, + footer_prefix: &'static str, +} + +impl ShortcutDescriptor { + fn binding_for(&self, state: ShortcutsState) -> Option { + self.bindings + .iter() + .find(|binding| binding.condition.matches(state)) + .copied() + } + + fn should_show(&self, state: ShortcutsState) -> bool { + match self.id { + ShortcutId::EditPrevious => state.esc_backtrack_hint, + _ => true, + } + } + + fn footer_segment(&self, state: ShortcutsState) -> Option { + if !self.should_show(state) { + return None; + } + let binding = self.binding_for(state)?; + Some(FooterSegment { + prefix: self.footer_prefix, + binding, + label: self.footer_label, + }) + } +} + +const SHORTCUTS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + id: ShortcutId::Send, + bindings: &[ShortcutBinding { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("⏎"), + condition: DisplayCondition::Always, + }], + footer_label: " send ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::InsertNewline, + bindings: &[ + ShortcutBinding { + code: KeyCode::Enter, + modifiers: KeyModifiers::SHIFT, + display: ShortcutDisplay::Shift('⏎'), + condition: DisplayCondition::WhenShiftEnterHint, + }, + ShortcutBinding { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('J'), + condition: DisplayCondition::WhenNotShiftEnterHint, + }, + ], + footer_label: " newline ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::ShowTranscript, + bindings: &[ShortcutBinding { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('T'), + condition: DisplayCondition::Always, + }], + footer_label: " transcript ", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::Quit, + bindings: &[ShortcutBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + display: ShortcutDisplay::Ctrl('C'), + condition: DisplayCondition::Always, + }], + footer_label: " quit", + footer_prefix: "", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + display: ShortcutDisplay::Plain("Esc"), + condition: DisplayCondition::Always, + }], + footer_label: " edit prev", + footer_prefix: " ", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::TokenUsage; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn snapshot_footer(name: &str, props: FooterProps<'_>) { + let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, f.area().width, 1); + render_footer(area, f.buffer_mut(), props); + }) + .unwrap(); + assert_snapshot!(name, terminal.backend()); + } + + fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { + let usage = TokenUsage { + input_tokens: total_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens, + }; + let last = TokenUsage { + input_tokens: last_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: last_tokens, + }; + TokenUsageInfo { + total_token_usage: usage, + last_token_usage: last, + model_context_window: Some(context_window), + } + } + + #[test] + fn footer_snapshots() { + snapshot_footer( + "footer_shortcuts_default", + FooterProps { + ctrl_c_quit_hint: false, + is_task_running: false, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_shift_and_esc", + FooterProps { + ctrl_c_quit_hint: false, + is_task_running: false, + esc_backtrack_hint: true, + use_shift_enter_hint: true, + token_usage_info: Some(&token_usage(4_200, 900, 8_000)), + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + ctrl_c_quit_hint: true, + is_task_running: false, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + ctrl_c_quit_hint: true, + is_task_running: true, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + token_usage_info: None, + }, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index bd26581b..61a2378a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -23,6 +23,7 @@ mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; mod file_search_popup; +mod footer; mod list_selection_view; pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 00000000..32482ba1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ⌃C again to quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 00000000..8c9c2b0d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ⌃C again to interrupt " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 00000000..8c8a10df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 00000000..9b94d89f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" +" " +" "