feat(menu): show shortcut hints in the right-click menu

Copy / Paste / Select All / Find are dispatched inline in
`handle_cmd_shortcut` with no registered `KeyBinding`, so gpui-component's
PopupMenu couldn't auto-derive their hints the way it does for the
keybinding-backed items (Clear, Split, Maximize, New Tab, Close Pane) — the
menu showed shortcuts on some rows and nothing on others. Render the hints
ourselves via `menu_row_with_hint`, styled to match the auto-rendered ones,
instead of registering real bindings (which would risk the Ctrl+C SIGINT
fall-through on Windows/Linux). ⌘A / ⌘F are macOS-only, so those hints are
gated behind `mac_only`.

Also fix the "Select All" menu item diverging from ⌘A: both now go through
`select_all_contextual` (select the edited command line at the prompt,
otherwise the whole terminal buffer) so click and shortcut stay in sync.
This commit is contained in:
l0ng-ai
2026-07-07 16:55:21 +08:00
parent 4e9604ffdc
commit 03fda9c33d
+83 -13
View File
@@ -11,8 +11,9 @@ use gpui::{
KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta, ScrollWheelEvent,
Window, actions, div, prelude::*, px,
};
use gpui_component::kbd::Kbd;
use gpui_component::menu::ContextMenuExt;
use gpui_component::{ActiveTheme as _, Icon, IconName, Size};
use gpui_component::{ActiveTheme as _, Icon, IconName, Size, h_flex};
use super::TermSize;
use super::cmd_editor::CmdEditor;
@@ -910,12 +911,7 @@ impl TerminalView {
"a" => {
// At the prompt, ⌘A selects the whole edited line; otherwise it
// selects the whole terminal buffer (scrollback included).
if self.input_active() {
self.cmd.select_all();
cx.notify();
} else {
self.select_all(cx);
}
self.select_all_contextual(cx);
CmdKey::Consumed
}
// The following are editor-only (macOS line editing); they're swallowed
@@ -1302,7 +1298,7 @@ impl TerminalView {
}
/// Select the entire buffer — from the top of scrollback to the last cell —
/// so Cmd+A then Cmd+C copies everything. Also used by the right-click menu.
/// so Cmd+A then Cmd+C copies everything.
pub fn select_all(&mut self, cx: &mut Context<Self>) {
let mut term = self.terminal.term.lock();
let grid = term.grid();
@@ -1315,6 +1311,19 @@ impl TerminalView {
cx.notify();
}
/// "Select All" as the user means it in context: at the prompt, select the
/// edited command line; otherwise select the whole terminal buffer. Shared by
/// the ⌘A shortcut and the right-click "Select All" item so the two never
/// drift apart.
pub fn select_all_contextual(&mut self, cx: &mut Context<Self>) {
if self.input_active() {
self.cmd.select_all();
cx.notify();
} else {
self.select_all(cx);
}
}
/// Paste clipboard text. While idle at the prompt it goes into the local
/// command editor (a single trailing newline is dropped so a copied line
/// doesn't auto-submit). Otherwise it's written to the PTY, wrapped in
@@ -2894,7 +2903,7 @@ impl Render for TerminalView {
// same menu fall through to `Tty7App`.
.on_action(cx.listener(|this, _: &CopyText, _w, cx| this.copy_selection(cx)))
.on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx)))
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all(cx)))
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx)))
.on_action(
cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)),
)
@@ -2932,14 +2941,33 @@ impl Render for TerminalView {
// Small size = tighter 20px rows; the default 26px felt too airy.
// A fixed min-width keeps the menu a consistent, intentional size
// instead of hugging the longest label (which reads ragged).
// Copy/Paste/Select All/Find are dispatched inline (see
// `handle_cmd_shortcut`) with no registered `KeyBinding`, so the menu
// can't auto-derive their hints the way it does for the items below.
// We render the hint ourselves via `menu_row_with_hint` to keep the
// whole menu consistent, rather than register real bindings (which
// would risk the Ctrl+C SIGINT fall-through on Windows/Linux).
menu.with_size(Size::Small)
.min_w(px(220.))
.action_context(menu_focus.clone())
.menu_with_disabled("Copy", Box::new(CopyText), !has_selection)
.menu("Paste", Box::new(PasteText))
.menu("Select All", Box::new(SelectAll))
.menu_element_with_disabled(
Box::new(CopyText),
!has_selection,
menu_row_with_hint("Copy", Some("secondary-c")),
)
.menu_element(
Box::new(PasteText),
menu_row_with_hint("Paste", Some("secondary-v")),
)
.menu_element(
Box::new(SelectAll),
menu_row_with_hint("Select All", mac_only("secondary-a")),
)
.separator()
.menu("Find…", Box::new(FindInTerminal))
.menu_element(
Box::new(FindInTerminal),
menu_row_with_hint("Find…", mac_only("secondary-f")),
)
.menu("Clear", Box::new(ClearScrollback))
.separator()
.menu("Split Right", Box::new(SplitRight))
@@ -2952,6 +2980,48 @@ impl Render for TerminalView {
}
}
/// Build a context-menu row that shows its shortcut right-aligned, matching the
/// hint gpui-component auto-renders for items whose action has a registered
/// keybinding. `key` is `None` when the action has no shortcut on this platform,
/// leaving the row hint-less like a plain item.
fn menu_row_with_hint(
label: &'static str,
key: Option<&'static str>,
) -> impl Fn(&mut Window, &mut App) -> gpui::AnyElement {
move |_window, _cx| {
let hint = key.map(|k| {
// Strip Kbd's keycap box (filled bg + border) so it reads as the same
// quiet muted-foreground hint the auto-rendered items show — see
// gpui-component's `PopupMenu::render_key_binding`.
Kbd::new(gpui::Keystroke::parse(k).expect("valid static keystroke"))
.p_0()
.flex_nowrap()
.border_0()
.bg(gpui::transparent_white())
});
h_flex()
.w_full()
.gap_3()
.items_center()
.justify_between()
.child(label)
.children(hint)
.into_any_element()
}
}
/// `Some(key)` on macOS, `None` elsewhere. ⌘A (Select All) and ⌘F (Find) are
/// wired only on macOS; on Windows/Linux those chords keep their readline meaning
/// (line-start / forward-char), so the menu must not advertise them there.
#[cfg(target_os = "macos")]
fn mac_only(key: &'static str) -> Option<&'static str> {
Some(key)
}
#[cfg(not(target_os = "macos"))]
fn mac_only(_key: &'static str) -> Option<&'static str> {
None
}
/// Approximate terminal display width of a char in cells: 2 for East-Asian
/// wide / fullwidth glyphs and most emoji, 1 otherwise. Mirrors how the grid
/// (alacritty) lays out wide characters, so the editor's per-char cells and