Vectrum
Sign In

Composer shortcuts as real key bindings; Esc gives focus back

Ctrl+Shift+M/E/A/G/X/H/L, Ctrl+S and Ctrl+F are now actions in the
keymap, so the Keybindings page lists them with T3 Code's names (and its
table now matches the app's actions). Ctrl+F wins over the text field's own
search. Closing the find bar or a picker with Esc, or a picker with a
click outside, puts the focus back in the composer, so shortcuts keep
working afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 1efac74commitd597e54

Showing 3 changed files with 89 additions and 57 deletions.

+21−0src/main.rsView File
@@ -57,6 +57,16 @@ gpui_kit::actions!(
5757 ToggleRightPanel,
5858 SelectTheme,
5959 CycleAppearance,
60+ // The composer's, in the open thread.
61+ ToggleModelPicker,
62+ ComposerEffort,
63+ ComposerMode,
64+ ComposerBranch,
65+ ComposerWorkspace,
66+ ComposerHost,
67+ ComposerPreviousWorktree,
68+ ComposerStash,
69+ FindInThread,
6070 // Keys for lists with a text field: the palette, sidebar search, the
6171 // project filter, and inline renames.
6272 SelectPrevious,
@@ -112,6 +122,17 @@ fn main() {
112122 KeyBinding::new("ctrl-,", OpenSettings, None),
113123 KeyBinding::new("ctrl-q", Quit, None),
114124 KeyBinding::new("escape", Interrupt, Some("ChatView")),
125+ KeyBinding::new("ctrl-shift-m", ToggleModelPicker, Some("ChatView")),
126+ KeyBinding::new("ctrl-shift-e", ComposerEffort, Some("ChatView")),
127+ KeyBinding::new("ctrl-shift-a", ComposerMode, Some("ChatView")),
128+ KeyBinding::new("ctrl-shift-g", ComposerBranch, Some("ChatView")),
129+ KeyBinding::new("ctrl-shift-x", ComposerWorkspace, Some("ChatView")),
130+ KeyBinding::new("ctrl-shift-h", ComposerHost, Some("ChatView")),
131+ KeyBinding::new("ctrl-shift-l", ComposerPreviousWorktree, Some("ChatView")),
132+ KeyBinding::new("ctrl-s", ComposerStash, Some("ChatView")),
133+ KeyBinding::new("ctrl-f", FindInThread, Some("ChatView")),
134+ // The composer's text field binds Ctrl+F to its own search.
135+ KeyBinding::new("ctrl-f", FindInThread, Some("ChatView > Input")),
115136 KeyBinding::new("escape", CloseSettings, Some("SettingsView")),
116137 // Lists with a text field.
117138 KeyBinding::new("up", SelectPrevious, Some("CommandPalette > Input")),
+40−53src/ui/chat.rsView File
@@ -26,7 +26,10 @@ use gpui_kit::component::{
2626 use gpui_kit::prelude::FluentBuilder as _;
2727 use gpui_kit::*;
2828
29- use crate::Interrupt;
29+ use crate::{
30+ ComposerBranch, ComposerEffort, ComposerHost, ComposerMode, ComposerPreviousWorktree, ComposerStash, ComposerWorkspace,
31+ FindInThread, Interrupt, ToggleModelPicker,
32+ };
3033 use crate::clock;
3134 use crate::git;
3235 use crate::models::{Catalog, ModelEntry, SlashCommand};
@@ -547,32 +550,16 @@ impl ChatView {
547550 }
548551 }
549552
550- /// T3 Code's composer shortcuts: Ctrl+Shift+M (model), E (effort),
551- /// A (access), G (branch), X (workspace), L (previous worktree) and
552- /// H (machine).
553- fn composer_shortcut(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
554- let keystroke = &event.keystroke;
555- let modifiers = &keystroke.modifiers;
556- if !(modifiers.control && modifiers.shift) || modifiers.alt || event.is_held {
557- return false;
558- }
553+ /// Opens (or closes) one of the composer's controls, as T3 Code's
554+ /// composer shortcuts do; a control folded into "…" opens that menu.
555+ fn open_control(&mut self, picker: Picker, window: &mut Window, cx: &mut Context<Self>) {
559556 let (traits_folded, access_folded) = self.folded.get();
560- let strip = self.strip_visible(cx);
561- let draft = self.session.read(cx).items.is_empty();
562- let picker = match keystroke.key.as_str() {
563- "m" => Picker::Model,
564- "e" if traits_folded => Picker::Overflow,
565- "e" => Picker::Traits,
566- "a" if access_folded => Picker::Overflow,
567- "a" => Picker::Access,
568- "g" if strip => Picker::Branch,
569- "x" if strip && draft => Picker::Workspace,
570- "h" if strip && draft => Picker::Environment,
571- "l" if draft => {
572- self.use_previous_worktree(window, cx);
573- return true;
574- }
575- _ => return false,
557+ let picker = match picker {
558+ Picker::Traits if traits_folded => Picker::Overflow,
559+ Picker::Access if access_folded => Picker::Overflow,
560+ Picker::Branch | Picker::Workspace | Picker::Environment if !self.strip_visible(cx) => return,
561+ Picker::Workspace | Picker::Environment if !self.session.read(cx).items.is_empty() => return,
562+ picker => picker,
576563 };
577564 self.expand(cx);
578565 let open = self.open_picker != Some(picker);
@@ -580,7 +567,6 @@ impl ChatView {
580567 if !open {
581568 self.focus_composer(window, cx);
582569 }
583- true
584570 }
585571
586572 /// Points a new thread at the project's previous worktree (Ctrl+Shift+L).
@@ -721,19 +707,7 @@ impl ChatView {
721707 }
722708
723709 /// 1–9 pick an option of the open question while the composer is empty.
724- fn pick_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
725- if self.composer_shortcut(event, window, cx) {
726- cx.stop_propagation();
727- return;
728- }
729- let modifiers = &event.keystroke.modifiers;
730- if event.keystroke.key == "f" && modifiers.control && !modifiers.shift && !modifiers.alt {
731- if !self.session.read(cx).items.is_empty() && self.diff.is_none() {
732- cx.stop_propagation();
733- self.open_find(window, cx);
734- }
735- return;
736- }
710+ fn pick_with_key(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
737711 let keystroke = &event.keystroke;
738712 if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() {
739713 return;
@@ -977,11 +951,7 @@ impl ChatView {
977951 cx.stop_propagation();
978952 return;
979953 }
980- if keystroke.key == "s" && modifiers.control && !modifiers.shift && !modifiers.alt {
981- cx.stop_propagation();
982- self.stash_prompt(window, cx);
983- return;
984- }
954+
985955 if keystroke.key == "enter" && modifiers.control && modifiers.shift {
986956 cx.stop_propagation();
987957 let queued = !self.session.read(cx).queue.is_empty();
@@ -1144,8 +1114,10 @@ impl ChatView {
11441114 .into_any_element()
11451115 }
11461116
1147- fn escape(&mut self, cx: &mut Context<Self>) {
1148- if self.find.take().is_some() {
1117+ fn escape(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1118+ // Closing the find bar or a picker hands the keyboard back to the composer.
1119+ if self.find.take().is_some() || self.open_picker.take().is_some() {
1120+ self.focus_composer(window, cx);
11491121 cx.notify();
11501122 return;
11511123 }
@@ -1159,10 +1131,6 @@ impl ChatView {
11591131 cx.notify();
11601132 return;
11611133 }
1162- if self.open_picker.take().is_some() {
1163- cx.notify();
1164- return;
1165- }
11661134 if self.diff.take().is_some() {
11671135 cx.notify();
11681136 return;
@@ -1290,6 +1258,8 @@ impl ChatView {
12901258 }
12911259 } else if self.open_picker == Some(picker) {
12921260 self.open_picker = None;
1261+ // A picker closed with Esc or a click outside leaves no focus.
1262+ self.focus_composer(window, cx);
12931263 }
12941264 cx.notify();
12951265 }
@@ -1902,8 +1872,25 @@ impl Render for ChatView {
19021872 v_flex()
19031873 .key_context("ChatView")
19041874 .size_full()
1905- .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
1906- .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx)))
1875+ .on_action(cx.listener(|this, _: &Interrupt, window, cx| this.escape(window, cx)))
1876+ .on_action(cx.listener(|this, _: &ToggleModelPicker, window, cx| this.open_control(Picker::Model, window, cx)))
1877+ .on_action(cx.listener(|this, _: &ComposerEffort, window, cx| this.open_control(Picker::Traits, window, cx)))
1878+ .on_action(cx.listener(|this, _: &ComposerMode, window, cx| this.open_control(Picker::Access, window, cx)))
1879+ .on_action(cx.listener(|this, _: &ComposerBranch, window, cx| this.open_control(Picker::Branch, window, cx)))
1880+ .on_action(cx.listener(|this, _: &ComposerWorkspace, window, cx| this.open_control(Picker::Workspace, window, cx)))
1881+ .on_action(cx.listener(|this, _: &ComposerHost, window, cx| this.open_control(Picker::Environment, window, cx)))
1882+ .on_action(cx.listener(|this, _: &ComposerPreviousWorktree, window, cx| {
1883+ if this.session.read(cx).items.is_empty() {
1884+ this.use_previous_worktree(window, cx);
1885+ }
1886+ }))
1887+ .on_action(cx.listener(|this, _: &ComposerStash, window, cx| this.stash_prompt(window, cx)))
1888+ .on_action(cx.listener(|this, _: &FindInThread, window, cx| {
1889+ if !this.session.read(cx).items.is_empty() && this.diff.is_none() {
1890+ this.open_find(window, cx);
1891+ }
1892+ }))
1893+ .on_action(cx.listener(|this, _: &input::Escape, window, cx| this.escape(window, cx)))
19071894 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.pick_with_key(event, window, cx)))
19081895 .map(|this| match &self.diff {
19091896 Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))),
+28−4src/ui/settings/keybindings.rsView File
@@ -25,24 +25,48 @@ struct Command {
2525
2626 const COMMANDS: &[Command] = &[
2727 Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] },
28- Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
28+ Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] },
29+ Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
2930 Command {
3031 id: "commandPalette.toggle",
3132 label: "Command Palette: Toggle",
3233 actions: &["vectrum_code::FocusSearch", "vectrum_code::ToggleCommandPalette"],
3334 },
34- Command { id: "projectSearch.toggle", label: "Project Search: Toggle", actions: &["vectrum_code::ProjectSearch"] },
35+ Command { id: "editor.openFavorite", label: "Editor: Open Favorite", actions: &["vectrum_code::OpenInEditor"] },
3536 Command { id: "chat.new", label: "Chat: New", actions: &["vectrum_code::NewThread"] },
36- Command { id: "chat.newLocal", label: "Chat: New Local", actions: &["vectrum_code::NewLocalThread"] },
37+ Command { id: "chat.newLocal", label: "Chat: New Local", actions: &["vectrum_code::NewThreadLocal"] },
38+ Command {
39+ id: "chat.newWithoutProject",
40+ label: "Chat: New Without Project",
41+ actions: &["vectrum_code::NewThreadWithoutProject"],
42+ },
3743 Command { id: "chat.find", label: "Chat: Find", actions: &["vectrum_code::FindInThread"] },
3844 Command { id: "thread.stop", label: "Thread: Stop", actions: &["vectrum_code::Interrupt"] },
3945 Command { id: "thread.previous", label: "Thread: Previous", actions: &["vectrum_code::PreviousThread"] },
4046 Command { id: "thread.next", label: "Thread: Next", actions: &["vectrum_code::NextThread"] },
4147 Command { id: "thread.settle", label: "Thread: Settle", actions: &["vectrum_code::SettleThread"] },
4248 Command { id: "thread.pin", label: "Thread: Pin", actions: &["vectrum_code::PinThread"] },
49+ Command { id: "thread.undo", label: "Thread: Undo", actions: &["vectrum_code::UndoThreadAction"] },
50+ Command {
51+ id: "thread.copyReference",
52+ label: "Pull Request: Copy Link or Thread ID",
53+ actions: &["vectrum_code::CopyThreadReference"],
54+ },
4355 Command { id: "modelPicker.toggle", label: "Model Picker: Toggle", actions: &["vectrum_code::ToggleModelPicker"] },
56+ Command { id: "composer.effort", label: "Composer: Effort", actions: &["vectrum_code::ComposerEffort"] },
57+ Command { id: "composer.mode", label: "Composer: Mode", actions: &["vectrum_code::ComposerMode"] },
58+ Command { id: "composer.host", label: "Composer: Host", actions: &["vectrum_code::ComposerHost"] },
59+ Command { id: "composer.workspace", label: "Composer: Workspace", actions: &["vectrum_code::ComposerWorkspace"] },
60+ Command { id: "composer.branch", label: "Composer: Branch", actions: &["vectrum_code::ComposerBranch"] },
61+ Command {
62+ id: "composer.previousWorktree",
63+ label: "Composer: Previous Worktree",
64+ actions: &["vectrum_code::ComposerPreviousWorktree"],
65+ },
66+ Command { id: "composer.stash", label: "Composer: Stash", actions: &["vectrum_code::ComposerStash"] },
4467 Command { id: "preview.toggle", label: "Preview: Toggle", actions: &["vectrum_code::ToggleBrowser"] },
45- Command { id: "diff.toggle", label: "Diff: Toggle", actions: &["vectrum_code::ToggleDiff"] },
68+ Command { id: "theme.select", label: "Theme: Select", actions: &["vectrum_code::SelectTheme"] },
69+ Command { id: "appearance.cycle", label: "Appearance: Cycle", actions: &["vectrum_code::CycleAppearance"] },
4670 Command { id: "usage.open", label: "Usage: Open", actions: &["vectrum_code::OpenUsage"] },
4771 Command { id: "project.add", label: "Project: Add", actions: &["vectrum_code::OpenProject"] },
4872 Command { id: "settings.open", label: "Settings: Toggle", actions: &["vectrum_code::OpenSettings"] },