Refactor shortcut overlay binding construction (#60962)

## What changed

Separate shortcut binding definitions from rendering in the TUI shortcut overlay. Store optional bindings, paired alternatives, and repeated-key labels in typed entries, then format them through a shared rendering path.

Use `KeyBinding` values for Page Up and Page Down instead of hard-coded labels.

GitOrigin-RevId: da5d61eb94aefa3334612005fe614f58e6bfcae8
This commit is contained in:
Benjamin Carlsson
2026-11-12 01:32:02 +00:00
committed by copyberry
parent 1b7c025258
commit 64acaba91c
+135 -107
View File
@@ -6,6 +6,8 @@
use super::footer::FooterProps; use super::footer::FooterProps;
use crate::key_hint; use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::multi_agents::next_agent_shortcut; use crate::multi_agents::next_agent_shortcut;
use crate::multi_agents::previous_agent_shortcut; use crate::multi_agents::previous_agent_shortcut;
use crate::shortcut_help::Group; use crate::shortcut_help::Group;
@@ -23,122 +25,148 @@ use ratatui::text::Line;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
pub(super) fn lines(props: &FooterProps, width: u16) -> Vec<Line<'static>> { struct Entry {
keys: [Option<ShortcutHint>; 2],
action: &'static str,
repeat_label: bool,
}
impl Entry {
fn hint(key: Option<ShortcutHint>, action: &'static str) -> Self {
Self {
keys: [key, None],
action,
repeat_label: false,
}
}
fn key(key: KeyBinding, action: &'static str) -> Self {
Self::hint(Some(key.into()), action)
}
fn pair(action: &'static str, keys: [KeyBinding; 2]) -> Self {
Self {
keys: keys.map(|key| Some(key.into())),
action,
repeat_label: false,
}
}
}
fn bindings(props: &FooterProps) -> [(&'static str, Vec<Entry>); 3] {
use KeyCode as K;
let hints = props.key_hints; let hints = props.key_hints;
let mut compose = Group { let paste = if props.is_wsl {
title: "Compose", key_hint::ctrl_alt(K::Char('v'))
entries: vec![ } else {
Shortcut::new(key_hint::plain(KeyCode::Char('/')), "Commands"), key_hint::ctrl(K::Char('v'))
Shortcut::new(key_hint::plain(KeyCode::Char('@')), "Mention files"),
Shortcut::new(key_hint::plain(KeyCode::Char('!')), "Shell command"),
],
}; };
compose.push(hints.insert_newline, "New line"); let compose = vec![
compose.entries.push(Shortcut::new( Entry::key(key_hint::plain(K::Char('/')), "Commands"),
if props.is_wsl { Entry::key(key_hint::plain(K::Char('@')), "Mention files"),
key_hint::ctrl_alt(KeyCode::Char('v')) Entry::key(key_hint::plain(K::Char('!')), "Shell command"),
} else { Entry::hint(hints.insert_newline, "New line"),
key_hint::ctrl(KeyCode::Char('v')) Entry::key(paste, "Paste image"),
}, Entry::hint(hints.external_editor, "External editor"),
"Paste image", Entry::hint(hints.history_search, "Search history"),
)); Entry {
compose.push(hints.external_editor, "External editor"); keys: [hints.edit_previous, None],
compose.push(hints.history_search, "Search history");
if let Some(key) = hints.edit_previous {
let label = key.display_label();
compose.entries.push(Shortcut {
key: if props.esc_backtrack_hint {
label
} else {
format!("{label} {label}")
},
action: "Edit last message", action: "Edit last message",
}); repeat_label: !props.esc_backtrack_hint,
}
let mut session = Group {
title: "Session",
entries: Vec::new(),
};
session.push(
hints.queue,
if props.is_task_running || props.queue_submissions {
"Queue message"
} else {
"Send message"
}, },
); ];
if props.collaboration_modes_enabled {
session let session = vec![
.entries Entry::hint(
.push(Shortcut::new(key_hint::shift(KeyCode::Tab), "Change mode")); hints.queue,
} if props.is_task_running || props.queue_submissions {
session.push(hints.reasoning_down, "Less reasoning"); "Queue message"
session.push(hints.reasoning_up, "More reasoning"); } else {
session.push(hints.toggle_voice, "Voice"); "Send message"
session.push(hints.agents, "Agents (empty prompt)"); },
session.entries.push(Shortcut {
key: format!(
"{} / {}",
previous_agent_shortcut().display_label(),
next_agent_shortcut().display_label()
), ),
action: "Subagents (empty prompt)", Entry::hint(
}); props
session.push(hints.focus_activity, "Inspect activity"); .collaboration_modes_enabled
session.entries.push(Shortcut::new( .then_some(key_hint::shift(K::Tab).into()),
key_hint::ctrl(KeyCode::Char('c')), "Change mode",
if props.is_task_running { ),
"Interrupt" Entry::hint(hints.reasoning_down, "Less reasoning"),
} else { Entry::hint(hints.reasoning_up, "More reasoning"),
"Quit" Entry::hint(hints.toggle_voice, "Voice"),
}, Entry::hint(hints.agents, "Agents (empty prompt)"),
)); Entry::pair(
"Subagents (empty prompt)",
[previous_agent_shortcut(), next_agent_shortcut()],
),
Entry::hint(hints.focus_activity, "Inspect activity"),
Entry::key(
key_hint::ctrl(K::Char('c')),
if props.is_task_running {
"Interrupt"
} else {
"Quit"
},
),
];
let mut transcript = Group { let alt_shift = KeyModifiers::ALT | KeyModifiers::SHIFT;
title: "Transcript (open first)", let transcript = vec![
entries: Vec::new(), Entry::hint(hints.show_transcript, "Open transcript"),
}; Entry::hint(hints.find_transcript, "Find text"),
transcript.push(hints.show_transcript, "Open transcript"); Entry::pair(
transcript.push(hints.find_transcript, "Find text"); "Scroll",
transcript.entries.extend([ [key_hint::plain(K::PageUp), key_hint::plain(K::PageDown)],
Shortcut { ),
key: "pgup / pgdn".into(), Entry::key(key_hint::ctrl(K::Char(' ')), "Start selection"),
action: "Scroll", Entry::pair(
}, "Top / latest",
Shortcut::new(key_hint::ctrl(KeyCode::Char(' ')), "Start selection"), [key_hint::ctrl(K::Home), key_hint::ctrl(K::End)],
Shortcut { ),
key: format!(
"{} / {}",
key_hint::ctrl(KeyCode::Home).display_label(),
key_hint::ctrl(KeyCode::End).display_label()
),
action: "Top / latest",
},
// Keep both jump alternatives: the terminal may be on another OS over SSH. // Keep both jump alternatives: the terminal may be on another OS over SSH.
Shortcut { Entry::pair(
key: format!( "Top / latest",
"{} / {}", [
key_hint::KeyBinding::new( KeyBinding::new(K::Char(','), alt_shift),
KeyCode::Char(','), KeyBinding::new(K::Char('.'), alt_shift),
KeyModifiers::ALT | KeyModifiers::SHIFT ],
) ),
.display_label(), ];
key_hint::KeyBinding::new(
KeyCode::Char('.'),
KeyModifiers::ALT | KeyModifiers::SHIFT
)
.display_label()
),
action: "Top / latest",
},
]);
[
("Compose", compose),
("Session", session),
("Transcript (open first)", transcript),
]
}
pub(super) fn lines(props: &FooterProps, width: u16) -> Vec<Line<'static>> {
let groups = bindings(props).map(|(title, entries)| {
let entries = entries
.into_iter()
.filter_map(|entry| {
let mut keys = entry
.keys
.into_iter()
.flatten()
.map(ShortcutHint::display_label);
let key = keys.next()?;
Some(Shortcut {
key: if entry.repeat_label {
format!("{key} {key}")
} else if let Some(alternative) = keys.next() {
format!("{key} / {alternative}")
} else {
key
},
action: entry.action,
})
})
.collect();
Group { title, entries }
});
let mut result = vec![Line::from("Keyboard shortcuts").bold(), Line::default()]; let mut result = vec![Line::from("Keyboard shortcuts").bold(), Line::default()];
result.extend(crate::shortcut_help::group_lines( result.extend(crate::shortcut_help::group_lines(groups, width));
[compose, session, transcript],
width,
));
let width = usize::from(width.max(/*other*/ 1)); let width = usize::from(width.max(/*other*/ 1));
result.push(Line::default()); result.push(Line::default());
result.extend(footer_lines(width)); result.extend(footer_lines(width));