The composer is laid out like T3 Code's. Docked on top of it, tucking under
its edge: queued messages (drag to reorder, edit in the composer, steer,
remove), notices ("Running: …" background work with Stop, a usage limit,
more behind a peek tab), and the drawer for what the agent waits on: an
approval (Decline, Approve, Cancel / Always allow this session), Claude's
questions (one at a time, 1-9 to pick, single-select moves on by itself,
Previous / Next question / Submit answers, foldable, dismissable), or a
plan (Plan ready, Implement / Implement in a new thread, or Refine with
feedback). The agent's task list sits there too, with its progress.
The footer has T3's model, traits and access controls, which lose their
labels and then fold into "…" when the composer is narrow; a Build/Plan
toggle when plan mode is on; the attach button, an optional context meter
with its hover card; and the send, stop, steer or queue button. The
placeholder follows the thread's state, `@` lists the thread folder's
files and folders, and Alt+Up edits the last queued message.
Below a new thread's composer hangs T3's strip: the machine, the workspace
(Current checkout, New worktree, Previous worktree) and the branch, with a
searchable branch picker (badges, Create new ref, Start from origin).
New worktrees start on a temporary vectrum/<hex> branch that's renamed
after the first message, as Settings' branch naming says, and go where
the worktree location setting points. The transcript ends in "Working for
12s" while the agent works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Showing 15 changed files with 3917 additions and 1357 deletions.
| @@ -83,7 +83,7 @@ impl PermissionMode { | ||
| 83 | 83 | match self { |
| 84 | 84 | PermissionMode::Default => "Ask before commands and file changes.", |
| 85 | 85 | PermissionMode::AcceptEdits => "Auto-approve edits, ask before other actions.", |
| 86 | - PermissionMode::Auto => "Claude approves routine actions; risky ones still ask.", | |
| 86 | + PermissionMode::Auto => "Supported providers approve routine actions; others still ask.", | |
| 87 | 87 | PermissionMode::Plan => "Read-only: Claude plans before it changes anything.", |
| 88 | 88 | PermissionMode::BypassPermissions => "Allow commands and edits without prompts.", |
| 89 | 89 | } |
| @@ -130,7 +130,7 @@ impl Effort { | ||
| 130 | 130 | Effort::Low => "Low", |
| 131 | 131 | Effort::Medium => "Medium", |
| 132 | 132 | Effort::High => "High", |
| 133 | - Effort::XHigh => "Extra high", | |
| 133 | + Effort::XHigh => "Extra High", | |
| 134 | 134 | Effort::Max => "Max", |
| 135 | 135 | } |
| 136 | 136 | } |
| @@ -77,6 +77,87 @@ pub fn read_info(path: &Path) -> Option<GitInfo> { | ||
| 77 | 77 | Some(GitInfo { branch, branches, added, removed }) |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | + /// A branch the branch picker lists. | |
| 81 | + #[derive(Clone, Debug, PartialEq)] | |
| 82 | + pub struct GitRef { | |
| 83 | + /// `main`, or `origin/main` for a remote branch. | |
| 84 | + pub name: String, | |
| 85 | + pub remote: bool, | |
| 86 | + /// Checked out in the folder the refs were read from. | |
| 87 | + pub current: bool, | |
| 88 | + /// The repository's default branch (origin's HEAD). | |
| 89 | + pub default: bool, | |
| 90 | + /// The worktree it's checked out in, when that's another folder. | |
| 91 | + pub worktree: Option<PathBuf>, | |
| 92 | + } | |
| 93 | + | |
| 94 | + impl GitRef { | |
| 95 | + /// The badge the branch picker shows next to it. | |
| 96 | + pub fn badge(&self) -> Option<&'static str> { | |
| 97 | + if self.current { | |
| 98 | + Some("current") | |
| 99 | + } else if self.worktree.is_some() { | |
| 100 | + Some("worktree") | |
| 101 | + } else if self.remote { | |
| 102 | + Some("remote") | |
| 103 | + } else if self.default { | |
| 104 | + Some("default") | |
| 105 | + } else { | |
| 106 | + None | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// The branches of the repository at `path`, ordered like T3 Code's branch | |
| 112 | + /// picker: the current one, the default one, then local before remote | |
| 113 | + /// branches, each newest commit first. A remote branch is left out when a | |
| 114 | + /// local one has its name. | |
| 115 | + pub fn list_refs(path: &Path) -> Vec<GitRef> { | |
| 116 | + let Ok(out) = git( | |
| 117 | + path, | |
| 118 | + &[ | |
| 119 | + "for-each-ref", | |
| 120 | + "--format=%(refname)%00%(committerdate:unix)%00%(HEAD)%00%(worktreepath)", | |
| 121 | + "refs/heads", | |
| 122 | + "refs/remotes", | |
| 123 | + ], | |
| 124 | + ) else { | |
| 125 | + return Vec::new(); | |
| 126 | + }; | |
| 127 | + let here = git(path, &["rev-parse", "--show-toplevel"]).map(|top| PathBuf::from(top.trim())).ok(); | |
| 128 | + let default = git(path, &["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]) | |
| 129 | + .ok() | |
| 130 | + .map(|name| name.trim().trim_start_matches("origin/").to_string()); | |
| 131 | + let mut refs: Vec<(i64, GitRef)> = Vec::new(); | |
| 132 | + for line in out.lines() { | |
| 133 | + let mut fields = line.split('\0'); | |
| 134 | + let (Some(refname), Some(date), Some(head), worktree) = (fields.next(), fields.next(), fields.next(), fields.next()) | |
| 135 | + else { | |
| 136 | + continue; | |
| 137 | + }; | |
| 138 | + let (name, remote) = match (refname.strip_prefix("refs/heads/"), refname.strip_prefix("refs/remotes/")) { | |
| 139 | + (Some(name), _) => (name.to_string(), false), | |
| 140 | + (None, Some(name)) if !name.ends_with("/HEAD") => (name.to_string(), true), | |
| 141 | + _ => continue, | |
| 142 | + }; | |
| 143 | + let worktree = worktree.filter(|path| !path.is_empty()).map(PathBuf::from).filter(|path| path.exists()); | |
| 144 | + let current = head == "*" || worktree.is_some() && worktree == here; | |
| 145 | + let is_default = default.as_deref().is_some_and(|default| { | |
| 146 | + if remote { name.strip_prefix("origin/") == Some(default) } else { name == default } | |
| 147 | + }); | |
| 148 | + refs.push(( | |
| 149 | + date.parse().unwrap_or(0), | |
| 150 | + GitRef { name, remote, current, default: is_default, worktree: worktree.filter(|_| !current) }, | |
| 151 | + )); | |
| 152 | + } | |
| 153 | + let locals: Vec<String> = refs.iter().filter(|(_, r)| !r.remote).map(|(_, r)| r.name.clone()).collect(); | |
| 154 | + refs.retain(|(_, r)| !r.remote || !r.name.strip_prefix("origin/").is_some_and(|name| locals.iter().any(|local| local == name))); | |
| 155 | + refs.sort_by(|(a_date, a), (b_date, b)| { | |
| 156 | + (!a.current, !a.default, a.remote, -a_date, &a.name).cmp(&(!b.current, !b.default, b.remote, -b_date, &b.name)) | |
| 157 | + }); | |
| 158 | + refs.into_iter().map(|(_, r)| r).collect() | |
| 159 | + } | |
| 160 | + | |
| 80 | 161 | /// Records the whole working tree, untracked files included, as a git tree |
| 81 | 162 | /// and returns its id. It stages into `index`, a private index file, so the |
| 82 | 163 | /// user's own staging area is never touched; the blobs land in the repository |
| @@ -103,6 +103,39 @@ impl ModelEntry { | ||
| 103 | 103 | } |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | + /// What T3 Code's model manifest says about a Claude model the CLI doesn't | |
| 107 | + /// report: the effort it defaults to, whether it takes Ultracode, and | |
| 108 | + /// whether it's new. | |
| 109 | + pub struct ClaudeProfile { | |
| 110 | + pub default_effort: Option<Effort>, | |
| 111 | + pub ultracode: bool, | |
| 112 | + pub new: bool, | |
| 113 | + } | |
| 114 | + | |
| 115 | + impl ModelEntry { | |
| 116 | + pub fn claude_profile(&self) -> ClaudeProfile { | |
| 117 | + let model = if self.resolved.is_empty() { self.id.as_str() } else { self.resolved.as_str() }; | |
| 118 | + let model = model.trim_end_matches("[1m]"); | |
| 119 | + let is = |prefix: &str| model.starts_with(prefix); | |
| 120 | + let default_effort = if self.provider != Provider::Claude { | |
| 121 | + None | |
| 122 | + } else if is("claude-fable") || is("claude-opus-5-5") || is("claude-haiku-5") || matches!(model, "opus" | "haiku" | "fable") { | |
| 123 | + Some(Effort::Medium) | |
| 124 | + } else if is("claude-opus-4-7") { | |
| 125 | + Some(Effort::XHigh) | |
| 126 | + } else if is("claude-haiku-4") { | |
| 127 | + None | |
| 128 | + } else { | |
| 129 | + Some(Effort::High) | |
| 130 | + }; | |
| 131 | + let ultracode = self.provider == Provider::Claude | |
| 132 | + && (is("claude-fable") || is("claude-opus-5") || is("claude-opus-4-8") || matches!(model, "opus" | "fable")); | |
| 133 | + let new = self.provider == Provider::Claude | |
| 134 | + && (is("claude-opus-5-5") || is("claude-sonnet-5-5") || is("claude-haiku-5-5")); | |
| 135 | + ClaudeProfile { default_effort, ultracode, new } | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 106 | 139 | /// A `/` command or skill an agent offers. |
| 107 | 140 | #[derive(Clone, Debug, PartialEq)] |
| 108 | 141 | pub struct SlashCommand { |
| @@ -20,7 +20,7 @@ use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessE | ||
| 20 | 20 | use crate::git::{self, TurnChanges}; |
| 21 | 21 | use crate::models::{Catalog, Provider}; |
| 22 | 22 | use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient}; |
| 23 | - use crate::settings::{self, Streaming}; | |
| 23 | + use crate::settings::{self, BranchNaming, Streaming}; | |
| 24 | 24 | use crate::worktree::{self, PullRequest, Worktree, WorktreePlan}; |
| 25 | 25 | |
| 26 | 26 | const DEFAULT_TITLE: &str = "New thread"; |
| @@ -166,8 +166,13 @@ pub struct UserQuestions { | ||
| 166 | 166 | pub questions: Vec<AskQuestion>, |
| 167 | 167 | /// The question on screen. |
| 168 | 168 | pub current: usize, |
| 169 | - /// Options ticked on each multi-select question, not yet committed. | |
| 169 | + /// The options selected on each question: at most one on a | |
| 170 | + /// single-select question. | |
| 170 | 171 | pub picked: Vec<Vec<usize>>, |
| 172 | + /// What the user typed as their own answer to each question. A | |
| 173 | + /// non-empty one wins over the selected options. | |
| 174 | + pub custom: Vec<String>, | |
| 175 | + /// The answers sent, once the form is submitted. | |
| 171 | 176 | pub answers: Vec<Option<SharedString>>, |
| 172 | 177 | } |
| 173 | 178 | |
| @@ -204,21 +209,37 @@ impl UserQuestions { | ||
| 204 | 209 | return None; |
| 205 | 210 | } |
| 206 | 211 | let count = questions.len(); |
| 207 | - Some(Self { questions, current: 0, picked: vec![Vec::new(); count], answers: vec![None; count] }) | |
| 212 | + Some(Self { | |
| 213 | + questions, | |
| 214 | + current: 0, | |
| 215 | + picked: vec![Vec::new(); count], | |
| 216 | + custom: vec![String::new(); count], | |
| 217 | + answers: vec![None; count], | |
| 218 | + }) | |
| 208 | 219 | } |
| 209 | 220 | |
| 210 | - /// Records an answer and moves to the next unanswered question. Returns | |
| 211 | - /// true once every question has one. | |
| 212 | - fn answer(&mut self, answer: SharedString) -> bool { | |
| 213 | - self.answers[self.current] = Some(answer); | |
| 214 | - let count = self.questions.len(); | |
| 215 | - match (1..count).map(|step| (self.current + step) % count).find(|&q| self.answers[q].is_none()) { | |
| 216 | - Some(next) => { | |
| 217 | - self.current = next; | |
| 218 | - false | |
| 219 | - } | |
| 220 | - None => self.answers.iter().all(Option::is_some), | |
| 221 | + /// The answer question `q` has so far: the user's own words if they | |
| 222 | + /// typed any, otherwise the selected options. `None` while it has neither. | |
| 223 | + pub fn resolved(&self, q: usize) -> Option<String> { | |
| 224 | + let custom = self.custom.get(q).map(|custom| custom.trim()).unwrap_or_default(); | |
| 225 | + if !custom.is_empty() { | |
| 226 | + return Some(custom.to_string()); | |
| 221 | 227 | } |
| 228 | + let question = self.questions.get(q)?; | |
| 229 | + let mut picked = self.picked.get(q)?.clone(); | |
| 230 | + picked.sort_unstable(); | |
| 231 | + let labels: Vec<&str> = | |
| 232 | + picked.iter().filter_map(|&option| question.options.get(option)).map(|option| option.label.as_ref()).collect(); | |
| 233 | + (!labels.is_empty()).then(|| labels.join(", ")) | |
| 234 | + } | |
| 235 | + | |
| 236 | + /// Whether every question has an answer. | |
| 237 | + pub fn is_complete(&self) -> bool { | |
| 238 | + (0..self.questions.len()).all(|q| self.resolved(q).is_some()) | |
| 239 | + } | |
| 240 | + | |
| 241 | + pub fn is_last(&self) -> bool { | |
| 242 | + self.current + 1 >= self.questions.len() | |
| 222 | 243 | } |
| 223 | 244 | } |
| 224 | 245 | |
| @@ -431,6 +452,8 @@ pub struct Session { | ||
| 431 | 452 | /// The concrete model the agent reported, e.g. `claude-opus-5-5`. |
| 432 | 453 | pub active_model: Option<String>, |
| 433 | 454 | pub permission_mode: PermissionMode, |
| 455 | + /// The access mode plan mode hands back to when it's switched off. | |
| 456 | + pub plan_return: PermissionMode, | |
| 434 | 457 | pub reasoning: Reasoning, |
| 435 | 458 | pub fast_mode: bool, |
| 436 | 459 | /// The thinking levels Pi offers for the current model. |
| @@ -551,6 +574,7 @@ impl Session { | ||
| 551 | 574 | model, |
| 552 | 575 | active_model: None, |
| 553 | 576 | permission_mode, |
| 577 | + plan_return: if permission_mode == PermissionMode::Plan { PermissionMode::Default } else { permission_mode }, | |
| 554 | 578 | reasoning, |
| 555 | 579 | fast_mode, |
| 556 | 580 | pi_efforts: Vec::new(), |
| @@ -788,8 +812,8 @@ impl Session { | ||
| 788 | 812 | self.preparing = true; |
| 789 | 813 | self.turn_started = Some(Instant::now()); |
| 790 | 814 | self.turn_cost = 0.; |
| 791 | - let title = self.title.to_string(); | |
| 792 | - let task = cx.background_executor().spawn(async move { worktree::create(&plan, &title) }); | |
| 815 | + let task = cx.background_executor().spawn(async move { worktree::create(&plan) }); | |
| 816 | + let first_message = text.clone(); | |
| 793 | 817 | cx.spawn(async move |this, cx| { |
| 794 | 818 | let created = task.await; |
| 795 | 819 | let _ = this.update(cx, |this, cx| { |
| @@ -797,6 +821,7 @@ impl Session { | ||
| 797 | 821 | match created { |
| 798 | 822 | Ok((worktree, cwd)) => { |
| 799 | 823 | this.cwd = cwd; |
| 824 | + this.name_branch(&worktree, first_message, cx); | |
| 800 | 825 | this.worktree = Some(worktree); |
| 801 | 826 | } |
| 802 | 827 | Err(err) => this.push_notice( |
| @@ -864,6 +889,27 @@ impl Session { | ||
| 864 | 889 | } |
| 865 | 890 | } |
| 866 | 891 | |
| 892 | + /// Replaces a queued message with an edited one, in place. | |
| 893 | + pub fn update_queued(&mut self, ix: usize, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) { | |
| 894 | + if self.remote.is_some() { | |
| 895 | + return; | |
| 896 | + } | |
| 897 | + if let Some(message) = self.queue.get_mut(ix) { | |
| 898 | + *message = QueuedMessage { text, attachments }; | |
| 899 | + cx.notify(); | |
| 900 | + } | |
| 901 | + } | |
| 902 | + | |
| 903 | + /// Moves a queued message up or down the queue. | |
| 904 | + pub fn move_queued(&mut self, from: usize, to: usize, cx: &mut Context<Self>) { | |
| 905 | + if self.remote.is_some() || from >= self.queue.len() || to >= self.queue.len() || from == to { | |
| 906 | + return; | |
| 907 | + } | |
| 908 | + let message = self.queue.remove(from); | |
| 909 | + self.queue.insert(to, message); | |
| 910 | + cx.notify(); | |
| 911 | + } | |
| 912 | + | |
| 867 | 913 | /// Sends the next queued message once a turn has ended. |
| 868 | 914 | fn send_next_queued(&mut self, cx: &mut Context<Self>) { |
| 869 | 915 | if !self.is_working() && !self.queue.is_empty() { |
| @@ -1027,6 +1073,76 @@ impl Session { | ||
| 1027 | 1073 | .detach(); |
| 1028 | 1074 | } |
| 1029 | 1075 | |
| 1076 | + /// Renames a new worktree's temporary branch after the thread's first | |
| 1077 | + /// message, as Settings → Source Control → Worktree branch naming says. | |
| 1078 | + /// The folder keeps its name. | |
| 1079 | + fn name_branch(&mut self, worktree: &Worktree, message: String, cx: &mut Context<Self>) { | |
| 1080 | + if settings::get(|settings| settings.text_model.is_empty()) || message.trim().is_empty() { | |
| 1081 | + return; | |
| 1082 | + } | |
| 1083 | + let (naming, prefix, instructions) = settings::get(|settings| { | |
| 1084 | + (settings.branch_naming, settings.branch_prefix.clone(), settings.branch_instructions.clone()) | |
| 1085 | + }); | |
| 1086 | + let worktree = worktree.clone(); | |
| 1087 | + let task = cx.background_executor().spawn(async move { | |
| 1088 | + let suggestion = crate::textgen::branch_name(&message, naming, &instructions)?; | |
| 1089 | + let name = worktree::branch_from_suggestion(&suggestion, naming, &prefix); | |
| 1090 | + worktree::rename_branch(&worktree, &name, naming != BranchNaming::Custom).ok() | |
| 1091 | + }); | |
| 1092 | + cx.spawn(async move |this, cx| { | |
| 1093 | + let Some(branch) = task.await else { return }; | |
| 1094 | + let _ = this.update(cx, |this, cx| { | |
| 1095 | + if let Some(worktree) = this.worktree.as_mut() { | |
| 1096 | + worktree.branch = branch; | |
| 1097 | + cx.notify(); | |
| 1098 | + } | |
| 1099 | + }); | |
| 1100 | + }) | |
| 1101 | + .detach(); | |
| 1102 | + } | |
| 1103 | + | |
| 1104 | + /// The branch a new worktree starts from. | |
| 1105 | + pub fn set_worktree_base(&mut self, base: String, cx: &mut Context<Self>) { | |
| 1106 | + if let Some(plan) = self.pending_worktree.as_mut() { | |
| 1107 | + plan.base = Some(base); | |
| 1108 | + cx.notify(); | |
| 1109 | + } | |
| 1110 | + } | |
| 1111 | + | |
| 1112 | + /// Whether a new worktree starts from origin's copy of its branch. | |
| 1113 | + pub fn set_worktree_from_origin(&mut self, on: bool, cx: &mut Context<Self>) { | |
| 1114 | + if let Some(plan) = self.pending_worktree.as_mut() { | |
| 1115 | + plan.from_origin = on; | |
| 1116 | + cx.notify(); | |
| 1117 | + } | |
| 1118 | + } | |
| 1119 | + | |
| 1120 | + /// Points the (still empty) thread at a worktree that already exists, | |
| 1121 | + /// such as the one a previous thread worked in, or back at the project | |
| 1122 | + /// folder with `None`. | |
| 1123 | + pub fn use_worktree(&mut self, worktree: Option<Worktree>, cx: &mut Context<Self>) { | |
| 1124 | + if self.remote.is_some() || !self.items.is_empty() { | |
| 1125 | + return; | |
| 1126 | + } | |
| 1127 | + let inside = |repo: &Path| self.project.strip_prefix(repo).map(Path::to_path_buf).unwrap_or_default(); | |
| 1128 | + let cwd = match &worktree { | |
| 1129 | + Some(worktree) => worktree.root.join(inside(&worktree.repo)), | |
| 1130 | + None => self.project.clone(), | |
| 1131 | + }; | |
| 1132 | + self.pending_worktree = None; | |
| 1133 | + self.worktree = worktree; | |
| 1134 | + if cwd != self.cwd { | |
| 1135 | + self.cwd = cwd; | |
| 1136 | + // Restart the agent where the thread now runs. | |
| 1137 | + self.process.take(); | |
| 1138 | + self._events_task = Task::ready(()); | |
| 1139 | + self.pending_controls.clear(); | |
| 1140 | + self.status = SessionStatus::Idle; | |
| 1141 | + self.start(None, cx); | |
| 1142 | + } | |
| 1143 | + cx.notify(); | |
| 1144 | + } | |
| 1145 | + | |
| 1030 | 1146 | pub fn rename(&mut self, title: String, cx: &mut Context<Self>) { |
| 1031 | 1147 | self.forward(ThreadAction::Rename { title: title.trim().to_string() }); |
| 1032 | 1148 | let title = title.trim(); |
| @@ -1210,6 +1326,44 @@ impl Session { | ||
| 1210 | 1326 | cx.notify(); |
| 1211 | 1327 | } |
| 1212 | 1328 | |
| 1329 | + /// The access mode the composer shows: the thread's own, or while it | |
| 1330 | + /// plans, the one plan mode hands back to. | |
| 1331 | + pub fn access_mode(&self) -> PermissionMode { | |
| 1332 | + if !self.provider.asks_permission() { | |
| 1333 | + PermissionMode::BypassPermissions | |
| 1334 | + } else if self.permission_mode == PermissionMode::Plan { | |
| 1335 | + self.plan_return | |
| 1336 | + } else { | |
| 1337 | + self.permission_mode | |
| 1338 | + } | |
| 1339 | + } | |
| 1340 | + | |
| 1341 | + /// Picks the access mode; while the thread plans, it applies once plan | |
| 1342 | + /// mode is switched off. | |
| 1343 | + pub fn set_access_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) { | |
| 1344 | + if self.permission_mode == PermissionMode::Plan { | |
| 1345 | + self.plan_return = mode; | |
| 1346 | + cx.notify(); | |
| 1347 | + } else { | |
| 1348 | + self.set_permission_mode(mode, cx); | |
| 1349 | + } | |
| 1350 | + } | |
| 1351 | + | |
| 1352 | + pub fn is_planning(&self) -> bool { | |
| 1353 | + self.permission_mode == PermissionMode::Plan | |
| 1354 | + } | |
| 1355 | + | |
| 1356 | + /// Switches between plan mode and building with the access mode. | |
| 1357 | + pub fn toggle_plan(&mut self, cx: &mut Context<Self>) { | |
| 1358 | + if self.is_planning() { | |
| 1359 | + let mode = self.plan_return; | |
| 1360 | + self.set_permission_mode(mode, cx); | |
| 1361 | + } else { | |
| 1362 | + self.plan_return = self.permission_mode; | |
| 1363 | + self.set_permission_mode(PermissionMode::Plan, cx); | |
| 1364 | + } | |
| 1365 | + } | |
| 1366 | + | |
| 1213 | 1367 | pub fn set_reasoning(&mut self, reasoning: Reasoning, cx: &mut Context<Self>) { |
| 1214 | 1368 | if self.forward(ThreadAction::SetReasoning { reasoning: reasoning.key().into() }) { |
| 1215 | 1369 | self.reasoning = reasoning; |
| @@ -1411,63 +1565,74 @@ impl Session { | ||
| 1411 | 1565 | } |
| 1412 | 1566 | } |
| 1413 | 1567 | |
| 1414 | - /// Shows another of the form's questions. | |
| 1415 | - pub fn ask_show(&mut self, ix: usize, question: usize, cx: &mut Context<Self>) { | |
| 1416 | - if let Some(ask) = self.pending_ask_mut(ix) | |
| 1417 | - && question < ask.questions.len() | |
| 1418 | - { | |
| 1419 | - ask.current = question; | |
| 1420 | - cx.notify(); | |
| 1568 | + /// Selects an option on the current question: the only one on a | |
| 1569 | + /// single-select question, one more (or one fewer) on a multi-select one. | |
| 1570 | + /// Either way it replaces what the user typed as their own answer. | |
| 1571 | + /// Returns whether the question is single-select, which moves on by itself. | |
| 1572 | + pub fn ask_toggle(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) -> bool { | |
| 1573 | + let Some(ask) = self.pending_ask_mut(ix) else { return false }; | |
| 1574 | + let current = ask.current; | |
| 1575 | + let question = &ask.questions[current]; | |
| 1576 | + if option >= question.options.len() { | |
| 1577 | + return false; | |
| 1421 | 1578 | } |
| 1422 | - } | |
| 1423 | - | |
| 1424 | - /// Picks an option on the current question: answers a single-select | |
| 1425 | - /// question, or ticks the option on a multi-select one. | |
| 1426 | - pub fn ask_pick(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) { | |
| 1427 | - let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 1428 | - let question = &ask.questions[ask.current]; | |
| 1429 | - let Some(label) = question.options.get(option).map(|option| option.label.clone()) else { return }; | |
| 1430 | - if question.multi_select { | |
| 1431 | - let picked = &mut ask.picked[ask.current]; | |
| 1579 | + let multi = question.multi_select; | |
| 1580 | + let picked = &mut ask.picked[current]; | |
| 1581 | + if multi { | |
| 1432 | 1582 | match picked.iter().position(|&picked| picked == option) { |
| 1433 | 1583 | Some(position) => { |
| 1434 | 1584 | picked.remove(position); |
| 1435 | 1585 | } |
| 1436 | 1586 | None => picked.push(option), |
| 1437 | 1587 | } |
| 1438 | - cx.notify(); | |
| 1439 | - return; | |
| 1440 | - } | |
| 1441 | - if ask.answer(label) { | |
| 1442 | - self.submit_ask(ix); | |
| 1588 | + } else { | |
| 1589 | + *picked = vec![option]; | |
| 1443 | 1590 | } |
| 1591 | + ask.custom[current].clear(); | |
| 1444 | 1592 | cx.notify(); |
| 1593 | + !multi | |
| 1445 | 1594 | } |
| 1446 | 1595 | |
| 1447 | - /// Commits the options ticked on the current multi-select question. | |
| 1448 | - pub fn ask_confirm(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 1596 | + /// Takes what the composer holds as the current question's own answer. | |
| 1597 | + /// Typing one deselects the options; clearing it brings them back. | |
| 1598 | + pub fn ask_set_custom(&mut self, ix: usize, text: String, cx: &mut Context<Self>) { | |
| 1449 | 1599 | let Some(ask) = self.pending_ask_mut(ix) else { return }; |
| 1450 | - let question = &ask.questions[ask.current]; | |
| 1451 | - let mut picked = ask.picked[ask.current].clone(); | |
| 1452 | - picked.sort_unstable(); | |
| 1453 | - let labels: Vec<&str> = | |
| 1454 | - picked.iter().filter_map(|&option| question.options.get(option)).map(|option| option.label.as_ref()).collect(); | |
| 1455 | - if labels.is_empty() { | |
| 1600 | + let current = ask.current; | |
| 1601 | + if ask.custom[current] != text { | |
| 1602 | + if !text.trim().is_empty() && ask.questions[current].multi_select { | |
| 1603 | + ask.picked[current].clear(); | |
| 1604 | + } | |
| 1605 | + ask.custom[current] = text; | |
| 1606 | + cx.notify(); | |
| 1607 | + } | |
| 1608 | + } | |
| 1609 | + | |
| 1610 | + /// Moves on from the current question once it has an answer: to the next | |
| 1611 | + /// question, or, on the last one, submits the form when it's complete. | |
| 1612 | + pub fn ask_advance(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 1613 | + let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 1614 | + if !ask.is_last() { | |
| 1615 | + if ask.resolved(ask.current).is_some() { | |
| 1616 | + ask.current += 1; | |
| 1617 | + cx.notify(); | |
| 1618 | + } | |
| 1456 | 1619 | return; |
| 1457 | 1620 | } |
| 1458 | - if ask.answer(labels.join(", ").into()) { | |
| 1459 | - self.submit_ask(ix); | |
| 1621 | + if !ask.is_complete() { | |
| 1622 | + return; | |
| 1460 | 1623 | } |
| 1461 | - cx.notify(); | |
| 1624 | + let answers: Vec<String> = (0..ask.questions.len()).filter_map(|q| ask.resolved(q)).collect(); | |
| 1625 | + self.ask_submit(ix, answers, cx); | |
| 1462 | 1626 | } |
| 1463 | 1627 | |
| 1464 | - /// Answers the current question in the user's own words. | |
| 1465 | - pub fn ask_custom(&mut self, ix: usize, text: String, cx: &mut Context<Self>) { | |
| 1466 | - let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 1467 | - if ask.answer(text.into()) { | |
| 1468 | - self.submit_ask(ix); | |
| 1628 | + /// Goes back to the previous question. | |
| 1629 | + pub fn ask_previous(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 1630 | + if let Some(ask) = self.pending_ask_mut(ix) | |
| 1631 | + && ask.current > 0 | |
| 1632 | + { | |
| 1633 | + ask.current -= 1; | |
| 1634 | + cx.notify(); | |
| 1469 | 1635 | } |
| 1470 | - cx.notify(); | |
| 1471 | 1636 | } |
| 1472 | 1637 | |
| 1473 | 1638 | /// Answers the whole form at once, as a client that filled it in asks. |
| @@ -7,7 +7,7 @@ use std::process::Stdio; | ||
| 7 | 7 | use crate::claude::{Effort, base_command}; |
| 8 | 8 | use crate::models::{Provider, split_key}; |
| 9 | 9 | use crate::session::Reasoning; |
| 10 | - use crate::settings; | |
| 10 | + use crate::settings::{self, BranchNaming}; | |
| 11 | 11 | |
| 12 | 12 | const TITLE_PROMPT: &str = "You name coding-assistant conversations. Reply with only a short title (3 to 6 words, \ |
| 13 | 13 | sentence case, no quotes, no trailing period) for a conversation that starts with the user's message below."; |
| @@ -15,6 +15,53 @@ sentence case, no quotes, no trailing period) for a conversation that starts wit | ||
| 15 | 15 | /// A title for a thread that starts with `message`, or `None` when text |
| 16 | 16 | /// generation is off or fails. |
| 17 | 17 | pub fn title(message: &str) -> Option<String> { |
| 18 | + let message: String = message.chars().take(2_000).collect(); | |
| 19 | + clean_title(&generate(&format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>"))?) | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// A branch name for a worktree whose thread starts with `message`, as | |
| 23 | + /// T3 Code asks for one: a fragment the configured prefix goes in front of, | |
| 24 | + /// one with a semantic prefix (`feat/…`), or a whole name following the | |
| 25 | + /// user's instructions. `None` when text generation is off or fails. | |
| 26 | + pub fn branch_name(message: &str, naming: BranchNaming, instructions: &str) -> Option<String> { | |
| 27 | + let mut rules = vec![ | |
| 28 | + "Branch should describe the requested work from the user message.", | |
| 29 | + "Return a valid Git branch name without spaces.", | |
| 30 | + ]; | |
| 31 | + match naming { | |
| 32 | + BranchNaming::Custom => rules.push( | |
| 33 | + "Return the complete branch name, following the user's naming instructions. No prefix or suffix will be added.", | |
| 34 | + ), | |
| 35 | + BranchNaming::Static | BranchNaming::Semantic => { | |
| 36 | + rules.push("Keep it short and specific (2-6 words), in lowercase with hyphen-separated words."); | |
| 37 | + rules.push(if naming == BranchNaming::Semantic { | |
| 38 | + "Include a semantic prefix and a slash in the branch name, for example feat/add-search, fix/login-error, \ | |
| 39 | + refactor/auth, docs/setup, or chore/update-deps. Choose the prefix that best describes the work." | |
| 40 | + } else { | |
| 41 | + "Return only the descriptive branch fragment, without a prefix or namespace. The application adds the \ | |
| 42 | + configured prefix." | |
| 43 | + }); | |
| 44 | + } | |
| 45 | + } | |
| 46 | + let message: String = message.chars().take(8_000).collect(); | |
| 47 | + let mut prompt = format!( | |
| 48 | + "You generate concise git branch names.\nReturn a JSON object with key: branch.\nRules:\n{}\n\nUser message:\n{message}", | |
| 49 | + rules.iter().map(|rule| format!("- {rule}")).collect::<Vec<_>>().join("\n") | |
| 50 | + ); | |
| 51 | + if naming == BranchNaming::Custom && !instructions.trim().is_empty() { | |
| 52 | + prompt.push_str(&format!("\n\nAdditional instructions:\n{}", instructions.trim())); | |
| 53 | + } | |
| 54 | + let reply = generate(&prompt)?; | |
| 55 | + // The reply is a JSON object, possibly wrapped in a code fence. | |
| 56 | + let start = reply.find('{')?; | |
| 57 | + let end = reply.rfind('}')?; | |
| 58 | + let value: serde_json::Value = serde_json::from_str(reply.get(start..=end)?).ok()?; | |
| 59 | + let branch = value.get("branch")?.as_str()?.trim().to_string(); | |
| 60 | + (!branch.is_empty()).then_some(branch) | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Runs `prompt` through the text generation model and returns its reply. | |
| 64 | + fn generate(prompt: &str) -> Option<String> { | |
| 18 | 65 | let (key, reasoning) = settings::get(|settings| (settings.text_model.clone(), settings.text_reasoning.clone())); |
| 19 | 66 | let (instance_id, model) = split_key(&key)?; |
| 20 | 67 | let kind = settings::get(|settings| settings.instance(instance_id).map(|instance| instance.kind)) |
| @@ -27,9 +74,6 @@ pub fn title(message: &str) -> Option<String> { | ||
| 27 | 74 | Reasoning::Level(effort) => Some(effort), |
| 28 | 75 | _ => None, |
| 29 | 76 | }); |
| 30 | - let message: String = message.chars().take(2_000).collect(); | |
| 31 | - let prompt = format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>"); | |
| 32 | - | |
| 33 | 77 | if !kind.answers_one_off() { |
| 34 | 78 | return None; |
| 35 | 79 | } |
| @@ -63,7 +107,7 @@ pub fn title(message: &str) -> Option<String> { | ||
| 63 | 107 | if !output.status.success() { |
| 64 | 108 | return None; |
| 65 | 109 | } |
| 66 | - clean_title(&String::from_utf8_lossy(&output.stdout)) | |
| 110 | + Some(String::from_utf8_lossy(&output.stdout).into_owned()) | |
| 67 | 111 | } |
| 68 | 112 | |
| 69 | 113 | /// The title out of a model's reply: its last non-empty line, without |
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | //! The conversation pane: transcript and composer, set on the sheet. A new |
| 2 | 2 | //! thread starts centered, T3 Code style; once it has messages the composer |
| 3 | - //! moves to the bottom. | |
| 3 | + //! moves to the bottom. What docks on top of the composer lives in `dock`, | |
| 4 | + //! its footer in `footer`, and the strip hanging below it in `strip`. | |
| 5 | + | |
| 6 | + mod dock; | |
| 7 | + mod footer; | |
| 8 | + mod strip; | |
| 4 | 9 | |
| 5 | 10 | use std::{ |
| 6 | 11 | path::PathBuf, |
| @@ -8,14 +13,12 @@ use std::{ | ||
| 8 | 13 | }; |
| 9 | 14 | |
| 10 | 15 | use gpui_kit::component::{ |
| 11 | - Icon, IconName, Sizable as _, h_flex, | |
| 16 | + Icon, IconName, h_flex, | |
| 12 | 17 | input::{self, EditorState, InputEvent}, |
| 13 | 18 | menu::{DropdownMenu as _, PopupMenuItem}, |
| 14 | 19 | message_scroller::{MessageScroller, MessageScrollerState}, |
| 15 | 20 | popover::Popover, |
| 16 | - progress::ProgressCircle, | |
| 17 | 21 | text::{TextView, TextViewStyle}, |
| 18 | - tooltip::Tooltip, | |
| 19 | 22 | v_flex, |
| 20 | 23 | }; |
| 21 | 24 | use gpui_kit::prelude::FluentBuilder as _; |
| @@ -26,20 +29,21 @@ use crate::clock; | ||
| 26 | 29 | use crate::git; |
| 27 | 30 | use crate::models::{Catalog, ModelEntry, SlashCommand}; |
| 28 | 31 | use crate::session::{ |
| 29 | - ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, | |
| 30 | - SessionEvent, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, | |
| 32 | + ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, SessionEvent, | |
| 33 | + TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, | |
| 31 | 34 | }; |
| 32 | 35 | use crate::settings::{self, FollowUp, WorkspaceMode}; |
| 33 | 36 | use crate::ui::changes::{self, DiffAction, DiffView}; |
| 37 | + use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone}; | |
| 34 | 38 | use crate::ui::icons::Lucide; |
| 35 | 39 | use crate::ui::markdown_input::{self, MarkdownInput}; |
| 36 | 40 | use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; |
| 37 | - use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent}; | |
| 41 | + use crate::ui::pickers::{ModelPicker, ModelPickerEvent}; | |
| 38 | 42 | use crate::ui::workspace::ProjectDirectory; |
| 39 | 43 | |
| 40 | - const CONTENT_WIDTH: f32 = 760.; | |
| 41 | - const COMPOSER_PLACEHOLDER: &str = "Ask for changes, send follow-ups, or attach images"; | |
| 42 | - const ANSWER_PLACEHOLDER: &str = "Type your own answer, or pick an option above"; | |
| 44 | + pub use strip::BranchPicker; | |
| 45 | + | |
| 46 | + const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands"; | |
| 43 | 47 | const MAX_TOOL_OUTPUT: usize = 6_000; |
| 44 | 48 | |
| 45 | 49 | /// What the conversation asks of the workspace around it. |
| @@ -52,23 +56,64 @@ pub enum ChatEvent { | ||
| 52 | 56 | SwitchProject(PathBuf, Option<String>), |
| 53 | 57 | AddProject, |
| 54 | 58 | StartWithoutProject, |
| 55 | - SwitchBranch(String), | |
| 59 | + /// Check out a branch in the thread's folder, creating it first when | |
| 60 | + /// `create`. | |
| 61 | + SwitchBranch { branch: String, create: bool }, | |
| 56 | 62 | /// Run this (still empty) thread in the project folder or a new worktree. |
| 57 | 63 | SetWorkspace(WorkspaceMode), |
| 64 | + /// Start a new thread in this project that implements this plan. | |
| 65 | + ImplementInNewThread(String), | |
| 58 | 66 | } |
| 59 | 67 | |
| 60 | 68 | /// Proactive panels open the diff after a turn that changed at least this |
| 61 | 69 | /// many files or lines. |
| 62 | 70 | const PROACTIVE_FILES: usize = 3; |
| 63 | 71 | const PROACTIVE_LINES: u64 = 50; |
| 64 | - /// The most rows the `/` menu lists. | |
| 65 | - const SLASH_ROWS: usize = 40; | |
| 72 | + /// The most rows the `/`, `$` and `@` menus list. | |
| 73 | + const MENU_ROWS: usize = 40; | |
| 66 | 74 | |
| 67 | 75 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 68 | 76 | enum Picker { |
| 69 | 77 | Model, |
| 70 | - Reasoning, | |
| 78 | + Traits, | |
| 71 | 79 | Access, |
| 80 | + Overflow, | |
| 81 | + Environment, | |
| 82 | + Workspace, | |
| 83 | + Branch, | |
| 84 | + } | |
| 85 | + | |
| 86 | + /// A notice docked above the composer, T3 Code's banner stack. | |
| 87 | + pub(crate) enum Notice { | |
| 88 | + /// Commands, monitors or subagents left running in the background. | |
| 89 | + BackgroundWork { title: SharedString, detail: Option<SharedString>, waiting: bool, task_ids: Vec<String> }, | |
| 90 | + /// A usage limit stopped the thread. | |
| 91 | + Limit { resets: SystemTime, resume_at: Option<SystemTime> }, | |
| 92 | + } | |
| 93 | + | |
| 94 | + impl Notice { | |
| 95 | + fn tone(&self) -> Tone { | |
| 96 | + match self { | |
| 97 | + Notice::BackgroundWork { .. } => Tone::Neutral, | |
| 98 | + Notice::Limit { .. } => Tone::Warning, | |
| 99 | + } | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// What the `/`, `$` or `@` menu offers for the word at the cursor. | |
| 104 | + struct CommandMenu { | |
| 105 | + /// `/`, `$` or `@`. | |
| 106 | + trigger: char, | |
| 107 | + /// Where the word (with its trigger) starts in the composer text. | |
| 108 | + start: usize, | |
| 109 | + items: Vec<MenuItem>, | |
| 110 | + } | |
| 111 | + | |
| 112 | + #[derive(Clone)] | |
| 113 | + enum MenuItem { | |
| 114 | + Command(SlashCommand), | |
| 115 | + /// A file or folder of the thread's folder, relative to it. | |
| 116 | + Path { path: String, folder: bool }, | |
| 72 | 117 | } |
| 73 | 118 | |
| 74 | 119 | pub struct ChatView { |
| @@ -76,15 +121,35 @@ pub struct ChatView { | ||
| 76 | 121 | composer: Entity<EditorState>, |
| 77 | 122 | scroller: Entity<MessageScrollerState>, |
| 78 | 123 | model_picker: Entity<ModelPicker>, |
| 124 | + branch_picker: Entity<BranchPicker>, | |
| 79 | 125 | open_picker: Option<Picker>, |
| 80 | 126 | attachments: Vec<PathBuf>, |
| 81 | 127 | diff: Option<DiffView>, |
| 82 | - /// Whether the composer is set up to answer an AskUserQuestion form. | |
| 83 | - answering: bool, | |
| 84 | - /// The highlighted row of the `/` menu. | |
| 85 | - slash_selected: usize, | |
| 86 | - /// The composer text the `/` menu was dismissed at, with Esc. | |
| 87 | - slash_dismissed: Option<String>, | |
| 128 | + /// The composer's placeholder, as the thread's state picks it. | |
| 129 | + placeholder: &'static str, | |
| 130 | + /// The question form's question the composer holds the answer to. | |
| 131 | + ask_shown: Option<(usize, usize)>, | |
| 132 | + /// A question folded away, by form and question. | |
| 133 | + question_collapsed: Option<(usize, usize)>, | |
| 134 | + /// The option just picked on a single-select question, shown checked | |
| 135 | + /// until the form moves on. | |
| 136 | + optimistic_pick: Option<(usize, usize, usize)>, | |
| 137 | + ask_timer: Task<()>, | |
| 138 | + /// The highlighted row of the `/`, `$` or `@` menu. | |
| 139 | + menu_selected: usize, | |
| 140 | + /// The composer text the menu was dismissed at, with Esc. | |
| 141 | + menu_dismissed: Option<String>, | |
| 142 | + /// The thread folder's files, for the `@` menu, once read. | |
| 143 | + files: Option<(PathBuf, Vec<String>)>, | |
| 144 | + files_task: Task<()>, | |
| 145 | + queue_expanded: bool, | |
| 146 | + /// The queued message being edited in the composer. | |
| 147 | + editing_queued: Option<usize>, | |
| 148 | + notices_expanded: bool, | |
| 149 | + tasks_expanded: bool, | |
| 150 | + /// The composer card's width, measured as it's laid out, so the footer | |
| 151 | + /// knows how much fits. | |
| 152 | + composer_width: Pixels, | |
| 88 | 153 | /// The composer rests on one line while the conversation scrolls. |
| 89 | 154 | collapsed: bool, |
| 90 | 155 | _subscriptions: Vec<Subscription>, |
| @@ -98,21 +163,16 @@ impl ChatView { | ||
| 98 | 163 | let composer = cx.new(|cx| { |
| 99 | 164 | markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer)) |
| 100 | 165 | .submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter)) |
| 101 | - .placeholder(COMPOSER_PLACEHOLDER) | |
| 166 | + .placeholder(DEFAULT_PLACEHOLDER) | |
| 102 | 167 | }); |
| 103 | 168 | let item_count = session.read(cx).items.len(); |
| 104 | 169 | let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx)); |
| 105 | 170 | let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx)); |
| 171 | + let branch_picker = cx.new(|cx| BranchPicker::new(window, cx)); | |
| 106 | 172 | |
| 107 | 173 | let subscriptions = vec![ |
| 108 | 174 | cx.observe_in(&session, window, |this, session, window, cx| { |
| 109 | - // While Claude waits on a question, the composer takes a custom answer. | |
| 110 | - let answering = session.read(cx).pending_ask().is_some(); | |
| 111 | - if answering != this.answering { | |
| 112 | - this.answering = answering; | |
| 113 | - let placeholder = if answering { ANSWER_PLACEHOLDER } else { COMPOSER_PLACEHOLDER }; | |
| 114 | - this.composer.update(cx, |composer, cx| composer.set_placeholder(placeholder, window, cx)); | |
| 115 | - } | |
| 175 | + this.sync_with_session(window, cx); | |
| 116 | 176 | let count = session.read(cx).items.len(); |
| 117 | 177 | this.scroller.update(cx, |scroller, cx| { |
| 118 | 178 | let current = scroller.item_count(); |
| @@ -134,8 +194,10 @@ impl ChatView { | ||
| 134 | 194 | } |
| 135 | 195 | } |
| 136 | 196 | InputEvent::Change => { |
| 137 | - this.slash_selected = 0; | |
| 197 | + this.menu_selected = 0; | |
| 198 | + this.sync_custom_answer(cx); | |
| 138 | 199 | this.expand(cx); |
| 200 | + this.load_files_if_mentioning(cx); | |
| 139 | 201 | cx.notify(); |
| 140 | 202 | } |
| 141 | 203 | InputEvent::Focus => this.expand(cx), |
| @@ -162,6 +224,7 @@ impl ChatView { | ||
| 162 | 224 | } |
| 163 | 225 | cx.notify(); |
| 164 | 226 | }), |
| 227 | + cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)), | |
| 165 | 228 | ]; |
| 166 | 229 | |
| 167 | 230 | // Re-render once a second while the agent works so the timer advances. |
| @@ -180,21 +243,35 @@ impl ChatView { | ||
| 180 | 243 | } |
| 181 | 244 | }); |
| 182 | 245 | |
| 183 | - Self { | |
| 246 | + let mut this = Self { | |
| 184 | 247 | session, |
| 185 | 248 | composer, |
| 186 | 249 | scroller, |
| 187 | 250 | model_picker, |
| 251 | + branch_picker, | |
| 188 | 252 | open_picker: None, |
| 189 | 253 | attachments: Vec::new(), |
| 190 | 254 | diff: None, |
| 191 | - answering: false, | |
| 192 | - slash_selected: 0, | |
| 193 | - slash_dismissed: None, | |
| 255 | + placeholder: DEFAULT_PLACEHOLDER, | |
| 256 | + ask_shown: None, | |
| 257 | + question_collapsed: None, | |
| 258 | + optimistic_pick: None, | |
| 259 | + ask_timer: Task::ready(()), | |
| 260 | + menu_selected: 0, | |
| 261 | + menu_dismissed: None, | |
| 262 | + files: None, | |
| 263 | + files_task: Task::ready(()), | |
| 264 | + queue_expanded: true, | |
| 265 | + editing_queued: None, | |
| 266 | + notices_expanded: false, | |
| 267 | + tasks_expanded: false, | |
| 268 | + composer_width: px(0.), | |
| 194 | 269 | collapsed: false, |
| 195 | 270 | _subscriptions: subscriptions, |
| 196 | 271 | _ticker: ticker, |
| 197 | - } | |
| 272 | + }; | |
| 273 | + this.sync_with_session(window, cx); | |
| 274 | + this | |
| 198 | 275 | } |
| 199 | 276 | |
| 200 | 277 | pub fn session(&self) -> &Entity<Session> { |
| @@ -203,11 +280,12 @@ impl ChatView { | ||
| 203 | 280 | |
| 204 | 281 | /// Picks up changed settings: the send key, and folding, which changes |
| 205 | 282 | /// the height of transcript rows. |
| 206 | - pub fn settings_changed(&mut self, _: &mut Window, cx: &mut Context<Self>) { | |
| 283 | + pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 207 | 284 | let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter); |
| 208 | 285 | self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx)); |
| 209 | 286 | markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx); |
| 210 | 287 | self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); |
| 288 | + self.sync_with_session(window, cx); | |
| 211 | 289 | cx.notify(); |
| 212 | 290 | } |
| 213 | 291 | |
| @@ -225,19 +303,81 @@ impl ChatView { | ||
| 225 | 303 | self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx)); |
| 226 | 304 | } |
| 227 | 305 | |
| 306 | + /// Keeps the composer in step with what the thread waits on: its | |
| 307 | + /// placeholder, whether it takes input, and (while a question is open) | |
| 308 | + /// the answer typed for the question on screen. | |
| 309 | + fn sync_with_session(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 310 | + let session = self.session.read(cx); | |
| 311 | + let approval = dock::pending_approval(session).is_some() && dock::pending_plan(session).is_none(); | |
| 312 | + let ask = session.pending_ask().map(|(ix, ask)| (ix, ask.current, ask.custom[ask.current].clone())); | |
| 313 | + let plan = dock::pending_plan(session).is_some(); | |
| 314 | + let no_provider = Catalog::global(cx).enabled().next().is_none(); | |
| 315 | + let placeholder = if approval { | |
| 316 | + "Resolve this approval request to continue" | |
| 317 | + } else if ask.is_some() { | |
| 318 | + "Type your own answer, or leave this blank to use the selected option" | |
| 319 | + } else if plan { | |
| 320 | + "Add feedback to refine the plan, or leave this blank to implement it" | |
| 321 | + } else if no_provider { | |
| 322 | + "Enable a provider in Settings to send a message" | |
| 323 | + } else { | |
| 324 | + DEFAULT_PLACEHOLDER | |
| 325 | + }; | |
| 326 | + if placeholder != self.placeholder { | |
| 327 | + self.placeholder = placeholder; | |
| 328 | + self.composer.update(cx, |composer, cx| { | |
| 329 | + composer.set_placeholder(placeholder, window, cx); | |
| 330 | + composer.set_disabled(approval, cx); | |
| 331 | + }); | |
| 332 | + } | |
| 333 | + // A question shown anew brings its own typed answer into the composer. | |
| 334 | + let shown = ask.as_ref().map(|(ix, current, _)| (*ix, *current)); | |
| 335 | + if shown != self.ask_shown { | |
| 336 | + let had_question = self.ask_shown.is_some(); | |
| 337 | + self.ask_shown = shown; | |
| 338 | + match ask { | |
| 339 | + Some((_, _, custom)) => self.composer.update(cx, |composer, cx| composer.set_value(custom, window, cx)), | |
| 340 | + // The form is answered: the answer typed for it goes away. | |
| 341 | + None if had_question => self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)), | |
| 342 | + None => {} | |
| 343 | + } | |
| 344 | + } | |
| 345 | + } | |
| 346 | + | |
| 347 | + /// Hands what the composer holds to the open question as the user's | |
| 348 | + /// own answer. | |
| 349 | + fn sync_custom_answer(&mut self, cx: &mut Context<Self>) { | |
| 350 | + let Some((ix, _)) = self.session.read(cx).pending_ask() else { return }; | |
| 351 | + if self.ask_shown.is_none() { | |
| 352 | + return; | |
| 353 | + } | |
| 354 | + let text = self.composer.read(cx).value().to_string(); | |
| 355 | + self.session.update(cx, |session, cx| session.ask_set_custom(ix, text, cx)); | |
| 356 | + } | |
| 357 | + | |
| 228 | 358 | /// Sends what's in the composer. While the agent works, the follow-up |
| 229 | 359 | /// setting decides between steering it now and queueing for after the |
| 230 | 360 | /// turn; `opposite` flips that for this one message. |
| 231 | 361 | fn submit(&mut self, opposite: bool, window: &mut Window, cx: &mut Context<Self>) { |
| 362 | + if self.command_menu(cx).is_some() { | |
| 363 | + return; | |
| 364 | + } | |
| 232 | 365 | let text = self.composer.read(cx).value().trim().to_string(); |
| 233 | - // An open question takes the text as a custom answer; Enter on an | |
| 234 | - // empty composer commits the options ticked on a multi-select one. | |
| 235 | - if let Some((ix, _)) = self.session.read(cx).pending_ask() { | |
| 366 | + let session = self.session.read(cx); | |
| 367 | + // An open question takes the text as the user's own answer and moves on. | |
| 368 | + if let Some((ix, _)) = session.pending_ask() { | |
| 369 | + self.sync_custom_answer(cx); | |
| 370 | + self.session.update(cx, |session, cx| session.ask_advance(ix, cx)); | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + if dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode") { | |
| 374 | + return; | |
| 375 | + } | |
| 376 | + if let Some((ix, _)) = dock::pending_plan(session) { | |
| 236 | 377 | if text.is_empty() { |
| 237 | - self.session.update(cx, |session, cx| session.ask_confirm(ix, cx)); | |
| 378 | + self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Allow, cx)); | |
| 238 | 379 | } else { |
| 239 | - self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); | |
| 240 | - self.session.update(cx, |session, cx| session.ask_custom(ix, text, cx)); | |
| 380 | + self.refine_plan(ix, window, cx); | |
| 241 | 381 | } |
| 242 | 382 | return; |
| 243 | 383 | } |
| @@ -246,6 +386,10 @@ impl ChatView { | ||
| 246 | 386 | } |
| 247 | 387 | let attachments = std::mem::take(&mut self.attachments); |
| 248 | 388 | self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); |
| 389 | + if let Some(ix) = self.editing_queued.take() { | |
| 390 | + self.session.update(cx, |session, cx| session.update_queued(ix, text, attachments, cx)); | |
| 391 | + return; | |
| 392 | + } | |
| 249 | 393 | let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue; |
| 250 | 394 | self.session.update(cx, |session, cx| { |
| 251 | 395 | if (session.is_working() || session.preparing) && queue_mode != opposite { |
| @@ -254,10 +398,55 @@ impl ChatView { | ||
| 254 | 398 | session.send_message(text, attachments, cx); |
| 255 | 399 | } |
| 256 | 400 | }); |
| 257 | - self.slash_dismissed = None; | |
| 401 | + self.menu_dismissed = None; | |
| 258 | 402 | self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx)); |
| 259 | 403 | } |
| 260 | 404 | |
| 405 | + /// Declines Claude's plan with the composer's text as feedback, so it | |
| 406 | + /// plans again. | |
| 407 | + fn refine_plan(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) { | |
| 408 | + let text = self.composer.read(cx).value().trim().to_string(); | |
| 409 | + if text.is_empty() { | |
| 410 | + return; | |
| 411 | + } | |
| 412 | + self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); | |
| 413 | + self.session.update(cx, |session, cx| { | |
| 414 | + session.respond_permission(ix, PermissionDecision::Deny, cx); | |
| 415 | + session.send_message(text, Vec::new(), cx); | |
| 416 | + }); | |
| 417 | + } | |
| 418 | + | |
| 419 | + /// Hands Claude's plan to a new thread to implement, and declines it here. | |
| 420 | + fn implement_in_new_thread(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) { | |
| 421 | + let plan = match self.session.read(cx).items.get(ix) { | |
| 422 | + Some(ChatItem::Tool(tool)) => tool_field(&tool.input, &["plan"]).unwrap_or_default(), | |
| 423 | + _ => return, | |
| 424 | + }; | |
| 425 | + self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Deny, cx)); | |
| 426 | + cx.emit(ChatEvent::ImplementInNewThread(plan)); | |
| 427 | + } | |
| 428 | + | |
| 429 | + /// Loads a queued message into the composer to edit it. | |
| 430 | + fn edit_queued(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) { | |
| 431 | + let Some(message) = self.session.read(cx).queue.get(ix) else { return }; | |
| 432 | + let (text, attachments) = (message.text.clone(), message.attachments.clone()); | |
| 433 | + self.editing_queued = Some(ix); | |
| 434 | + self.attachments = attachments; | |
| 435 | + self.composer.update(cx, |composer, cx| { | |
| 436 | + composer.set_value(text, window, cx); | |
| 437 | + composer.focus(window, cx); | |
| 438 | + }); | |
| 439 | + cx.notify(); | |
| 440 | + } | |
| 441 | + | |
| 442 | + fn cancel_queued_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 443 | + if self.editing_queued.take().is_some() { | |
| 444 | + self.attachments.clear(); | |
| 445 | + self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); | |
| 446 | + cx.notify(); | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 261 | 450 | /// 1–9 pick an option of the open question while the composer is empty. |
| 262 | 451 | fn pick_with_key(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) { |
| 263 | 452 | let keystroke = &event.keystroke; |
| @@ -268,10 +457,10 @@ impl ChatView { | ||
| 268 | 457 | return; |
| 269 | 458 | }; |
| 270 | 459 | let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return }; |
| 271 | - if digit > ask.questions[ask.current].options.len() { | |
| 460 | + if self.question_collapsed == Some((ix, ask.current)) || digit > ask.questions[ask.current].options.len() { | |
| 272 | 461 | return; |
| 273 | 462 | } |
| 274 | - self.session.update(cx, |session, cx| session.ask_pick(ix, digit - 1, cx)); | |
| 463 | + self.pick_option(ix, digit - 1, cx); | |
| 275 | 464 | cx.stop_propagation(); |
| 276 | 465 | } |
| 277 | 466 | |
| @@ -282,143 +471,341 @@ impl ChatView { | ||
| 282 | 471 | } |
| 283 | 472 | } |
| 284 | 473 | |
| 285 | - // ---- the `/` menu ------------------------------------------------------ | |
| 474 | + fn interrupt(&mut self, cx: &mut Context<Self>) { | |
| 475 | + self.session.update(cx, |session, cx| session.interrupt(cx)); | |
| 476 | + } | |
| 286 | 477 | |
| 287 | - /// The commands the `/` menu offers for what's typed: `/` lists commands | |
| 288 | - /// (and skills, when set to), `$` lists skills. `None` when the composer | |
| 289 | - /// doesn't hold a lone `/word` or `$word`. | |
| 290 | - fn slash_matches(&self, cx: &App) -> Option<Vec<SlashCommand>> { | |
| 291 | - let text = self.composer.read(cx).value(); | |
| 292 | - if self.slash_dismissed.as_deref() == Some(text.as_ref()) { | |
| 478 | + /// The notices docked above the composer, front first: work running in | |
| 479 | + /// the background, then a usage limit. | |
| 480 | + fn notices(&self, cx: &App) -> Vec<Notice> { | |
| 481 | + let session = self.session.read(cx); | |
| 482 | + let mut notices = Vec::new(); | |
| 483 | + if !session.background_tasks.is_empty() { | |
| 484 | + // What each task is, from the tool call that started it. | |
| 485 | + let kind = |task_id: &str| { | |
| 486 | + let tool = session.items.iter().find_map(|item| match item { | |
| 487 | + ChatItem::Tool(tool) if tool.task_id.as_deref() == Some(task_id) => Some(tool.name.as_str()), | |
| 488 | + _ => None, | |
| 489 | + }); | |
| 490 | + match tool { | |
| 491 | + Some("Task" | "Agent") => 0, | |
| 492 | + Some("Bash") => 1, | |
| 493 | + Some("Monitor") => 2, | |
| 494 | + _ => 3, | |
| 495 | + } | |
| 496 | + }; | |
| 497 | + const NOUNS: [(&str, &str); 4] = | |
| 498 | + [("subagent", "subagents"), ("command", "commands"), ("monitor", "monitors"), ("background task", "background tasks")]; | |
| 499 | + let mut tasks: Vec<(usize, &crate::session::BackgroundTask)> = | |
| 500 | + session.background_tasks.iter().map(|task| (kind(&task.task_id), task)).collect(); | |
| 501 | + tasks.sort_by_key(|(kind, _)| *kind); | |
| 502 | + // Subagents and monitors wake the agent; a dev server doesn't. | |
| 503 | + let waiting = tasks.iter().any(|(kind, _)| matches!(kind, 0 | 2)); | |
| 504 | + let title = if let [(kind, task)] = tasks.as_slice() { | |
| 505 | + let noun = NOUNS[*kind].0; | |
| 506 | + let named = !task.description.is_empty(); | |
| 507 | + match (waiting, named) { | |
| 508 | + (true, true) => format!("Waiting on {noun} {}", task.description), | |
| 509 | + (true, false) => format!("Waiting on a {noun}"), | |
| 510 | + (false, true) => format!("Running: {}", task.description), | |
| 511 | + (false, false) => format!("Running a {noun}"), | |
| 512 | + } | |
| 513 | + } else { | |
| 514 | + let mut groups: Vec<String> = Vec::new(); | |
| 515 | + for kind in 0..4 { | |
| 516 | + let count = tasks.iter().filter(|(task_kind, _)| *task_kind == kind).count(); | |
| 517 | + if count > 0 { | |
| 518 | + let (one, many) = NOUNS[kind]; | |
| 519 | + groups.push(format!("{count} {}", if count == 1 { one } else { many })); | |
| 520 | + } | |
| 521 | + } | |
| 522 | + let joined = match groups.len() { | |
| 523 | + 0 | 1 => groups.join(""), | |
| 524 | + n => format!("{} and {}", groups[..n - 1].join(", "), groups[n - 1]), | |
| 525 | + }; | |
| 526 | + format!("{} {joined}", if waiting { "Waiting on" } else { "Running" }) | |
| 527 | + }; | |
| 528 | + let detail = (tasks.len() > 1).then(|| { | |
| 529 | + tasks.iter().map(|(_, task)| task.description.to_string()).collect::<Vec<_>>().join(", ").into() | |
| 530 | + }); | |
| 531 | + notices.push(Notice::BackgroundWork { | |
| 532 | + title: title.into(), | |
| 533 | + detail, | |
| 534 | + waiting, | |
| 535 | + task_ids: tasks.iter().map(|(_, task)| task.task_id.clone()).collect(), | |
| 536 | + }); | |
| 537 | + } | |
| 538 | + if let Some(resets) = session.limited_until() { | |
| 539 | + notices.push(Notice::Limit { resets, resume_at: session.resume_at }); | |
| 540 | + } | |
| 541 | + notices | |
| 542 | + } | |
| 543 | + | |
| 544 | + // ---- the `/`, `$` and `@` menus --------------------------------------- | |
| 545 | + | |
| 546 | + /// The menu for the word at the cursor: `/` at the start of a line lists | |
| 547 | + /// commands (and skills, when set to), `$` lists skills, `@` lists the | |
| 548 | + /// thread folder's files and folders. | |
| 549 | + fn command_menu(&self, cx: &App) -> Option<CommandMenu> { | |
| 550 | + let composer = self.composer.read(cx); | |
| 551 | + let text = composer.value(); | |
| 552 | + if self.menu_dismissed.as_deref() == Some(text.as_ref()) { | |
| 293 | 553 | return None; |
| 294 | 554 | } |
| 295 | - let trigger = text.chars().next().filter(|c| matches!(c, '/' | '$'))?; | |
| 296 | - let query = &text[1..]; | |
| 297 | - if query.contains(char::is_whitespace) { | |
| 555 | + let cursor = composer.cursor().min(text.len()); | |
| 556 | + let before = &text[..cursor]; | |
| 557 | + let start = before.rfind(char::is_whitespace).map_or(0, |at| at + before[at..].chars().next().map_or(1, char::len_utf8)); | |
| 558 | + let word = &before[start..]; | |
| 559 | + let trigger = word.chars().next().filter(|c| matches!(c, '/' | '$' | '@'))?; | |
| 560 | + // `/` only opens a command at the start of a line. | |
| 561 | + if trigger == '/' && start > 0 && !before[..start].ends_with('\n') { | |
| 298 | 562 | return None; |
| 299 | 563 | } |
| 300 | - let query = query.to_lowercase(); | |
| 301 | - let skills_too = settings::get(|settings| settings.slash_skills); | |
| 302 | - let instance = self.session.read(cx).instance.clone(); | |
| 303 | - let commands = Catalog::global(cx).commands(&instance); | |
| 304 | - let wanted = |command: &&SlashCommand| match trigger { | |
| 305 | - '$' => command.skill, | |
| 306 | - _ => !command.skill || skills_too, | |
| 564 | + let query = word[1..].to_lowercase(); | |
| 565 | + let items: Vec<MenuItem> = match trigger { | |
| 566 | + '@' => { | |
| 567 | + let files = self.files.as_ref().map(|(_, files)| files.as_slice()).unwrap_or_default(); | |
| 568 | + let mut matches: Vec<MenuItem> = files | |
| 569 | + .iter() | |
| 570 | + .filter(|path| path.to_lowercase().contains(&query)) | |
| 571 | + .take(MENU_ROWS) | |
| 572 | + .map(|path| MenuItem::Path { folder: path.ends_with('/'), path: path.clone() }) | |
| 573 | + .collect(); | |
| 574 | + matches.sort_by_key(|item| match item { | |
| 575 | + MenuItem::Path { path, .. } => { | |
| 576 | + let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path).to_lowercase(); | |
| 577 | + (!name.starts_with(&query), path.len()) | |
| 578 | + } | |
| 579 | + MenuItem::Command(_) => (true, 0), | |
| 580 | + }); | |
| 581 | + matches | |
| 582 | + } | |
| 583 | + _ => { | |
| 584 | + let skills_too = settings::get(|settings| settings.slash_skills); | |
| 585 | + let instance = self.session.read(cx).instance.clone(); | |
| 586 | + let commands = Catalog::global(cx).commands(&instance); | |
| 587 | + let wanted = |command: &&SlashCommand| match trigger { | |
| 588 | + '$' => command.skill, | |
| 589 | + _ => !command.skill || skills_too, | |
| 590 | + }; | |
| 591 | + let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands | |
| 592 | + .iter() | |
| 593 | + .filter(wanted) | |
| 594 | + .filter(|command| command.name.to_lowercase().contains(&query)) | |
| 595 | + .cloned() | |
| 596 | + .partition(|command| command.name.to_lowercase().starts_with(&query)); | |
| 597 | + prefix.append(&mut inside); | |
| 598 | + prefix.truncate(MENU_ROWS); | |
| 599 | + prefix.into_iter().map(MenuItem::Command).collect() | |
| 600 | + } | |
| 307 | 601 | }; |
| 308 | - let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands | |
| 309 | - .iter() | |
| 310 | - .filter(wanted) | |
| 311 | - .filter(|command| command.name.to_lowercase().contains(&query)) | |
| 312 | - .cloned() | |
| 313 | - .partition(|command| command.name.to_lowercase().starts_with(&query)); | |
| 314 | - prefix.append(&mut inside); | |
| 315 | - prefix.truncate(SLASH_ROWS); | |
| 316 | - Some(prefix).filter(|matches| !matches.is_empty()) | |
| 602 | + Some(CommandMenu { trigger, start, items }) | |
| 317 | 603 | } |
| 318 | 604 | |
| 319 | - /// Puts the picked command in the composer, ready for its arguments. | |
| 320 | - fn accept_slash(&mut self, command: &SlashCommand, window: &mut Window, cx: &mut Context<Self>) { | |
| 321 | - let text = format!("/{} ", command.name); | |
| 605 | + /// Reads the thread folder's files in the background once `@` is typed. | |
| 606 | + fn load_files_if_mentioning(&mut self, cx: &mut Context<Self>) { | |
| 607 | + let cwd = self.session.read(cx).cwd.clone(); | |
| 608 | + let text = self.composer.read(cx).value(); | |
| 609 | + if !text.contains('@') || self.files.as_ref().is_some_and(|(dir, _)| *dir == cwd) || self.session.read(cx).is_remote() { | |
| 610 | + return; | |
| 611 | + } | |
| 612 | + self.files = Some((cwd.clone(), Vec::new())); | |
| 613 | + let task = cx.background_executor().spawn({ | |
| 614 | + let cwd = cwd.clone(); | |
| 615 | + async move { list_files(&cwd) } | |
| 616 | + }); | |
| 617 | + self.files_task = cx.spawn(async move |this, cx| { | |
| 618 | + let files = task.await; | |
| 619 | + let _ = this.update(cx, |this, cx| { | |
| 620 | + this.files = Some((cwd, files)); | |
| 621 | + cx.notify(); | |
| 622 | + }); | |
| 623 | + }); | |
| 624 | + } | |
| 625 | + | |
| 626 | + /// Puts the picked item in the composer in place of the word typed. | |
| 627 | + fn accept_menu_item(&mut self, menu: &CommandMenu, item: &MenuItem, window: &mut Window, cx: &mut Context<Self>) { | |
| 628 | + let replacement = match item { | |
| 629 | + MenuItem::Command(command) => format!("{}{} ", if menu.trigger == '$' { '$' } else { '/' }, command.name), | |
| 630 | + MenuItem::Path { path, .. } => format!("@{path} "), | |
| 631 | + }; | |
| 632 | + let start = menu.start; | |
| 322 | 633 | self.composer.update(cx, |composer, cx| { |
| 323 | - composer.set_value(text, window, cx); | |
| 634 | + let cursor = composer.cursor(); | |
| 635 | + composer.set_selected_range(start..cursor, cx); | |
| 636 | + composer.replace(replacement, window, cx); | |
| 324 | 637 | composer.focus(window, cx); |
| 325 | 638 | }); |
| 326 | - self.slash_selected = 0; | |
| 639 | + self.menu_selected = 0; | |
| 327 | 640 | cx.notify(); |
| 328 | 641 | } |
| 329 | 642 | |
| 330 | - /// Keys the `/` menu takes before the composer sees them, and | |
| 331 | - /// Ctrl+Shift+Enter, which sends against the follow-up setting when | |
| 332 | - /// Ctrl+Enter is the send key. | |
| 643 | + /// Keys the menus take before the composer sees them, Ctrl+Shift+Enter | |
| 644 | + /// (the send key's opposite, or steering the first queued message), and | |
| 645 | + /// Alt+↑ to edit the last queued message. | |
| 333 | 646 | fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) { |
| 334 | 647 | let keystroke = &event.keystroke; |
| 335 | 648 | let modifiers = &keystroke.modifiers; |
| 336 | 649 | if keystroke.key == "enter" && modifiers.control && modifiers.shift { |
| 337 | 650 | cx.stop_propagation(); |
| 338 | - self.submit(true, window, cx); | |
| 651 | + let queued = !self.session.read(cx).queue.is_empty(); | |
| 652 | + if queued && self.composer.read(cx).value().trim().is_empty() { | |
| 653 | + self.session.update(cx, |session, cx| session.steer_queued(0, cx)); | |
| 654 | + } else { | |
| 655 | + self.submit(true, window, cx); | |
| 656 | + } | |
| 339 | 657 | return; |
| 340 | 658 | } |
| 341 | - let Some(matches) = self.slash_matches(cx) else { return }; | |
| 659 | + if keystroke.key == "up" && modifiers.alt && !modifiers.control { | |
| 660 | + let count = self.session.read(cx).queue.len(); | |
| 661 | + if count > 0 { | |
| 662 | + cx.stop_propagation(); | |
| 663 | + self.edit_queued(count - 1, window, cx); | |
| 664 | + } | |
| 665 | + return; | |
| 666 | + } | |
| 667 | + if keystroke.key == "tab" && modifiers.shift && settings::get(|settings| settings.plan_mode) { | |
| 668 | + cx.stop_propagation(); | |
| 669 | + self.session.update(cx, |session, cx| session.toggle_plan(cx)); | |
| 670 | + return; | |
| 671 | + } | |
| 672 | + let Some(menu) = self.command_menu(cx) else { return }; | |
| 342 | 673 | if modifiers.control || modifiers.alt || modifiers.shift { |
| 343 | 674 | return; |
| 344 | 675 | } |
| 676 | + let count = menu.items.len(); | |
| 345 | 677 | match keystroke.key.as_str() { |
| 346 | - "up" => self.slash_selected = (self.slash_selected + matches.len() - 1) % matches.len(), | |
| 347 | - "down" => self.slash_selected = (self.slash_selected + 1) % matches.len(), | |
| 348 | - "enter" | "tab" => { | |
| 349 | - let command = matches[self.slash_selected.min(matches.len() - 1)].clone(); | |
| 678 | + "up" if count > 0 => self.menu_selected = (self.menu_selected + count - 1) % count, | |
| 679 | + "down" if count > 0 => self.menu_selected = (self.menu_selected + 1) % count, | |
| 680 | + "enter" | "tab" if count > 0 => { | |
| 681 | + let item = menu.items[self.menu_selected.min(count - 1)].clone(); | |
| 350 | 682 | cx.stop_propagation(); |
| 351 | - self.accept_slash(&command, window, cx); | |
| 683 | + self.accept_menu_item(&menu, &item, window, cx); | |
| 352 | 684 | return; |
| 353 | 685 | } |
| 354 | - "escape" => self.slash_dismissed = Some(self.composer.read(cx).value().to_string()), | |
| 686 | + "escape" => self.menu_dismissed = Some(self.composer.read(cx).value().to_string()), | |
| 355 | 687 | _ => return, |
| 356 | 688 | } |
| 357 | 689 | cx.stop_propagation(); |
| 358 | 690 | cx.notify(); |
| 359 | 691 | } |
| 360 | 692 | |
| 361 | - fn render_slash_menu(&self, matches: Vec<SlashCommand>, cx: &mut Context<Self>) -> Div { | |
| 693 | + /// The menu, docked on top of the composer like T3 Code's. | |
| 694 | + fn render_command_menu(&self, menu: CommandMenu, cx: &mut Context<Self>) -> AnyElement { | |
| 362 | 695 | let p = metal::palette(cx); |
| 363 | - let selected = self.slash_selected.min(matches.len().saturating_sub(1)); | |
| 364 | - pickers::panel(p) | |
| 365 | - .w_full() | |
| 366 | - .mb(px(8.)) | |
| 696 | + let selected = self.menu_selected.min(menu.items.len().saturating_sub(1)); | |
| 697 | + let loading = menu.trigger == '@' && self.files.as_ref().is_some_and(|(_, files)| files.is_empty()); | |
| 698 | + let empty = match menu.trigger { | |
| 699 | + '$' => "No skills found. Try / to browse provider commands.", | |
| 700 | + '@' if loading => "Searching workspace files...", | |
| 701 | + '@' => "No matching files or folders.", | |
| 702 | + _ => "No matching command.", | |
| 703 | + }; | |
| 704 | + let trigger = menu.trigger; | |
| 705 | + let rows: Vec<AnyElement> = menu | |
| 706 | + .items | |
| 707 | + .iter() | |
| 708 | + .enumerate() | |
| 709 | + .map(|(ix, item)| { | |
| 710 | + let pick = item.clone(); | |
| 711 | + let start = menu.start; | |
| 712 | + let row = h_flex() | |
| 713 | + .id(("command-menu-item", ix)) | |
| 714 | + .min_h(px(28.)) | |
| 715 | + .gap(px(8.)) | |
| 716 | + .px(px(8.)) | |
| 717 | + .py(px(6.)) | |
| 718 | + .rounded(px(6.)) | |
| 719 | + .cursor_pointer() | |
| 720 | + .text_size(px(13.5)) | |
| 721 | + .when(ix == selected, |this| this.bg(p.menu_row_hover)) | |
| 722 | + .hover(|style| style.bg(p.menu_row_hover)) | |
| 723 | + .on_click(cx.listener(move |this, _, window, cx| { | |
| 724 | + let menu = CommandMenu { trigger, start, items: Vec::new() }; | |
| 725 | + this.accept_menu_item(&menu, &pick, window, cx) | |
| 726 | + })); | |
| 727 | + match item { | |
| 728 | + MenuItem::Command(command) => row | |
| 729 | + .child( | |
| 730 | + div() | |
| 731 | + .flex_none() | |
| 732 | + .max_w(relative(0.45)) | |
| 733 | + .truncate() | |
| 734 | + .text_size(px(12.)) | |
| 735 | + .font_weight(FontWeight::MEDIUM) | |
| 736 | + .text_color(p.ink) | |
| 737 | + .when(command.skill && trigger == '/', |this| { | |
| 738 | + this.child(div().text_color(p.ink3).child("/skill:")).child(command.name.clone()) | |
| 739 | + }) | |
| 740 | + .when(!(command.skill && trigger == '/'), |this| { | |
| 741 | + this.child(format!("{}{}", if trigger == '$' { '$' } else { '/' }, command.name)) | |
| 742 | + }), | |
| 743 | + ) | |
| 744 | + .child( | |
| 745 | + div() | |
| 746 | + .flex_1() | |
| 747 | + .min_w_0() | |
| 748 | + .truncate() | |
| 749 | + .text_size(px(12.)) | |
| 750 | + .text_color(p.ink3) | |
| 751 | + .child(if command.description.is_empty() { | |
| 752 | + command.argument_hint.clone() | |
| 753 | + } else { | |
| 754 | + command.description.clone() | |
| 755 | + }), | |
| 756 | + ) | |
| 757 | + .when(command.skill, |this| { | |
| 758 | + this.child(metal::tag(if trigger == '$' { "Personal Skill" } else { "Personal" }, p)) | |
| 759 | + }) | |
| 760 | + .into_any_element(), | |
| 761 | + MenuItem::Path { path, folder } => { | |
| 762 | + let trimmed = path.trim_end_matches('/'); | |
| 763 | + let (dir, name) = match trimmed.rsplit_once('/') { | |
| 764 | + Some((dir, name)) => (dir.to_string(), name.to_string()), | |
| 765 | + None => (String::new(), trimmed.to_string()), | |
| 766 | + }; | |
| 767 | + row.child( | |
| 768 | + Icon::new(if *folder { IconName::Folder } else { IconName::File }).size(px(16.)).text_color(p.ink3), | |
| 769 | + ) | |
| 770 | + .child( | |
| 771 | + div() | |
| 772 | + .flex_none() | |
| 773 | + .max_w(relative(0.45)) | |
| 774 | + .truncate() | |
| 775 | + .text_size(px(12.)) | |
| 776 | + .font_weight(FontWeight::MEDIUM) | |
| 777 | + .text_color(p.ink) | |
| 778 | + .child(name), | |
| 779 | + ) | |
| 780 | + .child(div().flex_1().min_w_0().truncate().text_size(px(12.)).text_color(p.ink3).child(dir)) | |
| 781 | + .into_any_element() | |
| 782 | + } | |
| 783 | + } | |
| 784 | + }) | |
| 785 | + .collect(); | |
| 786 | + let empty_rows = rows.is_empty(); | |
| 787 | + composer::banner(Tone::Neutral, Density::Default, false, p) | |
| 367 | 788 | .child( |
| 368 | 789 | v_flex() |
| 369 | - .id("slash-menu") | |
| 370 | - .max_h(px(280.)) | |
| 790 | + .id("command-menu") | |
| 791 | + .max_h(px(288.)) | |
| 371 | 792 | .overflow_y_scroll() |
| 372 | - .gap(px(1.)) | |
| 373 | - .children(matches.into_iter().enumerate().map(|(ix, command)| { | |
| 374 | - let pick = command.clone(); | |
| 375 | - h_flex() | |
| 376 | - .id(("slash-command", ix)) | |
| 377 | - .gap(px(10.)) | |
| 378 | - .px(px(10.)) | |
| 379 | - .py(px(6.)) | |
| 380 | - .rounded(px(10.)) | |
| 381 | - .cursor_pointer() | |
| 382 | - .when(ix == selected, |this| this.bg(p.menu_row_hover)) | |
| 383 | - .hover(|style| style.bg(p.menu_row_hover)) | |
| 384 | - .on_click(cx.listener(move |this, _, window, cx| this.accept_slash(&pick, window, cx))) | |
| 385 | - .child( | |
| 386 | - div() | |
| 387 | - .flex_none() | |
| 388 | - .font_family(metal::MONO) | |
| 389 | - .text_size(px(12.5)) | |
| 390 | - .text_color(p.ink) | |
| 391 | - .child(format!("/{}", command.name)), | |
| 392 | - ) | |
| 393 | - .when(!command.argument_hint.is_empty(), |this| { | |
| 394 | - this.child( | |
| 395 | - div() | |
| 396 | - .min_w_0() | |
| 397 | - .max_w(px(200.)) | |
| 398 | - .truncate() | |
| 399 | - .font_family(metal::MONO) | |
| 400 | - .text_size(px(11.5)) | |
| 401 | - .text_color(p.ink3) | |
| 402 | - .child(command.argument_hint.clone()), | |
| 403 | - ) | |
| 404 | - }) | |
| 405 | - .child( | |
| 406 | - div() | |
| 407 | - .flex_1() | |
| 408 | - .min_w_0() | |
| 409 | - .truncate() | |
| 410 | - .text_size(px(12.5)) | |
| 411 | - .text_color(p.ink3) | |
| 412 | - .child(command.description.clone()), | |
| 413 | - ) | |
| 414 | - .when(command.skill, |this| this.child(metal::tag("Skill", p))) | |
| 415 | - })), | |
| 793 | + .p(px(4.)) | |
| 794 | + .children(rows) | |
| 795 | + .when(empty_rows, |this| { | |
| 796 | + this.child(div().px(px(14.)).pt(px(20.)).pb(px(12.)).text_size(px(12.)).text_color(p.ink3).child(empty)) | |
| 797 | + }), | |
| 416 | 798 | ) |
| 799 | + .into_any_element() | |
| 417 | 800 | } |
| 418 | 801 | |
| 419 | 802 | fn escape(&mut self, cx: &mut Context<Self>) { |
| 420 | - if self.slash_matches(cx).is_some() { | |
| 421 | - self.slash_dismissed = Some(self.composer.read(cx).value().to_string()); | |
| 803 | + if self.command_menu(cx).is_some() { | |
| 804 | + self.menu_dismissed = Some(self.composer.read(cx).value().to_string()); | |
| 805 | + cx.notify(); | |
| 806 | + return; | |
| 807 | + } | |
| 808 | + if self.open_picker.take().is_some() { | |
| 422 | 809 | cx.notify(); |
| 423 | 810 | return; |
| 424 | 811 | } |
| @@ -542,8 +929,10 @@ impl ChatView { | ||
| 542 | 929 | fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context<Self>) { |
| 543 | 930 | if open { |
| 544 | 931 | self.open_picker = Some(picker); |
| 545 | - if picker == Picker::Model { | |
| 546 | - self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)); | |
| 932 | + match picker { | |
| 933 | + Picker::Model => self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)), | |
| 934 | + Picker::Branch => self.open_branch_picker(window, cx), | |
| 935 | + _ => {} | |
| 547 | 936 | } |
| 548 | 937 | } else if self.open_picker == Some(picker) { |
| 549 | 938 | self.open_picker = None; |
| @@ -685,315 +1074,94 @@ impl ChatView { | ||
| 685 | 1074 | .child( |
| 686 | 1075 | MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| { |
| 687 | 1076 | render_item(&session, &scroller, &chat, ix, window, cx) |
| 688 | - }) | |
| 689 | - .flex_1() | |
| 690 | - .w_full() | |
| 691 | - .with_row_style(StyleRefinement::default().pb_0()) | |
| 692 | - .with_bottom_fade(metal::palette(cx).s), | |
| 693 | - ) | |
| 694 | - } | |
| 695 | - | |
| 696 | - /// Above the composer: the usage limit that stopped the thread, and when | |
| 697 | - /// it continues. | |
| 698 | - fn render_limit(&self, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 699 | - let p = metal::palette(cx); | |
| 700 | - let session = self.session.read(cx); | |
| 701 | - let resets = session.limited_until()?; | |
| 702 | - let resume_at = session.resume_at; | |
| 703 | - let session = self.session.clone(); | |
| 704 | - Some( | |
| 705 | - h_flex() | |
| 706 | - .w_full() | |
| 707 | - .max_w(px(CONTENT_WIDTH)) | |
| 708 | - .px(px(14.)) | |
| 709 | - .py(px(9.)) | |
| 710 | - .gap(px(10.)) | |
| 711 | - .rounded(px(14.)) | |
| 712 | - .region(p) | |
| 713 | - .child(div().pt(px(1.)).child(metal::led("limit-led", Led::Waiting, false, p))) | |
| 714 | - .child( | |
| 715 | - div() | |
| 716 | - .flex_1() | |
| 717 | - .min_w_0() | |
| 718 | - .text_size(px(13.)) | |
| 719 | - .text_color(p.ink2) | |
| 720 | - .child(match resume_at { | |
| 721 | - Some(at) => format!("Usage limit reached. The thread continues at {}.", clock::when(at)), | |
| 722 | - None => format!("Usage limit reached. It resets at {}.", clock::when(resets)), | |
| 723 | - }), | |
| 724 | - ) | |
| 725 | - .child(match resume_at { | |
| 726 | - Some(_) => MetalButton::new("cancel-resume") | |
| 727 | - .ghost() | |
| 728 | - .compact() | |
| 729 | - .label("Cancel") | |
| 730 | - .tooltip("Don't continue on its own") | |
| 731 | - .on_click(move |_, _, cx| session.update(cx, |session, cx| session.cancel_resume(cx))), | |
| 732 | - None => MetalButton::new("schedule-resume") | |
| 733 | - .compact() | |
| 734 | - .icon(Lucide::AlarmClock) | |
| 735 | - .label("Continue then") | |
| 736 | - .on_click(move |_, _, cx| { | |
| 737 | - session.update(cx, |session, cx| { | |
| 738 | - session.schedule_resume(resets + Duration::from_secs(60), cx) | |
| 739 | - }) | |
| 740 | - }), | |
| 741 | - }) | |
| 742 | - .into_any_element(), | |
| 743 | - ) | |
| 744 | - } | |
| 745 | - | |
| 746 | - /// Messages waiting for the turn to end, each with a way to send it now. | |
| 747 | - fn render_queue(&self, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 748 | - let p = metal::palette(cx); | |
| 749 | - let queue = self.session.read(cx).queue.clone(); | |
| 750 | - if queue.is_empty() { | |
| 751 | - return None; | |
| 752 | - } | |
| 753 | - Some( | |
| 754 | - v_flex() | |
| 755 | - .w_full() | |
| 756 | - .max_w(px(CONTENT_WIDTH)) | |
| 757 | - .gap(px(4.)) | |
| 758 | - .children(queue.into_iter().enumerate().map(|(ix, message)| { | |
| 759 | - let steer = self.session.clone(); | |
| 760 | - let remove = self.session.clone(); | |
| 761 | - let text = message.text.lines().next().unwrap_or_default().to_string(); | |
| 762 | - h_flex() | |
| 763 | - .h(px(32.)) | |
| 764 | - .pl(px(12.)) | |
| 765 | - .pr(px(4.)) | |
| 766 | - .gap(px(8.)) | |
| 767 | - .rounded(px(12.)) | |
| 768 | - .well(p) | |
| 769 | - .child(Icon::new(Lucide::ListOrdered).size(px(13.)).text_color(p.ink3)) | |
| 770 | - .child(metal::engraved("Queued", p)) | |
| 771 | - .child(div().flex_1().min_w_0().truncate().text_size(px(13.)).text_color(p.ink2).child(text)) | |
| 772 | - .when(!message.attachments.is_empty(), |this| { | |
| 773 | - this.child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3)) | |
| 774 | - }) | |
| 775 | - .child( | |
| 776 | - MetalButton::new(("steer-queued", ix)) | |
| 777 | - .ghost() | |
| 778 | - .mini() | |
| 779 | - .label("Steer") | |
| 780 | - .tooltip("Send it now, mid-turn") | |
| 781 | - .on_click(move |_, _, cx| steer.update(cx, |session, cx| session.steer_queued(ix, cx))), | |
| 782 | - ) | |
| 783 | - .child( | |
| 784 | - MetalButton::new(("remove-queued", ix)) | |
| 785 | - .ghost() | |
| 786 | - .mini() | |
| 787 | - .icon(IconName::Close) | |
| 788 | - .tooltip("Remove") | |
| 789 | - .on_click(move |_, _, cx| remove.update(cx, |session, cx| session.remove_queued(ix, cx))), | |
| 790 | - ) | |
| 791 | - })) | |
| 792 | - .into_any_element(), | |
| 793 | - ) | |
| 794 | - } | |
| 795 | - | |
| 796 | - /// The line above the composer: what the agent is doing and for how long. | |
| 797 | - fn render_activity(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 798 | - let p = metal::palette(cx); | |
| 799 | - let session = self.session.read(cx); | |
| 800 | - let working = session.is_working(); | |
| 801 | - let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0); | |
| 802 | - let waiting = session.needs_attention(); | |
| 803 | - let preparing = session.preparing; | |
| 804 | - | |
| 805 | - h_flex() | |
| 806 | - .w_full() | |
| 807 | - .max_w(px(CONTENT_WIDTH)) | |
| 808 | - .h(px(22.)) | |
| 809 | - .px(px(6.)) | |
| 810 | - .gap(px(8.)) | |
| 811 | - .when(working, |this| { | |
| 812 | - let (led, label) = if waiting { | |
| 813 | - (Led::Waiting, "Waiting for you") | |
| 814 | - } else if preparing { | |
| 815 | - (Led::Live, "Creating worktree") | |
| 816 | - } else if session.thinking { | |
| 817 | - (Led::Live, "Thinking") | |
| 818 | - } else { | |
| 819 | - (Led::Live, "Working") | |
| 820 | - }; | |
| 821 | - this.child(metal::led("activity-led", led, true, p)) | |
| 822 | - .child(metal::engraved(label, p)) | |
| 823 | - .child( | |
| 824 | - div() | |
| 825 | - .font_family(metal::PIXEL) | |
| 826 | - .text_size(px(15.)) | |
| 827 | - .text_color(p.ink) | |
| 828 | - .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)), | |
| 829 | - ) | |
| 830 | - .child(div().flex_1()) | |
| 831 | - .child(metal::keycap("Esc", p)) | |
| 832 | - .child(metal::engraved("Interrupt", p)) | |
| 833 | - }) | |
| 834 | - } | |
| 835 | - | |
| 836 | - /// What Claude left running in the background, each with its age and a | |
| 837 | - /// stop button. Claude is woken up when one finishes. | |
| 838 | - fn render_background_tasks(&self, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 839 | - let p = metal::palette(cx); | |
| 840 | - let session = self.session.read(cx); | |
| 841 | - if session.background_tasks.is_empty() { | |
| 842 | - return None; | |
| 843 | - } | |
| 844 | - let count = session.background_tasks.len(); | |
| 845 | - let rows = session.background_tasks.iter().enumerate().map(|(row, task)| { | |
| 846 | - let elapsed = task.started.elapsed().as_secs(); | |
| 847 | - let stop_session = self.session.clone(); | |
| 848 | - let task_id = task.task_id.clone(); | |
| 849 | - h_flex() | |
| 850 | - .h(px(28.)) | |
| 851 | - .gap(px(10.)) | |
| 852 | - .px(px(4.)) | |
| 853 | - .child(metal::led(("background-led", row), Led::Live, true, p)) | |
| 854 | - .child( | |
| 855 | - div() | |
| 856 | - .flex_1() | |
| 857 | - .min_w_0() | |
| 858 | - .truncate() | |
| 859 | - .text_size(px(12.5)) | |
| 860 | - .text_color(p.ink2) | |
| 861 | - .child(task.description.clone()), | |
| 862 | - ) | |
| 863 | - .child( | |
| 864 | - div() | |
| 865 | - .font_family(metal::PIXEL) | |
| 866 | - .text_size(px(14.)) | |
| 867 | - .text_color(p.ink2) | |
| 868 | - .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)), | |
| 869 | - ) | |
| 870 | - .child( | |
| 871 | - MetalButton::new(("background-stop", row)) | |
| 872 | - .ghost() | |
| 873 | - .mini() | |
| 874 | - .icon(IconName::Square) | |
| 875 | - .tooltip("Stop this task") | |
| 876 | - .on_click(move |_, _, cx| { | |
| 877 | - stop_session.update(cx, |session, cx| session.stop_task(&task_id, cx)) | |
| 878 | - }), | |
| 879 | - ) | |
| 880 | - }); | |
| 881 | - Some( | |
| 882 | - v_flex() | |
| 883 | - .w_full() | |
| 884 | - .max_w(px(CONTENT_WIDTH)) | |
| 885 | - .px(px(10.)) | |
| 886 | - .py(px(6.)) | |
| 887 | - .rounded(px(14.)) | |
| 888 | - .region(p) | |
| 889 | - .child( | |
| 890 | - h_flex() | |
| 891 | - .h(px(18.)) | |
| 892 | - .px(px(4.)) | |
| 893 | - .gap(px(8.)) | |
| 894 | - .child(metal::engraved( | |
| 895 | - if count == 1 { "1 background task".to_string() } else { format!("{count} background tasks") }, | |
| 896 | - p, | |
| 897 | - )) | |
| 898 | - .child(div().flex_1()) | |
| 899 | - .when(!session.is_working(), |this| { | |
| 900 | - this.child(metal::engraved("Claude reports back when it finishes", p)) | |
| 901 | - }), | |
| 902 | - ) | |
| 903 | - .children(rows) | |
| 904 | - .into_any_element(), | |
| 905 | - ) | |
| 906 | - } | |
| 907 | - | |
| 908 | - fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 909 | - let p = metal::palette(cx); | |
| 910 | - h_flex() | |
| 911 | - .flex_wrap() | |
| 912 | - .gap(px(6.)) | |
| 913 | - .px(px(12.)) | |
| 914 | - .pt(px(12.)) | |
| 915 | - .children(self.attachments.iter().enumerate().map(|(ix, path)| { | |
| 916 | - let name = path | |
| 917 | - .file_name() | |
| 918 | - .map(|name| name.to_string_lossy().to_string()) | |
| 919 | - .unwrap_or_else(|| path.display().to_string()); | |
| 920 | - let is_image = matches!( | |
| 921 | - path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(), | |
| 922 | - Some("png" | "jpg" | "jpeg" | "gif" | "webp") | |
| 923 | - ); | |
| 924 | - h_flex() | |
| 925 | - .h(px(28.)) | |
| 926 | - .pl(px(10.)) | |
| 927 | - .pr(px(3.)) | |
| 928 | - .gap(px(7.)) | |
| 929 | - .rounded(px(10.)) | |
| 930 | - .well(p) | |
| 931 | - .text_size(px(12.5)) | |
| 932 | - .text_color(p.ink2) | |
| 933 | - .child( | |
| 934 | - Icon::new(if is_image { Lucide::Image } else { Lucide::File }) | |
| 935 | - .size(px(13.)) | |
| 936 | - .text_color(p.ink3), | |
| 937 | - ) | |
| 938 | - .child(div().max_w(px(220.)).truncate().child(name)) | |
| 939 | - .child( | |
| 940 | - MetalButton::new(("remove-attachment", ix)) | |
| 941 | - .ghost() | |
| 942 | - .mini() | |
| 943 | - .icon(IconName::Close) | |
| 944 | - .tooltip("Remove") | |
| 945 | - .on_click(cx.listener(move |this, _, _, cx| { | |
| 946 | - if ix < this.attachments.len() { | |
| 947 | - this.attachments.remove(ix); | |
| 948 | - } | |
| 949 | - cx.notify(); | |
| 950 | - })), | |
| 951 | - ) | |
| 952 | - })) | |
| 1077 | + }) | |
| 1078 | + .flex_1() | |
| 1079 | + .w_full() | |
| 1080 | + .with_row_style(StyleRefinement::default().pb_0()) | |
| 1081 | + .with_bottom_fade(metal::palette(cx).s), | |
| 1082 | + ) | |
| 953 | 1083 | } |
| 954 | 1084 | |
| 955 | - /// A ring showing how full the context window is. | |
| 956 | - fn render_context_ring(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 1085 | + /// The line closing the conversation while the agent works, as T3 Code | |
| 1086 | + /// ends its timeline: "Working for 12s", or "Setting up worktree…"; and | |
| 1087 | + /// once it's done, whether the thread is snoozed or settled. | |
| 1088 | + fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 957 | 1089 | let p = metal::palette(cx); |
| 958 | 1090 | let session = self.session.read(cx); |
| 959 | - let tokens = session.context_tokens.unwrap_or(0); | |
| 960 | - let window = session.context_window; | |
| 961 | - let percent = window.map_or(0., |window| tokens as f32 * 100. / window.max(1) as f32); | |
| 962 | - let color = if percent >= 90. { | |
| 963 | - p.red | |
| 964 | - } else if percent >= 70. { | |
| 965 | - p.amber_ink | |
| 966 | - } else { | |
| 967 | - p.ink3 | |
| 968 | - }; | |
| 969 | - let tooltip: SharedString = match (session.context_tokens, window) { | |
| 970 | - (Some(tokens), Some(window)) => format!( | |
| 971 | - "Context: {} of {} tokens ({percent:.0}%)", | |
| 972 | - format_tokens(tokens), | |
| 973 | - format_tokens(window) | |
| 974 | - ) | |
| 975 | - .into(), | |
| 976 | - (Some(tokens), None) => format!("Context: {} tokens", format_tokens(tokens)).into(), | |
| 977 | - _ => "Context usage appears after the first reply".into(), | |
| 1091 | + if session.is_working() { | |
| 1092 | + if session.needs_attention() { | |
| 1093 | + return None; | |
| 1094 | + } | |
| 1095 | + let label = if session.preparing { | |
| 1096 | + "Setting up worktree…".to_string() | |
| 1097 | + } else { | |
| 1098 | + let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0); | |
| 1099 | + format!("Working for {}", format_elapsed(elapsed)) | |
| 1100 | + }; | |
| 1101 | + return Some( | |
| 1102 | + h_flex() | |
| 1103 | + .w_full() | |
| 1104 | + .max_w(px(CONTENT_WIDTH)) | |
| 1105 | + .px(px(16.)) | |
| 1106 | + .child( | |
| 1107 | + h_flex() | |
| 1108 | + .w_full() | |
| 1109 | + .h(px(24.)) | |
| 1110 | + .px(px(4.)) | |
| 1111 | + .gap(px(8.)) | |
| 1112 | + .mb(px(8.)) | |
| 1113 | + .text_size(px(14.)) | |
| 1114 | + .text_color(p.ink3) | |
| 1115 | + .child(metal::led("working-led", Led::Live, true, p)) | |
| 1116 | + .child(label), | |
| 1117 | + ) | |
| 1118 | + .into_any_element(), | |
| 1119 | + ); | |
| 1120 | + } | |
| 1121 | + let snoozed = session.snoozed_until.filter(|_| session.is_snoozed()); | |
| 1122 | + let settled = session.settled; | |
| 1123 | + if snoozed.is_none() && !settled { | |
| 1124 | + return None; | |
| 1125 | + } | |
| 1126 | + let action_session = self.session.clone(); | |
| 1127 | + let (icon, label, action): (Icon, String, &'static str) = match snoozed { | |
| 1128 | + Some(until) => (Icon::new(Lucide::AlarmClock), format!("Snoozed, {}", clock::when(until)), "Wake now"), | |
| 1129 | + None => (Icon::new(IconName::CircleCheck), "Settled".to_string(), "Un-settle"), | |
| 978 | 1130 | }; |
| 979 | - | |
| 980 | - div() | |
| 981 | - .id("context-ring") | |
| 982 | - .flex() | |
| 983 | - .items_center() | |
| 984 | - .justify_center() | |
| 985 | - .size(px(26.)) | |
| 986 | - .rounded_full() | |
| 987 | - .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) | |
| 988 | - .child( | |
| 989 | - ProgressCircle::new("context-progress") | |
| 990 | - .value(percent.max(if session.context_tokens.is_some() { 1. } else { 0. })) | |
| 991 | - .color(color) | |
| 992 | - .with_size(gpui_kit::component::Size::Size(px(16.))), | |
| 993 | - ) | |
| 1131 | + Some( | |
| 1132 | + h_flex() | |
| 1133 | + .w_full() | |
| 1134 | + .max_w(px(CONTENT_WIDTH)) | |
| 1135 | + .px(px(16.)) | |
| 1136 | + .pb(px(8.)) | |
| 1137 | + .gap(px(6.)) | |
| 1138 | + .text_size(px(12.)) | |
| 1139 | + .text_color(p.ink3) | |
| 1140 | + .child(icon.size(px(14.))) | |
| 1141 | + .child(div().truncate().child(label)) | |
| 1142 | + .child("·") | |
| 1143 | + .child( | |
| 1144 | + div() | |
| 1145 | + .id("status-line-action") | |
| 1146 | + .font_weight(FontWeight::MEDIUM) | |
| 1147 | + .cursor_pointer() | |
| 1148 | + .hover(|style| style.text_color(p.ink).underline()) | |
| 1149 | + .on_click(move |_, _, cx| { | |
| 1150 | + action_session.update(cx, |session, cx| { | |
| 1151 | + if snoozed.is_some() { | |
| 1152 | + session.snooze(None, cx) | |
| 1153 | + } else { | |
| 1154 | + session.unsettle(cx) | |
| 1155 | + } | |
| 1156 | + }) | |
| 1157 | + }) | |
| 1158 | + .child(action), | |
| 1159 | + ) | |
| 1160 | + .into_any_element(), | |
| 1161 | + ) | |
| 994 | 1162 | } |
| 995 | 1163 | |
| 996 | - /// A composer picker: a ghost button that opens `content` above it. | |
| 1164 | + /// A composer picker: a trigger that opens `content` above it. | |
| 997 | 1165 | fn picker( |
| 998 | 1166 | &self, |
| 999 | 1167 | picker: Picker, |
| @@ -1001,159 +1169,89 @@ impl ChatView { | ||
| 1001 | 1169 | content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, |
| 1002 | 1170 | cx: &mut Context<Self>, |
| 1003 | 1171 | ) -> impl IntoElement { |
| 1004 | - Popover::new(match picker { | |
| 1005 | - Picker::Model => "model-popover", | |
| 1006 | - Picker::Reasoning => "reasoning-popover", | |
| 1007 | - Picker::Access => "access-popover", | |
| 1008 | - }) | |
| 1009 | - .anchor(Anchor::BottomLeft) | |
| 1010 | - .p_0() | |
| 1011 | - .bg(gpui_kit::transparent_black()) | |
| 1012 | - .shadow_none() | |
| 1013 | - .open(self.open_picker == Some(picker)) | |
| 1014 | - .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) | |
| 1015 | - .trigger(trigger) | |
| 1016 | - .content(move |_, window, cx| content(window, cx)) | |
| 1172 | + Popover::new(SharedString::from(format!("{picker:?}-popover"))) | |
| 1173 | + .anchor(Anchor::BottomLeft) | |
| 1174 | + .p_0() | |
| 1175 | + .bg(gpui_kit::transparent_black()) | |
| 1176 | + .shadow_none() | |
| 1177 | + .open(self.open_picker == Some(picker)) | |
| 1178 | + .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) | |
| 1179 | + .trigger(trigger) | |
| 1180 | + .content(move |_, window, cx| content(window, cx)) | |
| 1181 | + } | |
| 1182 | + | |
| 1183 | + /// A picker whose content reads the conversation as it opens. | |
| 1184 | + fn picker_anchored( | |
| 1185 | + &self, | |
| 1186 | + picker: Picker, | |
| 1187 | + trigger: MetalButton, | |
| 1188 | + anchor: Anchor, | |
| 1189 | + cx: &mut Context<Self>, | |
| 1190 | + content: impl Fn(&mut Self, &mut Context<Self>) -> AnyElement + 'static, | |
| 1191 | + ) -> impl IntoElement { | |
| 1192 | + let chat = cx.entity().downgrade(); | |
| 1193 | + let content = std::rc::Rc::new(content); | |
| 1194 | + Popover::new(SharedString::from(format!("{picker:?}-popover"))) | |
| 1195 | + .anchor(anchor) | |
| 1196 | + .p_0() | |
| 1197 | + .bg(gpui_kit::transparent_black()) | |
| 1198 | + .shadow_none() | |
| 1199 | + .open(self.open_picker == Some(picker)) | |
| 1200 | + .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) | |
| 1201 | + .trigger(trigger) | |
| 1202 | + .content(move |_, _, cx| { | |
| 1203 | + let content = content.clone(); | |
| 1204 | + chat.update(cx, |this, cx| content(this, cx)).unwrap_or_else(|_| div().into_any_element()) | |
| 1205 | + }) | |
| 1017 | 1206 | } |
| 1018 | 1207 | |
| 1019 | 1208 | fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 1020 | 1209 | let p = metal::palette(cx); |
| 1021 | 1210 | let session = self.session.read(cx); |
| 1022 | - let working = session.is_working(); | |
| 1023 | - let provider = session.provider; | |
| 1024 | - let mode = session.permission_mode; | |
| 1025 | - let reasoning = session.reasoning; | |
| 1026 | - let fast_mode = session.fast_mode; | |
| 1027 | - let catalog = Catalog::global(cx); | |
| 1028 | - let model = catalog.find(&session.instance, session.model.as_deref()).cloned(); | |
| 1029 | - let model_label: SharedString = match (&model, &session.model) { | |
| 1030 | - (Some(model), _) => model.name.clone().into(), | |
| 1031 | - (None, Some(id)) => id.clone().into(), | |
| 1032 | - (None, None) => format!("{} default", catalog.label(&session.instance)).into(), | |
| 1033 | - }; | |
| 1034 | - let has_input = | |
| 1035 | - !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty(); | |
| 1211 | + let approval = dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode"); | |
| 1212 | + let asking = session.pending_ask().is_some(); | |
| 1036 | 1213 | let focused = self.composer.read(cx).focus_handle(cx).is_focused(window); |
| 1037 | - let ask_panel = session.pending_ask().map(|(ix, ask)| render_ask_panel(&self.session, ix, ask, p)); | |
| 1038 | - | |
| 1039 | - let collapsed = self.collapsed && !start && ask_panel.is_none(); | |
| 1040 | - let send_button = if working && !has_input { | |
| 1041 | - MetalButton::new("stop") | |
| 1042 | - .danger() | |
| 1043 | - .icon(IconName::Square) | |
| 1044 | - .tooltip("Stop (Esc)") | |
| 1045 | - .on_click(cx.listener(|this, _, _, cx| this.escape(cx))) | |
| 1046 | - } else { | |
| 1047 | - MetalButton::new("send") | |
| 1048 | - .primary() | |
| 1049 | - .icon(IconName::ArrowUp) | |
| 1050 | - .disabled(!has_input) | |
| 1051 | - .tooltip( | |
| 1052 | - match ( | |
| 1053 | - working, | |
| 1054 | - settings::get(|settings| settings.follow_up), | |
| 1055 | - settings::get(|settings| settings.send_with_ctrl_enter), | |
| 1056 | - ) { | |
| 1057 | - (true, FollowUp::Queue, false) => "Queue for after this turn (Enter)", | |
| 1058 | - (true, FollowUp::Queue, true) => "Queue for after this turn (Ctrl+Enter)", | |
| 1059 | - (true, FollowUp::Steer, false) => "Steer the running turn (Enter)", | |
| 1060 | - (true, FollowUp::Steer, true) => "Steer the running turn (Ctrl+Enter)", | |
| 1061 | - (false, _, false) => "Send (Enter)", | |
| 1062 | - (false, _, true) => "Send (Ctrl+Enter)", | |
| 1063 | - }, | |
| 1064 | - ) | |
| 1065 | - .on_click(cx.listener(|this, _, window, cx| this.submit(false, window, cx))) | |
| 1066 | - }; | |
| 1214 | + let dock = self.render_dock(cx); | |
| 1215 | + let collapsed = self.collapsed && !start && dock.is_empty(); | |
| 1216 | + let strip = self.strip_visible(cx).then(|| self.render_strip(cx)); | |
| 1067 | 1217 | |
| 1068 | 1218 | // MetalUI marks focus with a green-deep ring. |
| 1069 | 1219 | let mut card_shadow = p.raise.clone(); |
| 1070 | - if ask_panel.is_some() { | |
| 1071 | - card_shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); | |
| 1072 | - } else if focused { | |
| 1220 | + if focused && !approval { | |
| 1073 | 1221 | card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.))); |
| 1074 | 1222 | } |
| 1075 | - | |
| 1076 | - let model_picker = self.model_picker.clone(); | |
| 1077 | - let reasoning_session = self.session.clone(); | |
| 1078 | - let access_session = self.session.clone(); | |
| 1079 | - let reasoning_model = model.clone(); | |
| 1080 | - let access_model = model.clone(); | |
| 1081 | - let close = Self::close_picker(cx); | |
| 1082 | - let close_access = close.clone(); | |
| 1083 | - | |
| 1084 | - let model_button = MetalButton::new("model") | |
| 1085 | - .ghost() | |
| 1086 | - .compact() | |
| 1087 | - .icon(pickers::provider_icon(provider, px(14.), p)) | |
| 1088 | - .label(model_label) | |
| 1089 | - .caret(); | |
| 1090 | - let reasoning_button = MetalButton::new("reasoning") | |
| 1091 | - .ghost() | |
| 1092 | - .compact() | |
| 1093 | - .icon(Lucide::Brain) | |
| 1094 | - .label(if fast_mode { format!("{} · Fast", reasoning.label()) } else { reasoning.label().to_string() }) | |
| 1095 | - .caret(); | |
| 1096 | - let access_mode = | |
| 1097 | - if provider.asks_permission() { mode } else { crate::claude::PermissionMode::BypassPermissions }; | |
| 1098 | - let access_button = MetalButton::new("access") | |
| 1099 | - .ghost() | |
| 1100 | - .compact() | |
| 1101 | - .icon(pickers::access_icon(access_mode)) | |
| 1102 | - .label(access_mode.label()) | |
| 1103 | - .caret(); | |
| 1104 | - | |
| 1105 | - let toolbar = h_flex() | |
| 1106 | - .px(px(8.)) | |
| 1107 | - .pt(px(4.)) | |
| 1108 | - .pb(px(10.)) | |
| 1109 | - .gap(px(4.)) | |
| 1110 | - .child(self.picker(Picker::Model, model_button, move |_, _| model_picker.clone().into_any_element(), cx)) | |
| 1111 | - .child(metal::vrule(p)) | |
| 1112 | - .child(self.picker( | |
| 1113 | - Picker::Reasoning, | |
| 1114 | - reasoning_button, | |
| 1115 | - move |_, cx| { | |
| 1116 | - let p = metal::palette(cx); | |
| 1117 | - pickers::reasoning_menu(&reasoning_session, reasoning_model.as_ref(), close.clone(), p, cx) | |
| 1118 | - .into_any_element() | |
| 1119 | - }, | |
| 1120 | - cx, | |
| 1121 | - )) | |
| 1122 | - .child(metal::vrule(p)) | |
| 1123 | - .child(self.picker( | |
| 1124 | - Picker::Access, | |
| 1125 | - access_button, | |
| 1126 | - move |_, cx| { | |
| 1127 | - let p = metal::palette(cx); | |
| 1128 | - pickers::access_menu(&access_session, access_model.as_ref(), close_access.clone(), p, cx) | |
| 1129 | - .into_any_element() | |
| 1223 | + let measure = { | |
| 1224 | + let chat = cx.entity().downgrade(); | |
| 1225 | + let known = self.composer_width; | |
| 1226 | + canvas( | |
| 1227 | + move |bounds, _, cx| { | |
| 1228 | + if (bounds.size.width - known).abs() > px(0.5) { | |
| 1229 | + let _ = chat.update(cx, |this, cx| { | |
| 1230 | + this.composer_width = bounds.size.width; | |
| 1231 | + cx.notify(); | |
| 1232 | + }); | |
| 1233 | + } | |
| 1130 | 1234 | }, |
| 1131 | - cx, | |
| 1132 | - )) | |
| 1133 | - .child(div().flex_1()) | |
| 1134 | - .child( | |
| 1135 | - MetalButton::new("attach") | |
| 1136 | - .ghost() | |
| 1137 | - .compact() | |
| 1138 | - .icon(Lucide::Paperclip) | |
| 1139 | - .tooltip("Attach files or images") | |
| 1140 | - .on_click(cx.listener(|this, _, window, cx| this.pick_attachments(window, cx))), | |
| 1235 | + |_, _, _, _| {}, | |
| 1141 | 1236 | ) |
| 1142 | - .when(!start, |this| this.child(self.render_context_ring(cx))) | |
| 1143 | - .child(div().w(px(4.))); | |
| 1237 | + .absolute() | |
| 1238 | + .size_full() | |
| 1239 | + }; | |
| 1144 | 1240 | |
| 1145 | 1241 | let textarea = div() |
| 1146 | - .px(px(12.)) | |
| 1147 | 1242 | .text_size(px(14.)) |
| 1148 | 1243 | .text_color(p.ink) |
| 1149 | - .child(MarkdownInput::new(&self.composer).rows(2, 12)); | |
| 1244 | + .child(MarkdownInput::new(&self.composer).rows(if approval { 2 } else { 3 }, 9)); | |
| 1150 | 1245 | let card = if collapsed { |
| 1151 | 1246 | // Resting: one line, a click (or typing) away from the full composer. |
| 1152 | 1247 | h_flex() |
| 1153 | 1248 | .id("composer-collapsed") |
| 1249 | + .relative() | |
| 1154 | 1250 | .w_full() |
| 1155 | 1251 | .h(px(48.)) |
| 1252 | + .pl(px(16.)) | |
| 1156 | 1253 | .pr(px(8.)) |
| 1254 | + .gap(px(8.)) | |
| 1157 | 1255 | .rounded(px(24.)) |
| 1158 | 1256 | .bg(metal::vertical((p.s_hi, p.s))) |
| 1159 | 1257 | .shadow(card_shadow) |
| @@ -1162,33 +1260,43 @@ impl ChatView { | ||
| 1162 | 1260 | this.expand(cx); |
| 1163 | 1261 | this.focus_composer(window, cx); |
| 1164 | 1262 | })) |
| 1263 | + .child(measure) | |
| 1165 | 1264 | .child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden()) |
| 1166 | - .child(send_button) | |
| 1265 | + .child(self.render_actions(start, cx)) | |
| 1167 | 1266 | .into_any_element() |
| 1168 | 1267 | } else { |
| 1268 | + let right_width = self.actions_width(cx); | |
| 1169 | 1269 | v_flex() |
| 1270 | + .relative() | |
| 1170 | 1271 | .w_full() |
| 1272 | + .p(px(1.)) | |
| 1171 | 1273 | .rounded(px(24.)) |
| 1172 | 1274 | .bg(metal::vertical((p.s_hi, p.s))) |
| 1173 | 1275 | .shadow(card_shadow) |
| 1174 | - .children(ask_panel) | |
| 1175 | - .when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx))) | |
| 1176 | - .child(textarea.pt(px(10.))) | |
| 1177 | - .child(toolbar.child(send_button)) | |
| 1276 | + .when(approval, |this| this.opacity(0.92)) | |
| 1277 | + .child(measure) | |
| 1278 | + .child( | |
| 1279 | + v_flex() | |
| 1280 | + .px(px(16.)) | |
| 1281 | + .pt(px(16.)) | |
| 1282 | + .pb(px(if approval { 16. } else { 8. })) | |
| 1283 | + .when(!self.attachments.is_empty() && !asking, |this| this.child(self.render_attachments(cx))) | |
| 1284 | + .child(textarea), | |
| 1285 | + ) | |
| 1286 | + .when(!approval, |this| { | |
| 1287 | + this.child( | |
| 1288 | + h_flex() | |
| 1289 | + .w_full() | |
| 1290 | + .justify_between() | |
| 1291 | + .px(px(16.)) | |
| 1292 | + .pb(px(16.)) | |
| 1293 | + .when(asking, |this| this.pt(px(8.))) | |
| 1294 | + .child(self.render_controls(right_width, cx)) | |
| 1295 | + .child(self.render_actions(start, cx)), | |
| 1296 | + ) | |
| 1297 | + }) | |
| 1178 | 1298 | .into_any_element() |
| 1179 | 1299 | }; |
| 1180 | - let slash_menu = match self.slash_matches(cx) { | |
| 1181 | - Some(matches) => Some(self.render_slash_menu(matches, cx).into_any_element()), | |
| 1182 | - None => None, | |
| 1183 | - }; | |
| 1184 | - let above: Vec<AnyElement> = if start { | |
| 1185 | - Vec::new() | |
| 1186 | - } else { | |
| 1187 | - let mut above: Vec<AnyElement> = self.render_limit(cx).into_iter().chain(self.render_queue(cx)).collect(); | |
| 1188 | - above.push(self.render_activity(cx).into_any_element()); | |
| 1189 | - above.extend(self.render_background_tasks(cx)); | |
| 1190 | - above | |
| 1191 | - }; | |
| 1192 | 1300 | |
| 1193 | 1301 | v_flex() |
| 1194 | 1302 | .flex_none() |
| @@ -1196,103 +1304,70 @@ impl ChatView { | ||
| 1196 | 1304 | .items_center() |
| 1197 | 1305 | .px(px(24.)) |
| 1198 | 1306 | .pb(px(if start { 0. } else { 20. })) |
| 1199 | - .gap(px(8.)) | |
| 1200 | - .children(above) | |
| 1307 | + .when(!start, |this| this.children(self.render_status_line(cx))) | |
| 1201 | 1308 | .child( |
| 1202 | 1309 | v_flex() |
| 1203 | 1310 | .w_full() |
| 1204 | 1311 | .max_w(px(CONTENT_WIDTH)) |
| 1205 | - .items_center() | |
| 1206 | 1312 | .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { |
| 1207 | 1313 | this.composer_key(event, window, cx) |
| 1208 | 1314 | })) |
| 1209 | - .children(slash_menu) | |
| 1210 | - .child(card) | |
| 1211 | - .when(start, |this| this.child(self.render_start_footer(cx))), | |
| 1315 | + .children(dock) | |
| 1316 | + // The strip is laid out below the card but painted first, | |
| 1317 | + // so the card covers its top edge. | |
| 1318 | + .child(v_flex().w_full().flex_col_reverse().children(strip).child(card)), | |
| 1212 | 1319 | ) |
| 1213 | 1320 | } |
| 1214 | 1321 | |
| 1215 | - /// Under the start composer: where the thread will run, and on which branch. | |
| 1216 | - fn render_start_footer(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 1322 | + fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 1217 | 1323 | let p = metal::palette(cx); |
| 1218 | - let session = self.session.read(cx); | |
| 1219 | - let cwd = session.project.clone(); | |
| 1220 | - let env = session.remote.as_ref().map(|remote| remote.env.clone()); | |
| 1221 | - let directory = cx.try_global::<ProjectDirectory>(); | |
| 1222 | - let project = directory.and_then(|directory| directory.find(&cwd, env.as_deref())); | |
| 1223 | - let remote_host = project.and_then(|project| project.host.clone()); | |
| 1224 | - let is_remote = remote_host.is_some(); | |
| 1225 | - let hostname = remote_host.unwrap_or_else(|| directory.map(|directory| directory.hostname.clone()).unwrap_or_default()); | |
| 1226 | - let plan = self.session.read(cx).pending_worktree.clone(); | |
| 1227 | - let branch = match &plan { | |
| 1228 | - Some(plan) => plan.base.clone().or_else(|| project.and_then(|project| project.branch.clone())), | |
| 1229 | - None => project.and_then(|project| project.branch.clone()), | |
| 1230 | - }; | |
| 1231 | - let branches = project.map(|project| project.branches.clone()).unwrap_or_default(); | |
| 1232 | - let can_worktree = project.is_some_and(|project| project.git && !project.scratch); | |
| 1233 | - let mode = if plan.is_some() { WorkspaceMode::Worktree } else { WorkspaceMode::Local }; | |
| 1234 | - let chat = cx.entity().downgrade(); | |
| 1235 | - let workspace_chat = chat.clone(); | |
| 1236 | - let current = branch.clone(); | |
| 1237 | - | |
| 1238 | 1324 | h_flex() |
| 1239 | - .w(relative(0.94)) | |
| 1240 | - .h(px(38.)) | |
| 1241 | - .px(px(10.)) | |
| 1325 | + .flex_wrap() | |
| 1242 | 1326 | .gap(px(6.)) |
| 1243 | - .rounded_b(px(16.)) | |
| 1244 | - .region(p) | |
| 1245 | - .child( | |
| 1327 | + .px(px(12.)) | |
| 1328 | + .pt(px(12.)) | |
| 1329 | + .children(self.attachments.iter().enumerate().map(|(ix, path)| { | |
| 1330 | + let name = path | |
| 1331 | + .file_name() | |
| 1332 | + .map(|name| name.to_string_lossy().to_string()) | |
| 1333 | + .unwrap_or_else(|| path.display().to_string()); | |
| 1334 | + let is_image = matches!( | |
| 1335 | + path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(), | |
| 1336 | + Some("png" | "jpg" | "jpeg" | "gif" | "webp") | |
| 1337 | + ); | |
| 1246 | 1338 | h_flex() |
| 1247 | - .gap(px(6.)) | |
| 1248 | - .pl(px(4.)) | |
| 1339 | + .h(px(28.)) | |
| 1340 | + .pl(px(10.)) | |
| 1341 | + .pr(px(3.)) | |
| 1342 | + .gap(px(7.)) | |
| 1343 | + .rounded(px(10.)) | |
| 1344 | + .well(p) | |
| 1249 | 1345 | .text_size(px(12.5)) |
| 1250 | - .font_weight(FontWeight::MEDIUM) | |
| 1251 | - .text_color(p.ink3) | |
| 1252 | - .child(Icon::new(if is_remote { Lucide::Server } else { Lucide::Laptop }).size(px(13.))) | |
| 1253 | - .child(Icon::new(IconName::Folder).size(px(13.))) | |
| 1254 | - .child(hostname) | |
| 1255 | - .child(metal::tag(if is_remote { "Remote" } else { "Local" }, p)), | |
| 1256 | - ) | |
| 1257 | - .child(div().flex_1()) | |
| 1258 | - .when(can_worktree, |this| { | |
| 1259 | - this.child(metal::segmented( | |
| 1260 | - "start-workspace", | |
| 1261 | - &[(WorkspaceMode::Local, "Local"), (WorkspaceMode::Worktree, "New worktree")], | |
| 1262 | - mode, | |
| 1263 | - p, | |
| 1264 | - move |mode, _, cx| { | |
| 1265 | - let _ = workspace_chat.update(cx, |_, cx| cx.emit(ChatEvent::SetWorkspace(mode))); | |
| 1266 | - }, | |
| 1267 | - )) | |
| 1268 | - }) | |
| 1269 | - .when_some(branch, |this, branch| { | |
| 1270 | - this.child( | |
| 1271 | - MetalButton::new("start-branch") | |
| 1272 | - .ghost() | |
| 1273 | - .mini() | |
| 1274 | - .icon(Lucide::GitBranch) | |
| 1275 | - .label(format!("From {branch}")) | |
| 1276 | - .caret() | |
| 1277 | - .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { | |
| 1278 | - menu = menu.min_w(px(220.)).label("Start from branch"); | |
| 1279 | - for name in branches.clone() { | |
| 1280 | - let chat = chat.clone(); | |
| 1281 | - let checked = current.as_deref() == Some(name.as_str()); | |
| 1282 | - menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click( | |
| 1283 | - move |_, _, cx| { | |
| 1284 | - let name = name.clone(); | |
| 1285 | - let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchBranch(name))); | |
| 1286 | - }, | |
| 1287 | - )); | |
| 1288 | - } | |
| 1289 | - menu | |
| 1290 | - }), | |
| 1291 | - ) | |
| 1292 | - }) | |
| 1346 | + .text_color(p.ink2) | |
| 1347 | + .child( | |
| 1348 | + Icon::new(if is_image { Lucide::Image } else { Lucide::File }) | |
| 1349 | + .size(px(13.)) | |
| 1350 | + .text_color(p.ink3), | |
| 1351 | + ) | |
| 1352 | + .child(div().max_w(px(220.)).truncate().child(name)) | |
| 1353 | + .child( | |
| 1354 | + MetalButton::new(("remove-attachment", ix)) | |
| 1355 | + .ghost() | |
| 1356 | + .mini() | |
| 1357 | + .icon(IconName::Close) | |
| 1358 | + .tooltip("Remove") | |
| 1359 | + .on_click(cx.listener(move |this, _, _, cx| { | |
| 1360 | + if ix < this.attachments.len() { | |
| 1361 | + this.attachments.remove(ix); | |
| 1362 | + } | |
| 1363 | + cx.notify(); | |
| 1364 | + })), | |
| 1365 | + ) | |
| 1366 | + })) | |
| 1293 | 1367 | } |
| 1294 | 1368 | } |
| 1295 | 1369 | |
| 1370 | + | |
| 1296 | 1371 | impl Render for ChatView { |
| 1297 | 1372 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 1298 | 1373 | let is_empty = self.session.read(cx).items.is_empty(); |
| @@ -1480,174 +1555,6 @@ fn render_question(session: &Entity<Session>, ix: usize, question: &Question, p: | ||
| 1480 | 1555 | .into_any_element() |
| 1481 | 1556 | } |
| 1482 | 1557 | |
| 1483 | - /// Claude's AskUserQuestion form, docked on top of the composer: one | |
| 1484 | - /// question at a time, its options as numbered rows. The composer below takes | |
| 1485 | - /// a custom answer. | |
| 1486 | - fn render_ask_panel(session: &Entity<Session>, ix: usize, ask: &UserQuestions, p: &Palette) -> AnyElement { | |
| 1487 | - let current = ask.current; | |
| 1488 | - let question = &ask.questions[current]; | |
| 1489 | - let picked = &ask.picked[current]; | |
| 1490 | - let answer = ask.answers[current].as_ref(); | |
| 1491 | - let count = ask.questions.len(); | |
| 1492 | - | |
| 1493 | - let tabs = h_flex().gap(px(4.)).flex_wrap().when(count > 1, |this| { | |
| 1494 | - this.children(ask.questions.iter().enumerate().map(|(q, question)| { | |
| 1495 | - let session = session.clone(); | |
| 1496 | - let selected = q == current; | |
| 1497 | - let label: SharedString = | |
| 1498 | - if question.header.is_empty() { format!("Q{}", q + 1).into() } else { question.header.clone() }; | |
| 1499 | - h_flex() | |
| 1500 | - .id(ElementId::NamedInteger("ask-tab".into(), q as u64)) | |
| 1501 | - .h(px(22.)) | |
| 1502 | - .px(px(9.)) | |
| 1503 | - .gap(px(5.)) | |
| 1504 | - .rounded_full() | |
| 1505 | - .text_size(px(12.)) | |
| 1506 | - .font_weight(FontWeight::MEDIUM) | |
| 1507 | - .map(|this| { | |
| 1508 | - if selected { | |
| 1509 | - this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink) | |
| 1510 | - } else { | |
| 1511 | - this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink)) | |
| 1512 | - } | |
| 1513 | - }) | |
| 1514 | - .when(ask.answers[q].is_some(), |this| { | |
| 1515 | - this.child(Icon::new(IconName::Check).size(px(11.)).text_color(p.green_ink)) | |
| 1516 | - }) | |
| 1517 | - .child(label) | |
| 1518 | - .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_show(ix, q, cx))) | |
| 1519 | - })) | |
| 1520 | - }); | |
| 1521 | - | |
| 1522 | - let skip_session = session.clone(); | |
| 1523 | - let header = h_flex() | |
| 1524 | - .gap(px(10.)) | |
| 1525 | - .child(metal::led(("ask-led", ix), Led::Waiting, true, p)) | |
| 1526 | - .child( | |
| 1527 | - metal::engraved( | |
| 1528 | - if count > 1 { format!("Question {} of {count}", current + 1) } else { "Question".to_string() }, | |
| 1529 | - p, | |
| 1530 | - ) | |
| 1531 | - .text_color(p.amber_ink), | |
| 1532 | - ) | |
| 1533 | - .child(tabs) | |
| 1534 | - .child(div().flex_1()) | |
| 1535 | - .child( | |
| 1536 | - MetalButton::new(("ask-skip", ix)) | |
| 1537 | - .ghost() | |
| 1538 | - .mini() | |
| 1539 | - .label("Skip") | |
| 1540 | - .tooltip("Dismiss without answering") | |
| 1541 | - .on_click(move |_, _, cx| skip_session.update(cx, |session, cx| session.ask_skip(ix, cx))), | |
| 1542 | - ); | |
| 1543 | - | |
| 1544 | - let options = v_flex().gap(px(4.)).children(question.options.iter().enumerate().map(|(option, choice)| { | |
| 1545 | - let session = session.clone(); | |
| 1546 | - let selected = if question.multi_select { | |
| 1547 | - picked.contains(&option) | |
| 1548 | - } else { | |
| 1549 | - answer.is_some_and(|answer| *answer == choice.label) | |
| 1550 | - }; | |
| 1551 | - let marker = div() | |
| 1552 | - .flex() | |
| 1553 | - .flex_none() | |
| 1554 | - .items_center() | |
| 1555 | - .justify_center() | |
| 1556 | - .size(px(16.)) | |
| 1557 | - .map(|this| if question.multi_select { this.rounded(px(5.)) } else { this.rounded_full() }) | |
| 1558 | - .map(|this| { | |
| 1559 | - if selected { | |
| 1560 | - this.bg(metal::vertical((p.green_ink, p.green_deep))).shadow(p.pressed_sh.clone()) | |
| 1561 | - } else { | |
| 1562 | - this.well(p) | |
| 1563 | - } | |
| 1564 | - }) | |
| 1565 | - .when(selected, |this| { | |
| 1566 | - this.child(Icon::new(IconName::Check).size(px(11.)).text_color(gpui_kit::white())) | |
| 1567 | - }); | |
| 1568 | - h_flex() | |
| 1569 | - .id(ElementId::NamedInteger(format!("ask-{ix}-option").into(), option as u64)) | |
| 1570 | - .items_start() | |
| 1571 | - .gap(px(10.)) | |
| 1572 | - .px(px(10.)) | |
| 1573 | - .py(px(8.)) | |
| 1574 | - .rounded(px(12.)) | |
| 1575 | - .cursor_pointer() | |
| 1576 | - .map(|this| { | |
| 1577 | - if selected { | |
| 1578 | - this.bg(metal::vertical(p.row_on)).shadow({ | |
| 1579 | - let mut shadow = p.raise_sm.clone(); | |
| 1580 | - shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.45)).spread_radius(px(1.))); | |
| 1581 | - shadow | |
| 1582 | - }) | |
| 1583 | - } else { | |
| 1584 | - this.hover(|style| style.bg(p.row_hover)) | |
| 1585 | - } | |
| 1586 | - }) | |
| 1587 | - .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_pick(ix, option, cx))) | |
| 1588 | - .when(option < 9, |this| this.child(metal::keycap((option + 1).to_string(), p))) | |
| 1589 | - .child( | |
| 1590 | - v_flex() | |
| 1591 | - .flex_1() | |
| 1592 | - .min_w_0() | |
| 1593 | - .gap(px(2.)) | |
| 1594 | - .child( | |
| 1595 | - div() | |
| 1596 | - .text_size(px(13.5)) | |
| 1597 | - .font_weight(FontWeight::MEDIUM) | |
| 1598 | - .text_color(p.ink) | |
| 1599 | - .child(choice.label.clone()), | |
| 1600 | - ) | |
| 1601 | - .when_some(choice.description.clone().filter(|description| *description != choice.label), |this, description| { | |
| 1602 | - this.child(div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(description)) | |
| 1603 | - }), | |
| 1604 | - ) | |
| 1605 | - .child(div().pt(px(1.)).child(marker)) | |
| 1606 | - })); | |
| 1607 | - | |
| 1608 | - let hint = match (question.multi_select, answer) { | |
| 1609 | - (_, Some(answer)) if !question.options.iter().any(|option| option.label == *answer) && !question.multi_select => { | |
| 1610 | - format!("Your answer: {answer}") | |
| 1611 | - } | |
| 1612 | - (true, _) => "Pick any that apply, or type your own answer below.".to_string(), | |
| 1613 | - (false, _) => "Pick one, or type your own answer below.".to_string(), | |
| 1614 | - }; | |
| 1615 | - let last_open = ask.answers.iter().enumerate().all(|(q, answer)| q == current || answer.is_some()); | |
| 1616 | - let confirm_session = session.clone(); | |
| 1617 | - let footer = h_flex() | |
| 1618 | - .gap(px(8.)) | |
| 1619 | - .child(div().flex_1().min_w_0().text_size(px(12.)).text_color(p.ink3).child(hint)) | |
| 1620 | - .when(question.multi_select, |this| { | |
| 1621 | - this.child( | |
| 1622 | - MetalButton::new(("ask-confirm", ix)) | |
| 1623 | - .primary() | |
| 1624 | - .compact() | |
| 1625 | - .label(if last_open { "Submit" } else { "Next" }) | |
| 1626 | - .disabled(picked.is_empty()) | |
| 1627 | - .on_click(move |_, _, cx| confirm_session.update(cx, |session, cx| session.ask_confirm(ix, cx))), | |
| 1628 | - ) | |
| 1629 | - }); | |
| 1630 | - | |
| 1631 | - v_flex() | |
| 1632 | - .gap(px(10.)) | |
| 1633 | - .px(px(16.)) | |
| 1634 | - .pt(px(14.)) | |
| 1635 | - .pb(px(12.)) | |
| 1636 | - .child(header) | |
| 1637 | - .child( | |
| 1638 | - div() | |
| 1639 | - .text_size(px(15.)) | |
| 1640 | - .line_height(px(22.)) | |
| 1641 | - .font_weight(FontWeight::SEMIBOLD) | |
| 1642 | - .text_color(p.ink) | |
| 1643 | - .child(question.question.clone()), | |
| 1644 | - ) | |
| 1645 | - .child(options) | |
| 1646 | - .child(footer) | |
| 1647 | - .child(metal::rule(p).mt(px(2.))) | |
| 1648 | - .into_any_element() | |
| 1649 | - } | |
| 1650 | - | |
| 1651 | 1558 | /// What an AskUserQuestion call asked and what the user answered, as it |
| 1652 | 1559 | /// stays in the transcript. |
| 1653 | 1560 | fn render_ask_record(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement { |
| @@ -1978,7 +1885,7 @@ fn render_tool( | ||
| 1978 | 1885 | .permission |
| 1979 | 1886 | .as_ref() |
| 1980 | 1887 | .is_some_and(|prompt| prompt.state == PermissionState::Pending); |
| 1981 | - let expanded = tool.expanded || pending; | |
| 1888 | + let expanded = tool.expanded; | |
| 1982 | 1889 | |
| 1983 | 1890 | let status: AnyElement = match (tool.state, pending) { |
| 1984 | 1891 | (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p), |
| @@ -2004,7 +1911,7 @@ fn render_tool( | ||
| 2004 | 1911 | PermissionState::Allowed => Some(("Approved", p.green_ink)), |
| 2005 | 1912 | PermissionState::Denied => Some(("Denied", p.red)), |
| 2006 | 1913 | PermissionState::Cancelled => Some(("Cancelled", p.ink3)), |
| 2007 | - PermissionState::Pending => None, | |
| 1914 | + PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)), | |
| 2008 | 1915 | }); |
| 2009 | 1916 | |
| 2010 | 1917 | let toggle_session = session.clone(); |
| @@ -2062,7 +1969,6 @@ fn render_tool( | ||
| 2062 | 1969 | .shadow(shadow) |
| 2063 | 1970 | .child(header) |
| 2064 | 1971 | .when(expanded, |this| this.child(render_tool_details(ix, tool, cx))) |
| 2065 | - .when(pending, |this| this.child(render_permission_bar(session, ix, tool, cx))) | |
| 2066 | 1972 | .into_any_element() |
| 2067 | 1973 | } |
| 2068 | 1974 | |
| @@ -2149,68 +2055,6 @@ fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement | ||
| 2149 | 2055 | })) |
| 2150 | 2056 | } |
| 2151 | 2057 | |
| 2152 | - fn render_permission_bar( | |
| 2153 | - session: &Entity<Session>, | |
| 2154 | - ix: usize, | |
| 2155 | - tool: &ToolCall, | |
| 2156 | - cx: &App, | |
| 2157 | - ) -> impl IntoElement { | |
| 2158 | - let p = metal::palette(cx); | |
| 2159 | - let always_label = tool.permission.as_ref().and_then(|prompt| prompt.always_label()); | |
| 2160 | - let respond = |decision: PermissionDecision| { | |
| 2161 | - let session = session.clone(); | |
| 2162 | - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { | |
| 2163 | - session.update(cx, |session, cx| session.respond_permission(ix, decision, cx)); | |
| 2164 | - } | |
| 2165 | - }; | |
| 2166 | - let question = match tool.name.as_str() { | |
| 2167 | - "ExitPlanMode" => "Claude finished planning. Start implementing?".to_string(), | |
| 2168 | - name => format!("Allow Claude to use {name}?"), | |
| 2169 | - }; | |
| 2170 | - | |
| 2171 | - v_flex() | |
| 2172 | - .child(metal::rule(p)) | |
| 2173 | - .child( | |
| 2174 | - h_flex() | |
| 2175 | - .gap(px(8.)) | |
| 2176 | - .px(px(12.)) | |
| 2177 | - .py(px(10.)) | |
| 2178 | - .child(metal::engraved("Approval", p).text_color(p.amber_ink)) | |
| 2179 | - .child( | |
| 2180 | - div() | |
| 2181 | - .flex_1() | |
| 2182 | - .min_w_0() | |
| 2183 | - .text_size(px(13.)) | |
| 2184 | - .font_weight(FontWeight::MEDIUM) | |
| 2185 | - .text_color(p.ink) | |
| 2186 | - .child(question), | |
| 2187 | - ) | |
| 2188 | - .child( | |
| 2189 | - MetalButton::new(("deny", ix)) | |
| 2190 | - .ghost() | |
| 2191 | - .compact() | |
| 2192 | - .label("Deny") | |
| 2193 | - .on_click(respond(PermissionDecision::Deny)), | |
| 2194 | - ) | |
| 2195 | - .when_some(always_label, |this, label| { | |
| 2196 | - this.child( | |
| 2197 | - MetalButton::new(("allow-always", ix)) | |
| 2198 | - .compact() | |
| 2199 | - .label(label) | |
| 2200 | - .on_click(respond(PermissionDecision::AllowAlways)), | |
| 2201 | - ) | |
| 2202 | - }) | |
| 2203 | - .child( | |
| 2204 | - MetalButton::new(("allow", ix)) | |
| 2205 | - .primary() | |
| 2206 | - .compact() | |
| 2207 | - .icon(IconName::Check) | |
| 2208 | - .label("Allow") | |
| 2209 | - .on_click(respond(PermissionDecision::Allow)), | |
| 2210 | - ), | |
| 2211 | - ) | |
| 2212 | - } | |
| 2213 | - | |
| 2214 | 2058 | fn tool_icon(name: &str) -> IconName { |
| 2215 | 2059 | match name.to_ascii_lowercase().as_str() { |
| 2216 | 2060 | "bash" | "bashoutput" | "killshell" => IconName::SquareTerminal, |
| @@ -2245,3 +2089,55 @@ fn format_duration(ms: u64) -> String { | ||
| 2245 | 2089 | format!("{}m {}s", secs / 60, secs % 60) |
| 2246 | 2090 | } |
| 2247 | 2091 | } |
| 2092 | + | |
| 2093 | + /// The files and folders of `cwd` for the `@` menu: what git tracks (or | |
| 2094 | + /// would, untracked files included), else a shallow walk. Folders end in `/`. | |
| 2095 | + fn list_files(cwd: &std::path::Path) -> Vec<String> { | |
| 2096 | + const MAX_FILES: usize = 20_000; | |
| 2097 | + let mut files: Vec<String> = match git::git(cwd, &["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) { | |
| 2098 | + Ok(out) => out.split('\0').filter(|file| !file.is_empty()).take(MAX_FILES).map(str::to_string).collect(), | |
| 2099 | + Err(_) => { | |
| 2100 | + let mut found = Vec::new(); | |
| 2101 | + let mut stack = vec![(cwd.to_path_buf(), 0)]; | |
| 2102 | + while let Some((dir, depth)) = stack.pop() { | |
| 2103 | + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; | |
| 2104 | + for entry in entries.flatten() { | |
| 2105 | + let path = entry.path(); | |
| 2106 | + let name = entry.file_name().to_string_lossy().to_string(); | |
| 2107 | + if name.starts_with('.') || found.len() >= MAX_FILES { | |
| 2108 | + continue; | |
| 2109 | + } | |
| 2110 | + if path.is_dir() { | |
| 2111 | + if depth < 4 && name != "node_modules" && name != "target" { | |
| 2112 | + stack.push((path, depth + 1)); | |
| 2113 | + } | |
| 2114 | + } else if let Ok(relative) = path.strip_prefix(cwd) { | |
| 2115 | + found.push(relative.to_string_lossy().to_string()); | |
| 2116 | + } | |
| 2117 | + } | |
| 2118 | + } | |
| 2119 | + found | |
| 2120 | + } | |
| 2121 | + }; | |
| 2122 | + let mut folders: Vec<String> = files | |
| 2123 | + .iter() | |
| 2124 | + .flat_map(|file| { | |
| 2125 | + let parts: Vec<&str> = file.split('/').collect(); | |
| 2126 | + (1..parts.len()).map(move |n| format!("{}/", parts[..n].join("/"))) | |
| 2127 | + }) | |
| 2128 | + .collect(); | |
| 2129 | + folders.sort(); | |
| 2130 | + folders.dedup(); | |
| 2131 | + files.sort(); | |
| 2132 | + folders.extend(files); | |
| 2133 | + folders | |
| 2134 | + } | |
| 2135 | + | |
| 2136 | + /// `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads. | |
| 2137 | + fn format_elapsed(seconds: u64) -> String { | |
| 2138 | + match seconds { | |
| 2139 | + 0..60 => format!("{seconds}s"), | |
| 2140 | + 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60), | |
| 2141 | + _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60), | |
| 2142 | + } | |
| 2143 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.