Vectrum
Sign In

Composer like T3 Code: docked banners, question and approval drawers, footer controls and branch strip

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>
JjohannSocommitted 1 parent 77537cccommitf1b893e

Showing 15 changed files with 3917 additions and 1357 deletions.

+2−2src/claude.rsView File
@@ -83,7 +83,7 @@ impl PermissionMode {
8383 match self {
8484 PermissionMode::Default => "Ask before commands and file changes.",
8585 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.",
8787 PermissionMode::Plan => "Read-only: Claude plans before it changes anything.",
8888 PermissionMode::BypassPermissions => "Allow commands and edits without prompts.",
8989 }
@@ -130,7 +130,7 @@ impl Effort {
130130 Effort::Low => "Low",
131131 Effort::Medium => "Medium",
132132 Effort::High => "High",
133- Effort::XHigh => "Extra high",
133+ Effort::XHigh => "Extra High",
134134 Effort::Max => "Max",
135135 }
136136 }
+81−0src/git.rsView File
@@ -77,6 +77,87 @@ pub fn read_info(path: &Path) -> Option<GitInfo> {
7777 Some(GitInfo { branch, branches, added, removed })
7878 }
7979
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+
80161 /// Records the whole working tree, untracked files included, as a git tree
81162 /// and returns its id. It stages into `index`, a private index file, so the
82163 /// user's own staging area is never touched; the blobs land in the repository
+33−0src/models.rsView File
@@ -103,6 +103,39 @@ impl ModelEntry {
103103 }
104104 }
105105
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+
106139 /// A `/` command or skill an agent offers.
107140 #[derive(Clone, Debug, PartialEq)]
108141 pub struct SlashCommand {
+220−55src/session.rsView File
@@ -20,7 +20,7 @@ use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessE
2020 use crate::git::{self, TurnChanges};
2121 use crate::models::{Catalog, Provider};
2222 use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient};
23- use crate::settings::{self, Streaming};
23+ use crate::settings::{self, BranchNaming, Streaming};
2424 use crate::worktree::{self, PullRequest, Worktree, WorktreePlan};
2525
2626 const DEFAULT_TITLE: &str = "New thread";
@@ -166,8 +166,13 @@ pub struct UserQuestions {
166166 pub questions: Vec<AskQuestion>,
167167 /// The question on screen.
168168 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.
170171 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.
171176 pub answers: Vec<Option<SharedString>>,
172177 }
173178
@@ -204,21 +209,37 @@ impl UserQuestions {
204209 return None;
205210 }
206211 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+ })
208219 }
209220
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());
221227 }
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()
222243 }
223244 }
224245
@@ -431,6 +452,8 @@ pub struct Session {
431452 /// The concrete model the agent reported, e.g. `claude-opus-5-5`.
432453 pub active_model: Option<String>,
433454 pub permission_mode: PermissionMode,
455+ /// The access mode plan mode hands back to when it's switched off.
456+ pub plan_return: PermissionMode,
434457 pub reasoning: Reasoning,
435458 pub fast_mode: bool,
436459 /// The thinking levels Pi offers for the current model.
@@ -551,6 +574,7 @@ impl Session {
551574 model,
552575 active_model: None,
553576 permission_mode,
577+ plan_return: if permission_mode == PermissionMode::Plan { PermissionMode::Default } else { permission_mode },
554578 reasoning,
555579 fast_mode,
556580 pi_efforts: Vec::new(),
@@ -788,8 +812,8 @@ impl Session {
788812 self.preparing = true;
789813 self.turn_started = Some(Instant::now());
790814 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();
793817 cx.spawn(async move |this, cx| {
794818 let created = task.await;
795819 let _ = this.update(cx, |this, cx| {
@@ -797,6 +821,7 @@ impl Session {
797821 match created {
798822 Ok((worktree, cwd)) => {
799823 this.cwd = cwd;
824+ this.name_branch(&worktree, first_message, cx);
800825 this.worktree = Some(worktree);
801826 }
802827 Err(err) => this.push_notice(
@@ -864,6 +889,27 @@ impl Session {
864889 }
865890 }
866891
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+
867913 /// Sends the next queued message once a turn has ended.
868914 fn send_next_queued(&mut self, cx: &mut Context<Self>) {
869915 if !self.is_working() && !self.queue.is_empty() {
@@ -1027,6 +1073,76 @@ impl Session {
10271073 .detach();
10281074 }
10291075
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+
10301146 pub fn rename(&mut self, title: String, cx: &mut Context<Self>) {
10311147 self.forward(ThreadAction::Rename { title: title.trim().to_string() });
10321148 let title = title.trim();
@@ -1210,6 +1326,44 @@ impl Session {
12101326 cx.notify();
12111327 }
12121328
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+
12131367 pub fn set_reasoning(&mut self, reasoning: Reasoning, cx: &mut Context<Self>) {
12141368 if self.forward(ThreadAction::SetReasoning { reasoning: reasoning.key().into() }) {
12151369 self.reasoning = reasoning;
@@ -1411,63 +1565,74 @@ impl Session {
14111565 }
14121566 }
14131567
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;
14211578 }
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 {
14321582 match picked.iter().position(|&picked| picked == option) {
14331583 Some(position) => {
14341584 picked.remove(position);
14351585 }
14361586 None => picked.push(option),
14371587 }
1438- cx.notify();
1439- return;
1440- }
1441- if ask.answer(label) {
1442- self.submit_ask(ix);
1588+ } else {
1589+ *picked = vec![option];
14431590 }
1591+ ask.custom[current].clear();
14441592 cx.notify();
1593+ !multi
14451594 }
14461595
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>) {
14491599 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+ }
14561619 return;
14571620 }
1458- if ask.answer(labels.join(", ").into()) {
1459- self.submit_ask(ix);
1621+ if !ask.is_complete() {
1622+ return;
14601623 }
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);
14621626 }
14631627
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();
14691635 }
1470- cx.notify();
14711636 }
14721637
14731638 /// Answers the whole form at once, as a client that filled it in asks.
+49−5src/textgen.rsView File
@@ -7,7 +7,7 @@ use std::process::Stdio;
77 use crate::claude::{Effort, base_command};
88 use crate::models::{Provider, split_key};
99 use crate::session::Reasoning;
10- use crate::settings;
10+ use crate::settings::{self, BranchNaming};
1111
1212 const TITLE_PROMPT: &str = "You name coding-assistant conversations. Reply with only a short title (3 to 6 words, \
1313 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
1515 /// A title for a thread that starts with `message`, or `None` when text
1616 /// generation is off or fails.
1717 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> {
1865 let (key, reasoning) = settings::get(|settings| (settings.text_model.clone(), settings.text_reasoning.clone()));
1966 let (instance_id, model) = split_key(&key)?;
2067 let kind = settings::get(|settings| settings.instance(instance_id).map(|instance| instance.kind))
@@ -27,9 +74,6 @@ pub fn title(message: &str) -> Option<String> {
2774 Reasoning::Level(effort) => Some(effort),
2875 _ => None,
2976 });
30- let message: String = message.chars().take(2_000).collect();
31- let prompt = format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>");
32-
3377 if !kind.answers_one_off() {
3478 return None;
3579 }
@@ -63,7 +107,7 @@ pub fn title(message: &str) -> Option<String> {
63107 if !output.status.success() {
64108 return None;
65109 }
66- clean_title(&String::from_utf8_lossy(&output.stdout))
110+ Some(String::from_utf8_lossy(&output.stdout).into_owned())
67111 }
68112
69113 /// The title out of a model's reply: its last non-empty line, without
+797−901src/ui/chat.rsView File
@@ -1,6 +1,11 @@
11 //! The conversation pane: transcript and composer, set on the sheet. A new
22 //! 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;
49
510 use std::{
611 path::PathBuf,
@@ -8,14 +13,12 @@ use std::{
813 };
914
1015 use gpui_kit::component::{
11- Icon, IconName, Sizable as _, h_flex,
16+ Icon, IconName, h_flex,
1217 input::{self, EditorState, InputEvent},
1318 menu::{DropdownMenu as _, PopupMenuItem},
1419 message_scroller::{MessageScroller, MessageScrollerState},
1520 popover::Popover,
16- progress::ProgressCircle,
1721 text::{TextView, TextViewStyle},
18- tooltip::Tooltip,
1922 v_flex,
2023 };
2124 use gpui_kit::prelude::FluentBuilder as _;
@@ -26,20 +29,21 @@ use crate::clock;
2629 use crate::git;
2730 use crate::models::{Catalog, ModelEntry, SlashCommand};
2831 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,
3134 };
3235 use crate::settings::{self, FollowUp, WorkspaceMode};
3336 use crate::ui::changes::{self, DiffAction, DiffView};
37+ use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone};
3438 use crate::ui::icons::Lucide;
3539 use crate::ui::markdown_input::{self, MarkdownInput};
3640 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};
3842 use crate::ui::workspace::ProjectDirectory;
3943
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";
4347 const MAX_TOOL_OUTPUT: usize = 6_000;
4448
4549 /// What the conversation asks of the workspace around it.
@@ -52,23 +56,64 @@ pub enum ChatEvent {
5256 SwitchProject(PathBuf, Option<String>),
5357 AddProject,
5458 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 },
5662 /// Run this (still empty) thread in the project folder or a new worktree.
5763 SetWorkspace(WorkspaceMode),
64+ /// Start a new thread in this project that implements this plan.
65+ ImplementInNewThread(String),
5866 }
5967
6068 /// Proactive panels open the diff after a turn that changed at least this
6169 /// many files or lines.
6270 const PROACTIVE_FILES: usize = 3;
6371 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;
6674
6775 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
6876 enum Picker {
6977 Model,
70- Reasoning,
78+ Traits,
7179 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 },
72117 }
73118
74119 pub struct ChatView {
@@ -76,15 +121,35 @@ pub struct ChatView {
76121 composer: Entity<EditorState>,
77122 scroller: Entity<MessageScrollerState>,
78123 model_picker: Entity<ModelPicker>,
124+ branch_picker: Entity<BranchPicker>,
79125 open_picker: Option<Picker>,
80126 attachments: Vec<PathBuf>,
81127 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,
88153 /// The composer rests on one line while the conversation scrolls.
89154 collapsed: bool,
90155 _subscriptions: Vec<Subscription>,
@@ -98,21 +163,16 @@ impl ChatView {
98163 let composer = cx.new(|cx| {
99164 markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer))
100165 .submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter))
101- .placeholder(COMPOSER_PLACEHOLDER)
166+ .placeholder(DEFAULT_PLACEHOLDER)
102167 });
103168 let item_count = session.read(cx).items.len();
104169 let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx));
105170 let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx));
171+ let branch_picker = cx.new(|cx| BranchPicker::new(window, cx));
106172
107173 let subscriptions = vec![
108174 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);
116176 let count = session.read(cx).items.len();
117177 this.scroller.update(cx, |scroller, cx| {
118178 let current = scroller.item_count();
@@ -134,8 +194,10 @@ impl ChatView {
134194 }
135195 }
136196 InputEvent::Change => {
137- this.slash_selected = 0;
197+ this.menu_selected = 0;
198+ this.sync_custom_answer(cx);
138199 this.expand(cx);
200+ this.load_files_if_mentioning(cx);
139201 cx.notify();
140202 }
141203 InputEvent::Focus => this.expand(cx),
@@ -162,6 +224,7 @@ impl ChatView {
162224 }
163225 cx.notify();
164226 }),
227+ cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)),
165228 ];
166229
167230 // Re-render once a second while the agent works so the timer advances.
@@ -180,21 +243,35 @@ impl ChatView {
180243 }
181244 });
182245
183- Self {
246+ let mut this = Self {
184247 session,
185248 composer,
186249 scroller,
187250 model_picker,
251+ branch_picker,
188252 open_picker: None,
189253 attachments: Vec::new(),
190254 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.),
194269 collapsed: false,
195270 _subscriptions: subscriptions,
196271 _ticker: ticker,
197- }
272+ };
273+ this.sync_with_session(window, cx);
274+ this
198275 }
199276
200277 pub fn session(&self) -> &Entity<Session> {
@@ -203,11 +280,12 @@ impl ChatView {
203280
204281 /// Picks up changed settings: the send key, and folding, which changes
205282 /// 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>) {
207284 let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
208285 self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx));
209286 markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx);
210287 self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
288+ self.sync_with_session(window, cx);
211289 cx.notify();
212290 }
213291
@@ -225,19 +303,81 @@ impl ChatView {
225303 self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx));
226304 }
227305
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+
228358 /// Sends what's in the composer. While the agent works, the follow-up
229359 /// setting decides between steering it now and queueing for after the
230360 /// turn; `opposite` flips that for this one message.
231361 fn submit(&mut self, opposite: bool, window: &mut Window, cx: &mut Context<Self>) {
362+ if self.command_menu(cx).is_some() {
363+ return;
364+ }
232365 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) {
236377 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));
238379 } 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);
241381 }
242382 return;
243383 }
@@ -246,6 +386,10 @@ impl ChatView {
246386 }
247387 let attachments = std::mem::take(&mut self.attachments);
248388 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+ }
249393 let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
250394 self.session.update(cx, |session, cx| {
251395 if (session.is_working() || session.preparing) && queue_mode != opposite {
@@ -254,10 +398,55 @@ impl ChatView {
254398 session.send_message(text, attachments, cx);
255399 }
256400 });
257- self.slash_dismissed = None;
401+ self.menu_dismissed = None;
258402 self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
259403 }
260404
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+
261450 /// 1–9 pick an option of the open question while the composer is empty.
262451 fn pick_with_key(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) {
263452 let keystroke = &event.keystroke;
@@ -268,10 +457,10 @@ impl ChatView {
268457 return;
269458 };
270459 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() {
272461 return;
273462 }
274- self.session.update(cx, |session, cx| session.ask_pick(ix, digit - 1, cx));
463+ self.pick_option(ix, digit - 1, cx);
275464 cx.stop_propagation();
276465 }
277466
@@ -282,143 +471,341 @@ impl ChatView {
282471 }
283472 }
284473
285- // ---- the `/` menu ------------------------------------------------------
474+ fn interrupt(&mut self, cx: &mut Context<Self>) {
475+ self.session.update(cx, |session, cx| session.interrupt(cx));
476+ }
286477
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()) {
293553 return None;
294554 }
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') {
298562 return None;
299563 }
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+ }
307601 };
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 })
317603 }
318604
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;
322633 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);
324637 composer.focus(window, cx);
325638 });
326- self.slash_selected = 0;
639+ self.menu_selected = 0;
327640 cx.notify();
328641 }
329642
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.
333646 fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
334647 let keystroke = &event.keystroke;
335648 let modifiers = &keystroke.modifiers;
336649 if keystroke.key == "enter" && modifiers.control && modifiers.shift {
337650 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+ }
339657 return;
340658 }
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 };
342673 if modifiers.control || modifiers.alt || modifiers.shift {
343674 return;
344675 }
676+ let count = menu.items.len();
345677 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();
350682 cx.stop_propagation();
351- self.accept_slash(&command, window, cx);
683+ self.accept_menu_item(&menu, &item, window, cx);
352684 return;
353685 }
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()),
355687 _ => return,
356688 }
357689 cx.stop_propagation();
358690 cx.notify();
359691 }
360692
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 {
362695 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)
367788 .child(
368789 v_flex()
369- .id("slash-menu")
370- .max_h(px(280.))
790+ .id("command-menu")
791+ .max_h(px(288.))
371792 .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+ }),
416798 )
799+ .into_any_element()
417800 }
418801
419802 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() {
422809 cx.notify();
423810 return;
424811 }
@@ -542,8 +929,10 @@ impl ChatView {
542929 fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context<Self>) {
543930 if open {
544931 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+ _ => {}
547936 }
548937 } else if self.open_picker == Some(picker) {
549938 self.open_picker = None;
@@ -685,315 +1074,94 @@ impl ChatView {
6851074 .child(
6861075 MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| {
6871076 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+ )
9531083 }
9541084
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> {
9571089 let p = metal::palette(cx);
9581090 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"),
9781130 };
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+ )
9941162 }
9951163
996- /// A composer picker: a ghost button that opens `content` above it.
1164+ /// A composer picker: a trigger that opens `content` above it.
9971165 fn picker(
9981166 &self,
9991167 picker: Picker,
@@ -1001,159 +1169,89 @@ impl ChatView {
10011169 content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
10021170 cx: &mut Context<Self>,
10031171 ) -> 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+ })
10171206 }
10181207
10191208 fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
10201209 let p = metal::palette(cx);
10211210 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();
10361213 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));
10671217
10681218 // MetalUI marks focus with a green-deep ring.
10691219 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 {
10731221 card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.)));
10741222 }
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+ }
11301234 },
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+ |_, _, _, _| {},
11411236 )
1142- .when(!start, |this| this.child(self.render_context_ring(cx)))
1143- .child(div().w(px(4.)));
1237+ .absolute()
1238+ .size_full()
1239+ };
11441240
11451241 let textarea = div()
1146- .px(px(12.))
11471242 .text_size(px(14.))
11481243 .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));
11501245 let card = if collapsed {
11511246 // Resting: one line, a click (or typing) away from the full composer.
11521247 h_flex()
11531248 .id("composer-collapsed")
1249+ .relative()
11541250 .w_full()
11551251 .h(px(48.))
1252+ .pl(px(16.))
11561253 .pr(px(8.))
1254+ .gap(px(8.))
11571255 .rounded(px(24.))
11581256 .bg(metal::vertical((p.s_hi, p.s)))
11591257 .shadow(card_shadow)
@@ -1162,33 +1260,43 @@ impl ChatView {
11621260 this.expand(cx);
11631261 this.focus_composer(window, cx);
11641262 }))
1263+ .child(measure)
11651264 .child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden())
1166- .child(send_button)
1265+ .child(self.render_actions(start, cx))
11671266 .into_any_element()
11681267 } else {
1268+ let right_width = self.actions_width(cx);
11691269 v_flex()
1270+ .relative()
11701271 .w_full()
1272+ .p(px(1.))
11711273 .rounded(px(24.))
11721274 .bg(metal::vertical((p.s_hi, p.s)))
11731275 .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+ })
11781298 .into_any_element()
11791299 };
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- };
11921300
11931301 v_flex()
11941302 .flex_none()
@@ -1196,103 +1304,70 @@ impl ChatView {
11961304 .items_center()
11971305 .px(px(24.))
11981306 .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)))
12011308 .child(
12021309 v_flex()
12031310 .w_full()
12041311 .max_w(px(CONTENT_WIDTH))
1205- .items_center()
12061312 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
12071313 this.composer_key(event, window, cx)
12081314 }))
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)),
12121319 )
12131320 }
12141321
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 {
12171323 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-
12381324 h_flex()
1239- .w(relative(0.94))
1240- .h(px(38.))
1241- .px(px(10.))
1325+ .flex_wrap()
12421326 .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+ );
12461338 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)
12491345 .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+ }))
12931367 }
12941368 }
12951369
1370+
12961371 impl Render for ChatView {
12971372 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
12981373 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:
14801555 .into_any_element()
14811556 }
14821557
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-
16511558 /// What an AskUserQuestion call asked and what the user answered, as it
16521559 /// stays in the transcript.
16531560 fn render_ask_record(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement {
@@ -1978,7 +1885,7 @@ fn render_tool(
19781885 .permission
19791886 .as_ref()
19801887 .is_some_and(|prompt| prompt.state == PermissionState::Pending);
1981- let expanded = tool.expanded || pending;
1888+ let expanded = tool.expanded;
19821889
19831890 let status: AnyElement = match (tool.state, pending) {
19841891 (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p),
@@ -2004,7 +1911,7 @@ fn render_tool(
20041911 PermissionState::Allowed => Some(("Approved", p.green_ink)),
20051912 PermissionState::Denied => Some(("Denied", p.red)),
20061913 PermissionState::Cancelled => Some(("Cancelled", p.ink3)),
2007- PermissionState::Pending => None,
1914+ PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)),
20081915 });
20091916
20101917 let toggle_session = session.clone();
@@ -2062,7 +1969,6 @@ fn render_tool(
20621969 .shadow(shadow)
20631970 .child(header)
20641971 .when(expanded, |this| this.child(render_tool_details(ix, tool, cx)))
2065- .when(pending, |this| this.child(render_permission_bar(session, ix, tool, cx)))
20661972 .into_any_element()
20671973 }
20681974
@@ -2149,68 +2055,6 @@ fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement
21492055 }))
21502056 }
21512057
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-
22142058 fn tool_icon(name: &str) -> IconName {
22152059 match name.to_ascii_lowercase().as_str() {
22162060 "bash" | "bashoutput" | "killshell" => IconName::SquareTerminal,
@@ -2245,3 +2089,55 @@ fn format_duration(ms: u64) -> String {
22452089 format!("{}m {}s", secs / 60, secs % 60)
22462090 }
22472091 }
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+ }
+758−0src/ui/chat/dock.rsaddedView File

Large diffs are not rendered by default.

+505−0src/ui/chat/footer.rsaddedView File

Large diffs are not rendered by default.

+581−0src/ui/chat/strip.rsaddedView File

Large diffs are not rendered by default.

+229−0src/ui/composer.rsaddedView File

Large diffs are not rendered by default.

+17−0src/ui/icons.rsView File

Large diffs are not rendered by default.

+1−0src/ui/mod.rsView File

Large diffs are not rendered by default.

+464−320src/ui/pickers.rsView File

Large diffs are not rendered by default.

+42−26src/ui/workspace.rsView File

Large diffs are not rendered by default.

+138−48src/worktree.rsView File

Large diffs are not rendered by default.