//! The conversation pane: transcript and composer, set on the sheet. A new //! thread starts centered, T3 Code style; once it has messages the composer //! moves to the bottom. What docks on top of the composer lives in `dock`, //! its footer in `footer`, and the strip hanging below it in `strip`. mod dock; mod find; mod footer; mod strip; mod timeline; pub(crate) use timeline::{describe_call, format_duration}; use std::{ path::PathBuf, time::{Duration, SystemTime}, }; use gpui_kit::component::{ Icon, IconName, h_flex, input::{self, EditorState, InputEvent}, menu::{DropdownMenu as _, PopupMenuItem}, message_scroller::{MessageScroller, MessageScrollerState}, popover::Popover, text::{TextView, TextViewStyle}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::{ ComposerBranch, ComposerEffort, ComposerHost, ComposerMode, ComposerPreviousWorktree, ComposerStash, ComposerWorkspace, FindInThread, Interrupt, ToggleModelPicker, }; use crate::clock; use crate::git; use crate::models::{Catalog, ModelEntry, SlashCommand}; use crate::session::{ ChatItem, PermissionDecision, Session, SessionEvent, SetupCancel, tool_field, }; use crate::worktree::StageStatus; use crate::settings::{self, FollowUp, WorkspaceMode}; use crate::ui::changes::{self, DiffAction, DiffView}; use crate::ui::composer::{self, Density, Tone}; use crate::ui::icons::Lucide; use crate::ui::markdown_input::{self, MarkdownInput}; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers::{ModelPicker, ModelPickerEvent}; use crate::ui::workspace::ProjectDirectory; pub use strip::BranchPicker; const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands"; const MAX_TOOL_OUTPUT: usize = 6_000; /// What the conversation asks of the workspace around it. pub enum ChatEvent { /// Start a thread on this model; it belongs to another provider than this /// thread's. NewThreadWithModel(ModelEntry), /// Move this (still empty) thread to another project: its folder, and /// the remote connection it's on (`None` here). SwitchProject(PathBuf, Option), AddProject, StartWithoutProject, /// Check out a branch in the thread's folder, creating it first when /// `create`. SwitchBranch { branch: String, create: bool }, /// Run this (still empty) thread in the project folder or a new worktree. SetWorkspace(WorkspaceMode), /// Start a new thread in this project that implements this plan. ImplementInNewThread(String), /// No agent is on: open Settings → Providers. OpenProviderSettings, } /// Proactive panels open the diff after a turn that changed at least this /// many files or lines. const PROACTIVE_FILES: usize = 3; const PROACTIVE_LINES: u64 = 50; /// The most rows the `/`, `$` and `@` menus list. const MENU_ROWS: usize = 40; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Picker { Model, Traits, Access, Overflow, Environment, Workspace, Branch, } /// A notice docked above the composer, T3 Code's banner stack. pub(crate) enum Notice { /// Commands, monitors or subagents left running in the background. BackgroundWork { title: SharedString, detail: Option, waiting: bool, task_ids: Vec }, /// A usage limit stopped the thread. Limit { resets: SystemTime, resume_at: Option }, /// The project's checkout moved to another branch since the thread /// last ran there. BranchChanged { was: String, now: String }, } impl Notice { fn tone(&self) -> Tone { match self { Notice::BackgroundWork { .. } | Notice::BranchChanged { .. } => Tone::Neutral, Notice::Limit { .. } => Tone::Warning, } } } /// A prompt put aside with Ctrl+S. struct Stashed { text: String, attachments: Vec, at: SystemTime, } impl Stashed { /// The stash menu's line for it: its start, or what it carries. fn snippet(&self) -> String { let text = self.text.split_whitespace().collect::>().join(" "); if !text.is_empty() { let mut snippet: String = text.chars().take(90).collect(); if text.chars().count() > 90 { snippet.push('…'); } return snippet; } match self.attachments.len() { 0 => "(empty)".into(), 1 => "(1 file)".into(), n => format!("({n} files)"), } } } /// What the `/`, `$` or `@` menu offers for the word at the cursor. struct CommandMenu { /// `/`, `$` or `@`. trigger: char, /// Where the word (with its trigger) starts in the composer text. start: usize, items: Vec, } #[derive(Clone)] enum MenuItem { Command(SlashCommand), /// A file or folder of the thread's folder, relative to it. Path { path: String, folder: bool }, } pub struct ChatView { session: Entity, composer: Entity, scroller: Entity, model_picker: Entity, branch_picker: Entity, open_picker: Option, attachments: Vec, diff: Option, /// The composer's placeholder, as the thread's state picks it. placeholder: &'static str, /// The question form's question the composer holds the answer to. ask_shown: Option<(usize, usize)>, /// A question folded away, by form and question. question_collapsed: Option<(usize, usize)>, /// The option just picked on a single-select question, shown checked /// until the form moves on. optimistic_pick: Option<(usize, usize, usize)>, ask_timer: Task<()>, /// The highlighted row of the `/`, `$` or `@` menu. menu_selected: usize, /// The composer text the menu was dismissed at, with Esc. menu_dismissed: Option, /// The thread folder's files, for the `@` menu, once read. files: Option<(PathBuf, Vec)>, files_task: Task<()>, /// Where ↑/↓ are in the thread's sent prompts: the entry shown, and the /// text put in the composer for it. history: Option<(usize, String)>, /// The find bar, while open (Ctrl+F). find: Option, /// Runs of tool calls unfolded in the timeline, by their first call. expanded_groups: std::collections::HashSet, /// Long user messages unfolded in the timeline. expanded_messages: std::collections::HashSet, /// Prompts put aside with Ctrl+S, oldest first. stash: Vec, stash_open: bool, stash_selected: usize, /// When a prompt was last stashed, for the tab's brief pulse. stash_pulse: Option, queue_expanded: bool, /// The queued message being edited in the composer. editing_queued: Option, notices_expanded: bool, tasks_expanded: bool, /// The composer card's width, measured as it's laid out, so the footer /// knows how much fits. composer_width: Pixels, /// Whether the worktree setup card shows its details. setup_details: bool, /// Whether the traits and access controls folded into "…" last frame. folded: std::cell::Cell<(bool, bool)>, /// The composer rests on one line while the conversation scrolls. collapsed: bool, /// Whether the conversation was scrolled away from its end last frame. was_scrolled_up: bool, _subscriptions: Vec, _ticker: Task<()>, } impl EventEmitter for ChatView {} impl ChatView { pub fn new(session: Entity, window: &mut Window, cx: &mut Context) -> Self { let composer = cx.new(|cx| { markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer)) .submit_on_enter(settings::get(|settings| settings.send_shortcut) != settings::SendShortcut::ModEnter) .placeholder(DEFAULT_PLACEHOLDER) }); let item_count = session.read(cx).items.len(); let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx)); let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx)); let branch_picker = cx.new(|cx| BranchPicker::new(window, cx)); let subscriptions = vec![ cx.observe_in(&session, window, |this, session, window, cx| { this.sync_with_session(window, cx); let count = session.read(cx).items.len(); this.scroller.update(cx, |scroller, cx| { let current = scroller.item_count(); if count > current { scroller.append(count - current, cx); } else if count < current { scroller.reset(count, cx); } }); cx.notify(); }), cx.subscribe_in(&composer, window, |this, _, event, window, cx| match event { InputEvent::PressEnter { shift: false, secondary } => { let ctrl_enter = this.ctrl_sends(cx); if *secondary || !ctrl_enter { // With Enter sending, Ctrl+Enter does the opposite // of the follow-up setting. this.submit(*secondary && !ctrl_enter, window, cx); } } InputEvent::Change => { this.menu_selected = 0; this.stash_open = false; this.update_send_key(cx); this.sync_custom_answer(cx); this.expand(cx); this.load_files_if_mentioning(cx); cx.notify(); } InputEvent::Focus => this.expand(cx), _ => {} }), cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| { let SessionEvent::TurnChanges(end) = event; let big = session.read(cx).turn_changes(*end).is_some_and(|changes| { changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES }); if big && this.diff.is_none() && settings::get(|settings| settings.proactive_panels) { this.open_diff(*end, None, window, cx); } }), cx.subscribe_in(&model_picker, window, |this, _, event, window, cx| { let ModelPickerEvent::Picked(model) = event; this.open_picker = None; if model.provider == this.session.read(cx).provider { let id = model.id.clone(); this.session.update(cx, |session, cx| session.set_model(Some(id), cx)); this.focus_composer(window, cx); } else { cx.emit(ChatEvent::NewThreadWithModel(model.clone())); } cx.notify(); }), cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)), ]; // Re-render once a second while the agent works so the timer advances. let ticker = cx.spawn(async move |this, cx| { loop { cx.background_executor().timer(Duration::from_secs(1)).await; let alive = this.update(cx, |this, cx| { let session = this.session.read(cx); if session.is_working() || !session.background_tasks.is_empty() { cx.notify(); } }); if alive.is_err() { break; } } }); let mut this = Self { session, composer, scroller, model_picker, branch_picker, open_picker: None, attachments: Vec::new(), diff: None, placeholder: DEFAULT_PLACEHOLDER, ask_shown: None, question_collapsed: None, optimistic_pick: None, ask_timer: Task::ready(()), menu_selected: 0, menu_dismissed: None, files: None, files_task: Task::ready(()), history: None, find: None, expanded_groups: Default::default(), expanded_messages: Default::default(), stash: Vec::new(), stash_open: false, stash_selected: 0, stash_pulse: None, queue_expanded: true, editing_queued: None, notices_expanded: false, tasks_expanded: false, composer_width: px(0.), setup_details: false, folded: Default::default(), collapsed: false, was_scrolled_up: false, _subscriptions: subscriptions, _ticker: ticker, }; this.sync_with_session(window, cx); this } pub fn session(&self) -> &Entity { &self.session } /// Picks up changed settings: the send key, and folding, which changes /// the height of transcript rows. pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context) { self.update_send_key(cx); markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx); self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx)); self.sync_with_session(window, cx); cx.notify(); } /// Whether Ctrl+Enter sends right now, as Settings → Send shortcut says: /// always, never, or once the prompt runs over several lines. pub(super) fn ctrl_sends(&self, cx: &App) -> bool { match settings::get(|settings| settings.send_shortcut) { settings::SendShortcut::Enter => false, settings::SendShortcut::ModEnter => true, settings::SendShortcut::ModEnterMultiline => self.composer.read(cx).value().contains('\n'), } } /// Lets Enter send, or start a new line, as the send shortcut wants. fn update_send_key(&mut self, cx: &mut Context) { let enter_sends = !self.ctrl_sends(cx); self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx)); } /// Shows a tool call in the conversation, unfolded: opens the finished /// turn and the run of calls it's folded into, and scrolls to it. The /// subagents panel's rows lead here. pub fn reveal_tool(&mut self, ix: usize, cx: &mut Context) { let session = self.session.read(cx); if !matches!(session.items.get(ix), Some(ChatItem::Tool(_))) { return; } let fold = settings::get(|settings| settings.fold_turns); let hidden = session .finished_turn(ix) .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold) .map(|turn| turn.end); let group = timeline::group_of(&session.items, ix); if group.len() > 1 { self.expanded_groups.insert(group[0]); } self.session.update(cx, |session, cx| { if let Some(end) = hidden { session.toggle_turn(end, cx); } if let Some(ChatItem::Tool(tool)) = session.items.get_mut(ix) { tool.expanded = true; } cx.notify(); }); self.scroller.update(cx, |scroller, cx| { scroller.remeasure(cx); scroller.scroll_to_item(ix, cx); }); cx.notify(); } pub fn focus_composer(&self, window: &mut Window, cx: &mut App) { self.composer.update(cx, |composer, cx| composer.focus(window, cx)); } /// Puts text into the composer at its cursor, on a line of its own, and /// focuses it: the terminal's Add to chat. pub fn insert_into_composer(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.composer.update(cx, |composer, cx| { let value = composer.value(); let text = if value.is_empty() || value.ends_with('\n') { text.to_string() } else { format!("\n{text}") }; composer.insert(text, window, cx); composer.focus(window, cx); }); cx.notify(); } /// Carries what the user typed over from another thread, when this one /// replaces it (a provider or project switch before the first message). pub fn take_draft_from(&mut self, other: &Entity, window: &mut Window, cx: &mut Context) { let (text, attachments) = other.update(cx, |other, cx| { (other.composer.read(cx).value().to_string(), std::mem::take(&mut other.attachments)) }); self.attachments = attachments; self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx)); } /// Keeps the composer in step with what the thread waits on: its /// placeholder, whether it takes input, and (while a question is open) /// the answer typed for the question on screen. fn sync_with_session(&mut self, window: &mut Window, cx: &mut Context) { let session = self.session.read(cx); let approval = dock::pending_approval(session).is_some() && dock::pending_plan(session).is_none(); let ask = session.pending_ask().map(|(ix, ask)| (ix, ask.current, ask.custom[ask.current].clone())); let plan = dock::pending_plan(session).is_some(); let choice_only = dock::pending_extension_question(session).is_some() && ask.is_none(); let no_provider = Catalog::global(cx).enabled().next().is_none(); let placeholder = if approval { "Resolve this approval request to continue" } else if choice_only { "Choose an option above" } else if ask.is_some() { "Type your own answer, or leave this blank to use the selected option" } else if plan { "Add feedback to refine the plan, or leave this blank to implement it" } else if no_provider { "Enable a provider in Settings to send a message" } else { DEFAULT_PLACEHOLDER }; if placeholder != self.placeholder { self.placeholder = placeholder; self.composer.update(cx, |composer, cx| { composer.set_placeholder(placeholder, window, cx); composer.set_disabled(approval || choice_only, cx); }); } // A question shown anew brings its own typed answer into the composer. let shown = ask.as_ref().map(|(ix, current, _)| (*ix, *current)); if shown != self.ask_shown { let had_question = self.ask_shown.is_some(); self.ask_shown = shown; match ask { Some((_, _, custom)) => self.composer.update(cx, |composer, cx| composer.set_value(custom, window, cx)), // The form is answered: the answer typed for it goes away. None if had_question => self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)), None => {} } } } /// Hands what the composer holds to the open question as the user's /// own answer. fn sync_custom_answer(&mut self, cx: &mut Context) { let Some((ix, _)) = self.session.read(cx).pending_ask() else { return }; if self.ask_shown.is_none() { return; } let text = self.composer.read(cx).value().to_string(); self.session.update(cx, |session, cx| session.ask_set_custom(ix, text, cx)); } /// What's typed and attached but not sent, for the sidebar's draft rows. pub fn draft(&self, cx: &App) -> (String, usize) { (self.composer.read(cx).value().to_string(), self.attachments.len()) } /// Empties the composer, as discarding an unsent draft does. pub fn clear_draft(&mut self, window: &mut Window, cx: &mut Context) { self.attachments.clear(); self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); } /// Sends what's in the composer. While the agent works, the follow-up /// setting decides between steering it now and queueing for after the /// turn; `opposite` flips that for this one message. fn submit(&mut self, opposite: bool, window: &mut Window, cx: &mut Context) { if self.command_menu(cx).is_some() { return; } let text = self.composer.read(cx).value().trim().to_string(); let session = self.session.read(cx); // An open question takes the text as the user's own answer and moves on. if let Some((ix, _)) = session.pending_ask() { self.sync_custom_answer(cx); self.session.update(cx, |session, cx| session.ask_advance(ix, cx)); return; } if dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode") { return; } if let Some((ix, _)) = dock::pending_plan(session) { if text.is_empty() { self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Allow, cx)); } else { self.refine_plan(ix, window, cx); } return; } if text.is_empty() && self.attachments.is_empty() { return; } let attachments = std::mem::take(&mut self.attachments); self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); if let Some(ix) = self.editing_queued.take() { self.session.update(cx, |session, cx| session.update_queued(ix, text, attachments, cx)); return; } let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue; let branch = self.checkout_branch(cx); self.session.update(cx, |session, cx| { if branch.is_some() { session.branch = branch; } if (session.is_working() || session.preparing) && queue_mode != opposite { session.queue_message(text, attachments, cx); } else { session.send_message(text, attachments, cx); } }); self.menu_dismissed = None; self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx)); } /// Declines Claude's plan with the composer's text as feedback, so it /// plans again. fn refine_plan(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let text = self.composer.read(cx).value().trim().to_string(); if text.is_empty() { return; } self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); self.session.update(cx, |session, cx| { session.respond_permission(ix, PermissionDecision::Deny, cx); session.send_message(text, Vec::new(), cx); }); } /// Hands Claude's plan to a new thread to implement, and declines it here. fn implement_in_new_thread(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { let plan = match self.session.read(cx).items.get(ix) { Some(ChatItem::Tool(tool)) => tool_field(&tool.input, &["plan"]).unwrap_or_default(), _ => return, }; self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Deny, cx)); cx.emit(ChatEvent::ImplementInNewThread(plan)); } /// Loads a queued message into the composer to edit it. fn edit_queued(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(message) = self.session.read(cx).queue.get(ix) else { return }; let (text, attachments) = (message.text.clone(), message.attachments.clone()); self.editing_queued = Some(ix); self.attachments = attachments; self.composer.update(cx, |composer, cx| { composer.set_value(text, window, cx); composer.focus(window, cx); }); cx.notify(); } fn cancel_queued_edit(&mut self, window: &mut Window, cx: &mut Context) { if self.editing_queued.take().is_some() { self.attachments.clear(); self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); cx.notify(); } } /// Opens (or closes) one of the composer's controls, as T3 Code's /// composer shortcuts do; a control folded into "…" opens that menu. fn open_control(&mut self, picker: Picker, window: &mut Window, cx: &mut Context) { let (traits_folded, access_folded) = self.folded.get(); let picker = match picker { Picker::Traits if traits_folded => Picker::Overflow, Picker::Access if access_folded => Picker::Overflow, Picker::Branch | Picker::Workspace | Picker::Environment if !self.strip_visible(cx) => return, Picker::Workspace | Picker::Environment if !self.session.read(cx).items.is_empty() => return, picker => picker, }; self.expand(cx); let open = self.open_picker != Some(picker); self.set_picker(picker, open, window, cx); if !open { self.focus_composer(window, cx); } } /// Points a new thread at the project's previous worktree (Ctrl+Shift+L). fn use_previous_worktree(&mut self, window: &mut Window, cx: &mut Context) { let session = self.session.read(cx); let env = session.remote.as_ref().map(|remote| remote.env.clone()); let previous = cx .try_global::() .and_then(|directory| directory.find(&session.project, env.as_deref())) .and_then(|project| project.previous_worktree.clone()); if let Some(worktree) = previous { cx.emit(ChatEvent::SetWorkspace(WorkspaceMode::Local)); self.session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx)); self.focus_composer(window, cx); } } /// The thread's sent prompts, oldest first, without repeats in a row. fn prompt_history(&self, cx: &App) -> Vec { let mut prompts: Vec = Vec::new(); for item in &self.session.read(cx).items { if let ChatItem::User { text, .. } = item && !text.trim().is_empty() && prompts.last().map(String::as_str) != Some(text.as_ref()) { prompts.push(text.to_string()); } } prompts } /// Terminal-style recall, as in T3 Code: ↑ on an empty composer walks /// back through the thread's prompts, ↓ walks forward and past the /// newest clears the composer again. Editing a recalled prompt ends it. fn recall_prompt(&mut self, older: bool, window: &mut Window, cx: &mut Context) -> bool { let text = self.composer.read(cx).value().to_string(); let browsing = self.history.as_ref().filter(|(_, recalled)| *recalled == text).map(|(at, _)| *at); if browsing.is_none() && (!text.is_empty() || !older) { self.history = None; return false; } let prompts = self.prompt_history(cx); if prompts.is_empty() { return false; } let next = match (browsing, older) { (None, _) => Some(prompts.len() - 1), (Some(at), true) => Some(at.saturating_sub(1)), (Some(at), false) if at + 1 < prompts.len() => Some(at + 1), (Some(_), false) => None, }; let value = match next { Some(at) => { self.history = Some((at, prompts[at].clone())); prompts[at].clone() } None => { self.history = None; String::new() } }; self.composer.update(cx, |composer, cx| composer.set_value(value, window, cx)); true } /// Puts the composer's prompt aside (Ctrl+S), or with nothing to stash, /// opens the stash. fn stash_prompt(&mut self, window: &mut Window, cx: &mut Context) { let text = self.composer.read(cx).value().to_string(); if self.session.read(cx).pending_ask().is_some() || text.trim().is_empty() && self.attachments.is_empty() { self.toggle_stash(cx); return; } self.stash.push(Stashed { text, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() }); self.stash_pulse = Some(std::time::Instant::now()); self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); cx.notify(); } fn toggle_stash(&mut self, cx: &mut Context) { self.stash_open = !self.stash_open; self.stash_selected = self.stash.len().saturating_sub(1); cx.notify(); } /// Puts a stashed prompt back in the composer, stashing what's there. fn restore_stash(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { if ix >= self.stash.len() { return; } let stashed = self.stash.remove(ix); let current = self.composer.read(cx).value().to_string(); if !current.trim().is_empty() || !self.attachments.is_empty() { self.stash.push(Stashed { text: current, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() }); } self.attachments = stashed.attachments; self.composer.update(cx, |composer, cx| { composer.set_value(stashed.text, window, cx); composer.focus(window, cx); }); self.stash_open = false; cx.notify(); } fn delete_stash(&mut self, ix: usize, cx: &mut Context) { if ix < self.stash.len() { self.stash.remove(ix); self.stash_selected = self.stash_selected.min(self.stash.len().saturating_sub(1)); cx.notify(); } } /// Keys the open stash takes: arrows, Enter to restore, Ctrl+Backspace /// to delete, Esc to close. fn stash_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) -> bool { if !self.stash_open { return false; } let count = self.stash.len(); let modifiers = &event.keystroke.modifiers; match event.keystroke.key.as_str() { // Newest is listed first, so up moves to newer ones. "up" if count > 0 => self.stash_selected = (self.stash_selected + 1) % count, "down" if count > 0 => self.stash_selected = (self.stash_selected + count - 1) % count, "enter" if count > 0 => { let ix = self.stash_selected.min(count - 1); self.restore_stash(ix, window, cx); } "backspace" if modifiers.control && count > 0 => { let ix = self.stash_selected.min(count - 1); self.delete_stash(ix, cx); } "escape" => self.stash_open = false, _ => return false, } cx.notify(); true } /// 1–9 pick an option of the open question while the composer is empty. fn pick_with_key(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { let keystroke = &event.keystroke; if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() { return; } let Some(digit) = keystroke.key.parse::().ok().filter(|digit| (1..=9).contains(digit)) else { return; }; if let Some((ix, question)) = dock::pending_extension_question(self.session.read(cx)) && self.session.read(cx).pending_ask().is_none() { if digit <= question.options.len() { self.session.update(cx, |session, cx| session.answer_question(ix, digit - 1, cx)); cx.stop_propagation(); } return; } let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return }; if self.question_collapsed == Some((ix, ask.current)) || digit > ask.questions[ask.current].options.len() { return; } self.pick_option(ix, digit - 1, cx); cx.stop_propagation(); } fn expand(&mut self, cx: &mut Context) { if self.collapsed { self.collapsed = false; cx.notify(); } } fn interrupt(&mut self, cx: &mut Context) { self.session.update(cx, |session, cx| session.interrupt(cx)); } /// The notices docked above the composer, front first: work running in /// the background, then a usage limit. fn notices(&self, cx: &App) -> Vec { let session = self.session.read(cx); let mut notices = Vec::new(); if !session.background_tasks.is_empty() { // What each task is, from the tool call that started it. let kind = |task_id: &str| { let tool = session.items.iter().find_map(|item| match item { ChatItem::Tool(tool) if tool.task_id.as_deref() == Some(task_id) => Some(tool.name.as_str()), _ => None, }); match tool { Some("Task" | "Agent") => 0, Some("Bash") => 1, Some("Monitor") => 2, _ => 3, } }; const NOUNS: [(&str, &str); 4] = [("subagent", "subagents"), ("command", "commands"), ("monitor", "monitors"), ("background task", "background tasks")]; let mut tasks: Vec<(usize, &crate::session::BackgroundTask)> = session.background_tasks.iter().map(|task| (kind(&task.task_id), task)).collect(); tasks.sort_by_key(|(kind, _)| *kind); // Subagents and monitors wake the agent; a dev server doesn't. let waiting = tasks.iter().any(|(kind, _)| matches!(kind, 0 | 2)); let title = if let [(kind, task)] = tasks.as_slice() { let noun = NOUNS[*kind].0; let named = !task.description.is_empty(); match (waiting, named) { (true, true) => format!("Waiting on {noun} {}", task.description), (true, false) => format!("Waiting on a {noun}"), (false, true) => format!("Running: {}", task.description), (false, false) => format!("Running a {noun}"), } } else { let mut groups: Vec = Vec::new(); for kind in 0..4 { let count = tasks.iter().filter(|(task_kind, _)| *task_kind == kind).count(); if count > 0 { let (one, many) = NOUNS[kind]; groups.push(format!("{count} {}", if count == 1 { one } else { many })); } } let joined = match groups.len() { 0 | 1 => groups.join(""), n => format!("{} and {}", groups[..n - 1].join(", "), groups[n - 1]), }; format!("{} {joined}", if waiting { "Waiting on" } else { "Running" }) }; let detail = (tasks.len() > 1).then(|| { tasks.iter().map(|(_, task)| task.description.to_string()).collect::>().join(", ").into() }); notices.push(Notice::BackgroundWork { title: title.into(), detail, waiting, task_ids: tasks.iter().map(|(_, task)| task.task_id.clone()).collect(), }); } if let Some(resets) = session.limited_until() { notices.push(Notice::Limit { resets, resume_at: session.resume_at }); } // Shown once there's something to send, as in T3 Code. let typing = !self.composer.read(cx).value().trim().is_empty(); if typing && let Some(was) = session.branch.clone() && let Some(now) = self.checkout_branch(cx) && was != now { notices.push(Notice::BranchChanged { was, now }); } notices } /// The branch the project's checkout is on, for a thread that runs in /// it (not in a worktree of its own, nor on another device). fn checkout_branch(&self, cx: &App) -> Option { let session = self.session.read(cx); if session.worktree.is_some() || session.pending_worktree.is_some() || session.is_remote() { return None; } cx.try_global::()?.find(&session.project, None)?.branch.clone() } // ---- the `/`, `$` and `@` menus --------------------------------------- /// The menu for the word at the cursor: `/` at the start of a line lists /// commands (and skills, when set to), `$` lists skills, `@` lists the /// thread folder's files and folders. fn command_menu(&self, cx: &App) -> Option { let composer = self.composer.read(cx); let text = composer.value(); if self.menu_dismissed.as_deref() == Some(text.as_ref()) { return None; } let cursor = composer.cursor().min(text.len()); let before = &text[..cursor]; let start = before.rfind(char::is_whitespace).map_or(0, |at| at + before[at..].chars().next().map_or(1, char::len_utf8)); let word = &before[start..]; let trigger = word.chars().next().filter(|c| matches!(c, '/' | '$' | '@'))?; // `/` only opens a command at the start of a line. if trigger == '/' && start > 0 && !before[..start].ends_with('\n') { return None; } let query = word[1..].to_lowercase(); let items: Vec = match trigger { '@' => { let files = self.files.as_ref().map(|(_, files)| files.as_slice()).unwrap_or_default(); let mut matches: Vec = files .iter() .filter(|path| path.to_lowercase().contains(&query)) .take(MENU_ROWS) .map(|path| MenuItem::Path { folder: path.ends_with('/'), path: path.clone() }) .collect(); matches.sort_by_key(|item| match item { MenuItem::Path { path, .. } => { let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path).to_lowercase(); (!name.starts_with(&query), path.len()) } MenuItem::Command(_) => (true, 0), }); matches } _ => { let skills_too = settings::get(|settings| settings.slash_skills); let instance = self.session.read(cx).instance.clone(); let commands = Catalog::global(cx).commands(&instance); let wanted = |command: &&SlashCommand| match trigger { '$' => command.skill, _ => !command.skill || skills_too, }; let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands .iter() .filter(wanted) .filter(|command| command.name.to_lowercase().contains(&query)) .cloned() .partition(|command| command.name.to_lowercase().starts_with(&query)); prefix.append(&mut inside); prefix.truncate(MENU_ROWS); prefix.into_iter().map(MenuItem::Command).collect() } }; Some(CommandMenu { trigger, start, items }) } /// Reads the thread folder's files in the background once `@` is typed. fn load_files_if_mentioning(&mut self, cx: &mut Context) { let cwd = self.session.read(cx).cwd.clone(); let text = self.composer.read(cx).value(); if !text.contains('@') || self.files.as_ref().is_some_and(|(dir, _)| *dir == cwd) || self.session.read(cx).is_remote() { return; } self.files = Some((cwd.clone(), Vec::new())); let task = cx.background_executor().spawn({ let cwd = cwd.clone(); async move { list_files(&cwd) } }); self.files_task = cx.spawn(async move |this, cx| { let files = task.await; let _ = this.update(cx, |this, cx| { this.files = Some((cwd, files)); cx.notify(); }); }); } /// Puts the picked item in the composer in place of the word typed. fn accept_menu_item(&mut self, menu: &CommandMenu, item: &MenuItem, window: &mut Window, cx: &mut Context) { // `/model` opens the model picker, as in T3 Code. if let MenuItem::Command(command) = item && command.name == "model" && menu.trigger == '/' { let start = menu.start; self.composer.update(cx, |composer, cx| { let cursor = composer.cursor(); composer.set_selected_range(start..cursor, cx); composer.replace("", window, cx); }); self.set_picker(Picker::Model, true, window, cx); return; } let replacement = match item { MenuItem::Command(command) => format!("{}{} ", if menu.trigger == '$' { '$' } else { '/' }, command.name), MenuItem::Path { path, .. } => format!("@{path} "), }; let start = menu.start; self.composer.update(cx, |composer, cx| { let cursor = composer.cursor(); composer.set_selected_range(start..cursor, cx); composer.replace(replacement, window, cx); composer.focus(window, cx); }); self.menu_selected = 0; cx.notify(); } /// Keys the menus take before the composer sees them, Ctrl+Shift+Enter /// (the send key's opposite, or steering the first queued message), and /// Alt+↑ to edit the last queued message. fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let keystroke = &event.keystroke; let modifiers = &keystroke.modifiers; if self.stash_key(event, window, cx) { cx.stop_propagation(); return; } if keystroke.key == "enter" && modifiers.control && modifiers.shift { cx.stop_propagation(); let queued = !self.session.read(cx).queue.is_empty(); if queued && self.composer.read(cx).value().trim().is_empty() { self.session.update(cx, |session, cx| session.steer_queued(0, cx)); } else { self.submit(true, window, cx); } return; } if keystroke.key == "up" && modifiers.alt && !modifiers.control { let count = self.session.read(cx).queue.len(); if count > 0 { cx.stop_propagation(); self.edit_queued(count - 1, window, cx); } return; } if keystroke.key == "tab" && modifiers.shift && settings::get(|settings| settings.plan_mode) { cx.stop_propagation(); self.session.update(cx, |session, cx| session.toggle_plan(cx)); return; } let Some(menu) = self.command_menu(cx) else { let plain = !(modifiers.control || modifiers.alt || modifiers.shift || modifiers.platform); let asking = self.session.read(cx).pending_ask().is_some(); if plain && !asking && matches!(keystroke.key.as_str(), "up" | "down") { if self.recall_prompt(keystroke.key == "up", window, cx) { cx.stop_propagation(); } } return; }; if modifiers.control || modifiers.alt || modifiers.shift { return; } let count = menu.items.len(); match keystroke.key.as_str() { "up" if count > 0 => self.menu_selected = (self.menu_selected + count - 1) % count, "down" if count > 0 => self.menu_selected = (self.menu_selected + 1) % count, "enter" | "tab" if count > 0 => { let item = menu.items[self.menu_selected.min(count - 1)].clone(); cx.stop_propagation(); self.accept_menu_item(&menu, &item, window, cx); return; } "escape" => self.menu_dismissed = Some(self.composer.read(cx).value().to_string()), _ => return, } cx.stop_propagation(); cx.notify(); } /// The menu, docked on top of the composer like T3 Code's. fn render_command_menu(&self, menu: CommandMenu, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let selected = self.menu_selected.min(menu.items.len().saturating_sub(1)); let loading = menu.trigger == '@' && self.files.as_ref().is_some_and(|(_, files)| files.is_empty()); let empty = match menu.trigger { '$' => "No skills found. Try / to browse provider commands.", '@' if loading => "Searching workspace files...", '@' => "No matching files or folders.", _ => "No matching command.", }; let trigger = menu.trigger; let rows: Vec = menu .items .iter() .enumerate() .map(|(ix, item)| { let pick = item.clone(); let start = menu.start; let row = h_flex() .id(("command-menu-item", ix)) .min_h(px(28.)) .gap(px(8.)) .px(px(8.)) .py(px(6.)) .rounded(px(6.)) .cursor_pointer() .text_size(px(13.5)) .when(ix == selected, |this| this.bg(p.menu_row_hover)) .hover(|style| style.bg(p.menu_row_hover)) .on_click(cx.listener(move |this, _, window, cx| { let menu = CommandMenu { trigger, start, items: Vec::new() }; this.accept_menu_item(&menu, &pick, window, cx) })); match item { MenuItem::Command(command) => row .child( div() .flex_none() .max_w(relative(0.45)) .truncate() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .when(command.skill && trigger == '/', |this| { this.child(div().text_color(p.ink3).child("/skill:")).child(command.name.clone()) }) .when(!(command.skill && trigger == '/'), |this| { this.child(format!("{}{}", if trigger == '$' { '$' } else { '/' }, command.name)) }), ) .child( div() .flex_1() .min_w_0() .truncate() .text_size(px(12.)) .text_color(p.ink3) .child(if command.description.is_empty() { command.argument_hint.clone() } else { command.description.clone() }), ) .when(command.skill, |this| { this.child(metal::tag(if trigger == '$' { "Personal Skill" } else { "Personal" }, p)) }) .into_any_element(), MenuItem::Path { path, folder } => { let trimmed = path.trim_end_matches('/'); let (dir, name) = match trimmed.rsplit_once('/') { Some((dir, name)) => (dir.to_string(), name.to_string()), None => (String::new(), trimmed.to_string()), }; row.child( Icon::new(if *folder { IconName::Folder } else { IconName::File }).size(px(16.)).text_color(p.ink3), ) .child( div() .flex_none() .max_w(relative(0.45)) .truncate() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(name), ) .child(div().flex_1().min_w_0().truncate().text_size(px(12.)).text_color(p.ink3).child(dir)) .into_any_element() } } }) .collect(); let empty_rows = rows.is_empty(); composer::banner(Tone::Neutral, Density::Default, false, p) .child( v_flex() .id("command-menu") .max_h(px(288.)) .overflow_y_scroll() .p(px(4.)) .children(rows) .when(empty_rows, |this| { this.child(div().px(px(14.)).pt(px(20.)).pb(px(12.)).text_size(px(12.)).text_color(p.ink3).child(empty)) }), ) .into_any_element() } fn escape(&mut self, window: &mut Window, cx: &mut Context) { // Closing the find bar or a picker hands the keyboard back to the composer. if self.find.take().is_some() || self.open_picker.take().is_some() { self.focus_composer(window, cx); cx.notify(); return; } if self.stash_open { self.stash_open = false; cx.notify(); return; } if self.command_menu(cx).is_some() { self.menu_dismissed = Some(self.composer.read(cx).value().to_string()); cx.notify(); return; } if self.diff.take().is_some() { cx.notify(); return; } self.session.update(cx, |session, cx| session.interrupt(cx)); } fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context) { let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: true, prompt: Some("Attach".into()), }); cx.spawn_in(window, async move |this, cx| { let Ok(Ok(Some(paths))) = paths.await else { return }; let _ = this.update_in(cx, |this, window, cx| { for path in paths { if !this.attachments.contains(&path) { this.attachments.push(path); } } this.focus_composer(window, cx); cx.notify(); }); }) .detach(); } /// Shows what a finished turn changed, all files or one. fn open_diff(&mut self, end: usize, path: Option, window: &mut Window, cx: &mut Context) { let Some(changes) = self.session.read(cx).turn_changes(end) else { return }; let title = match &path { Some(path) => path.clone(), None => format!("{} changed files", changes.files.len()), }; let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| { (settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed) }); self.diff = Some(DiffView { title: title.into(), end, path, ignore_whitespace, side_by_side, start_collapsed: collapsed, ..Default::default() }); self.load_diff(window, cx); cx.notify(); } /// Reads the open diff in the background, as its whitespace toggle asks. fn load_diff(&mut self, window: &mut Window, cx: &mut Context) { let Some(diff) = &self.diff else { return }; let session = self.session.read(cx); let Some(changes) = session.turn_changes(diff.end) else { return }; if session.is_remote() { // The snapshots live in the host's repository. let note = Err("This thread runs on another device; its diffs open there.".to_string()); if let Some(diff) = self.diff.as_mut() { diff.set_files(note); } return; } let (base, head) = (changes.base.clone(), changes.head.clone()); let cwd = session.cwd.clone(); let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace); let task = cx.background_executor().spawn({ let path = path.clone(); async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) } }); cx.spawn_in(window, async move |this, cx| { let files = task.await; let _ = this.update(cx, |this, cx| { // Unless the diff was closed, replaced or toggled meanwhile. if let Some(diff) = this.diff.as_mut() && (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace) { diff.set_files(files); cx.notify(); } }); }) .detach(); } fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context) { let Some(diff) = self.diff.as_mut() else { return }; match action { DiffAction::Close => self.diff = None, DiffAction::ToggleWhitespace => { diff.ignore_whitespace = !diff.ignore_whitespace; self.load_diff(window, cx); } DiffAction::SetSideBySide(side_by_side) => { diff.side_by_side = side_by_side; settings::update(|settings| settings.diff_side_by_side = side_by_side); } DiffAction::ToggleFile(path) => diff.toggle_file(path), DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed), } cx.notify(); } /// A callback that closes whichever picker is open. fn close_picker(cx: &Context) -> impl Fn(&mut Window, &mut App) + Clone + 'static { let chat = cx.entity().downgrade(); move |window: &mut Window, cx: &mut App| { let _ = chat.update(cx, |this, cx| { this.open_picker = None; this.focus_composer(window, cx); cx.notify(); }); } } fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context) { if open { self.open_picker = Some(picker); match picker { Picker::Model => self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)), Picker::Branch => self.open_branch_picker(window, cx), _ => {} } } else if self.open_picker == Some(picker) { self.open_picker = None; // A picker closed with Esc or a click outside leaves no focus. self.focus_composer(window, cx); } cx.notify(); } /// "What should we build in T3-2?" with the project as a switcher. fn render_start(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let cwd = session.project.clone(); let env = session.remote.as_ref().map(|remote| remote.env.clone()); let directory = cx.try_global::(); let current = directory.and_then(|directory| directory.find(&cwd, env.as_deref())); let scratch = current.is_some_and(|project| project.scratch); let project = current.map(|project| project.name.clone()).unwrap_or_else(|| display_path(&cwd).into()); let projects: Vec<(SharedString, PathBuf, Option)> = directory .map(|directory| { directory .projects .iter() .filter(|project| !project.scratch) .map(|project| { let name = match &project.host { Some(host) => format!("{} · {host}", project.name).into(), None => project.name.clone(), }; (name, project.path.clone(), project.environment.clone()) }) .collect() }) .unwrap_or_default(); let chat = cx.entity().downgrade(); // T3 Code's project menu: No project, the projects, Add project. let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context| { menu = menu.min_w(px(240.)).max_h(px(320.)); let no_project = chat.clone(); menu = menu.item( PopupMenuItem::new("No project") .icon(Icon::new(Lucide::MessageSquareDashed)) .checked(scratch) .on_click(move |_, _, cx| { let _ = no_project.update(cx, |_, cx| cx.emit(ChatEvent::StartWithoutProject)); }), ); for (name, path, project_env) in projects.clone() { let chat = chat.clone(); let checked = path == cwd && project_env == env; menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| { let (path, project_env) = (path.clone(), project_env.clone()); let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path, project_env))); })); } let chat = chat.clone(); menu.separator().item(PopupMenuItem::new("Add project").icon(Icon::new(Lucide::FolderPlus)).on_click( move |_, _, cx| { let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::AddProject)); }, )) }; let picker = |id: &'static str, label: SharedString| { MetalButton::new(id) .ghost() .label(label) .px(px(2.)) .rounded(px(0.)) .border_b(px(1.)) .border_dashed() .border_color(p.ink3.opacity(0.6)) }; v_flex() .flex_1() .items_center() .justify_center() .px(px(24.)) .pb(px(48.)) .child( h_flex() .flex_wrap() .justify_center() .items_center() .text_size(px(30.)) .line_height(px(36.)) .text_color(p.ink) .map(|this| { if scratch { this.child("What should we work on?") } else { this.child("What should we build in\u{a0}") .child( picker("start-project", project) .h(px(36.)) .max_w(px(256.)) .text_size(px(30.)) .font_weight(FontWeight::NORMAL) .text_color(p.ink) .dropdown_menu(project_menu.clone()), ) .child("?") } }), ) .child(div().mt(px(8.)).h(px(24.)).flex().items_center().text_size(px(14.)).map(|this| { if scratch { this.child( picker("pick-project", "No project".into()) .h(px(24.)) .text_size(px(14.)) .text_color(p.ink3) .dropdown_menu(project_menu), ) } else { this.child( div() .id("start-without-project") .text_color(p.ink3) .cursor_pointer() .hover(|style| style.text_color(p.ink).underline()) .tooltip(|window, cx| { gpui_kit::component::tooltip::Tooltip::new("Ctrl+Alt+N").build(window, cx) }) .on_click(cx.listener(|_, _, _, cx| cx.emit(ChatEvent::StartWithoutProject))) .child("or start without a project"), ) } })) .child(div().h(px(24.))) .child(self.render_composer(true, window, cx)) } fn render_transcript(&self, cx: &mut Context) -> impl IntoElement { let session = self.session.clone(); let scroller = self.scroller.clone(); let chat = cx.entity().downgrade(); div() .relative() .flex_1() .min_h_0() .w_full() .flex() .flex_col() // Scrolling rests the composer, when set to. .child( MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| { timeline::render_item(&session, &scroller, &chat, ix, window, cx) }) .with_jump_button_label("Scroll to end") .with_jump_button_renderer(|button| button.icon(IconName::ChevronDown).label("Scroll to end")) .flex_1() .w_full() .with_row_style(StyleRefinement::default().pb_0()) .with_bottom_fade(metal::palette(cx).s), ) .children(self.render_find_bar(cx)) } /// The line closing the conversation, as T3 Code ends its timeline: the /// worktree being set up, or once the agent is done, whether the thread /// is snoozed or settled. (While the agent works, "Working for 12s" /// heads its turn in the timeline.) fn render_status_line(&self, cx: &mut Context) -> Option { let p = metal::palette(cx); let session = self.session.read(cx); if session.worktree_setup.is_some() { return Some(self.render_worktree_setup(cx)); } let snoozed = session.snoozed_until.filter(|_| session.is_snoozed()); let settled = session.settled; if snoozed.is_none() && !settled { return None; } let action_session = self.session.clone(); let (icon, label, action): (Icon, String, &'static str) = match snoozed { Some(until) => (Icon::new(Lucide::AlarmClock), format!("Snoozed, {}", clock::when(until)), "Wake now"), None => (Icon::new(IconName::CircleCheck), "Settled".to_string(), "Un-settle"), }; Some( h_flex() .w_full() .max_w(composer::content_width()) .px(px(16.)) .pb(px(8.)) .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink3) .child(icon.size(px(14.))) .child(div().truncate().child(label)) .child("·") .child( div() .id("status-line-action") .font_weight(FontWeight::MEDIUM) .cursor_pointer() .hover(|style| style.text_color(p.ink).underline()) .on_click(move |_, _, cx| { action_session.update(cx, |session, cx| { if snoozed.is_some() { session.snooze(None, cx) } else { session.unsettle(cx) } }) }) .child(action), ) .into_any_element(), ) } /// T3 Code's worktree setup card: the stages of making the thread's /// worktree as they run, with Details, Work locally and Cancel; or what /// went wrong once it failed or was cancelled. fn render_worktree_setup(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let session = self.session.read(cx); let Some(setup) = session.worktree_setup.as_ref() else { return div().into_any_element() }; let running = session.preparing; let header = if running { "Setting up worktree…" } else if setup.cancelled || setup.cancel.is_some() { "Worktree setup cancelled" } else if setup.error.is_some() { "Worktree setup failed" } else { "Worktree ready" }; let header_color = if setup.error.is_some() && !running { p.red } else { p.ink3 }; let stage_rows = setup.stages.iter().map(|(stage, status, started, ended)| { let (icon, color): (AnyElement, Hsla) = match status { StageStatus::Done => (Icon::new(IconName::Check).size(px(14.)).into_any_element(), p.ink3), StageStatus::Running => (metal::led(("setup-led", *stage as usize), Led::Live, true, p), p.ink2), StageStatus::Failed => (Icon::new(IconName::Close).size(px(14.)).into_any_element(), p.red), StageStatus::Skipped => (Icon::new(IconName::Minus).size(px(14.)).into_any_element(), p.ink3), StageStatus::Pending => (Icon::new(Lucide::Circle).size(px(14.)).into_any_element(), p.ink3.opacity(0.4)), }; let elapsed = started.map(|started| ended.unwrap_or_else(std::time::Instant::now).duration_since(started).as_secs()); let trailing = match status { StageStatus::Skipped => Some("skipped".to_string()), StageStatus::Pending => None, _ => elapsed.map(format_elapsed), }; h_flex() .h(px(24.)) .gap(px(10.)) .text_size(px(13.)) .text_color(color) .child(div().w(px(16.)).flex().justify_center().child(icon)) .child(div().flex_1().min_w_0().truncate().child(stage.label())) .when_some(trailing, |this, trailing| this.child(div().text_size(px(12.)).text_color(p.ink3).child(trailing))) }); let details = self.setup_details.then(|| { let row = |label: &'static str, value: String| { h_flex() .gap(px(12.)) .child(div().w(px(48.)).text_color(p.ink2).child(label)) .child(div().flex_1().min_w_0().truncate().font_family(metal::MONO).child(value)) }; v_flex() .ml(px(26.)) .mt(px(4.)) .gap(px(2.)) .text_size(px(12.)) .text_color(p.ink3) .when_some(setup.branch.clone(), |this, branch| this.child(row("Branch", branch))) .child(row("Base", setup.base.clone())) .when_some(setup.path.clone(), |this, path| this.child(row("Path", display_path(&path)))) }); let (cancel, local) = (self.session.clone(), self.session.clone()); v_flex() .w_full() .max_w(composer::content_width()) .px(px(16.)) .pb(px(8.)) .child( h_flex() .h(px(24.)) .px(px(4.)) .gap(px(8.)) .mb(px(4.)) .text_size(px(14.)) .text_color(header_color) .when(running, |this| this.child(metal::led("setup-header-led", Led::Live, true, p))) .child(header) .child(div().flex_1()) .child(div().text_size(px(12.)).text_color(p.ink3).child(format_elapsed(setup.started.elapsed().as_secs()))), ) .child(v_flex().px(px(4.)).children(stage_rows)) .when_some(setup.error.clone().filter(|_| !running), |this, error| { this.child(div().ml(px(30.)).mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(error)) }) .children(details) .child( h_flex() .ml(px(26.)) .mt(px(6.)) .gap(px(4.)) .child( MetalButton::new("setup-details") .ghost() .mini() .label("Details") .on_click(cx.listener(|this, _, _, cx| { this.setup_details = !this.setup_details; cx.notify(); })), ) .when(running, |this| { this.child( MetalButton::new("setup-local") .ghost() .mini() .icon(Lucide::Laptop) .label("Work locally") .on_click(move |_, _, cx| { local.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Local, cx)) }), ) .child( MetalButton::new("setup-cancel") .ghost() .mini() .label("Cancel") .on_click(move |_, _, cx| { cancel.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Cancel, cx)) }), ) }), ) .into_any_element() } /// A composer picker: a trigger that opens `content` above it. fn picker( &self, picker: Picker, trigger: MetalButton, content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, cx: &mut Context, ) -> impl IntoElement { Popover::new(SharedString::from(format!("{picker:?}-popover"))) .anchor(Anchor::BottomLeft) .p_0() .bg(gpui_kit::transparent_black()) .shadow_none() .open(self.open_picker == Some(picker)) .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) .trigger(trigger) .content(move |_, window, cx| content(window, cx)) } /// A picker whose content reads the conversation as it opens. fn picker_anchored( &self, picker: Picker, trigger: MetalButton, anchor: Anchor, cx: &mut Context, content: impl Fn(&mut Self, &mut Context) -> AnyElement + 'static, ) -> impl IntoElement { let chat = cx.entity().downgrade(); let content = std::rc::Rc::new(content); Popover::new(SharedString::from(format!("{picker:?}-popover"))) .anchor(anchor) .p_0() .bg(gpui_kit::transparent_black()) .shadow_none() .open(self.open_picker == Some(picker)) .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx))) .trigger(trigger) .content(move |_, _, cx| { let content = content.clone(); chat.update(cx, |this, cx| content(this, cx)).unwrap_or_else(|_| div().into_any_element()) }) } fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let approval = dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode"); let asking = session.pending_ask().is_some(); let focused = self.composer.read(cx).focus_handle(cx).is_focused(window); let dock = self.render_dock(cx); let collapsed = self.collapsed && !start && dock.is_empty(); let strip = self.strip_visible(cx).then(|| self.render_strip(cx)); // MetalUI marks focus with a green-deep ring. let mut card_shadow = p.raise.clone(); if focused && !approval { card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.))); } let measure = { let chat = cx.entity().downgrade(); let known = self.composer_width; canvas( move |bounds, _, cx| { if (bounds.size.width - known).abs() > px(0.5) { let _ = chat.update(cx, |this, cx| { this.composer_width = bounds.size.width; cx.notify(); }); } }, |_, _, _, _| {}, ) .absolute() .size_full() }; let rows = if collapsed { (1, 1) } else if approval { (2, 9) } else { (3, 9) }; let textarea = div() .text_size(px(14.)) .text_color(p.ink) .child(MarkdownInput::new(&self.composer).rows(rows.0, rows.1)); let card = if collapsed { // Resting: one line, a click (or typing) away from the full composer. h_flex() .id("composer-collapsed") .relative() .w_full() .h(px(48.)) .pl(px(16.)) .pr(px(8.)) .gap(px(8.)) .rounded(px(24.)) .bg(metal::vertical((p.s_hi, p.s))) .shadow(card_shadow) .cursor_text() .on_click(cx.listener(|this, _, window, cx| { this.expand(cx); this.focus_composer(window, cx); })) .child(measure) .child(textarea.flex_1().min_w_0().overflow_hidden()) .child(self.render_actions(start, cx)) .into_any_element() } else { let right_width = self.actions_width(cx); v_flex() .id("composer-card") .relative() .w_full() .p(px(1.)) .rounded(px(24.)) .bg(metal::vertical((p.s_hi, p.s))) .shadow(card_shadow) .when(approval, |this| this.opacity(0.92)) // Files dropped on the composer are attached. .drag_over::(move |style, _, _, _| { style.bg(p.menu_row_hover).border_1().border_color(p.green_deep.opacity(0.7)) }) .on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| { for path in paths.paths() { if !this.attachments.contains(path) { this.attachments.push(path.clone()); } } this.focus_composer(window, cx); cx.notify(); })) .child(measure) .child( v_flex() .px(px(16.)) .pt(px(16.)) .pb(px(if approval { 16. } else { 8. })) .when(!self.attachments.is_empty() && !asking, |this| this.child(self.render_attachments(cx))) .child(textarea), ) .when(!approval, |this| { this.child( h_flex() .w_full() .justify_between() .px(px(16.)) .pb(px(16.)) .when(asking, |this| this.pt(px(8.))) .child(self.render_controls(right_width, cx)) .child(self.render_actions(start, cx)), ) }) .into_any_element() }; v_flex() .flex_none() .w_full() .items_center() .px(px(24.)) .pb(px(if start { 0. } else { 20. })) .when(!start, |this| this.children(self.render_status_line(cx))) .child( v_flex() .w_full() .max_w(composer::content_width()) .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.composer_key(event, window, cx) })) .children(dock) // The strip is laid out below the card but painted first, // so the card covers its top edge. .child(v_flex().w_full().flex_col_reverse().children(strip).child(card)), ) } /// What's attached to the message, as T3 Code shows it above the /// prompt: images as thumbnails, other files as a list with their size. fn render_attachments(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let is_image = |path: &PathBuf| { matches!( path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("png" | "jpg" | "jpeg" | "gif" | "webp") ) }; let chat = cx.entity().downgrade(); let remove = |ix: usize, _: &mut Context| { let chat = chat.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = chat.update(cx, |this, cx| { if ix < this.attachments.len() { this.attachments.remove(ix); } cx.notify(); }); } }; let name_of = |path: &PathBuf| { path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.display().to_string()) }; let images: Vec = self .attachments .iter() .enumerate() .filter(|(_, path)| is_image(path)) .map(|(ix, path)| { let name = name_of(path); div() .id(("attachment-image", ix)) .relative() .size(px(64.)) .rounded(px(8.)) .overflow_hidden() .well(p) .child(img(path.clone()).size_full().object_fit(ObjectFit::Cover)) .child( div().absolute().top(px(4.)).right(px(4.)).child( MetalButton::new(("remove-image", ix)) .mini() .size(px(20.)) .icon(Icon::new(IconName::Close).size(px(11.))) .tooltip(format!("Remove {name}")) .on_click(remove(ix, cx)), ), ) .into_any_element() }) .collect(); let files: Vec = self .attachments .iter() .enumerate() .filter(|(_, path)| !is_image(path)) .map(|(ix, path)| { let name = name_of(path); let size = std::fs::metadata(path).map(|meta| format_size(meta.len())).unwrap_or_default(); h_flex() .id(("attachment-file", ix)) .gap(px(8.)) .py(px(4.)) .text_size(px(14.)) .child(Icon::new(Lucide::File).size(px(16.)).text_color(p.ink3)) .child(div().flex_1().min_w_0().truncate().text_color(p.ink).child(name.clone())) .child(div().text_size(px(12.)).text_color(p.ink3).child(size)) .child( MetalButton::new(("remove-file", ix)) .ghost() .mini() .size(px(24.)) .icon(IconName::Close) .tooltip(format!("Remove {name}")) .on_click(remove(ix, cx)), ) .into_any_element() }) .collect(); v_flex() .when(!images.is_empty(), |this| this.child(h_flex().flex_wrap().gap(px(8.)).mb(px(12.)).children(images))) .when(!files.is_empty(), |this| this.child(v_flex().gap(px(4.)).mb(px(12.)).children(files))) } } impl Render for ChatView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.session.read(cx).items.is_empty(); // Scrolling up through the conversation rests the composer, when set // to, as in T3 Code; a click or typing wakes it. let scrolled_up = self.scroller.read(cx).is_scrolled_up(); if scrolled_up && !self.was_scrolled_up { let empty = self.composer.read(cx).value().is_empty() && self.attachments.is_empty(); if empty && settings::get(|settings| settings.collapse_composer) { self.collapsed = true; } } self.was_scrolled_up = scrolled_up; let on_diff = { let chat = cx.entity().downgrade(); move |action: DiffAction, window: &mut Window, cx: &mut App| { let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx)); } }; v_flex() .key_context("ChatView") .size_full() .on_action(cx.listener(|this, _: &Interrupt, window, cx| this.escape(window, cx))) .on_action(cx.listener(|this, _: &ToggleModelPicker, window, cx| this.open_control(Picker::Model, window, cx))) .on_action(cx.listener(|this, _: &ComposerEffort, window, cx| this.open_control(Picker::Traits, window, cx))) .on_action(cx.listener(|this, _: &ComposerMode, window, cx| this.open_control(Picker::Access, window, cx))) .on_action(cx.listener(|this, _: &ComposerBranch, window, cx| this.open_control(Picker::Branch, window, cx))) .on_action(cx.listener(|this, _: &ComposerWorkspace, window, cx| this.open_control(Picker::Workspace, window, cx))) .on_action(cx.listener(|this, _: &ComposerHost, window, cx| this.open_control(Picker::Environment, window, cx))) .on_action(cx.listener(|this, _: &ComposerPreviousWorktree, window, cx| { if this.session.read(cx).items.is_empty() { this.use_previous_worktree(window, cx); } })) .on_action(cx.listener(|this, _: &ComposerStash, window, cx| this.stash_prompt(window, cx))) .on_action(cx.listener(|this, _: &FindInThread, window, cx| { if !this.session.read(cx).items.is_empty() && this.diff.is_none() { this.open_find(window, cx); } })) .on_action(cx.listener(|this, _: &input::Escape, window, cx| this.escape(window, cx))) .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.pick_with_key(event, window, cx))) .map(|this| match &self.diff { Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))), None if is_empty => this.child(self.render_start(window, cx)), None => this.child(self.render_transcript(cx)), }) .when(!is_empty, |this| this.child(self.render_composer(false, window, cx))) } } /// Shortens a path under the home directory to `~/…`. pub fn display_path(path: &std::path::Path) -> String { let home = std::env::var_os("HOME").map(std::path::PathBuf::from); match home.as_deref().and_then(|home| path.strip_prefix(home).ok()) { Some(rest) if rest.as_os_str().is_empty() => "~".to_string(), Some(rest) => format!("~/{}", rest.display()), None => path.display().to_string(), } } fn format_tokens(tokens: u64) -> String { let (value, unit) = match tokens { 0..1_000 => return tokens.to_string(), 1_000..1_000_000 => (tokens as f64 / 1_000., "k"), _ => (tokens as f64 / 1_000_000., "M"), }; let value = format!("{value:.1}"); format!("{}{unit}", value.trim_end_matches(".0")) } /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from /// the theme, which `metal` keeps in step with the theme. pub(super) fn markdown_style(p: &Palette) -> TextViewStyle { let mut code_block = StyleRefinement::default() .rounded(px(14.)) .bg(metal::vertical((p.well_top, p.well_bot))) .shadow(p.well.clone()); code_block.padding.left = Some(px(14.).into()); code_block.padding.right = Some(px(14.).into()); TextViewStyle { is_dark: p.dark, ..TextViewStyle::default() } .code_block(code_block) .table(StyleRefinement::default().rounded(px(12.))) .inline_code(HighlightStyle { background_color: Some(p.well_top), ..Default::default() }) } /// Markdown with a copy button on every code block. pub(super) fn markdown(state: &Entity, p: &Palette) -> TextView { TextView::new(state) .style(markdown_style(p)) .selectable(true) .code_block_actions(|block, _, _| { let code = block.code(); let id = { use std::hash::{Hash as _, Hasher as _}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); code.hash(&mut hasher); hasher.finish() }; MetalButton::new(ElementId::NamedInteger("copy-code".into(), id)) .ghost() .mini() .icon(IconName::Copy) .tooltip("Copy") .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string()))) }) } /// The row a finished turn folds into: "Worked for 11s ›". pub(super) fn truncate(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } let mut truncated: String = text.chars().take(max_chars).collect(); truncated.push_str("\n… (truncated)"); truncated } /// The files and folders of `cwd` for the `@` menu: what git tracks (or /// would, untracked files included), else a shallow walk. Folders end in `/`. fn list_files(cwd: &std::path::Path) -> Vec { const MAX_FILES: usize = 20_000; let mut files: Vec = match git::git(cwd, &["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) { Ok(out) => out.split('\0').filter(|file| !file.is_empty()).take(MAX_FILES).map(str::to_string).collect(), Err(_) => { let mut found = Vec::new(); let mut stack = vec![(cwd.to_path_buf(), 0)]; while let Some((dir, depth)) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') || found.len() >= MAX_FILES { continue; } if path.is_dir() { if depth < 4 && name != "node_modules" && name != "target" { stack.push((path, depth + 1)); } } else if let Ok(relative) = path.strip_prefix(cwd) { found.push(relative.to_string_lossy().to_string()); } } } found } }; let mut folders: Vec = files .iter() .flat_map(|file| { let parts: Vec<&str> = file.split('/').collect(); (1..parts.len()).map(move |n| format!("{}/", parts[..n].join("/"))) }) .collect(); folders.sort(); folders.dedup(); files.sort(); folders.extend(files); folders } /// `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads. pub(super) fn format_elapsed(seconds: u64) -> String { match seconds { 0..60 => format!("{seconds}s"), 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60), _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60), } } /// `12 KB`, `3.4 MB`. fn format_size(bytes: u64) -> String { match bytes { 0..1_024 => format!("{bytes} B"), 1_024..1_048_576 => format!("{:.0} KB", bytes as f64 / 1_024.), _ => format!("{:.1} MB", bytes as f64 / 1_048_576.), } }