| @@ -100,6 +100,32 @@ impl Notice { |
| 100 | 100 | } |
| 101 | 101 | } |
| 102 | 102 | |
| 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 | + |
| 103 | 129 | /// What the `/`, `$` or `@` menu offers for the word at the cursor. |
| 104 | 130 | struct CommandMenu { |
| 105 | 131 | /// `/`, `$` or `@`. |
| @@ -142,6 +168,12 @@ pub struct ChatView { |
| 142 | 168 | /// The thread folder's files, for the `@` menu, once read. |
| 143 | 169 | files: Option<(PathBuf, Vec<String>)>, |
| 144 | 170 | 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>, |
| 145 | 177 | queue_expanded: bool, |
| 146 | 178 | /// The queued message being edited in the composer. |
| 147 | 179 | editing_queued: Option<usize>, |
| @@ -150,6 +182,8 @@ pub struct ChatView { |
| 150 | 182 | /// The composer card's width, measured as it's laid out, so the footer |
| 151 | 183 | /// knows how much fits. |
| 152 | 184 | composer_width: Pixels, |
| 185 | + /// Whether the traits and access controls folded into "…" last frame. |
| 186 | + folded: std::cell::Cell<(bool, bool)>, |
| 153 | 187 | /// The composer rests on one line while the conversation scrolls. |
| 154 | 188 | collapsed: bool, |
| 155 | 189 | _subscriptions: Vec<Subscription>, |
| @@ -195,6 +229,7 @@ impl ChatView { |
| 195 | 229 | } |
| 196 | 230 | InputEvent::Change => { |
| 197 | 231 | this.menu_selected = 0; |
| 232 | + this.stash_open = false; |
| 198 | 233 | this.sync_custom_answer(cx); |
| 199 | 234 | this.expand(cx); |
| 200 | 235 | this.load_files_if_mentioning(cx); |
| @@ -261,11 +296,16 @@ impl ChatView { |
| 261 | 296 | menu_dismissed: None, |
| 262 | 297 | files: None, |
| 263 | 298 | files_task: Task::ready(()), |
| 299 | + stash: Vec::new(), |
| 300 | + stash_open: false, |
| 301 | + stash_selected: 0, |
| 302 | + stash_pulse: None, |
| 264 | 303 | queue_expanded: true, |
| 265 | 304 | editing_queued: None, |
| 266 | 305 | notices_expanded: false, |
| 267 | 306 | tasks_expanded: false, |
| 268 | 307 | composer_width: px(0.), |
| 308 | + folded: Default::default(), |
| 269 | 309 | collapsed: false, |
| 270 | 310 | _subscriptions: subscriptions, |
| 271 | 311 | _ticker: ticker, |
| @@ -447,8 +487,137 @@ impl ChatView { |
| 447 | 487 | } |
| 448 | 488 | } |
| 449 | 489 | |
| 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 | + |
| 450 | 615 | /// 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 | + } |
| 452 | 621 | let keystroke = &event.keystroke; |
| 453 | 622 | if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() { |
| 454 | 623 | return; |
| @@ -646,6 +815,15 @@ impl ChatView { |
| 646 | 815 | fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) { |
| 647 | 816 | let keystroke = &event.keystroke; |
| 648 | 817 | 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 | + } |
| 649 | 827 | if keystroke.key == "enter" && modifiers.control && modifiers.shift { |
| 650 | 828 | cx.stop_propagation(); |
| 651 | 829 | let queued = !self.session.read(cx).queue.is_empty(); |
| @@ -800,6 +978,11 @@ impl ChatView { |
| 800 | 978 | } |
| 801 | 979 | |
| 802 | 980 | fn escape(&mut self, cx: &mut Context<Self>) { |
| 981 | + if self.stash_open { |
| 982 | + self.stash_open = false; |
| 983 | + cx.notify(); |
| 984 | + return; |
| 985 | + } |
| 803 | 986 | if self.command_menu(cx).is_some() { |
| 804 | 987 | self.menu_dismissed = Some(self.composer.read(cx).value().to_string()); |
| 805 | 988 | cx.notify(); |
| @@ -1382,7 +1565,7 @@ impl Render for ChatView { |
| 1382 | 1565 | .size_full() |
| 1383 | 1566 | .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx))) |
| 1384 | 1567 | .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))) |
| 1386 | 1569 | .map(|this| match &self.diff { |
| 1387 | 1570 | Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))), |
| 1388 | 1571 | None if is_empty => this.child(self.render_start(window, cx)), |