Vectrum
Sign In

Composer shortcuts and a prompt stash, like T3 Code

Ctrl+Shift+M opens the model picker, Ctrl+Shift+E the traits menu,
Ctrl+Shift+A the access menu (or "…" when they folded into it), and on a
new thread Ctrl+Shift+G the branch picker, Ctrl+Shift+X the workspace menu,
Ctrl+Shift+H the machine menu and Ctrl+Shift+L the previous worktree.

Ctrl+S puts the composer's prompt aside. A Stash tab docks beside the
composer's banners; it opens the stashed prompts, newest first, to restore
(arrows and Enter) or delete (Ctrl+Backspace).

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

Showing 4 changed files with 303 additions and 11 deletions.

+185−2src/ui/chat.rsView File
@@ -100,6 +100,32 @@ impl Notice {
100100 }
101101 }
102102
103+ /// A prompt put aside with Ctrl+S.
104+ struct Stashed {
105+ text: String,
106+ attachments: Vec<PathBuf>,
107+ at: SystemTime,
108+ }
109+
110+ impl Stashed {
111+ /// The stash menu's line for it: its start, or what it carries.
112+ fn snippet(&self) -> String {
113+ let text = self.text.split_whitespace().collect::<Vec<_>>().join(" ");
114+ if !text.is_empty() {
115+ let mut snippet: String = text.chars().take(90).collect();
116+ if text.chars().count() > 90 {
117+ snippet.push('…');
118+ }
119+ return snippet;
120+ }
121+ match self.attachments.len() {
122+ 0 => "(empty)".into(),
123+ 1 => "(1 file)".into(),
124+ n => format!("({n} files)"),
125+ }
126+ }
127+ }
128+
103129 /// What the `/`, `$` or `@` menu offers for the word at the cursor.
104130 struct CommandMenu {
105131 /// `/`, `$` or `@`.
@@ -142,6 +168,12 @@ pub struct ChatView {
142168 /// The thread folder's files, for the `@` menu, once read.
143169 files: Option<(PathBuf, Vec<String>)>,
144170 files_task: Task<()>,
171+ /// Prompts put aside with Ctrl+S, oldest first.
172+ stash: Vec<Stashed>,
173+ stash_open: bool,
174+ stash_selected: usize,
175+ /// When a prompt was last stashed, for the tab's brief pulse.
176+ stash_pulse: Option<std::time::Instant>,
145177 queue_expanded: bool,
146178 /// The queued message being edited in the composer.
147179 editing_queued: Option<usize>,
@@ -150,6 +182,8 @@ pub struct ChatView {
150182 /// The composer card's width, measured as it's laid out, so the footer
151183 /// knows how much fits.
152184 composer_width: Pixels,
185+ /// Whether the traits and access controls folded into "…" last frame.
186+ folded: std::cell::Cell<(bool, bool)>,
153187 /// The composer rests on one line while the conversation scrolls.
154188 collapsed: bool,
155189 _subscriptions: Vec<Subscription>,
@@ -195,6 +229,7 @@ impl ChatView {
195229 }
196230 InputEvent::Change => {
197231 this.menu_selected = 0;
232+ this.stash_open = false;
198233 this.sync_custom_answer(cx);
199234 this.expand(cx);
200235 this.load_files_if_mentioning(cx);
@@ -261,11 +296,16 @@ impl ChatView {
261296 menu_dismissed: None,
262297 files: None,
263298 files_task: Task::ready(()),
299+ stash: Vec::new(),
300+ stash_open: false,
301+ stash_selected: 0,
302+ stash_pulse: None,
264303 queue_expanded: true,
265304 editing_queued: None,
266305 notices_expanded: false,
267306 tasks_expanded: false,
268307 composer_width: px(0.),
308+ folded: Default::default(),
269309 collapsed: false,
270310 _subscriptions: subscriptions,
271311 _ticker: ticker,
@@ -447,8 +487,137 @@ impl ChatView {
447487 }
448488 }
449489
490+ /// T3 Code's composer shortcuts: Ctrl+Shift+M (model), E (effort),
491+ /// A (access), G (branch), X (workspace), L (previous worktree) and
492+ /// H (machine).
493+ fn composer_shortcut(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
494+ let keystroke = &event.keystroke;
495+ let modifiers = &keystroke.modifiers;
496+ if !(modifiers.control && modifiers.shift) || modifiers.alt || event.is_held {
497+ return false;
498+ }
499+ let (traits_folded, access_folded) = self.folded.get();
500+ let strip = self.strip_visible(cx);
501+ let draft = self.session.read(cx).items.is_empty();
502+ let picker = match keystroke.key.as_str() {
503+ "m" => Picker::Model,
504+ "e" if traits_folded => Picker::Overflow,
505+ "e" => Picker::Traits,
506+ "a" if access_folded => Picker::Overflow,
507+ "a" => Picker::Access,
508+ "g" if strip => Picker::Branch,
509+ "x" if strip && draft => Picker::Workspace,
510+ "h" if strip && draft => Picker::Environment,
511+ "l" if draft => {
512+ self.use_previous_worktree(window, cx);
513+ return true;
514+ }
515+ _ => return false,
516+ };
517+ self.expand(cx);
518+ let open = self.open_picker != Some(picker);
519+ self.set_picker(picker, open, window, cx);
520+ if !open {
521+ self.focus_composer(window, cx);
522+ }
523+ true
524+ }
525+
526+ /// Points a new thread at the project's previous worktree (Ctrl+Shift+L).
527+ fn use_previous_worktree(&mut self, window: &mut Window, cx: &mut Context<Self>) {
528+ let session = self.session.read(cx);
529+ let env = session.remote.as_ref().map(|remote| remote.env.clone());
530+ let previous = cx
531+ .try_global::<ProjectDirectory>()
532+ .and_then(|directory| directory.find(&session.project, env.as_deref()))
533+ .and_then(|project| project.previous_worktree.clone());
534+ if let Some(worktree) = previous {
535+ cx.emit(ChatEvent::SetWorkspace(WorkspaceMode::Local));
536+ self.session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx));
537+ self.focus_composer(window, cx);
538+ }
539+ }
540+
541+ /// Puts the composer's prompt aside (Ctrl+S), or with nothing to stash,
542+ /// opens the stash.
543+ fn stash_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
544+ let text = self.composer.read(cx).value().to_string();
545+ if self.session.read(cx).pending_ask().is_some() || text.trim().is_empty() && self.attachments.is_empty() {
546+ self.toggle_stash(cx);
547+ return;
548+ }
549+ self.stash.push(Stashed { text, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
550+ self.stash_pulse = Some(std::time::Instant::now());
551+ self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
552+ cx.notify();
553+ }
554+
555+ fn toggle_stash(&mut self, cx: &mut Context<Self>) {
556+ self.stash_open = !self.stash_open;
557+ self.stash_selected = self.stash.len().saturating_sub(1);
558+ cx.notify();
559+ }
560+
561+ /// Puts a stashed prompt back in the composer, stashing what's there.
562+ fn restore_stash(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
563+ if ix >= self.stash.len() {
564+ return;
565+ }
566+ let stashed = self.stash.remove(ix);
567+ let current = self.composer.read(cx).value().to_string();
568+ if !current.trim().is_empty() || !self.attachments.is_empty() {
569+ self.stash.push(Stashed { text: current, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
570+ }
571+ self.attachments = stashed.attachments;
572+ self.composer.update(cx, |composer, cx| {
573+ composer.set_value(stashed.text, window, cx);
574+ composer.focus(window, cx);
575+ });
576+ self.stash_open = false;
577+ cx.notify();
578+ }
579+
580+ fn delete_stash(&mut self, ix: usize, cx: &mut Context<Self>) {
581+ if ix < self.stash.len() {
582+ self.stash.remove(ix);
583+ self.stash_selected = self.stash_selected.min(self.stash.len().saturating_sub(1));
584+ cx.notify();
585+ }
586+ }
587+
588+ /// Keys the open stash takes: arrows, Enter to restore, Ctrl+Backspace
589+ /// to delete, Esc to close.
590+ fn stash_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
591+ if !self.stash_open {
592+ return false;
593+ }
594+ let count = self.stash.len();
595+ let modifiers = &event.keystroke.modifiers;
596+ match event.keystroke.key.as_str() {
597+ // Newest is listed first, so up moves to newer ones.
598+ "up" if count > 0 => self.stash_selected = (self.stash_selected + 1) % count,
599+ "down" if count > 0 => self.stash_selected = (self.stash_selected + count - 1) % count,
600+ "enter" if count > 0 => {
601+ let ix = self.stash_selected.min(count - 1);
602+ self.restore_stash(ix, window, cx);
603+ }
604+ "backspace" if modifiers.control && count > 0 => {
605+ let ix = self.stash_selected.min(count - 1);
606+ self.delete_stash(ix, cx);
607+ }
608+ "escape" => self.stash_open = false,
609+ _ => return false,
610+ }
611+ cx.notify();
612+ true
613+ }
614+
450615 /// 1–9 pick an option of the open question while the composer is empty.
451- fn pick_with_key(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) {
616+ fn pick_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
617+ if self.composer_shortcut(event, window, cx) {
618+ cx.stop_propagation();
619+ return;
620+ }
452621 let keystroke = &event.keystroke;
453622 if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() {
454623 return;
@@ -646,6 +815,15 @@ impl ChatView {
646815 fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
647816 let keystroke = &event.keystroke;
648817 let modifiers = &keystroke.modifiers;
818+ if self.stash_key(event, window, cx) {
819+ cx.stop_propagation();
820+ return;
821+ }
822+ if keystroke.key == "s" && modifiers.control && !modifiers.shift && !modifiers.alt {
823+ cx.stop_propagation();
824+ self.stash_prompt(window, cx);
825+ return;
826+ }
649827 if keystroke.key == "enter" && modifiers.control && modifiers.shift {
650828 cx.stop_propagation();
651829 let queued = !self.session.read(cx).queue.is_empty();
@@ -800,6 +978,11 @@ impl ChatView {
800978 }
801979
802980 fn escape(&mut self, cx: &mut Context<Self>) {
981+ if self.stash_open {
982+ self.stash_open = false;
983+ cx.notify();
984+ return;
985+ }
803986 if self.command_menu(cx).is_some() {
804987 self.menu_dismissed = Some(self.composer.read(cx).value().to_string());
805988 cx.notify();
@@ -1382,7 +1565,7 @@ impl Render for ChatView {
13821565 .size_full()
13831566 .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
13841567 .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx)))
1385- .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| this.pick_with_key(event, cx)))
1568+ .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.pick_with_key(event, window, cx)))
13861569 .map(|this| match &self.diff {
13871570 Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))),
13881571 None if is_empty => this.child(self.render_start(window, cx)),
+113−5src/ui/chat/dock.rsView File
@@ -115,9 +115,13 @@ impl ChatView {
115115 /// The docked surfaces, top to bottom. Each tucks under the next, the
116116 /// last under the composer.
117117 pub(super) fn render_dock(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
118- // The `/`, `$` and `@` menus cover everything else while open.
118+ // The `/`, `$` and `@` menus, and the stash, cover everything else
119+ // while open.
119120 if let Some(menu) = self.command_menu(cx) {
120- return vec![self.render_command_menu(menu, cx)];
121+ return vec![div().mx(px(composer::INSET)).child(self.render_command_menu(menu, cx)).into_any_element()];
122+ }
123+ if self.stash_open {
124+ return vec![div().mx(px(composer::INSET)).child(self.render_stash_menu(cx)).into_any_element()];
121125 }
122126
123127 let session = self.session.read(cx);
@@ -148,7 +152,111 @@ impl ChatView {
148152 if show_tasks {
149153 items.push(self.render_tasks(steps, !items.is_empty(), cx));
150154 }
151- items
155+ // The stash sits beside the others as a tab of its own.
156+ let stash = (!self.stash.is_empty() && approval.is_none()).then(|| self.render_stash_badge(cx));
157+ if items.is_empty() && stash.is_none() {
158+ return Vec::new();
159+ }
160+ vec![
161+ h_flex()
162+ .mx(px(composer::INSET))
163+ .items_end()
164+ .gap(px(4.))
165+ .child(v_flex().flex_1().min_w_0().children(items))
166+ .children(stash)
167+ .into_any_element(),
168+ ]
169+ }
170+
171+ /// The tab that opens the stash: "Stash" and how many prompts it holds.
172+ fn render_stash_badge(&self, cx: &mut Context<Self>) -> AnyElement {
173+ let p = metal::palette(cx);
174+ let pulsing = self.stash_pulse.is_some_and(|at| at.elapsed() < Duration::from_millis(1200));
175+ composer::banner(Tone::Neutral, Density::Comfortable, false, p)
176+ .flex_none()
177+ .child(
178+ composer::row()
179+ .id("stash-badge")
180+ .pr(px(4.))
181+ .cursor_pointer()
182+ .rounded(px(6.))
183+ .text_color(if pulsing { p.ink } else { p.ink3 })
184+ .hover(|style| style.text_color(p.ink))
185+ .tooltip({
186+ let count = self.stash.len();
187+ move |window, cx| Tooltip::new(format!("Stashed prompts: {count}. Open stash.")).build(window, cx)
188+ })
189+ .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx)))
190+ .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p))
191+ .child("Stash")
192+ .child(composer::count(self.stash.len().to_string(), p).when(pulsing, |this| this.text_color(p.green_ink))),
193+ )
194+ .into_any_element()
195+ }
196+
197+ /// The stashed prompts: each restores into the composer, or goes away.
198+ fn render_stash_menu(&self, cx: &mut Context<Self>) -> AnyElement {
199+ let p = metal::palette(cx);
200+ let selected = self.stash_selected.min(self.stash.len().saturating_sub(1));
201+ let header = composer::row()
202+ .id("stash-header")
203+ .cursor_pointer()
204+ .rounded(px(6.))
205+ .tooltip(|window, cx| Tooltip::new("Close stash").build(window, cx))
206+ .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx)))
207+ .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p))
208+ .child(composer::content().child(div().text_color(p.ink3).child("Stash")))
209+ .child(composer::count(self.stash.len().to_string(), p))
210+ .child(composer::toggle_icon(true, p));
211+ let rows: Vec<AnyElement> = self
212+ .stash
213+ .iter()
214+ .enumerate()
215+ .rev()
216+ .map(|(ix, stashed)| {
217+ composer::row()
218+ .id(("stashed", ix))
219+ .rounded(px(4.))
220+ .cursor_pointer()
221+ .when(ix == selected, |this| this.bg(p.menu_row_hover))
222+ .hover(|style| style.bg(p.menu_row_hover))
223+ .on_click(cx.listener(move |this, _, window, cx| this.restore_stash(ix, window, cx)))
224+ .child(composer::icon_cell(Some(Icon::new(IconName::FileText).size(px(12.))), p))
225+ .child(composer::content().text_color(p.ink2).child(div().flex_1().min_w_0().truncate().child(stashed.snippet())))
226+ .when(!stashed.attachments.is_empty(), |this| {
227+ this.child(
228+ h_flex()
229+ .gap(px(2.))
230+ .text_color(p.ink3)
231+ .child(Icon::new(IconName::File).size(px(12.)))
232+ .child(stashed.attachments.len().to_string()),
233+ )
234+ })
235+ .child(div().text_color(p.ink3).child(clock::when(stashed.at)))
236+ .child(
237+ composer::icon_button(("stashed-delete", ix), IconName::Close, "Delete stashed prompt")
238+ .on_click(cx.listener(move |this, _, _, cx| {
239+ cx.stop_propagation();
240+ this.delete_stash(ix, cx);
241+ })),
242+ )
243+ .into_any_element()
244+ })
245+ .collect();
246+ let empty = rows.is_empty();
247+ composer::banner(Tone::Neutral, Density::Default, false, p)
248+ .child(header)
249+ .child(v_flex().id("stash-list").max_h(px(384.)).overflow_y_scroll().children(rows))
250+ .when(empty, |this| {
251+ this.child(
252+ div()
253+ .px(px(28.))
254+ .py(px(8.))
255+ .text_color(p.ink3)
256+ .child("Nothing stashed yet. Press Ctrl+S with a prompt in the composer to stash it."),
257+ )
258+ })
259+ .into_any_element()
152260 }
153261
154262 /// Messages waiting for the turn to end: each can be dragged into
@@ -280,7 +388,7 @@ impl ChatView {
280388 let chat = chat.clone();
281389 div()
282390 .id("notice-peek")
283- .mx(px(composer::INSET + 8.))
391+ .mx(px(8.))
284392 .h(px(12.))
285393 .rounded_t(px(16.))
286394 .bg(metal::vertical((p.s_hi, p.s)))
@@ -296,7 +404,7 @@ impl ChatView {
296404 })
297405 });
298406 let stacked = expanded.then(|| {
299- v_flex().gap(px(8.)).pb(px(8.)).mx(px(composer::INSET)).children(rest.iter().enumerate().map(|(ix, notice)| {
407+ v_flex().gap(px(8.)).pb(px(8.)).children(rest.iter().enumerate().map(|(ix, notice)| {
300408 render_notice(&self.session, ("notice-stacked", ix), notice, p)
301409 .rounded(px(16.))
302410 .px(px(4.))
+2−0src/ui/chat/footer.rsView File
@@ -98,6 +98,8 @@ impl ChatView {
9898 let traits_ix = traits.then_some(0);
9999 let mode_ix = blocks.len() - 1;
100100 let shown = |ix: usize| ix < blocks.len() - layout.hidden;
101+ // What folded into "…", for the shortcuts that open these controls.
102+ self.folded.set((traits_ix.is_some_and(|ix| !shown(ix)), !shown(mode_ix)));
101103 let icon_only = |ix: usize| ix >= blocks.len() - layout.icon_only.min(blocks.len());
102104
103105 let model_picker = self.model_picker.clone();
+3−4src/ui/composer.rsView File
@@ -38,9 +38,9 @@ pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
3838 Rgba { r: lerp(a.r, b.r), g: lerp(a.g, b.g), b: lerp(a.b, b.b), a: lerp(a.a, b.a) }.into()
3939 }
4040
41- /// A surface docked on top of the composer: inset from its sides, rounded on
42- /// top, its bottom tucked under the composer (or under the next docked
43- /// surface). `joined` drops the rounded top where it sits under another
41+ /// A surface docked on top of the composer (the dock insets it from the
42+ /// composer's sides): rounded on top, its bottom tucked under the composer
43+ /// (or under the next docked surface). `joined` drops the rounded top where it sits under another
4444 /// docked surface, so the stack reads as one card.
4545 pub fn banner(tone: Tone, density: Density, joined: bool, p: &Palette) -> Div {
4646 let (pad_x, pad_y) = match density {
@@ -58,7 +58,6 @@ pub fn banner(tone: Tone, density: Density, joined: bool, p: &Palette) -> Div {
5858 shadow.push(BoxShadow::new(px(0.), px(0.), ring).spread_radius(px(1.)).inset());
5959 }
6060 v_flex()
61- .mx(px(INSET))
6261 .mb(px(-OVERLAP))
6362 .px(px(pad_x))
6463 .pt(px(pad_y))