Vectrum
Sign In

Prompt history on ↑ and ↓, like T3 Code

On an empty composer, ↑ walks back through the thread's sent prompts and ↓
walks forward, past the newest back to an empty composer. Editing a
recalled prompt ends it.

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

Showing 1 changed file with 62 additions and 1 deletion.

+62−1src/ui/chat.rsView File
@@ -174,6 +174,9 @@ pub struct ChatView {
174174 /// The thread folder's files, for the `@` menu, once read.
175175 files: Option<(PathBuf, Vec<String>)>,
176176 files_task: Task<()>,
177+ /// Where ↑/↓ are in the thread's sent prompts: the entry shown, and the
178+ /// text put in the composer for it.
179+ history: Option<(usize, String)>,
177180 /// Prompts put aside with Ctrl+S, oldest first.
178181 stash: Vec<Stashed>,
179182 stash_open: bool,
@@ -304,6 +307,7 @@ impl ChatView {
304307 menu_dismissed: None,
305308 files: None,
306309 files_task: Task::ready(()),
310+ history: None,
307311 stash: Vec::new(),
308312 stash_open: false,
309313 stash_selected: 0,
@@ -554,6 +558,54 @@ impl ChatView {
554558 }
555559 }
556560
561+ /// The thread's sent prompts, oldest first, without repeats in a row.
562+ fn prompt_history(&self, cx: &App) -> Vec<String> {
563+ let mut prompts: Vec<String> = Vec::new();
564+ for item in &self.session.read(cx).items {
565+ if let ChatItem::User { text, .. } = item
566+ && !text.trim().is_empty()
567+ && prompts.last().map(String::as_str) != Some(text.as_ref())
568+ {
569+ prompts.push(text.to_string());
570+ }
571+ }
572+ prompts
573+ }
574+
575+ /// Terminal-style recall, as in T3 Code: ↑ on an empty composer walks
576+ /// back through the thread's prompts, ↓ walks forward and past the
577+ /// newest clears the composer again. Editing a recalled prompt ends it.
578+ fn recall_prompt(&mut self, older: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
579+ let text = self.composer.read(cx).value().to_string();
580+ let browsing = self.history.as_ref().filter(|(_, recalled)| *recalled == text).map(|(at, _)| *at);
581+ if browsing.is_none() && (!text.is_empty() || !older) {
582+ self.history = None;
583+ return false;
584+ }
585+ let prompts = self.prompt_history(cx);
586+ if prompts.is_empty() {
587+ return false;
588+ }
589+ let next = match (browsing, older) {
590+ (None, _) => Some(prompts.len() - 1),
591+ (Some(at), true) => Some(at.saturating_sub(1)),
592+ (Some(at), false) if at + 1 < prompts.len() => Some(at + 1),
593+ (Some(_), false) => None,
594+ };
595+ let value = match next {
596+ Some(at) => {
597+ self.history = Some((at, prompts[at].clone()));
598+ prompts[at].clone()
599+ }
600+ None => {
601+ self.history = None;
602+ String::new()
603+ }
604+ };
605+ self.composer.update(cx, |composer, cx| composer.set_value(value, window, cx));
606+ true
607+ }
608+
557609 /// Puts the composer's prompt aside (Ctrl+S), or with nothing to stash,
558610 /// opens the stash.
559611 fn stash_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -891,7 +943,16 @@ impl ChatView {
891943 self.session.update(cx, |session, cx| session.toggle_plan(cx));
892944 return;
893945 }
894- let Some(menu) = self.command_menu(cx) else { return };
946+ let Some(menu) = self.command_menu(cx) else {
947+ let plain = !(modifiers.control || modifiers.alt || modifiers.shift || modifiers.platform);
948+ let asking = self.session.read(cx).pending_ask().is_some();
949+ if plain && !asking && matches!(keystroke.key.as_str(), "up" | "down") {
950+ if self.recall_prompt(keystroke.key == "up", window, cx) {
951+ cx.stop_propagation();
952+ }
953+ }
954+ return;
955+ };
895956 if modifiers.control || modifiers.alt || modifiers.shift {
896957 return;
897958 }