diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 511039df..d85e5db5 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -11,6 +11,7 @@ use crate::bottom_pane::list_selection_view::SelectionViewParams; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell; +use crate::key_hint; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -116,7 +117,13 @@ impl ApprovalOverlay { .collect(); let params = SelectionViewParams { - footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()), + footer_hint: Some(Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to cancel".into(), + ])), items, header, ..Default::default() diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 07bd9203..54f474fb 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use crate::render::renderable::Renderable; -use super::popup_consts::STANDARD_POPUP_HINT_LINE; +use super::popup_consts::standard_popup_hint_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -221,7 +221,7 @@ impl Renderable for CustomPromptView { let hint_y = hint_blank_y.saturating_add(1); if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(STANDARD_POPUP_HINT_LINE).render( + Paragraph::new(standard_popup_hint_line()).render( Rect { x: area.x, y: hint_y, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 133c0b34..3fc9b2e1 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,13 +1,15 @@ +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::render::line_utils::prefix_lines; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; -use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::WidgetRef; -use std::iter; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { @@ -61,15 +63,12 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 { } pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - let lines = footer_lines(props); - for (idx, line) in lines.into_iter().enumerate() { - let y = area.y + idx as u16; - if y >= area.y + area.height { - break; - } - let row = Rect::new(area.x, y, area.width, 1); - line.render_ref(row, buf); - } + Paragraph::new(prefix_lines( + footer_lines(props), + " ".repeat(FOOTER_INDENT_COLS).into(), + " ".repeat(FOOTER_INDENT_COLS).into(), + )) + .render(area, buf); } fn footer_lines(props: FooterProps) -> Vec> { @@ -81,7 +80,10 @@ fn footer_lines(props: FooterProps) -> Vec> { if props.is_task_running { vec![context_window_line(props.context_window_percent)] } else { - vec![dim_line(indent_text("? for shortcuts"))] + vec![Line::from(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ])] } } FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { @@ -110,27 +112,36 @@ fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { } else { "quit" }; - let text = format!("ctrl + c again to {action}"); - dim_line(indent_text(&text)) + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + format!(" again to {action}").into(), + ]) + .dim() } fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { - let text = if esc_backtrack_hint { - "esc again to edit previous message" + let esc = key_hint::plain(KeyCode::Esc); + if esc_backtrack_hint { + Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() } else { - "esc esc to edit previous message" - }; - dim_line(indent_text(text)) + Line::from(vec![ + esc.into(), + " ".into(), + esc.into(), + " to edit previous message".into(), + ]) + .dim() + } } fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut commands = String::new(); - let mut newline = String::new(); - let mut file_paths = String::new(); - let mut paste_image = String::new(); - let mut edit_previous = String::new(); - let mut quit = String::new(); - let mut show_transcript = String::new(); + let mut commands = Line::from(""); + let mut newline = Line::from(""); + let mut file_paths = Line::from(""); + let mut paste_image = Line::from(""); + let mut edit_previous = Line::from(""); + let mut quit = Line::from(""); + let mut show_transcript = Line::from(""); for descriptor in SHORTCUTS { if let Some(text) = descriptor.overlay_entry(state) { @@ -153,14 +164,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, edit_previous, quit, - String::new(), + Line::from(""), show_transcript, ]; build_columns(ordered) } -fn build_columns(entries: Vec) -> Vec> { +fn build_columns(entries: Vec>) -> Vec> { if entries.is_empty() { return Vec::new(); } @@ -174,7 +185,7 @@ fn build_columns(entries: Vec) -> Vec> { let mut entries = entries; if entries.len() < target_len { entries.extend(std::iter::repeat_n( - String::new(), + Line::from(""), target_len - entries.len(), )); } @@ -183,7 +194,7 @@ fn build_columns(entries: Vec) -> Vec> { for (idx, entry) in entries.iter().enumerate() { let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.len()); + column_widths[column] = column_widths[column].max(entry.width()); } for (idx, width) in column_widths.iter_mut().enumerate() { @@ -193,42 +204,30 @@ fn build_columns(entries: Vec) -> Vec> { entries .chunks(COLUMNS) .map(|chunk| { - let mut line = String::new(); + let mut line = Line::from(""); for (col, entry) in chunk.iter().enumerate() { - line.push_str(entry); + line.extend(entry.spans.clone()); if col < COLUMNS - 1 { let target_width = column_widths[col]; - let padding = target_width.saturating_sub(entry.len()) + COLUMN_GAP; - line.push_str(&" ".repeat(padding)); + let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; + line.push_span(Span::from(" ".repeat(padding))); } } - let indented = indent_text(&line); - dim_line(indented) + line.dim() }) .collect() } -fn indent_text(text: &str) -> String { - let mut indented = String::with_capacity(FOOTER_INDENT_COLS + text.len()); - indented.extend(iter::repeat_n(' ', FOOTER_INDENT_COLS)); - indented.push_str(text); - indented -} - -fn dim_line(text: String) -> Line<'static> { - Line::from(text).dim() -} - fn context_window_line(percent: Option) -> Line<'static> { let mut spans: Vec> = Vec::new(); - spans.push(indent_text("").into()); match percent { Some(percent) => { spans.push(format!("{percent}%").bold()); spans.push(" context left".dim()); } None => { - spans.push("? for shortcuts".dim()); + spans.push(key_hint::plain(KeyCode::Char('?')).into()); + spans.push(" for shortcuts".dim()); } } Line::from(spans) @@ -247,9 +246,7 @@ enum ShortcutId { #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ShortcutBinding { - code: KeyCode, - modifiers: KeyModifiers, - overlay_text: &'static str, + key: KeyBinding, condition: DisplayCondition, } @@ -288,20 +285,24 @@ impl ShortcutDescriptor { self.bindings.iter().find(|binding| binding.matches(state)) } - fn overlay_entry(&self, state: ShortcutsState) -> Option { + fn overlay_entry(&self, state: ShortcutsState) -> Option> { let binding = self.binding_for(state)?; - let label = match self.id { + let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + match self.id { ShortcutId::EditPrevious => { if state.esc_backtrack_hint { - " again to edit previous message" + line.push_span(" again to edit previous message"); } else { - " esc to edit previous message" + line.extend(vec![ + " ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to edit previous message".into(), + ]); } } - _ => self.label, + _ => line.push_span(self.label), }; - let text = format!("{}{}{}", self.prefix, binding.overlay_text, label); - Some(text) + Some(line) } } @@ -309,9 +310,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::Commands, bindings: &[ShortcutBinding { - code: KeyCode::Char('/'), - modifiers: KeyModifiers::NONE, - overlay_text: "/", + key: key_hint::plain(KeyCode::Char('/')), condition: DisplayCondition::Always, }], prefix: "", @@ -321,15 +320,11 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ id: ShortcutId::InsertNewline, bindings: &[ ShortcutBinding { - code: KeyCode::Enter, - modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + enter", + key: key_hint::shift(KeyCode::Enter), condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + j", + key: key_hint::ctrl(KeyCode::Char('j')), condition: DisplayCondition::WhenNotShiftEnterHint, }, ], @@ -339,9 +334,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", + key: key_hint::plain(KeyCode::Char('@')), condition: DisplayCondition::Always, }], prefix: "", @@ -350,9 +343,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + v", + key: key_hint::ctrl(KeyCode::Char('v')), condition: DisplayCondition::Always, }], prefix: "", @@ -361,9 +352,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", + key: key_hint::plain(KeyCode::Esc), condition: DisplayCondition::Always, }], prefix: "", @@ -372,9 +361,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + c", + key: key_hint::ctrl(KeyCode::Char('c')), condition: DisplayCondition::Always, }], prefix: "", @@ -383,9 +370,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::ShowTranscript, bindings: &[ShortcutBinding { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + t", + key: key_hint::ctrl(KeyCode::Char('t')), condition: DisplayCondition::Always, }], prefix: "", diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e2614885..cb3de212 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -43,7 +43,7 @@ pub(crate) struct SelectionItem { pub(crate) struct SelectionViewParams { pub title: Option, pub subtitle: Option, - pub footer_hint: Option, + pub footer_hint: Option>, pub items: Vec, pub is_searchable: bool, pub search_placeholder: Option, @@ -65,7 +65,7 @@ impl Default for SelectionViewParams { } pub(crate) struct ListSelectionView { - footer_hint: Option, + footer_hint: Option>, items: Vec, state: ScrollState, complete: bool, @@ -416,7 +416,7 @@ impl Renderable for ListSelectionView { width: footer_area.width.saturating_sub(2), height: footer_area.height, }; - Line::from(hint.clone().dim()).render(hint_area, buf); + hint.clone().dim().render(hint_area, buf); } } } @@ -425,7 +425,7 @@ impl Renderable for ListSelectionView { mod tests { use super::*; use crate::app_event::AppEvent; - use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; + use crate::bottom_pane::popup_consts::standard_popup_hint_line; use insta::assert_snapshot; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; @@ -455,7 +455,7 @@ mod tests { SelectionViewParams { title: Some("Select Approval Mode".to_string()), subtitle: subtitle.map(str::to_string), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }, @@ -517,7 +517,7 @@ mod tests { let mut view = ListSelectionView::new( SelectionViewParams { title: Some("Select Approval Mode".to_string()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 5147b2ee..2cabe389 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -1,8 +1,21 @@ //! Shared popup-related constants for bottom pane widgets. +use crossterm::event::KeyCode; +use ratatui::text::Line; + +use crate::key_hint; + /// Maximum number of rows any popup should attempt to display. /// Keep this consistent across all popups for a uniform feel. pub(crate) const MAX_POPUP_ROWS: usize = 8; /// Standard footer hint text used by popups. -pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back"; +pub(crate) fn standard_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to confirm or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 170dedc0..3b6782d0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " 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 index ffa4c5b0..264515a6 100644 --- 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 @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- " / for commands shift + enter for newline " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index 9cbfe88f..512f6bbc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -9,4 +9,4 @@ expression: render_lines(&view) › 1. Read Only (current) Codex can read files 2. Full Access Codex can edit files - Press Enter to confirm or Esc to go back + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index 5e3cf2c6..ddd0f90c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -8,4 +8,4 @@ expression: render_lines(&view) › 1. Read Only (current) Codex can read files 2. Full Access Codex can edit files - Press Enter to confirm or Esc to go back + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9940c8f7..32566b3e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -68,7 +68,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; -use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -1625,7 +1625,7 @@ impl ChatWidget { subtitle: Some( "Switch between OpenAI models for this and future Codex CLI session".to_string(), ), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); @@ -1668,7 +1668,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Approval Mode".to_string()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); @@ -1843,7 +1843,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a review preset".into()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); @@ -1879,7 +1879,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a base branch".to_string()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), @@ -1920,7 +1920,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), @@ -2145,7 +2145,7 @@ pub(crate) fn show_review_commit_picker_with_entries( chat.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), - footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), + footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 10ded3d3..558728bd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -13,4 +13,4 @@ expression: terminal.backend().vt100().screen().contents() rest of the session 3. Cancel Do not run the command - Press Enter to confirm or Esc to cancel + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index c3e04bd1..53348af1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -13,5 +13,5 @@ expression: terminal.backend() " rest of the session " " 3. Cancel Do not run the command " " " -" Press Enter to confirm or Esc to cancel " +" Press enter to confirm or esc to cancel " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index fd1860ca..64fbada3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -15,5 +15,5 @@ expression: terminal.backend() "› 1. Approve Apply the proposed changes " " 2. Cancel Do not apply the changes " " " -" Press Enter to confirm or Esc to cancel " +" Press enter to confirm or esc to cancel " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 5164cd45..3b236fff 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -" Thinking (0s • Esc to interrupt) " +" Thinking (0s • esc to interrupt) " "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index e3f7f532..a05ef279 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -9,7 +9,7 @@ expression: term.backend().vt100().screen().contents() └ Search Change Approved Read diff_render.rs - Investigating rendering code (0s • Esc to interrupt) + Investigating rendering code (0s • esc to interrupt) › Summarize recent commits diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index 5beb5323..8b7c5838 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -18,7 +18,7 @@ Buffer { " rest of the session ", " 3. Cancel Do not run the command ", " ", - " Press Enter to confirm or Esc to cancel ", + " Press enter to confirm or esc to cancel ", " ", ], styles: [ @@ -37,6 +37,6 @@ Buffer { x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 41, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index e4ae90ba..46577460 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -" Analyzing (0s • Esc to interrupt) " +" Analyzing (0s • esc to interrupt) " " " " " "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 86d89b80..96b51f7a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -15,5 +15,5 @@ expression: terminal.backend() " rest of the session " " 3. Cancel Do not run the command " " " -" Press Enter to confirm or Esc to cancel " +" Press enter to confirm or esc to cancel " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f68a493e..b79c2a27 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -1,23 +1,86 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Span; -use std::fmt::Display; -#[cfg(test)] -const ALT_PREFIX: &str = "⌥"; -#[cfg(all(not(test), target_os = "macos"))] -const ALT_PREFIX: &str = "⌥"; -#[cfg(all(not(test), not(target_os = "macos")))] -const ALT_PREFIX: &str = "Alt+"; +const ALT_PREFIX: &str = "alt + "; +const CTRL_PREFIX: &str = "ctrl + "; +const SHIFT_PREFIX: &str = "shift + "; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct KeyBinding { + key: KeyCode, + modifiers: KeyModifiers, +} + +impl KeyBinding { + pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self { + Self { key, modifiers } + } + + pub fn is_press(&self, event: KeyEvent) -> bool { + self.key == event.code + && self.modifiers == event.modifiers + && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat) + } +} + +pub(crate) const fn plain(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::NONE) +} + +pub(crate) const fn alt(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::ALT) +} + +pub(crate) const fn shift(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::SHIFT) +} + +pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::CONTROL) +} + +fn modifiers_to_string(modifiers: KeyModifiers) -> String { + let mut result = String::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + result.push_str(CTRL_PREFIX); + } + if modifiers.contains(KeyModifiers::SHIFT) { + result.push_str(SHIFT_PREFIX); + } + if modifiers.contains(KeyModifiers::ALT) { + result.push_str(ALT_PREFIX); + } + result +} + +impl From for Span<'static> { + fn from(binding: KeyBinding) -> Self { + (&binding).into() + } +} +impl From<&KeyBinding> for Span<'static> { + fn from(binding: &KeyBinding) -> Self { + let KeyBinding { key, modifiers } = binding; + let modifiers = modifiers_to_string(*modifiers); + let key = match key { + KeyCode::Enter => "enter".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::PageUp => "pgup".to_string(), + KeyCode::PageDown => "pgdn".to_string(), + _ => format!("{key}").to_ascii_lowercase(), + }; + Span::styled(format!("{modifiers}{key}"), key_hint_style()) + } +} fn key_hint_style() -> Style { - Style::default().bold() -} - -fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { - Span::styled(format!("{prefix}{key}"), key_hint_style()) -} - -pub(crate) fn alt(key: impl Display) -> Span<'static> { - modifier_span(ALT_PREFIX, key) + Style::default().dim() } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 6a0562fe..7997625a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -3,18 +3,16 @@ use std::sync::Arc; use std::time::Duration; use crate::history_cell::HistoryCell; +use crate::key_hint; +use crate::key_hint::KeyBinding; use crate::render::renderable::Renderable; use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -61,23 +59,40 @@ impl Overlay { } } +const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); +const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); +const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); +const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); +const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); +const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); +const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); +const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); +const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); +const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t')); +const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); + // Common pager navigation hints rendered on the first line -const PAGER_KEY_HINTS: &[(&str, &str)] = &[ - ("↑/↓", "scroll"), - ("PgUp/PgDn", "page"), - ("Home/End", "jump"), +const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ + (&[KEY_UP, KEY_DOWN], "to scroll"), + (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"), + (&[KEY_HOME, KEY_END], "to jump"), ]; -// Render a single line of key hints from (key, description) pairs. -fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) { - let key_hint_style = Style::default().fg(Color::Cyan); +// Render a single line of key hints from (key(s), description) pairs. +fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) { let mut spans: Vec> = vec![" ".into()]; let mut first = true; - for (key, desc) in pairs { + for (keys, desc) in pairs { if !first { spans.push(" ".into()); } - spans.push(Span::from(key.to_string()).set_style(key_hint_style)); + for (i, key) in keys.iter().enumerate() { + if i > 0 { + spans.push("/".into()); + } + spans.push(Span::from(key)); + } spans.push(" ".into()); spans.push(Span::from(desc.to_string())); first = false; @@ -214,48 +229,24 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - KeyEvent { - code: KeyCode::Up, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_UP.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - KeyEvent { - code: KeyCode::Down, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_DOWN.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - KeyEvent { - code: KeyCode::PageUp, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_PAGE_UP.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); } - KeyEvent { - code: KeyCode::PageDown | KeyCode::Char(' '), - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); } - KeyEvent { - code: KeyCode::Home, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; } - KeyEvent { - code: KeyCode::End, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { + e if KEY_END.is_press(e) => { self.scroll_offset = usize::MAX; } _ => { @@ -434,9 +425,11 @@ impl TranscriptOverlay { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")]; + + let mut pairs: Vec<(&[KeyBinding], &str)> = + vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; if self.highlight_cell.is_some() { - pairs.push(("⏎", "edit message")); + pairs.push((&[KEY_ENTER], "to edit message")); } render_key_hints(line2, buf, &pairs); } @@ -454,23 +447,7 @@ impl TranscriptOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - .. - } - | KeyEvent { - code: KeyCode::Char('t'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { + e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => { self.is_done = true; Ok(()) } @@ -516,7 +493,7 @@ impl StaticOverlay { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); - let pairs = [("q", "quit")]; + let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; render_key_hints(line2, buf, &pairs); } @@ -533,17 +510,7 @@ impl StaticOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { + e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => { self.is_done = true; Ok(()) } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 9a8e8f40..ded7d41a 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -24,6 +24,7 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use unicode_width::UnicodeWidthStr; +use crate::key_hint; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; @@ -678,16 +679,18 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Hint line let hint_line: Line = vec![ - "Enter".bold(), - " to resume ".into(), - "• ".dim(), - "Esc".bold(), - " to start new ".into(), - "• ".dim(), - "Ctrl+C".into(), - " to quit ".into(), - "• ".dim(), - "↑/↓".into(), + key_hint::plain(KeyCode::Enter).into(), + " to resume ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), " to browse".dim(), ] .into(); diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap index ee65b04d..bc7818af 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -9,6 +9,6 @@ expression: term.backend() "~ " "~ " "───────────────────────────────── 100% ─" -" ↑/↓ scroll PgUp/PgDn page Home/End " -" q quit " +" ↑/↓ to scroll pgup/pgdn to page hom" +" q to quit " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap index df5f17ad..7beca4a4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -11,5 +11,5 @@ expression: snapshot 1 +hello 2 +world ─────────────────────────────────────────────────────────────────────────── 0% ─ - ↑/↓ scroll PgUp/PgDn page Home/End jump - q quit Esc edit prev + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap index 0c03edef..61fcf7d2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -9,6 +9,6 @@ expression: term.backend() " " "gamma " "───────────────────────────────── 100% ─" -" ↑/↓ scroll PgUp/PgDn page Home/End " -" q quit Esc edit prev " +" ↑/↓ to scroll pgup/pgdn to page hom" +" q to quit esc to edit prev " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap index 6aa34017..95dbc33b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap @@ -2,5 +2,5 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -" Working (0s • Esc " +" Working (0s • esc " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap index 061f3a13..7df7e5de 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap @@ -2,11 +2,11 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -" Working (0s • Esc to interrupt) " +" Working (0s • esc to interrupt) " " " " ↳ first " " ↳ second " -" ⌥↑ edit " +" alt + ↑ edit " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap index debf2821..c02e8326 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap @@ -2,5 +2,5 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -" Working (0s • Esc to interrupt) " +" Working (0s • esc to interrupt) " " " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index afb79520..caf15b28 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -5,6 +5,7 @@ use std::time::Duration; use std::time::Instant; use codex_core::protocol::Op; +use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -164,7 +165,7 @@ impl WidgetRef for StatusIndicatorWidget { spans.extend(vec![ " ".into(), format!("({pretty_elapsed} • ").dim(), - "Esc".dim().bold(), + key_hint::plain(KeyCode::Esc).into(), " to interrupt)".dim(), ]); @@ -188,8 +189,14 @@ impl WidgetRef for StatusIndicatorWidget { } } if !self.queued_messages.is_empty() { - let shortcut = key_hint::alt("↑"); - lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim()); + lines.push( + Line::from(vec![ + " ".into(), + key_hint::alt(KeyCode::Up).into(), + " edit".into(), + ]) + .dim(), + ); } let paragraph = Paragraph::new(lines);