//! The Keybindings page, laid out like T3 Code's: a search field, then the
//! bindings in T3 Code's groups (Navigation, Threads, Composer, …), each
//! row the command, when it applies and its keys. The rows come from the
//! app's live keymap, so they show whatever is bound; keys the composer
//! handles itself are listed from the send shortcut setting.
use gpui_kit::component::{h_flex, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::search::Item;
use super::widgets::{Section, SettingRow, empty_row, search_field};
use super::{Page, SettingsView};
use crate::settings::{self, QuitShortcut, SendShortcut};
use crate::ui::metal;
/// A command the app binds, as T3 Code names it.
struct Command {
/// T3 Code's command id, for the group and the tooltip.
id: &'static str,
label: &'static str,
/// The GPUI actions that run it.
actions: &'static [&'static str],
}
const COMMANDS: &[Command] = &[
Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] },
Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] },
Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
Command {
id: "commandPalette.toggle",
label: "Command Palette: Toggle",
actions: &["vectrum_code::FocusSearch", "vectrum_code::ToggleCommandPalette"],
},
Command { id: "editor.openFavorite", label: "Editor: Open Favorite", actions: &["vectrum_code::OpenInEditor"] },
Command { id: "chat.new", label: "Chat: New", actions: &["vectrum_code::NewThread"] },
Command { id: "chat.newLocal", label: "Chat: New Local", actions: &["vectrum_code::NewThreadLocal"] },
Command {
id: "chat.newWithoutProject",
label: "Chat: New Without Project",
actions: &["vectrum_code::NewThreadWithoutProject"],
},
Command { id: "chat.find", label: "Chat: Find", actions: &["vectrum_code::FindInThread"] },
Command { id: "thread.stop", label: "Thread: Stop", actions: &["vectrum_code::Interrupt"] },
Command { id: "thread.previous", label: "Thread: Previous", actions: &["vectrum_code::PreviousThread"] },
Command { id: "thread.next", label: "Thread: Next", actions: &["vectrum_code::NextThread"] },
Command { id: "thread.settle", label: "Thread: Settle", actions: &["vectrum_code::SettleThread"] },
Command { id: "thread.pin", label: "Thread: Pin", actions: &["vectrum_code::PinThread"] },
Command { id: "thread.undo", label: "Thread: Undo", actions: &["vectrum_code::UndoThreadAction"] },
Command {
id: "thread.copyReference",
label: "Pull Request: Copy Link or Thread ID",
actions: &["vectrum_code::CopyThreadReference"],
},
Command { id: "terminal.toggle", label: "Terminal: Toggle", actions: &["vectrum_code::ToggleTerminal"] },
Command { id: "terminal.split", label: "Terminal: Split", actions: &["vectrum_code::SplitTerminal"] },
Command {
id: "terminal.splitVertical",
label: "Terminal: Split Vertical",
actions: &["vectrum_code::SplitTerminalVertical"],
},
Command { id: "terminal.new", label: "Terminal: New", actions: &["vectrum_code::NewTerminal"] },
Command { id: "terminal.close", label: "Terminal: Close", actions: &["vectrum_code::CloseTerminal"] },
Command { id: "terminal.copy", label: "Terminal: Copy", actions: &["vectrum_code::TerminalCopy"] },
Command { id: "terminal.paste", label: "Terminal: Paste", actions: &["vectrum_code::TerminalPaste"] },
Command { id: "modelPicker.toggle", label: "Model Picker: Toggle", actions: &["vectrum_code::ToggleModelPicker"] },
Command { id: "composer.effort", label: "Composer: Effort", actions: &["vectrum_code::ComposerEffort"] },
Command { id: "composer.mode", label: "Composer: Mode", actions: &["vectrum_code::ComposerMode"] },
Command { id: "composer.host", label: "Composer: Host", actions: &["vectrum_code::ComposerHost"] },
Command { id: "composer.workspace", label: "Composer: Workspace", actions: &["vectrum_code::ComposerWorkspace"] },
Command { id: "composer.branch", label: "Composer: Branch", actions: &["vectrum_code::ComposerBranch"] },
Command {
id: "composer.previousWorktree",
label: "Composer: Previous Worktree",
actions: &["vectrum_code::ComposerPreviousWorktree"],
},
Command { id: "composer.stash", label: "Composer: Stash", actions: &["vectrum_code::ComposerStash"] },
Command { id: "preview.toggle", label: "Preview: Toggle", actions: &["vectrum_code::ToggleBrowser"] },
Command { id: "theme.select", label: "Theme: Select", actions: &["vectrum_code::SelectTheme"] },
Command { id: "appearance.cycle", label: "Appearance: Cycle", actions: &["vectrum_code::CycleAppearance"] },
Command { id: "usage.open", label: "Usage: Open", actions: &["vectrum_code::OpenUsage"] },
Command { id: "project.add", label: "Project: Add", actions: &["vectrum_code::OpenProject"] },
Command { id: "settings.open", label: "Settings: Toggle", actions: &["vectrum_code::OpenSettings"] },
Command { id: "settings.close", label: "Settings: Close", actions: &["vectrum_code::CloseSettings"] },
Command { id: "app.quit", label: "App: Quit", actions: &["vectrum_code::Quit"] },
];
/// Keys the composer and pickers handle themselves rather than through the
/// keymap.
const COMPOSER_COMMANDS: &[(&str, &str)] = &[
("composer.send", "Composer: Send"),
("composer.newLine", "Composer: New Line"),
("composer.sendAlternate", "Composer: Opposite Queue or Steer Action"),
("composer.slashMenu", "Composer: Commands and Skills"),
("composer.skills", "Composer: Skills"),
("modelPicker.jump", "Model Picker: Jump"),
];
/// T3 Code's groups, in page order; a command joins the first group
/// listing its id's prefix.
const GROUPS: &[(&str, &[&str])] = &[
("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
("Threads", &["thread", "chat", "pullRequest"]),
("Composer", &["composer", "modelPicker"]),
("Terminal", &["terminal"]),
("Preview & diff", &["preview", "diff"]),
("Appearance", &["theme", "appearance", "themeEditor"]),
("Project scripts", &["script"]),
];
fn group_of(id: &str) -> &'static str {
let prefix = id.split('.').next().unwrap_or_default();
GROUPS.iter().find(|(_, prefixes)| prefixes.contains(&prefix)).map_or("Other", |(title, _)| title)
}
/// The search results for the commands, after every other setting.
pub(super) fn search_items() -> impl Iterator<Item = Item> {
COMMANDS.iter().map(|command| (command.id, command.label)).chain(COMPOSER_COMMANDS.iter().copied()).map(|(id, label)| Item {
id: "keybindings",
title: label,
page: Page::Keybindings,
terms: id,
fold: None,
secondary: true,
fallback: None,
})
}
/// `vectrum_code::ToggleCommandPalette` → `Toggle Command Palette`.
fn title_case(action: &str) -> String {
let name = action.rsplit("::").next().unwrap_or(action);
let mut words = String::new();
for (ix, c) in name.chars().enumerate() {
if ix > 0 && c.is_uppercase() {
words.push(' ');
}
words.push(c);
}
words
}
/// What a binding's context means, in T3 Code's when-clause words.
fn when_text(context: &str) -> String {
context
.replace("ChatView", "composerFocus")
.replace("SettingsView", "settingsOpen")
.replace("Terminal", "terminalFocus")
.replace("Workspace", "always")
}
/// One row on the page.
struct Binding {
id: String,
label: String,
when: String,
keys: Vec<Vec<String>>,
}
impl SettingsView {
pub(super) fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let query = self.keybinding_search.read(cx).value().trim().to_lowercase();
let mut bindings = keymap_bindings(cx);
bindings.extend(composer_bindings());
bindings.sort_by(|a, b| a.label.cmp(&b.label));
let shown: Vec<Binding> = bindings
.into_iter()
.filter(|binding| {
query.is_empty()
|| binding.label.to_lowercase().contains(&query)
|| binding.when.to_lowercase().contains(&query)
|| binding.id.to_lowercase().contains(&query)
|| binding.keys.iter().any(|keys| keys.join("+").to_lowercase().contains(&query))
})
.collect();
let toolbar = h_flex()
.px(px(16.))
.py(px(12.))
.gap(px(8.))
.child(search_field(&self.keybinding_search, 0., cx).flex_1())
.into_any_element();
let mut pages = vec![
Section::new("Keybindings")
.id("keybindings")
.row(toolbar)
.when(shown.is_empty(), |section| section.row(empty_row("No keybindings match your search.", p)))
.render(cx),
];
let mut groups: Vec<&'static str> = GROUPS.iter().map(|(title, _)| *title).collect();
groups.push("Other");
for group in groups {
let rows: Vec<AnyElement> = shown
.iter()
.filter(|binding| group_of(&binding.id) == group)
.map(|binding| binding_row(binding, cx))
.collect();
if !rows.is_empty() {
pages.push(Section::new(group).rows(rows).render(cx));
}
}
pages
}
}
/// The app's own bindings in the live keymap, one row per command and
/// context, with every key that runs it.
fn keymap_bindings(cx: &App) -> Vec<Binding> {
let keymap = cx.key_bindings();
let keymap = keymap.borrow();
let mut rows: Vec<Binding> = Vec::new();
for binding in keymap.bindings() {
let action = binding.action().name();
if !action.starts_with("vectrum_code::") {
continue;
}
let command = COMMANDS.iter().find(|command| command.actions.contains(&action));
let (id, label) = match command {
Some(command) => (command.id.to_string(), command.label.to_string()),
None => (format!("app.{action}"), title_case(action)),
};
let when = binding.predicate().map(|predicate| when_text(&predicate.to_string())).unwrap_or_else(|| "Always".into());
let keys: Vec<String> = binding
.keystrokes()
.iter()
.flat_map(|keystroke| {
let modifiers = keystroke.modifiers();
let key = &keystroke.inner().key;
let mut caps = Vec::new();
if modifiers.control {
caps.push("Ctrl".to_string());
}
if modifiers.alt {
caps.push("Alt".to_string());
}
if modifiers.shift {
caps.push("Shift".to_string());
}
if modifiers.platform {
caps.push("Super".to_string());
}
caps.push(key_label(key));
caps
})
.collect();
match rows.iter_mut().find(|row| row.id == id && row.when == when) {
Some(row) => {
if !row.keys.contains(&keys) {
row.keys.push(keys);
}
}
None => rows.push(Binding { id, label, when, keys: vec![keys] }),
}
}
let hold = settings::get(|settings| settings.quit_shortcut);
if let Some(quit) = rows.iter_mut().find(|row| row.id == "app.quit") {
quit.label = match hold {
QuitShortcut::Press => "App: Quit",
QuitShortcut::Hold => "App: Quit (hold, or press twice)",
QuitShortcut::Double => "App: Quit (press twice)",
}
.into();
}
rows
}
/// `escape` → `Esc`, `,` → `,`, `n` → `N`.
fn key_label(key: &str) -> String {
match key {
"escape" => "Esc".into(),
"enter" => "Enter".into(),
"space" => "Space".into(),
"tab" => "Tab".into(),
"backspace" => "Backspace".into(),
"up" => "↑".into(),
"down" => "↓".into(),
"left" => "←".into(),
"right" => "→".into(),
key if key.chars().count() == 1 => key.to_uppercase(),
key => {
let mut chars = key.chars();
chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
}
}
}
/// The composer's keys, which follow the send shortcut setting.
fn composer_bindings() -> Vec<Binding> {
let send = settings::get(|settings| settings.send_shortcut);
let caps = |keys: &[&str]| keys.iter().map(|key| key.to_string()).collect::<Vec<_>>();
let row = |id: &str, label: &str, when: &str, keys: Vec<Vec<String>>| Binding {
id: id.into(),
label: label.into(),
when: when.into(),
keys,
};
let (send_keys, new_line, opposite) = match send {
SendShortcut::Enter => (vec![caps(&["Enter"])], vec![caps(&["Shift", "Enter"])], caps(&["Ctrl", "Enter"])),
SendShortcut::ModEnterMultiline => (
vec![caps(&["Enter"]), caps(&["Ctrl", "Enter"])],
vec![caps(&["Shift", "Enter"])],
caps(&["Ctrl", "Shift", "Enter"]),
),
SendShortcut::ModEnter => (vec![caps(&["Ctrl", "Enter"])], vec![caps(&["Enter"])], caps(&["Ctrl", "Shift", "Enter"])),
};
vec![
row("composer.send", "Composer: Send", "composerFocus", send_keys),
row("composer.newLine", "Composer: New Line", "composerFocus", new_line),
row("composer.sendAlternate", "Composer: Opposite Queue or Steer Action", "composerFocus && turnRunning", vec![opposite]),
row("composer.slashMenu", "Composer: Commands and Skills", "composerFocus", vec![caps(&["/"])]),
row("composer.skills", "Composer: Skills", "composerFocus", vec![caps(&["$"])]),
row("modelPicker.jump", "Model Picker: Jump", "modelPickerOpen", vec![caps(&["Ctrl", "1–9"])]),
]
}
fn binding_row(binding: &Binding, cx: &App) -> AnyElement {
let p = metal::palette(cx);
let id = SharedString::from(binding.id.clone());
let when = binding.when.clone();
let keys = h_flex().gap(px(8.)).children(binding.keys.iter().map(|keys| {
h_flex().gap(px(3.)).children(keys.iter().map(|key| metal::keycap(key.clone(), p)))
}));
let title = div()
.id(SharedString::from(format!("keybinding-{}-{}", binding.id, binding.when)))
.tooltip(move |window, cx| Tooltip::new(id.clone()).build(window, cx))
.child(binding.label.clone());
SettingRow::new("")
.title_suffix(title.text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink))
.status_element(
h_flex()
.gap(px(6.))
.child("When")
.child(
div()
.px(px(6.))
.rounded(px(6.))
.bg(p.ink.opacity(0.05))
.font_family(metal::MONO)
.text_size(px(11.))
.text_color(p.ink2)
.child(when),
),
)
.control(v_flex().items_end().child(keys))
.into_any_element()
}