diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5d46e565..3497f981 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1204,8 +1204,11 @@ async fn run_event_loop( let now = Instant::now(); app.flush_paste_burst_if_due(now); - let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL) - || key.modifiers.contains(KeyModifiers::ALT) + // On Windows, AltGr is delivered as `Ctrl+Alt`; treat + // AltGr-typed chars (e.g. European layouts producing `@`, `\`, + // `|`) as plain text rather than swallowing them as a modified + // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out. + let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers) || key.modifiers.contains(KeyModifiers::SUPER); let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super; let is_enter = matches!(key.code, KeyCode::Enter); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 5082e0bc..af300f3e 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -978,6 +978,16 @@ impl ModalView for HelpView { Clear.render(popup_area, buf); + // Render keybinding hints through `key_hint::KeyBinding` so they pick + // up the host-platform notation (`⌥` on macOS, `alt+X` on Linux / + // Windows). See `crates/tui/src/tui/widgets/key_hint.rs`. + use crate::tui::widgets::key_hint::{alt, ctrl, plain, shift}; + let kb = |b: crate::tui::widgets::key_hint::KeyBinding| b.to_string(); + + let row = |label: String, desc: &str| -> Line<'static> { + Line::from(format!(" {:<22} - {}", label, desc)) + }; + let mut help_lines: Vec = vec![ Line::from(vec![Span::styled( "DeepSeek TUI Help", @@ -988,75 +998,175 @@ impl ModalView for HelpView { "=== Navigation ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Up / Down - Scroll transcript (or navigate history)"), - Line::from(" Ctrl+Up / Ctrl+Down - Navigate input history"), - Line::from(" Alt+Up / Alt+Down - Scroll transcript"), - Line::from(" PageUp / PageDown - Scroll transcript by page"), - Line::from(" Home / End - Jump to top / bottom of transcript"), - Line::from(" g / G - Jump to top / bottom (when input empty)"), - Line::from(" [ / ] - Jump between tool output blocks"), + row( + format!("{} / {}", kb(plain(KeyCode::Up)), kb(plain(KeyCode::Down))), + "Scroll transcript (or navigate history)", + ), + row( + format!("{} / {}", kb(ctrl(KeyCode::Up)), kb(ctrl(KeyCode::Down))), + "Navigate input history", + ), + row( + format!("{} / {}", kb(alt(KeyCode::Up)), kb(alt(KeyCode::Down))), + "Scroll transcript", + ), + row( + format!( + "{} / {}", + kb(plain(KeyCode::PageUp)), + kb(plain(KeyCode::PageDown)) + ), + "Scroll transcript by page", + ), + row( + format!("{} / {}", kb(plain(KeyCode::Home)), kb(plain(KeyCode::End))), + "Jump to top / bottom of transcript", + ), + row( + format!("{} / {}", kb(plain(KeyCode::Char('g'))), "G"), + "Jump to top / bottom (when input empty)", + ), + row("[ / ]".to_string(), "Jump between tool output blocks"), Line::from(""), Line::from(vec![Span::styled( "=== Input Editing ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Left / Right - Move cursor"), - Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"), - Line::from(" Backspace / Delete - Delete character before / after cursor"), - Line::from(" Ctrl+U - Clear the current draft"), + row( + format!( + "{} / {}", + kb(plain(KeyCode::Left)), + kb(plain(KeyCode::Right)) + ), + "Move cursor", + ), + row( + format!( + "{} / {}", + kb(ctrl(KeyCode::Char('a'))), + kb(ctrl(KeyCode::Char('e'))) + ), + "Jump to start / end of line", + ), + row( + format!( + "{} / {}", + kb(plain(KeyCode::Backspace)), + kb(plain(KeyCode::Delete)) + ), + "Delete character before / after cursor", + ), + row(kb(ctrl(KeyCode::Char('u'))), "Clear the current draft"), Line::from(""), Line::from(vec![Span::styled( "=== Multi-line Input ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Ctrl+J / Alt+Enter - Insert a new line in the composer"), + row( + format!( + "{} / {}", + kb(ctrl(KeyCode::Char('j'))), + kb(alt(KeyCode::Enter)) + ), + "Insert a new line in the composer", + ), Line::from(""), Line::from(vec![Span::styled( "=== Actions ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Enter - Send the current draft"), - Line::from( - " Esc - Close menu, cancel request, discard draft, or clear input", + row(kb(plain(KeyCode::Enter)), "Send the current draft"), + row( + kb(plain(KeyCode::Esc)), + "Close menu, cancel request, discard draft, or clear input", + ), + row( + kb(ctrl(KeyCode::Char('c'))), + "Cancel request or exit application", + ), + row(kb(ctrl(KeyCode::Char('d'))), "Exit when input is empty"), + row(kb(ctrl(KeyCode::Char('k'))), "Open command palette"), + row( + kb(plain(KeyCode::Char('l'))), + "Open pager for last message (when input empty)", + ), + row( + kb(plain(KeyCode::Char('v'))), + "Open details for the selected tool or message", + ), + row( + format!("{} (selection)", kb(plain(KeyCode::Enter))), + "Open pager for selected text", ), - Line::from(" Ctrl+C - Cancel request or exit application"), - Line::from(" Ctrl+D - Exit when input is empty"), - Line::from(" Ctrl+K - Open command palette"), - Line::from(" l - Open pager for last message (when input empty)"), - Line::from(" v - Open details for the selected tool or message"), - Line::from(" Enter (selection) - Open pager for selected text"), Line::from(""), Line::from(vec![Span::styled( "=== Modes ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Tab / Shift+Tab - Complete /command or cycle modes"), - Line::from(" Alt+1/2/3 - Directly jump to Plan/Agent/YOLO"), - Line::from(" Alt+P/A/Y - Alternative jump to Plan/Agent/YOLO"), - Line::from(" Alt+!/@/#/$/+) - Focus Plan/Todos/Tasks/Agents/Auto sidebar"), - Line::from(" /agent /yolo /plan - Set mode directly"), - Line::from(" Ctrl+X - Toggle between Plan and Agent modes"), + row( + format!("{} / {}", kb(plain(KeyCode::Tab)), kb(shift(KeyCode::Tab))), + "Complete /command or cycle modes", + ), + row( + format!("{}/2/3", kb(alt(KeyCode::Char('1')))), + "Directly jump to Plan/Agent/YOLO", + ), + row( + format!("{}/A/Y", kb(alt(KeyCode::Char('p')))), + "Alternative jump to Plan/Agent/YOLO", + ), + row( + format!("{}/@/#/$/)", kb(alt(KeyCode::Char('!')))), + "Focus Plan/Todos/Tasks/Agents/Auto sidebar", + ), + row("/agent /yolo /plan".to_string(), "Set mode directly"), + row( + kb(ctrl(KeyCode::Char('x'))), + "Toggle between Plan and Agent modes", + ), Line::from(""), Line::from(vec![Span::styled( "=== Sessions ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Ctrl+R - Open session picker"), + row(kb(ctrl(KeyCode::Char('r'))), "Open session picker"), Line::from(""), Line::from(vec![Span::styled( "=== Clipboard ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" Ctrl+V - Paste text or attach clipboard image"), - Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"), - Line::from(" @path - Add local text file or directory context"), - Line::from(" /attach - Attach local image/video media path"), + row( + kb(ctrl(KeyCode::Char('v'))), + "Paste text or attach clipboard image", + ), + row( + kb(crate::tui::widgets::key_hint::KeyBinding::new( + KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + )), + "Copy selection (Cmd+C on macOS)", + ), + row( + "@path".to_string(), + "Add local text file or directory context", + ), + row( + "/attach ".to_string(), + "Attach local image/video media path", + ), Line::from(""), Line::from(vec![Span::styled( "=== Help ===", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), - Line::from(" F1 / Ctrl+/ - Toggle this help view"), + row( + format!( + "{} / {}", + kb(plain(KeyCode::F(1))), + kb(ctrl(KeyCode::Char('/'))) + ), + "Toggle this help view", + ), Line::from(""), Line::from(vec![Span::styled( "=== Mouse ===", diff --git a/crates/tui/src/tui/widgets/key_hint.rs b/crates/tui/src/tui/widgets/key_hint.rs new file mode 100644 index 00000000..65b78504 --- /dev/null +++ b/crates/tui/src/tui/widgets/key_hint.rs @@ -0,0 +1,313 @@ +//! Terminal-aware keybinding rendering. +//! +//! `KeyBinding` is a typed representation of a chord (a [`KeyCode`] plus a +//! [`KeyModifiers`] set) that knows how to render itself in a way that matches +//! the host platform's conventions. On macOS the Option key renders as `⌥` +//! (matching how every other Mac app — including Terminal, iTerm2, and the +//! system menu bar — labels Option chords). On Linux and Windows we keep the +//! plain-text `alt + X` notation that users coming from other CLIs already +//! recognise. +//! +//! See `codex-rs/tui/src/key_hint.rs` for the original design; this is a +//! ratatui-compatible port that exposes a [`Display`] impl plus a +//! `KeyBinding -> Span` conversion so call sites can use it equally well in +//! plain `format!` calls and inside ratatui [`Line`] / [`Span`] builders. +//! +//! Windows AltGr disambiguation: many European keyboard layouts produce +//! `Ctrl+Alt` events when AltGr is pressed alone (to type `@`, `\`, etc.). +//! [`is_altgr`] returns `true` for that combination on Windows so callers can +//! suppress alt-bound shortcut matching when the user is genuinely just +//! reaching for a glyph. On non-Windows targets the function always returns +//! `false`. See [`has_ctrl_or_alt`] for the convenience predicate that +//! shortcut handlers should prefer over a raw `mods.contains(...)` check. + +use std::fmt; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{ + style::{Style, Stylize}, + text::Span, +}; + +// Compile-time platform detection. The `#[cfg(test)]` arm forces the macOS +// rendering during `cargo test` so unit tests are deterministic regardless of +// the host they run on (CI hits Ubuntu, macOS, and Windows). +#[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 CTRL_PREFIX: &str = "ctrl+"; +const SHIFT_PREFIX: &str = "shift+"; + +/// A typed representation of a single chord (key + modifiers). +/// +/// Construct via [`plain`], [`alt`], [`shift`], [`ctrl`], or [`ctrl_alt`] for +/// the common cases, or [`KeyBinding::new`] for arbitrary modifier sets. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KeyBinding { + key: KeyCode, + modifiers: KeyModifiers, +} + +impl KeyBinding { + /// Build a binding from a key code and modifier set. + pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self { + Self { key, modifiers } + } + + /// `true` if the supplied [`KeyEvent`] matches this binding (key + mods), + /// considering only `Press` / `Repeat` events (release events are ignored + /// — crossterm only emits them when key-release reporting is on, and we + /// never want to fire a shortcut on key-up regardless). + pub fn is_press(&self, event: KeyEvent) -> bool { + self.key == event.code + && self.modifiers == event.modifiers + && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat) + } +} + +/// A binding with no modifiers. +pub const fn plain(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::NONE) +} + +/// `Alt`-modified binding (renders as `⌥` on macOS, `alt+` elsewhere). +pub const fn alt(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::ALT) +} + +/// `Shift`-modified binding. +pub const fn shift(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::SHIFT) +} + +/// `Ctrl`-modified binding. +pub const fn ctrl(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::CONTROL) +} + +/// `Ctrl+Alt`-modified binding. +pub const fn ctrl_alt(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT)) +} + +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 +} + +fn keycode_to_string(key: &KeyCode) -> String { + match key { + KeyCode::Enter => "enter".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "shift+tab".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Delete => "del".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_string().to_ascii_lowercase(), + 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(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::F(n) => format!("f{n}"), + _ => format!("{key}").to_ascii_lowercase(), + } +} + +impl fmt::Display for KeyBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}{}", + modifiers_to_string(self.modifiers), + keycode_to_string(&self.key) + ) + } +} + +impl From for Span<'static> { + fn from(binding: KeyBinding) -> Self { + (&binding).into() + } +} + +impl From<&KeyBinding> for Span<'static> { + fn from(binding: &KeyBinding) -> Self { + Span::styled(binding.to_string(), key_hint_style()) + } +} + +fn key_hint_style() -> Style { + Style::default().dim() +} + +/// `true` if `mods` carries Ctrl or Alt — but not the AltGr Ctrl+Alt +/// combination on Windows. Shortcut handlers should prefer this predicate +/// over `mods.contains(CONTROL) || mods.contains(ALT)` so they don't fire on +/// AltGr keypresses (which on European keyboard layouts are how users type +/// `@`, `\`, `|`, etc.). +pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool { + (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods) +} + +/// On Windows, AltGr is delivered as `Ctrl+Alt`. There's no terminal-portable +/// way to tell a real `Ctrl+Alt` chord apart from a layout-emitted AltGr glyph +/// — crossterm doesn't expose left-vs-right modifier distinction across all +/// backends — so we treat any `Ctrl+Alt` (with no other modifiers) as AltGr. +/// This trades the (rare) ability to bind `Ctrl+Alt+` for not +/// swallowing accented characters European users type. On non-Windows +/// platforms this always returns `false`. +#[cfg(windows)] +#[inline] +pub fn is_altgr(mods: KeyModifiers) -> bool { + mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL) +} + +#[cfg(not(windows))] +#[inline] +pub fn is_altgr(_mods: KeyModifiers) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests force ALT_PREFIX = "⌥+" via `cfg(test)`. We verify both + // platform-specific renderings explicitly by invoking the helper code + // paths the host-OS cfg arms would select. + + #[test] + fn plain_renders_just_the_key() { + assert_eq!(plain(KeyCode::Enter).to_string(), "enter"); + assert_eq!(plain(KeyCode::Char(' ')).to_string(), "space"); + assert_eq!(plain(KeyCode::Up).to_string(), "↑"); + } + + #[test] + fn alt_renders_with_macos_glyph_in_tests() { + // Under cfg(test) we force the macOS prefix so test output is + // deterministic. The non-macOS rendering is exercised in + // `non_macos_alt_prefix` below. + assert_eq!(alt(KeyCode::Up).to_string(), "⌥+↑"); + assert_eq!(alt(KeyCode::Char('p')).to_string(), "⌥+p"); + } + + #[test] + fn shift_and_ctrl_render_in_canonical_order() { + // Order is: ctrl, shift, alt — matching codex-rs and what users + // expect from cross-tool muscle memory. + assert_eq!(ctrl(KeyCode::Char('c')).to_string(), "ctrl+c"); + assert_eq!(shift(KeyCode::Tab).to_string(), "shift+tab"); + assert_eq!( + KeyBinding::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + ) + .to_string(), + "ctrl+shift+x" + ); + } + + #[test] + fn ctrl_alt_combo_renders_both_modifiers() { + assert_eq!(ctrl_alt(KeyCode::Char('a')).to_string(), "ctrl+⌥+a"); + } + + #[test] + fn keycode_lowercases_letters() { + assert_eq!(plain(KeyCode::Char('A')).to_string(), "a"); + } + + #[test] + fn function_keys_render_as_f_n() { + assert_eq!(plain(KeyCode::F(1)).to_string(), "f1"); + assert_eq!(plain(KeyCode::F(12)).to_string(), "f12"); + } + + #[test] + fn span_conversion_carries_dim_style() { + let span: Span<'static> = alt(KeyCode::Up).into(); + assert_eq!(span.content, "⌥+↑"); + // The exact `Style` representation in ratatui isn't trivially + // comparable, so we just verify the style was set (not default). + assert_ne!(span.style, Style::default()); + } + + #[test] + fn is_press_matches_press_and_repeat() { + let binding = ctrl(KeyCode::Char('c')); + let press = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }; + let repeat = KeyEvent { + kind: KeyEventKind::Repeat, + ..press + }; + let release = KeyEvent { + kind: KeyEventKind::Release, + ..press + }; + let wrong_mods = KeyEvent { + modifiers: KeyModifiers::NONE, + ..press + }; + assert!(binding.is_press(press)); + assert!(binding.is_press(repeat)); + assert!(!binding.is_press(release)); + assert!(!binding.is_press(wrong_mods)); + } + + #[test] + fn altgr_only_fires_on_windows() { + let altgr_mods = KeyModifiers::ALT | KeyModifiers::CONTROL; + if cfg!(windows) { + assert!(is_altgr(altgr_mods)); + assert!(!has_ctrl_or_alt(altgr_mods)); + } else { + assert!(!is_altgr(altgr_mods)); + assert!(has_ctrl_or_alt(altgr_mods)); + } + // Plain Alt is never AltGr. + assert!(!is_altgr(KeyModifiers::ALT)); + assert!(has_ctrl_or_alt(KeyModifiers::ALT)); + // No modifiers: never Ctrl/Alt. + assert!(!has_ctrl_or_alt(KeyModifiers::NONE)); + } + + /// Render an alt-prefixed binding the way the Linux/Windows non-test arm + /// would. We can't toggle the cfg at runtime, so we rebuild the rendering + /// with the alternate prefix to lock in the expected string shape. + #[test] + fn non_macos_alt_prefix_shape() { + let mods = modifiers_to_string(KeyModifiers::ALT); + // Under cfg(test), this is "⌥+". Strip and re-render with "alt+" to + // demonstrate the shape that ships on Linux/Windows release builds. + let linux_shape = mods.replace("⌥+", "alt+"); + assert_eq!(linux_shape, "alt+"); + + let mods_mixed = modifiers_to_string(KeyModifiers::CONTROL | KeyModifiers::ALT); + let linux_shape_mixed = mods_mixed.replace("⌥+", "alt+"); + assert_eq!(linux_shape_mixed, "ctrl+alt+"); + } +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 564f8ad2..e31cf955 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1,5 +1,10 @@ mod footer; mod header; +// Some helpers (`shift`, `ctrl_alt`, `is_press`, etc.) are part of the +// public surface for issue #93's help overlay and future call sites; allow +// dead code rather than scattering `#[allow]` across every constructor. +#[allow(dead_code)] +pub mod key_hint; mod renderable; pub use footer::{