use crate::{ DEFAULT_THREAD_TITLE, SelectPermissionGranularity, agent_configuration::configure_context_server_modal::default_markdown_style, thread_metadata_store::{ThreadId, ThreadMetadataStore}, }; use agent_client_protocol::schema as acp; use std::cell::RefCell; use acp_thread::{ContentBlock, PlanEntry}; use agent::{SkillLoadingError, SkillLoadingErrorsUpdated}; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; use feature_flags::AcpBetaFeatureFlag; use crate::message_editor::SharedSessionCapabilities; use gpui::List; use gpui::TaskExt; use heapless::Vec as ArrayVec; use language_model::{LanguageModelEffortLevel, Speed}; use settings::{SidebarSide, update_settings_file}; use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab}; use workspace::SERIALIZATION_THROTTLE_TIME; use super::*; #[derive(Default)] struct ThreadFeedbackState { feedback: Option, comments_editor: Option>, } impl ThreadFeedbackState { pub fn submit( &mut self, thread: Entity, feedback: ThreadFeedback, window: &mut Window, cx: &mut App, ) { let Some(telemetry) = thread.read(cx).connection().telemetry() else { return; }; let project = thread.read(cx).project().read(cx); let client = project.client(); let user_store = project.user_store(); let organization = user_store.read(cx).current_organization(); if self.feedback == Some(feedback) { return; } self.feedback = Some(feedback); match feedback { ThreadFeedback::Positive => { self.comments_editor = None; } ThreadFeedback::Negative => { self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx)); } } let session_id = thread.read(cx).session_id().clone(); let parent_session_id = thread.read(cx).parent_session_id().cloned(); let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", ThreadFeedback::Negative => "negative", }; cx.background_spawn(async move { let thread = task.await?; client .cloud_client() .submit_agent_feedback(SubmitAgentThreadFeedbackBody { organization_id: organization.map(|organization| organization.id.clone()), agent: agent_telemetry_id.to_string(), session_id: session_id.to_string(), parent_session_id: parent_session_id.map(|id| id.to_string()), rating: rating.to_string(), thread, }) .await?; anyhow::Ok(()) }) .detach_and_log_err(cx); } pub fn submit_comments(&mut self, thread: Entity, cx: &mut App) { let Some(telemetry) = thread.read(cx).connection().telemetry() else { return; }; let Some(comments) = self .comments_editor .as_ref() .map(|editor| editor.read(cx).text(cx)) .filter(|text| !text.trim().is_empty()) else { return; }; self.comments_editor.take(); let project = thread.read(cx).project().read(cx); let client = project.client(); let user_store = project.user_store(); let organization = user_store.read(cx).current_organization(); let session_id = thread.read(cx).session_id().clone(); let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; client .cloud_client() .submit_agent_feedback_comments(SubmitAgentThreadFeedbackCommentsBody { organization_id: organization.map(|organization| organization.id.clone()), agent: agent_telemetry_id.to_string(), session_id: session_id.to_string(), comments, thread, }) .await?; anyhow::Ok(()) }) .detach_and_log_err(cx); } pub fn clear(&mut self) { *self = Self::default() } pub fn dismiss_comments(&mut self) { self.comments_editor.take(); } fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity { let buffer = cx.new(|cx| { let empty_string = String::new(); MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx) }); let editor = cx.new(|cx| { let mut editor = Editor::new( editor::EditorMode::AutoHeight { min_lines: 1, max_lines: Some(4), }, buffer, None, window, cx, ); editor.set_placeholder_text( "What went wrong? Share your feedback so we can improve.", window, cx, ); editor }); editor.read(cx).focus_handle(cx).focus(window, cx); editor } } struct GeneratingSpinner { variant: SpinnerVariant, } impl GeneratingSpinner { fn new(variant: SpinnerVariant) -> Self { Self { variant } } } impl Render for GeneratingSpinner { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { SpinnerLabel::with_variant(self.variant).size(LabelSize::Small) } } #[derive(IntoElement)] struct GeneratingSpinnerElement { variant: SpinnerVariant, } impl GeneratingSpinnerElement { fn new(variant: SpinnerVariant) -> Self { Self { variant } } } impl RenderOnce for GeneratingSpinnerElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = match self.variant { SpinnerVariant::Dots => "generating-spinner-view", SpinnerVariant::Sand => "confirmation-spinner-view", _ => "spinner-view", }; window.with_id(id, |window| { window.use_state(cx, |_, _| GeneratingSpinner::new(self.variant)) }) } } pub enum AcpThreadViewEvent { Interacted, } impl EventEmitter for ThreadView {} /// Tracks the user's permission dropdown selection state for a specific tool call. /// /// Default (no entry in the map) means the last dropdown choice is selected, /// which is typically "Only this time". #[derive(Clone)] pub(crate) enum PermissionSelection { /// A specific choice from the dropdown (e.g., "Always for terminal", "Only this time"). /// The index corresponds to the position in the `choices` list from `PermissionOptions`. Choice(usize), /// "Select options…" mode where individual command patterns can be toggled. /// Contains the indices of checked patterns in the `patterns` list. /// All patterns start checked when this mode is first activated. SelectedPatterns(Vec), } impl PermissionSelection { /// Returns the choice index if a specific dropdown choice is selected, /// or `None` if in per-command pattern mode. pub(crate) fn choice_index(&self) -> Option { match self { Self::Choice(index) => Some(*index), Self::SelectedPatterns(_) => None, } } fn is_pattern_checked(&self, index: usize) -> bool { match self { Self::SelectedPatterns(checked) => checked.contains(&index), _ => false, } } fn has_any_checked_patterns(&self) -> bool { match self { Self::SelectedPatterns(checked) => !checked.is_empty(), _ => false, } } fn toggle_pattern(&mut self, index: usize) { if let Self::SelectedPatterns(checked) = self { if let Some(pos) = checked.iter().position(|&i| i == index) { checked.swap_remove(pos); } else { checked.push(index); } } } } pub struct ThreadView { pub(crate) root_thread_id: ThreadId, pub session_id: acp::SessionId, pub parent_session_id: Option, pub thread: Entity, pub(crate) conversation: Entity, pub server_view: WeakEntity, pub agent_icon: IconName, pub agent_icon_from_external_svg: Option, pub agent_id: AgentId, pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, pub title_editor: Entity, pub config_options_view: Option>, pub mode_selector: Option>, pub model_selector: Option>, pub profile_selector: Option>, pub permission_dropdown_handle: PopoverMenuHandle, pub thread_retry_status: Option, pub(super) thread_error: Option, pub thread_error_markdown: Option>, pub token_limit_callout_dismissed: bool, pub last_token_limit_telemetry: Option, thread_feedback: ThreadFeedbackState, pub list_state: ListState, pub session_capabilities: SharedSessionCapabilities, /// Tracks which tool calls have their content/output expanded. /// Used for showing/hiding tool call results, terminal output, etc. pub expanded_tool_calls: HashSet, pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, user_toggled_thinking_blocks: HashSet<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, pub queue_expanded: bool, pub editor_expanded: bool, pub should_be_following: bool, pub editing_message: Option, pub local_queued_messages: Vec, pub queued_message_editors: Vec>, pub queued_message_editor_subscriptions: Vec, pub last_synced_queue_length: usize, pub turn_fields: TurnFields, pub discarded_partial_edits: HashSet, pub is_loading_contents: bool, pub new_server_version_available: Option, pub resumed_without_history: bool, pub(crate) permission_selections: HashMap, pub _cancel_task: Option>, _save_task: Option>, _draft_resolve_task: Option>, pub skip_queue_processing_count: usize, pub user_interrupted_generation: bool, pub can_fast_track_queue: bool, pub hovered_edited_file_buttons: Option, pub in_flight_prompt: Option>, pub _subscriptions: Vec, pub message_editor: Entity, pub add_context_menu_handle: PopoverMenuHandle, pub thinking_effort_menu_handle: PopoverMenuHandle, pub project: WeakEntity, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, pub multi_root_callout_dismissed: bool, pub generating_indicator_in_list: bool, /// Errors emitted by the agent while loading SKILL.md files. Each one /// renders as a clickable banner that opens the offending file. pub skill_loading_errors: Vec, /// Errors the user has explicitly dismissed. Each entry is matched against /// emitted errors by full equality; when an error no longer appears in the /// emitted list (i.e. the underlying file was fixed or removed), it's /// dropped from this set so a future regression of the same kind would /// re-show. dismissed_skill_loading_errors: HashSet, } impl Focusable for ThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { if self.parent_session_id.is_some() { self.focus_handle.clone() } else { self.active_editor(cx).focus_handle(cx) } } } #[derive(Default)] pub struct TurnFields { pub _turn_timer_task: Option>, pub last_turn_duration: Option, pub last_turn_tokens: Option, pub turn_generation: usize, pub turn_started_at: Option, pub turn_tokens: Option, } impl ThreadView { pub(crate) fn new( root_thread_id: ThreadId, thread: Entity, conversation: Entity, server_view: WeakEntity, agent_icon: IconName, agent_icon_from_external_svg: Option, agent_id: AgentId, agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, config_options_view: Option>, mode_selector: Option>, model_selector: Option>, profile_selector: Option>, list_state: ListState, session_capabilities: SharedSessionCapabilities, resumed_without_history: bool, project: WeakEntity, thread_store: Option>, prompt_store: Option>, initial_content: Option, mut subscriptions: Vec, window: &mut Window, cx: &mut Context, ) -> Self { let session_id = thread.read(cx).session_id().clone(); let parent_session_id = thread.read(cx).parent_session_id().cloned(); let has_commands = !session_capabilities.read().available_commands().is_empty(); let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); let mut should_auto_submit = false; let mut show_external_source_prompt_warning = false; let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( workspace.clone(), project.clone(), thread_store, prompt_store, session_capabilities.clone(), agent_id.clone(), &placeholder, editor::EditorMode::AutoHeight { min_lines: AgentSettings::get_global(cx).message_editor_min_lines, max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), }, window, cx, ); if let Some(content) = initial_content { match content { AgentInitialContent::ThreadSummary { session_id, title } => { editor.insert_thread_summary(session_id, title, window, cx); } AgentInitialContent::ContentBlock { blocks, auto_submit, } => { should_auto_submit = auto_submit; editor.set_message(blocks, window, cx); } AgentInitialContent::FromExternalSource(prompt) => { show_external_source_prompt_warning = true; // SECURITY: Be explicit about not auto submitting prompt from external source. should_auto_submit = false; editor.set_message( vec![acp::ContentBlock::Text(acp::TextContent::new( prompt.into_string(), ))], window, cx, ); } } } else if let Some(draft) = thread.read(cx).draft_prompt() { editor.set_message(draft.to_vec(), window, cx); } editor }); let show_codex_windows_warning = cfg!(windows) && project.upgrade().is_some_and(|p| p.read(cx).is_local()) && agent_id.as_ref() == "Codex"; let title_editor = { let metadata = ThreadMetadataStore::try_global(cx) .and_then(|store| store.read(cx).entry(root_thread_id).cloned()); let initial_title = if parent_session_id.is_none() { metadata.as_ref().and_then(|m| m.title()) } else { thread.read(cx).title() } .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_text(initial_title, window, cx); editor }); subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event)); editor }; subscriptions.push(cx.subscribe_in( &entry_view_state, window, Self::handle_entry_view_event, )); subscriptions.push(cx.subscribe_in( &message_editor, window, Self::handle_message_editor_event, )); // If this thread is backed by a NativeAgent, listen for skill loading // errors so we can surface them as banners. The agent emits a single // replacement-style event per project refresh, so we overwrite our // local list rather than appending — this also clears stale errors // once a user resolves them. if let Some(native_connection) = thread .read(cx) .connection() .clone() .downcast::() { let project_id = thread.read(cx).project().entity_id(); subscriptions.push(cx.subscribe( &native_connection.0, move |this: &mut Self, _agent, event: &SkillLoadingErrorsUpdated, cx| { if event.project_id != project_id { return; } // Drop dismissals for errors that no longer appear in the emitted // list — the underlying file must have been fixed or removed, so a // future regression should re-show. this.dismissed_skill_loading_errors .retain(|dismissed| event.errors.contains(dismissed)); // Show only errors that haven't been dismissed. this.skill_loading_errors = event .errors .iter() .filter(|e| !this.dismissed_skill_loading_errors.contains(e)) .cloned() .collect(); cx.notify(); }, )); } subscriptions.push(cx.observe(&message_editor, |this, editor, cx| { let is_empty = editor.read(cx).text(cx).is_empty(); let draft_contents_task = if is_empty { None } else { Some(editor.update(cx, |editor, cx| editor.draft_contents(cx))) }; this._draft_resolve_task = Some(cx.spawn(async move |this, cx| { let draft = if let Some(task) = draft_contents_task { let blocks = task.await.ok().filter(|b| !b.is_empty()); blocks } else { None }; this.update(cx, |this, cx| { this.thread.update(cx, |thread, cx| { thread.set_draft_prompt(draft, cx); }); this.schedule_save(cx); }) .ok(); })); })); let mut this = Self { root_thread_id, session_id, parent_session_id, focus_handle: cx.focus_handle(), thread, conversation, server_view, agent_icon, agent_icon_from_external_svg, agent_id, workspace, entry_view_state, title_editor, config_options_view, mode_selector, model_selector, profile_selector, list_state, session_capabilities, resumed_without_history, _subscriptions: subscriptions, permission_dropdown_handle: PopoverMenuHandle::default(), thread_retry_status: None, thread_error: None, thread_error_markdown: None, token_limit_callout_dismissed: false, last_token_limit_telemetry: None, thread_feedback: Default::default(), expanded_tool_calls: HashSet::default(), expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, user_toggled_thinking_blocks: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, queue_expanded: true, editor_expanded: false, should_be_following: false, editing_message: None, local_queued_messages: Vec::new(), queued_message_editors: Vec::new(), queued_message_editor_subscriptions: Vec::new(), last_synced_queue_length: 0, turn_fields: TurnFields::default(), discarded_partial_edits: HashSet::default(), is_loading_contents: false, new_server_version_available: None, permission_selections: HashMap::default(), _cancel_task: None, _save_task: None, _draft_resolve_task: None, skip_queue_processing_count: 0, user_interrupted_generation: false, can_fast_track_queue: false, hovered_edited_file_buttons: None, in_flight_prompt: None, message_editor, add_context_menu_handle: PopoverMenuHandle::default(), thinking_effort_menu_handle: PopoverMenuHandle::default(), project, show_external_source_prompt_warning, show_codex_windows_warning, multi_root_callout_dismissed: false, generating_indicator_in_list: false, skill_loading_errors: Vec::new(), dismissed_skill_loading_errors: HashSet::default(), }; this.sync_generating_indicator(cx); this.sync_editor_mode_for_empty_state(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); this.list_state .set_scroll_handler(move |_event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); }); } this.schedule_save(cx); }); }); }); if should_auto_submit { this.send(window, cx); } this } /// Schedule a throttled save of the thread state (draft prompt, scroll position, etc.). /// Multiple calls within `SERIALIZATION_THROTTLE_TIME` are coalesced into a single save. fn schedule_save(&mut self, cx: &mut Context) { self._save_task = Some(cx.spawn(async move |this, cx| { cx.background_executor() .timer(SERIALIZATION_THROTTLE_TIME) .await; this.update(cx, |this, cx| { if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |_thread, cx| cx.notify()); } }) .ok(); })); } pub fn handle_message_editor_event( &mut self, _editor: &Entity, event: &MessageEditorEvent, window: &mut Window, cx: &mut Context, ) { // The three skill-watcher trigger points all live here: // - `Focus` fires when the user clicks into the input box. // - `SlashAutocompleteOpened` fires when the completion // provider is asked for slash commands. // - `Send` fires when the user submits the conversation. // All three triggers are idempotent; firing the same one // repeatedly is a no-op once a scan or watch is active. if matches!( event, MessageEditorEvent::Focus | MessageEditorEvent::SlashAutocompleteOpened | MessageEditorEvent::Send ) { if let Some(connection) = self.as_native_connection(cx) { connection.ensure_skills_scan_started(cx); } } match event { MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx), MessageEditorEvent::Cancel => self.cancel_generation(cx), MessageEditorEvent::Focus => { self.cancel_editing(&Default::default(), window, cx); } MessageEditorEvent::LostFocus => {} MessageEditorEvent::SlashAutocompleteOpened => {} MessageEditorEvent::InputAttempted { .. } => {} } } pub(crate) fn as_native_connection( &self, cx: &App, ) -> Option> { let acp_thread = self.thread.read(cx); acp_thread.connection().clone().downcast() } pub fn as_native_thread(&self, cx: &App) -> Option> { let acp_thread = self.thread.read(cx); self.as_native_connection(cx)? .thread(acp_thread.session_id(), cx) } /// Resolves the message editor's contents into content blocks. For profiles /// that do not enable any tools, directory mentions are expanded to inline /// file contents since the agent can't read files on its own. fn resolve_message_contents( &self, message_editor: &Entity, cx: &mut App, ) -> Task, Vec>)>> { let expand = self.as_native_thread(cx).is_some_and(|thread| { let thread = thread.read(cx); AgentSettings::get_global(cx) .profiles .get(thread.profile()) .is_some_and(|profile| profile.tools.is_empty()) }); message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx)) } pub fn current_model_id(&self, cx: &App) -> Option { let selector = self.model_selector.as_ref()?; let model = selector.read(cx).active_model(cx)?; Some(model.id.to_string()) } pub fn current_mode_id(&self, cx: &App) -> Option> { if let Some(thread) = self.as_native_thread(cx) { Some(thread.read(cx).profile().0.clone()) } else { let mode_selector = self.mode_selector.as_ref()?; Some(mode_selector.read(cx).mode().0) } } fn is_subagent(&self) -> bool { self.parent_session_id.is_some() } /// Returns the currently active editor, either for a message that is being /// edited or the editor for a new message. pub(crate) fn active_editor(&self, cx: &App) -> Entity { if let Some(index) = self.editing_message && let Some(editor) = self .entry_view_state .read(cx) .entry(index) .and_then(|entry| entry.message_editor()) .cloned() { editor } else { self.message_editor.clone() } } pub fn has_queued_messages(&self) -> bool { !self.local_queued_messages.is_empty() } pub fn is_imported_thread(&self, cx: &App) -> bool { let Some(thread) = self.as_native_thread(cx) else { return false; }; thread.read(cx).is_imported() } // events pub fn handle_entry_view_event( &mut self, _: &Entity, event: &EntryViewEvent, window: &mut Window, cx: &mut Context, ) { match &event.view_event { ViewEvent::NewDiff(tool_call_id) => { if AgentSettings::get_global(cx).expand_edit_card { self.expanded_tool_calls.insert(tool_call_id.clone()); } } ViewEvent::NewTerminal(tool_call_id) => { if AgentSettings::get_global(cx).expand_terminal_card { self.expanded_tool_calls.insert(tool_call_id.clone()); } } ViewEvent::TerminalMovedToBackground(tool_call_id) => { self.expanded_tool_calls.remove(tool_call_id); } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && self.thread.read(cx).supports_truncate(cx) && user_message.id.is_some() && !self.is_subagent() { self.editing_message = Some(event.entry_index); cx.notify(); } } ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && self.thread.read(cx).supports_truncate(cx) && user_message.id.is_some() && !self.is_subagent() { if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { self.editing_message = None; cx.notify(); } } } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {} ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { if !self.is_subagent() { self.regenerate(event.entry_index, editor.clone(), window, cx); } } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { path, position, split, } => { self.open_diff_location(path, *position, *split, window, cx); } } } fn open_diff_location( &self, path: &str, position: Point, split: bool, window: &mut Window, cx: &mut Context, ) { let Some(project) = self.project.upgrade() else { return; }; let Some(project_path) = project.read(cx).find_project_path(path, cx) else { return; }; let open_task = if split { self.workspace .update(cx, |workspace, cx| { workspace.split_path(project_path, window, cx) }) .log_err() } else { self.workspace .update(cx, |workspace, cx| { workspace.open_path(project_path, None, true, window, cx) }) .log_err() }; let Some(open_task) = open_task else { return; }; window .spawn(cx, async move |cx| { let item = open_task.await?; let Some(editor) = item.downcast::() else { return anyhow::Ok(()); }; editor.update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, |selections| { selections.select_ranges([position..position]); }, ); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } // turns pub fn start_turn(&mut self, cx: &mut Context) -> usize { self.turn_fields.turn_generation += 1; let generation = self.turn_fields.turn_generation; self.turn_fields.turn_started_at = Some(Instant::now()); self.turn_fields.last_turn_duration = None; self.turn_fields.last_turn_tokens = None; self.turn_fields.turn_tokens = Some(0); self.turn_fields._turn_timer_task = Some(cx.spawn(async move |this, cx| { loop { cx.background_executor().timer(Duration::from_secs(1)).await; if this.update(cx, |_, cx| cx.notify()).is_err() { break; } } })); generation } pub fn stop_turn(&mut self, generation: usize, _cx: &mut Context) { if self.turn_fields.turn_generation != generation { return; } self.turn_fields.last_turn_duration = self .turn_fields .turn_started_at .take() .map(|started| started.elapsed()); self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); self.turn_fields._turn_timer_task = None; } pub fn update_turn_tokens(&mut self, cx: &App) { if let Some(usage) = self.thread.read(cx).token_usage() { if let Some(tokens) = &mut self.turn_fields.turn_tokens { *tokens += usage.output_tokens; self.emit_token_limit_telemetry_if_needed(cx); } } } fn emit_token_limit_telemetry_if_needed(&mut self, cx: &App) { let (ratio, agent_telemetry_id, session_id) = { let thread_data = self.thread.read(cx); let Some(token_usage) = thread_data.token_usage() else { return; }; ( token_usage.ratio(), thread_data.connection().telemetry_id(), thread_data.session_id().clone(), ) }; let kind = match ratio { acp_thread::TokenUsageRatio::Normal => { self.last_token_limit_telemetry = None; return; } acp_thread::TokenUsageRatio::Warning => "warning", acp_thread::TokenUsageRatio::Exceeded => "exceeded", }; let should_skip = self .last_token_limit_telemetry .as_ref() .is_some_and(|last| *last >= ratio); if should_skip { return; } self.last_token_limit_telemetry = Some(ratio); telemetry::event!( "Agent Token Limit Warning", agent = agent_telemetry_id, session_id = session_id, kind = kind, ); } // sending fn clear_external_source_prompt_warning(&mut self, cx: &mut Context) { if self.show_external_source_prompt_warning { self.show_external_source_prompt_warning = false; cx.notify(); } } pub fn send(&mut self, window: &mut Window, cx: &mut Context) { let thread = &self.thread; if self.is_loading_contents { return; } let message_editor = self.message_editor.clone(); let is_editor_empty = message_editor.read(cx).is_empty(cx); let is_generating = thread.read(cx).status() != ThreadStatus::Idle; let has_queued = self.has_queued_messages(); if is_editor_empty && self.can_fast_track_queue && has_queued { self.can_fast_track_queue = false; self.send_queued_message_at_index(0, true, window, cx); return; } if is_editor_empty { return; } if is_generating { cx.emit(AcpThreadViewEvent::Interacted); self.queue_message(message_editor, window, cx); return; } let text = message_editor.read(cx).text(cx); let text = text.trim(); if text == "/login" || text == "/logout" { let connection = thread.read(cx).connection().clone(); let can_login = !connection.auth_methods().is_empty(); // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. let logout_supported = text == "/logout" && self .session_capabilities .read() .available_commands() .iter() .any(|command| command.name == "logout"); if can_login && !logout_supported { message_editor.update(cx, |editor, cx| editor.clear(window, cx)); self.clear_external_source_prompt_warning(cx); let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConversationView::handle_auth_required( server_view.clone(), AuthRequired::new(), agent_id, connection, window, cx, ); } }); cx.notify(); return; } } cx.emit(AcpThreadViewEvent::Interacted); self.send_impl(message_editor, window, cx) } pub fn send_impl( &mut self, message_editor: Entity, window: &mut Window, cx: &mut Context, ) { let contents = self.resolve_message_contents(&message_editor, cx); self.thread_error.take(); self.thread_feedback.clear(); self.editing_message.take(); if self.should_be_following { self.workspace .update(cx, |workspace, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) .ok(); } let contents_task = cx.spawn_in(window, async move |_this, cx| { let (contents, tracked_buffers) = contents.await?; if contents.is_empty() { return Ok(None); } let _ = cx.update(|window, cx| { message_editor.update(cx, |message_editor, cx| { message_editor.clear(window, cx); }); }); Ok(Some((contents, tracked_buffers))) }); self.send_content(contents_task, window, cx); } pub fn send_content( &mut self, contents_task: Task, Vec>)>>>, window: &mut Window, cx: &mut Context, ) { let session_id = self.thread.read(cx).session_id().clone(); let parent_session_id = self.thread.read(cx).parent_session_id().cloned(); let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id(); let is_first_message = self.thread.read(cx).entries().is_empty(); let thread = self.thread.downgrade(); self.is_loading_contents = true; let model_id = self.current_model_id(cx); let mode_id = self.current_mode_id(cx); let guard = cx.new(|_| ()); cx.observe_release(&guard, |this, _guard, cx| { this.is_loading_contents = false; cx.notify(); }) .detach(); let side = match AgentSettings::get_global(cx).sidebar_side() { SidebarSide::Left => "left", SidebarSide::Right => "right", }; let task = cx.spawn_in(window, async move |this, cx| { let Some((contents, tracked_buffers)) = contents_task.await? else { return Ok(()); }; let generation = this.update(cx, |this, cx| { this.clear_external_source_prompt_warning(cx); let generation = this.start_turn(cx); this.in_flight_prompt = Some(contents.clone()); generation })?; this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; let _ = this.update(cx, |this, cx| { this.list_state.scroll_to_end(); cx.notify(); }); let _stop_turn = defer({ let this = this.clone(); let mut cx = cx.clone(); move || { this.update(&mut cx, |this, cx| { this.stop_turn(generation, cx); cx.notify(); }) .ok(); } }); if is_first_message && thread.read_with(cx, |thread, _cx| thread.title().is_none())? { let text: String = contents .iter() .filter_map(|block| match block { acp::ContentBlock::Text(text_content) => Some(text_content.text.clone()), acp::ContentBlock::ResourceLink(resource_link) => { Some(format!("@{}", resource_link.name)) } _ => None, }) .collect::>() .join(" "); let text = text.lines().next().unwrap_or("").trim(); if !text.is_empty() { let title: SharedString = util::truncate_and_trailoff(text, 200).into(); thread.update(cx, |thread, cx| { thread.set_provisional_title(title, cx); })?; } } let turn_start_time = Instant::now(); let send = thread.update(cx, |thread, cx| { thread.action_log().update(cx, |action_log, cx| { for buffer in tracked_buffers { action_log.buffer_read(buffer, cx) } }); drop(guard); telemetry::event!( "Agent Message Sent", agent = agent_telemetry_id, session = session_id, parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()), model = model_id, mode = mode_id, side = side ); thread.send(contents, cx) })?; let _ = this.update(cx, |this, cx| { this.sync_generating_indicator(cx); cx.notify(); }); let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); let status = if res.is_ok() { let _ = this.update(cx, |this, _| this.in_flight_prompt.take()); "success" } else { "failure" }; telemetry::event!( "Agent Turn Completed", agent = agent_telemetry_id, session = session_id, parent_session_id = parent_session_id.as_ref().map(|id| id.to_string()), model = model_id, mode = mode_id, status, turn_time_ms, side = side ); res.map(|_| ()) }); cx.spawn(async move |this, cx| { if let Err(err) = task.await { this.update(cx, |this, cx| { this.handle_thread_error(err, cx); }) .ok(); } else { this.update(cx, |this, cx| { let should_be_following = this .workspace .update(cx, |workspace, _| { workspace.is_being_followed(CollaboratorId::Agent) }) .unwrap_or_default(); this.should_be_following = should_be_following; }) .ok(); } }) .detach(); } pub fn interrupt_and_send(&mut self, window: &mut Window, cx: &mut Context) { let thread = &self.thread; if self.is_loading_contents { return; } cx.emit(AcpThreadViewEvent::Interacted); let message_editor = self.message_editor.clone(); if thread.read(cx).status() == ThreadStatus::Idle { self.send_impl(message_editor, window, cx); return; } self.stop_current_and_send_new_message(message_editor, window, cx); } fn stop_current_and_send_new_message( &mut self, message_editor: Entity, window: &mut Window, cx: &mut Context, ) { let thread = self.thread.clone(); self.skip_queue_processing_count = 0; self.user_interrupted_generation = true; let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); cx.spawn_in(window, async move |this, cx| { cancelled.await; this.update_in(cx, |this, window, cx| { this.send_impl(message_editor, window, cx); }) .ok(); }) .detach(); } pub(crate) fn handle_thread_error( &mut self, error: impl Into, cx: &mut Context, ) { let error = error.into(); self.emit_thread_error_telemetry(&error, cx); self.thread_error = Some(error); cx.notify(); } fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context) { let (error_kind, acp_error_code, message): (&str, Option, SharedString) = match error { ThreadError::PaymentRequired => ( "payment_required", None, "You reached your free usage limit. Upgrade to Zed Pro for more prompts." .into(), ), ThreadError::Refusal => { let model_or_agent_name = self.current_model_name(cx); let message = format!( "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.", model_or_agent_name ); ("refusal", None, message.into()) } ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } ThreadError::RateLimitExceeded { provider } => ( "rate_limit_exceeded", None, format!("{provider}'s rate limit was reached.").into(), ), ThreadError::ServerOverloaded { provider } => ( "server_overloaded", None, format!("{provider}'s servers are temporarily unavailable.").into(), ), ThreadError::PromptTooLarge => ( "prompt_too_large", None, "Context too large for the model's context window.".into(), ), ThreadError::NoApiKey { provider } => ( "no_api_key", None, format!("No API key configured for {provider}.").into(), ), ThreadError::StreamError { provider } => ( "stream_error", None, format!("Connection to {provider}'s API was interrupted.").into(), ), ThreadError::InvalidApiKey { provider } => ( "invalid_api_key", None, format!("Invalid or expired API key for {provider}.").into(), ), ThreadError::PermissionDenied { provider } => ( "permission_denied", None, format!( "{provider}'s API rejected the request due to insufficient permissions." ) .into(), ), ThreadError::RequestFailed => ( "request_failed", None, "Request could not be completed after multiple attempts.".into(), ), ThreadError::MaxOutputTokens => ( "max_output_tokens", None, "Model reached its maximum output length.".into(), ), ThreadError::NoModelSelected => { ("no_model_selected", None, "No model selected.".into()) } ThreadError::ApiError { provider } => ( "api_error", None, format!("{provider}'s API returned an unexpected error.").into(), ), ThreadError::Other { acp_error_code, message, } => ("other", acp_error_code.clone(), message.clone()), }; let agent_telemetry_id = self.thread.read(cx).connection().telemetry_id(); let session_id = self.thread.read(cx).session_id().clone(); let parent_session_id = self .thread .read(cx) .parent_session_id() .map(|id| id.to_string()); telemetry::event!( "Agent Panel Error Shown", agent = agent_telemetry_id, session_id = session_id, parent_session_id = parent_session_id, kind = error_kind, acp_error_code = acp_error_code, message = message, ); } pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); self.sync_generating_indicator(cx); cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { self.thread_error.take(); let thread = &self.thread; if !thread.read(cx).can_retry(cx) { return; } let task = thread.update(cx, |thread, cx| thread.retry(cx)); cx.emit(AcpThreadViewEvent::Interacted); self.sync_generating_indicator(cx); cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { if let Err(err) = result { this.handle_thread_error(err, cx); } }) }) .detach(); } pub fn regenerate( &mut self, entry_ix: usize, message_editor: Entity, window: &mut Window, cx: &mut Context, ) { if self.is_loading_contents { return; } let thread = self.thread.clone(); let Some(user_message_id) = thread.update(cx, |thread, _| { thread.entries().get(entry_ix)?.user_message()?.id.clone() }) else { return; }; cx.spawn_in(window, async move |this, cx| { // Check if there are any edits from prompts before the one being regenerated. // // If there are, we keep/accept them since we're not regenerating the prompt that created them. // // If editing the prompt that generated the edits, they are auto-rejected // through the `rewind` function in the `acp_thread`. let has_earlier_edits = thread.read_with(cx, |thread, _| { thread .entries() .iter() .take(entry_ix) .any(|entry| entry.diffs().next().is_some()) }); if has_earlier_edits { thread.update(cx, |thread, cx| { thread.action_log().update(cx, |action_log, cx| { action_log.keep_all_edits(None, cx); }); }); } thread .update(cx, |thread, cx| thread.rewind(user_message_id, cx)) .await?; this.update_in(cx, |thread, window, cx| { cx.emit(AcpThreadViewEvent::Interacted); thread.send_impl(message_editor, window, cx); thread.focus_handle(cx).focus(window, cx); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } // message queueing fn queue_message( &mut self, message_editor: Entity, window: &mut Window, cx: &mut Context, ) { let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle; if is_idle { self.send_impl(message_editor, window, cx); return; } let contents = self.resolve_message_contents(&message_editor, cx); cx.spawn_in(window, async move |this, cx| { let (content, tracked_buffers) = contents.await?; if content.is_empty() { return Ok::<(), anyhow::Error>(()); } this.update_in(cx, |this, window, cx| { this.add_to_queue(content, tracked_buffers, cx); this.can_fast_track_queue = true; message_editor.update(cx, |message_editor, cx| { message_editor.clear(window, cx); }); cx.notify(); })?; Ok(()) }) .detach_and_log_err(cx); } pub fn add_to_queue( &mut self, content: Vec, tracked_buffers: Vec>, cx: &mut Context, ) { self.local_queued_messages.push(QueuedMessage { content, tracked_buffers, }); self.sync_queue_flag_to_native_thread(cx); } pub fn remove_from_queue( &mut self, index: usize, cx: &mut Context, ) -> Option { if index < self.local_queued_messages.len() { let removed = self.local_queued_messages.remove(index); self.sync_queue_flag_to_native_thread(cx); Some(removed) } else { None } } pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context) { if let Some(native_thread) = self.as_native_thread(cx) { let has_queued = self.has_queued_messages(); native_thread.update(cx, |thread, _| { thread.set_has_queued_message(has_queued); }); } } pub fn send_queued_message_at_index( &mut self, index: usize, is_send_now: bool, window: &mut Window, cx: &mut Context, ) { let Some(queued) = self.remove_from_queue(index, cx) else { return; }; cx.emit(AcpThreadViewEvent::Interacted); self.message_editor.focus_handle(cx).focus(window, cx); let content = queued.content; let tracked_buffers = queued.tracked_buffers; // Only increment skip count for "Send Now" operations (out-of-order sends) // Normal auto-processing from the Stopped handler doesn't need to skip. // We only skip the Stopped event from the cancelled generation, NOT the // Stopped event from the newly sent message (which should trigger queue processing). if is_send_now { let is_generating = self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating; self.skip_queue_processing_count += if is_generating { 1 } else { 0 }; } let cancelled = self.thread.update(cx, |thread, cx| thread.cancel(cx)); let workspace = self.workspace.clone(); let should_be_following = self.should_be_following; let contents_task = cx.spawn_in(window, async move |_this, cx| { cancelled.await; if should_be_following { workspace .update_in(cx, |workspace, window, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) .ok(); } Ok(Some((content, tracked_buffers))) }); self.send_content(contents_task, window, cx); } pub fn move_queued_message_to_main_editor( &mut self, index: usize, attempt: Option, cursor_offset: Option, window: &mut Window, cx: &mut Context, ) -> bool { let Some(queued_message) = self.remove_from_queue(index, cx) else { return false; }; let queued_content = queued_message.content; let message_editor = self.message_editor.clone(); window.focus(&message_editor.focus_handle(cx), cx); let adjusted_cursor_offset = if message_editor.read(cx).is_empty(cx) { message_editor.update(cx, |editor, cx| { editor.set_message(queued_content, window, cx); }); cursor_offset } else { let existing_len = message_editor.read(cx).text(cx).len(); let separator = "\n\n"; message_editor.update(cx, |editor, cx| { editor.append_message(queued_content, Some(separator), window, cx); }); cursor_offset.map(|offset| existing_len + separator.len() + offset) }; message_editor.update(cx, |editor, cx| { if let Some(offset) = adjusted_cursor_offset { editor.set_cursor_offset(offset, window, cx); } match attempt { Some(InputAttempt::Text(text)) => { editor.insert_text(&text, window, cx); } Some(InputAttempt::Paste(clipboard)) => { editor.paste_item(&clipboard, window, cx); } None => {} } }); cx.notify(); true } // editor methods pub fn expand_message_editor( &mut self, _: &ExpandMessageEditor, _window: &mut Window, cx: &mut Context, ) { if self.list_state.item_count() == 0 { return; } self.set_editor_is_expanded(!self.editor_expanded, cx); cx.stop_propagation(); cx.notify(); } pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; self.message_editor.update(cx, |editor, cx| { if is_expanded { editor.set_mode( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, }, cx, ) } else { let agent_settings = AgentSettings::get_global(cx); editor.set_mode( EditorMode::AutoHeight { min_lines: agent_settings.message_editor_min_lines, max_lines: Some(agent_settings.set_message_editor_max_lines()), }, cx, ) } }); cx.notify(); } pub fn handle_title_editor_event( &mut self, title_editor: &Entity, event: &EditorEvent, window: &mut Window, cx: &mut Context, ) { let thread = &self.thread; match event { EditorEvent::BufferEdited => { // We only want to set the title if the user has actively edited // it. If the title editor is not focused, we programmatically // changed the text, so we don't want to set the title again. if !title_editor.read(cx).is_focused(window) { return; } let new_title = title_editor.read(cx).text(cx); if new_title.is_empty() { return; } let title = SharedString::from(new_title); if let Some(store) = ThreadMetadataStore::try_global(cx) && !self.is_subagent() { let thread_id = self.root_thread_id; store.update(cx, |store, cx| { store.set_title_override(thread_id, title.clone(), cx); }); } thread.update(cx, |thread, cx| { if thread.can_set_title(cx) { thread.set_title(title, cx).detach_and_log_err(cx); } }); } EditorEvent::Blurred => { if title_editor.read(cx).text(cx).is_empty() { title_editor.update(cx, |editor, cx| { editor.set_text(DEFAULT_THREAD_TITLE, window, cx); }); } } _ => {} } } pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { if let Some(index) = self.editing_message.take() && let Some(editor) = &self .entry_view_state .read(cx) .entry(index) .and_then(|e| e.message_editor()) .cloned() { editor.update(cx, |editor, cx| { if let Some(user_message) = self .thread .read(cx) .entries() .get(index) .and_then(|e| e.user_message()) { editor.set_message(user_message.chunks.clone(), window, cx); } }) }; self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, tool_call_id: acp::ToolCallId, outcome: SelectedPermissionOutcome, window: &mut Window, cx: &mut Context, ) { self.conversation.update(cx, |conversation, cx| { conversation.authorize_tool_call(session_id, tool_call_id, outcome, cx); }); if self.should_be_following { self.workspace .update(cx, |workspace, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) .ok(); } cx.notify(); } pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); } pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { self.authorize_pending_with_granularity(true, window, cx); } pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { self.authorize_pending_with_granularity(false, window, cx); } pub fn authorize_pending_tool_call( &mut self, kind: acp::PermissionOptionKind, window: &mut Window, cx: &mut Context, ) -> Option<()> { let session_id = self.thread.read(cx).session_id().clone(); self.conversation.update(cx, |conversation, cx| { conversation.authorize_pending_tool_call(&session_id, kind, cx) })?; if self.should_be_following { self.workspace .update(cx, |workspace, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) .ok(); } cx.notify(); Some(()) } fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { if let AgentThreadEntry::ToolCall(tool_call) = entry { matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } ) } else { false } } fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, window: &mut Window, cx: &mut Context, ) { let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone()); let option_id = acp::PermissionOptionId::new(action.option_id.clone()); let option_kind = match action.option_kind.as_str() { "AllowOnce" => acp::PermissionOptionKind::AllowOnce, "AllowAlways" => acp::PermissionOptionKind::AllowAlways, "RejectOnce" => acp::PermissionOptionKind::RejectOnce, "RejectAlways" => acp::PermissionOptionKind::RejectAlways, _ => acp::PermissionOptionKind::AllowOnce, }; let session_id = self.thread.read(cx).session_id().clone(); self.authorize_tool_call( session_id, tool_call_id, SelectedPermissionOutcome::new(option_id, option_kind), window, cx, ); } pub fn handle_select_permission_granularity( &mut self, action: &SelectPermissionGranularity, _window: &mut Window, cx: &mut Context, ) { let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone()); self.permission_selections .insert(tool_call_id, PermissionSelection::Choice(action.index)); cx.notify(); } pub fn handle_toggle_command_pattern( &mut self, action: &crate::ToggleCommandPattern, _window: &mut Window, cx: &mut Context, ) { let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone()); match self.permission_selections.get_mut(&tool_call_id) { Some(PermissionSelection::SelectedPatterns(checked)) => { // Already in pattern mode — toggle the individual pattern. if let Some(pos) = checked.iter().position(|&i| i == action.pattern_index) { checked.swap_remove(pos); } else { checked.push(action.pattern_index); } } _ => { // First click: activate "Select options" with all patterns checked. let thread = self.thread.read(cx); let pattern_count = thread .entries() .iter() .find_map(|entry| { if let AgentThreadEntry::ToolCall(call) = entry { if call.id == tool_call_id { if let ToolCallStatus::WaitingForConfirmation { options, .. } = &call.status { if let PermissionOptions::DropdownWithPatterns { patterns, .. } = options { return Some(patterns.len()); } } } } None }) .unwrap_or(0); self.permission_selections.insert( tool_call_id, PermissionSelection::SelectedPatterns((0..pattern_count).collect()), ); } } cx.notify(); } fn authorize_pending_with_granularity( &mut self, is_allow: bool, window: &mut Window, cx: &mut Context, ) -> Option<()> { let session_id = self.thread.read(cx).session_id().clone(); let (returned_session_id, tool_call_id, _) = self .conversation .read(cx) .pending_tool_call(&session_id, cx)?; self.authorize_with_granularity(returned_session_id, tool_call_id, is_allow, window, cx) } fn authorize_with_granularity( &mut self, session_id: acp::SessionId, tool_call_id: acp::ToolCallId, is_allow: bool, window: &mut Window, cx: &mut Context, ) -> Option<()> { let selection = self.permission_selections.get(&tool_call_id).cloned(); let result = self.conversation.update(cx, |conversation, cx| { conversation.authorize_with_granularity( session_id, tool_call_id, selection.as_ref(), is_allow, cx, ) }); if self.should_be_following { self.workspace .update(cx, |workspace, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) .ok(); } cx.notify(); result } // edits pub fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { let thread = &self.thread; let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); action_log.update(cx, |action_log, cx| { action_log.keep_all_edits(Some(telemetry), cx) }); } pub fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { let thread = &self.thread; let telemetry = ActionLogTelemetry::from(thread.read(cx)); let action_log = thread.read(cx).action_log().clone(); let has_changes = action_log.read(cx).changed_buffers(cx).len() > 0; action_log .update(cx, |action_log, cx| { action_log.reject_all_edits(Some(telemetry), cx) }) .detach(); if has_changes { if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { crate::ui::show_undo_reject_toast(workspace, action_log, cx); }); } } } pub fn undo_last_reject( &mut self, _: &UndoLastReject, _window: &mut Window, cx: &mut Context, ) { let thread = &self.thread; let action_log = thread.read(cx).action_log().clone(); action_log .update(cx, |action_log, cx| action_log.undo_last_reject(cx)) .detach() } pub fn open_edited_buffer( &mut self, buffer: &Entity, window: &mut Window, cx: &mut Context, ) { let thread = &self.thread; let Some(diff) = AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err() else { return; }; diff.update(cx, |diff, cx| { diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx) }) } // thread stuff fn share_thread(&mut self, _window: &mut Window, cx: &mut Context) { let Some((thread, project)) = self.as_native_thread(cx).zip(self.project.upgrade()) else { return; }; let client = project.read(cx).client(); let workspace = self.workspace.clone(); let session_id = thread.read(cx).id().to_string(); let load_task = thread.read(cx).to_db(cx); cx.spawn(async move |_this, cx| { let db_thread = load_task.await; let shared_thread = SharedThread::from_db_thread(&db_thread); let thread_data = shared_thread.to_bytes()?; let title = shared_thread.title.to_string(); client .request(proto::ShareAgentThread { session_id: session_id.clone(), title, thread_data, }) .await?; let share_url = client::zed_urls::shared_agent_thread_url(&session_id); cx.update(|cx| { if let Some(workspace) = workspace.upgrade() { workspace.update(cx, |workspace, cx| { struct ThreadSharedToast; workspace.show_toast( Toast::new( NotificationId::unique::(), "Thread shared!", ) .on_click( "Copy URL", move |_window, cx| { cx.write_to_clipboard(ClipboardItem::new_string( share_url.clone(), )); }, ), cx, ); }); } }); anyhow::Ok(()) }) .detach_and_log_err(cx); } pub fn sync_thread( &mut self, project: Entity, server_view: Entity, window: &mut Window, cx: &mut Context, ) { if !self.is_imported_thread(cx) { return; } let Some(session_list) = self .as_native_connection(cx) .and_then(|connection| connection.session_list(cx)) .and_then(|list| list.downcast::()) else { return; }; let thread_store = session_list.thread_store().clone(); let client = project.read(cx).client(); let session_id = self.thread.read(cx).session_id().clone(); cx.spawn_in(window, async move |this, cx| { let response = client .request(proto::GetSharedAgentThread { session_id: session_id.to_string(), }) .await?; let shared_thread = SharedThread::from_bytes(&response.thread_data)?; let db_thread = shared_thread.to_db_thread(); thread_store .update(&mut cx.clone(), |store, cx| { store.save_thread(session_id.clone(), db_thread, Default::default(), cx) }) .await?; server_view.update_in(cx, |server_view, window, cx| server_view.reset(window, cx))?; this.update_in(cx, |this, _window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { struct ThreadSyncedToast; workspace.show_toast( Toast::new( NotificationId::unique::(), "Thread synced with latest version", ) .autohide(), cx, ); }); } })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } pub fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { self.thread .update(cx, |thread, cx| { thread.restore_checkpoint(message_id.clone(), cx) }) .detach_and_log_err(cx); } pub fn clear_thread_error(&mut self, cx: &mut Context) { self.thread_error = None; self.thread_error_markdown = None; self.token_limit_callout_dismissed = true; cx.notify(); } fn is_following(&self, cx: &App) -> bool { match self.thread.read(cx).status() { ThreadStatus::Generating => self .workspace .read_with(cx, |workspace, _| { workspace.is_being_followed(CollaboratorId::Agent) }) .unwrap_or(false), _ => self.should_be_following, } } fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { let following = self.is_following(cx); self.should_be_following = !following; if self.thread.read(cx).status() == ThreadStatus::Generating { self.workspace .update(cx, |workspace, cx| { if following { workspace.unfollow(CollaboratorId::Agent, window, cx); } else { workspace.follow(CollaboratorId::Agent, window, cx); } }) .ok(); } telemetry::event!("Follow Agent Selected", following = !following); } // other pub fn render_thread_retry_status_callout(&self) -> Option { let state = self.thread_retry_status.as_ref()?; let next_attempt_in = state .duration .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); if next_attempt_in.is_zero() { return None; } let next_attempt_in_secs = next_attempt_in.as_secs() + 1; let retry_message = if state.max_attempts == 1 { if next_attempt_in_secs == 1 { "Retrying. Next attempt in 1 second.".to_string() } else { format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.") } } else if next_attempt_in_secs == 1 { format!( "Retrying. Next attempt in 1 second (Attempt {} of {}).", state.attempt, state.max_attempts, ) } else { format!( "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).", state.attempt, state.max_attempts, ) }; Some( Callout::new() .icon(IconName::Warning) .severity(Severity::Warning) .title(state.last_error.clone()) .description(retry_message), ) } fn activity_bar_bg(&self, cx: &Context) -> Hsla { let editor_bg_color = cx.theme().colors().editor_background; let active_color = cx.theme().colors().element_selected; editor_bg_color.blend(active_color.opacity(0.3)) } pub fn render_activity_bar( &self, window: &mut Window, cx: &Context, ) -> Option { let thread = self.thread.read(cx); let action_log = thread.action_log(); let telemetry = ActionLogTelemetry::from(thread); let changed_buffers = action_log.read(cx).changed_buffers(cx); let plan = thread.plan(); let queue_is_empty = !self.has_queued_messages(); let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx); let has_subagents_awaiting = subagents_awaiting_permission.is_some(); if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty && !has_subagents_awaiting { return None; } // Temporarily always enable ACP edit controls. This is temporary, to lessen the // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't // be, which blocks you from being able to accept or reject edits. This switches the // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't // block you from using the panel. let pending_edits = false; let plan_expanded = self.plan_expanded; let edits_expanded = self.edits_expanded; let queue_expanded = self.queue_expanded; let max_content_width = AgentSettings::get_global(cx).max_content_width; h_flex() .w_full() .px_2() .justify_center() .child( v_flex() .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(max_content_width.is_none(), |this| this.w_full()) .flex_shrink() .flex_grow_0() .max_w_full() .bg(self.activity_bar_bg(cx)) .border_1() .border_b_0() .border_color(cx.theme().colors().border) .rounded_t_md() .shadow(vec![gpui::BoxShadow { color: gpui::black().opacity(0.12), offset: point(px(1.), px(-1.)), blur_radius: px(2.), spread_radius: px(0.), }]) .when_some(subagents_awaiting_permission, |this, element| { this.child(element) }) .when( has_subagents_awaiting && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty), |this| this.child(Divider::horizontal().color(DividerColor::Border)), ) .when(!plan.is_empty(), |this| { this.child(self.render_plan_summary(plan, window, cx)) .when(plan_expanded, |parent| { parent.child(self.render_plan_entries(plan, window, cx)) }) }) .when(!plan.is_empty() && !changed_buffers.is_empty(), |this| { this.child(Divider::horizontal().color(DividerColor::Border)) }) .when( !changed_buffers.is_empty() && thread.parent_session_id().is_none(), |this| { this.child(self.render_edits_summary( &changed_buffers, edits_expanded, pending_edits, cx, )) .when(edits_expanded, |parent| { parent.child(self.render_edited_files( action_log, telemetry.clone(), &changed_buffers, pending_edits, cx, )) }) }, ) .when(!queue_is_empty, |this| { this.when(!plan.is_empty() || !changed_buffers.is_empty(), |this| { this.child(Divider::horizontal().color(DividerColor::Border)) }) .child(self.render_message_queue_summary(window, cx)) .when(queue_expanded, |parent| { parent.child(self.render_message_queue_entries(window, cx)) }) }), ) .into_any() .into() } fn render_edited_files( &self, action_log: &Entity, telemetry: ActionLogTelemetry, changed_buffers: &BTreeMap, Entity>, pending_edits: bool, cx: &Context, ) -> impl IntoElement { let editor_bg_color = cx.theme().colors().editor_background; // Sort edited files alphabetically for consistency with Git diff view let mut sorted_buffers: Vec<_> = changed_buffers.iter().collect(); sorted_buffers.sort_by(|(buffer_a, _), (buffer_b, _)| { let path_a = buffer_a.read(cx).file().map(|f| f.path().clone()); let path_b = buffer_b.read(cx).file().map(|f| f.path().clone()); path_a.cmp(&path_b) }); v_flex() .id("edited_files_list") .max_h_40() .overflow_y_scroll() .child( v_flex().children(sorted_buffers.into_iter().enumerate().flat_map( |(index, (buffer, diff))| { let file = buffer.read(cx).file()?; let path = file.path(); let path_style = file.path_style(cx); let separator = file.path_style(cx).primary_separator(); let file_path = path.parent().and_then(|parent| { if parent.is_empty() { None } else { Some( Label::new(format!( "{}{separator}", parent.display(path_style) )) .color(Color::Muted) .size(LabelSize::XSmall) .buffer_font(cx), ) } }); let file_name = path.file_name().map(|name| { Label::new(name.to_string()) .size(LabelSize::XSmall) .buffer_font(cx) .ml_1() }); let full_path = path.display(path_style).to_string(); let file_icon = FileIcons::get_icon(path.as_std_path(), cx) .map(Icon::from_path) .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) .unwrap_or_else(|| { Icon::new(IconName::File) .color(Color::Muted) .size(IconSize::Small) }); let file_stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); let buttons = self.render_edited_files_buttons( index, buffer, action_log, &telemetry, pending_edits, editor_bg_color, cx, ); let element = h_flex() .group("edited-code") .id(("file-container", index)) .relative() .min_w_0() .p_1p5() .gap_2() .justify_between() .bg(editor_bg_color) .when(index < changed_buffers.len() - 1, |parent| { parent.border_color(cx.theme().colors().border).border_b_1() }) .child( h_flex() .id(("file-name-path", index)) .cursor_pointer() .pr_0p5() .gap_0p5() .rounded_xs() .child(file_icon) .children(file_name) .children(file_path) .child( DiffStat::new( "file", file_stats.lines_added as usize, file_stats.lines_removed as usize, ) .label_size(LabelSize::XSmall), ) .hover(|s| s.bg(cx.theme().colors().element_hover)) .tooltip({ move |_, cx| { Tooltip::with_meta( "Go to File", None, full_path.clone(), cx, ) } }) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { this.open_edited_buffer(&buffer, window, cx); }) }), ) .child(buttons); Some(element) }, )), ) .into_any_element() } fn render_edited_files_buttons( &self, index: usize, buffer: &Entity, action_log: &Entity, telemetry: &ActionLogTelemetry, pending_edits: bool, editor_bg_color: Hsla, cx: &Context, ) -> impl IntoElement { h_flex() .id("edited-buttons-container") .visible_on_hover("edited-code") .absolute() .right_0() .px_1() .gap_1() .bg(editor_bg_color) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_edited_file_buttons = Some(index); } else if this.hovered_edited_file_buttons == Some(index) { this.hovered_edited_file_buttons = None; } cx.notify(); })) .child( Button::new("review", "Review") .label_size(LabelSize::Small) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { this.open_edited_buffer(&buffer, window, cx); }) }), ) .child( Button::new(("reject-file", index), "Reject") .label_size(LabelSize::Small) .disabled(pending_edits) .on_click({ let buffer = buffer.clone(); let action_log = action_log.clone(); let telemetry = telemetry.clone(); move |_, _, cx| { action_log.update(cx, |action_log, cx| { action_log .reject_edits_in_ranges( buffer.clone(), vec![Anchor::min_max_range_for_buffer( buffer.read(cx).remote_id(), )], Some(telemetry.clone()), cx, ) .0 .detach_and_log_err(cx); }) } }), ) .child( Button::new(("keep-file", index), "Keep") .label_size(LabelSize::Small) .disabled(pending_edits) .on_click({ let buffer = buffer.clone(); let action_log = action_log.clone(); let telemetry = telemetry.clone(); move |_, _, cx| { action_log.update(cx, |action_log, cx| { action_log.keep_edits_in_range( buffer.clone(), Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), Some(telemetry.clone()), cx, ); }) } }), ) } fn collect_subagent_items_for_sessions( entries: &[AgentThreadEntry], awaiting_session_ids: &[acp::SessionId], cx: &App, ) -> Vec<(SharedString, usize)> { let tool_calls_by_session: HashMap<_, _> = entries .iter() .enumerate() .filter_map(|(entry_ix, entry)| { let AgentThreadEntry::ToolCall(tool_call) = entry else { return None; }; let info = tool_call.subagent_session_info.as_ref()?; let summary_text = tool_call.label.read(cx).source().to_string(); let subagent_summary = if summary_text.is_empty() { SharedString::from("Subagent") } else { SharedString::from(summary_text) }; Some((info.session_id.clone(), (subagent_summary, entry_ix))) }) .collect(); awaiting_session_ids .iter() .filter_map(|session_id| tool_calls_by_session.get(session_id).cloned()) .collect() } fn render_subagents_awaiting_permission(&self, cx: &Context) -> Option { let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx); if awaiting.is_empty() { return None; } let awaiting_session_ids: Vec<_> = awaiting .iter() .map(|(session_id, _)| session_id.clone()) .collect(); let thread = self.thread.read(cx); let entries = thread.entries(); let subagent_items = Self::collect_subagent_items_for_sessions(entries, &awaiting_session_ids, cx); if subagent_items.is_empty() { return None; } let item_count = subagent_items.len(); Some( v_flex() .child( h_flex() .py_1() .px_2() .w_full() .gap_1() .border_b_1() .border_color(cx.theme().colors().border) .child( Label::new("Subagents Awaiting Permission:") .size(LabelSize::Small) .color(Color::Muted), ) .child(Label::new(item_count.to_string()).size(LabelSize::Small)), ) .child( v_flex().children(subagent_items.into_iter().enumerate().map( |(ix, (label, entry_ix))| { let is_last = ix == item_count - 1; let group = format!("group-{}", entry_ix); h_flex() .cursor_pointer() .id(format!("subagent-permission-{}", entry_ix)) .group(&group) .p_1() .pl_2() .min_w_0() .w_full() .gap_1() .justify_between() .bg(cx.theme().colors().editor_background) .hover(|s| s.bg(cx.theme().colors().element_hover)) .when(!is_last, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .gap_1p5() .child( Icon::new(IconName::Circle) .size(IconSize::XSmall) .color(Color::Warning), ) .child( Label::new(label) .size(LabelSize::Small) .color(Color::Muted) .truncate(), ), ) .child( div().visible_on_hover(&group).child( Label::new("Scroll to Subagent") .size(LabelSize::Small) .color(Color::Muted) .truncate(), ), ) .on_click(cx.listener(move |this, _, _, cx| { this.list_state.scroll_to(ListOffset { item_ix: entry_ix, offset_in_item: px(0.0), }); cx.notify(); })) }, )), ) .into_any(), ) } fn render_message_queue_summary( &self, _window: &mut Window, cx: &Context, ) -> impl IntoElement { let queue_count = self.local_queued_messages.len(); let title: SharedString = if queue_count == 1 { "1 Queued Message".into() } else { format!("{} Queued Messages", queue_count).into() }; h_flex() .p_1() .w_full() .gap_1() .justify_between() .when(self.queue_expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .id("queue_summary") .gap_1() .child(Disclosure::new("queue_disclosure", self.queue_expanded)) .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) .on_click(cx.listener(|this, _, _, cx| { this.queue_expanded = !this.queue_expanded; cx.notify(); })), ) .child( Button::new("clear_queue", "Clear All") .label_size(LabelSize::Small) .key_binding( KeyBinding::for_action(&ClearMessageQueue, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(|this, _, _, cx| { this.clear_queue(cx); this.can_fast_track_queue = false; cx.notify(); })), ) .into_any_element() } fn clear_queue(&mut self, cx: &mut Context) { self.local_queued_messages.clear(); self.sync_queue_flag_to_native_thread(cx); } fn render_plan_summary( &self, plan: &Plan, window: &mut Window, cx: &Context, ) -> impl IntoElement { let plan_expanded = self.plan_expanded; let stats = plan.stats(); let title = if let Some(entry) = stats.in_progress_entry && !plan_expanded { h_flex() .cursor_default() .relative() .w_full() .gap_1() .truncate() .child( Label::new("Current:") .size(LabelSize::Small) .color(Color::Muted), ) .child( div() .text_xs() .text_color(cx.theme().colors().text_muted) .line_clamp(1) .child(MarkdownElement::new( entry.content.clone(), plan_label_markdown_style(&entry.status, window, cx), )), ) .when(stats.pending > 0, |this| { this.child( h_flex() .absolute() .top_0() .right_0() .h_full() .child(div().min_w_8().h_full().bg(linear_gradient( 90., linear_color_stop(self.activity_bar_bg(cx), 1.), linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.), ))) .child( div().pr_0p5().bg(self.activity_bar_bg(cx)).child( Label::new(format!("{} left", stats.pending)) .size(LabelSize::Small) .color(Color::Muted), ), ), ) }) } else { let status_label = if stats.pending == 0 { "All Done".to_string() } else if stats.completed == 0 { format!("{} Tasks", plan.entries.len()) } else { format!("{}/{}", stats.completed, plan.entries.len()) }; h_flex() .w_full() .gap_1() .justify_between() .child( Label::new("Plan") .size(LabelSize::Small) .color(Color::Muted), ) .child( Label::new(status_label) .size(LabelSize::Small) .color(Color::Muted) .mr_1(), ) }; h_flex() .id("plan_summary") .p_1() .w_full() .gap_1() .when(plan_expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child(Disclosure::new("plan_disclosure", plan_expanded)) .child(title.flex_1()) .child( IconButton::new("dismiss-plan", IconName::Close) .icon_size(IconSize::XSmall) .shape(ui::IconButtonShape::Square) .tooltip(Tooltip::text("Clear Plan")) .on_click(cx.listener(|this, _, _, cx| { this.thread.update(cx, |thread, cx| thread.clear_plan(cx)); cx.stop_propagation(); })), ) .on_click(cx.listener(|this, _, _, cx| { this.plan_expanded = !this.plan_expanded; cx.notify(); })) .into_any_element() } fn render_plan_entries( &self, plan: &Plan, window: &mut Window, cx: &Context, ) -> impl IntoElement { v_flex() .id("plan_items_list") .max_h_40() .overflow_y_scroll() .child( v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| { let entry_bg = cx.theme().colors().editor_background; let tooltip_text: SharedString = entry.content.read(cx).source().to_string().into(); Some( h_flex() .id(("plan_entry_row", index)) .py_1() .px_2() .gap_2() .justify_between() .relative() .bg(entry_bg) .when(index < plan.entries.len() - 1, |parent| { parent.border_color(cx.theme().colors().border).border_b_1() }) .overflow_hidden() .child( h_flex() .id(("plan_entry", index)) .gap_1p5() .min_w_0() .text_xs() .text_color(cx.theme().colors().text_muted) .child(match entry.status { acp::PlanEntryStatus::InProgress => { Icon::new(IconName::TodoProgress) .size(IconSize::Small) .color(Color::Accent) .with_rotate_animation(2) .into_any_element() } acp::PlanEntryStatus::Completed => { Icon::new(IconName::TodoComplete) .size(IconSize::Small) .color(Color::Success) .into_any_element() } acp::PlanEntryStatus::Pending | _ => { Icon::new(IconName::TodoPending) .size(IconSize::Small) .color(Color::Muted) .into_any_element() } }) .child(MarkdownElement::new( entry.content.clone(), plan_label_markdown_style(&entry.status, window, cx), )), ) .child(div().absolute().top_0().right_0().h_full().w_8().bg( linear_gradient( 90., linear_color_stop(entry_bg, 1.), linear_color_stop(entry_bg.opacity(0.), 0.), ), )) .tooltip(Tooltip::text(tooltip_text)), ) })), ) .into_any_element() } fn render_completed_plan( &self, entries: &[PlanEntry], window: &Window, cx: &Context, ) -> AnyElement { v_flex() .px_5() .py_1p5() .w_full() .child( v_flex() .w_full() .rounded_md() .border_1() .border_color(self.tool_card_border_color(cx)) .child( h_flex() .px_2() .py_1() .gap_1() .bg(self.tool_card_header_bg(cx)) .border_b_1() .border_color(self.tool_card_border_color(cx)) .child( Label::new("Completed Plan") .size(LabelSize::Small) .color(Color::Muted), ) .child( Label::new(format!( "— {} {}", entries.len(), if entries.len() == 1 { "step" } else { "steps" } )) .size(LabelSize::Small) .color(Color::Muted), ), ) .child( v_flex().children(entries.iter().enumerate().map(|(index, entry)| { h_flex() .py_1() .px_2() .gap_1p5() .when(index < entries.len() - 1, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( Icon::new(IconName::TodoComplete) .size(IconSize::Small) .color(Color::Success), ) .child( div() .max_w_full() .overflow_x_hidden() .text_xs() .text_color(cx.theme().colors().text_muted) .child(MarkdownElement::new( entry.content.clone(), default_markdown_style(window, cx), )), ) })), ), ) .into_any() } fn render_edits_summary( &self, changed_buffers: &BTreeMap, Entity>, expanded: bool, pending_edits: bool, cx: &Context, ) -> Div { const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; let focus_handle = self.focus_handle(cx); h_flex() .p_1() .justify_between() .flex_wrap() .when(expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .id("edits-container") .cursor_pointer() .gap_1() .child(Disclosure::new("edits-disclosure", expanded)) .map(|this| { if pending_edits { this.child( Label::new(format!( "Editing {} {}…", changed_buffers.len(), if changed_buffers.len() == 1 { "file" } else { "files" } )) .color(Color::Muted) .size(LabelSize::Small) .with_animation( "edit-label", Animation::new(Duration::from_secs(2)) .repeat() .with_easing(pulsating_between(0.3, 0.7)), |label, delta| label.alpha(delta), ), ) } else { let stats = DiffStats::all_files(changed_buffers, cx); let dot_divider = || { Label::new("•") .size(LabelSize::XSmall) .color(Color::Disabled) }; this.child( Label::new("Edits") .size(LabelSize::Small) .color(Color::Muted), ) .child(dot_divider()) .child( Label::new(format!( "{} {}", changed_buffers.len(), if changed_buffers.len() == 1 { "file" } else { "files" } )) .size(LabelSize::Small) .color(Color::Muted), ) .child(dot_divider()) .child(DiffStat::new( "total", stats.lines_added as usize, stats.lines_removed as usize, )) } }) .on_click(cx.listener(|this, _, _, cx| { this.edits_expanded = !this.edits_expanded; cx.notify(); })), ) .child( h_flex() .gap_1() .child( IconButton::new("review-changes", IconName::ListTodo) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { Tooltip::for_action_in( "Review Changes", &OpenAgentDiff, &focus_handle, cx, ) } }) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(OpenAgentDiff.boxed_clone(), cx); })), ) .child(Divider::vertical().color(DividerColor::Border)) .child( Button::new("reject-all-changes", "Reject All") .label_size(LabelSize::Small) .disabled(pending_edits) .when(pending_edits, |this| { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(move |this, _, window, cx| { this.reject_all(&RejectAll, window, cx); })), ) .child( Button::new("keep-all-changes", "Keep All") .label_size(LabelSize::Small) .disabled(pending_edits) .when(pending_edits, |this| { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(cx.listener(move |this, _, window, cx| { this.keep_all(&KeepAll, window, cx); })), ), ) } fn is_subagent_canceled_or_failed(&self, cx: &App) -> bool { let Some(parent_session_id) = self.parent_session_id.as_ref() else { return false; }; let my_session_id = self.thread.read(cx).session_id().clone(); self.server_view .upgrade() .and_then(|sv| sv.read(cx).thread_view(parent_session_id)) .is_some_and(|parent_view| { parent_view .read(cx) .thread .read(cx) .tool_call_for_subagent(&my_session_id) .is_some_and(|tc| { matches!( tc.status, ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected ) }) }) } pub(crate) fn render_subagent_titlebar(&mut self, cx: &mut Context) -> Option
{ if self.parent_session_id.is_none() { return None; } let parent_session_id = self.thread.read(cx).parent_session_id()?.clone(); let server_view = self.server_view.clone(); let thread = self.thread.clone(); let is_done = thread.read(cx).status() == ThreadStatus::Idle; let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx); let max_content_width = AgentSettings::get_global(cx).max_content_width; Some( h_flex() .w_full() .h(Tab::container_height(cx)) .border_b_1() .when(is_done && is_canceled_or_failed, |this| { this.border_dashed() }) .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background.opacity(0.2)) .child( h_flex() .size_full() .when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto()) .pl_2() .pr_1() .flex_shrink_0() .justify_between() .gap_1() .child( h_flex() .flex_1() .gap_2() .child( Icon::new(IconName::ForwardArrowUp) .size(IconSize::Small) .color(Color::Muted), ) .child(self.title_editor.clone()) .when(is_done && is_canceled_or_failed, |this| { this.child(Icon::new(IconName::Close).color(Color::Error)) }) .when(is_done && !is_canceled_or_failed, |this| { this.child(Icon::new(IconName::Check).color(Color::Success)) }), ) .child( h_flex() .gap_0p5() .when(!is_done, |this| { this.child( IconButton::new("stop_subagent", IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .tooltip(Tooltip::text("Stop Subagent")) .on_click(move |_, _, cx| { thread.update(cx, |thread, cx| { thread.cancel(cx).detach(); }); }), ) }) .child( IconButton::new("minimize_subagent", IconName::Dash) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Minimize Subagent")) .on_click(move |_, window, cx| { let _ = server_view.update(cx, |server_view, cx| { server_view.navigate_to_thread( parent_session_id.clone(), window, cx, ); }); }), ), ), ), ) } pub(crate) fn render_message_editor( &mut self, window: &mut Window, cx: &mut Context, ) -> AnyElement { if self.is_subagent() { return div().into_any_element(); } let focus_handle = self.message_editor.focus_handle(cx); let editor_bg_color = cx.theme().colors().editor_background; let editor_expanded = self.editor_expanded; let (expand_icon, expand_tooltip) = if editor_expanded { (IconName::Minimize, "Minimize Message Editor") } else { (IconName::Maximize, "Expand Message Editor") }; let max_content_width = AgentSettings::get_global(cx).max_content_width; let has_messages = self.list_state.item_count() > 0; let fills_container = !has_messages || editor_expanded; h_flex() .p_2() .bg(editor_bg_color) .justify_center() .map(|this| { if has_messages { this.on_action(cx.listener(Self::expand_message_editor)) .border_t_1() .border_color(cx.theme().colors().border) .when(editor_expanded, |this| this.h(vh(0.8, window))) } else { this.flex_1().size_full() } }) .child( v_flex() .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(fills_container, |this| this.h_full()) .flex_shrink() .flex_grow_0() .justify_between() .gap_2() .child( v_flex() .relative() .w_full() .min_h_0() .when(fills_container, |this| this.flex_1()) .pt_1() .pr_2p5() .child(self.message_editor.clone()) .when(has_messages, |this| { this.child( h_flex() .absolute() .top_0() .right_0() .opacity(0.5) .hover(|s| s.opacity(1.0)) .child( IconButton::new("toggle-height", expand_icon) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ move |_window, cx| { Tooltip::for_action_in( expand_tooltip, &ExpandMessageEditor, &focus_handle, cx, ) } }) .on_click(cx.listener(|this, _, window, cx| { this.expand_message_editor( &ExpandMessageEditor, window, cx, ); })), ), ) }), ) .child( h_flex() .w_full() .flex_none() .flex_wrap() .justify_between() .child( h_flex() .gap_0p5() .child(self.render_add_context_button(cx)) .child(self.render_follow_toggle(cx)) .children(self.render_fast_mode_control(cx)) .children(self.render_thinking_control(cx)), ) .child( h_flex() .flex_wrap() .gap_1() .children(self.render_token_usage(cx)) .children(self.profile_selector.clone()) .map(|this| match self.config_options_view.clone() { Some(config_view) => this.child(config_view), None => this .children(self.mode_selector.clone()) .children(self.model_selector.clone()), }) .child(self.render_send_button(cx)), ), ), ) .into_any() } fn render_message_queue_entries( &self, _window: &mut Window, cx: &Context, ) -> impl IntoElement { let message_editor = self.message_editor.read(cx); let focus_handle = message_editor.focus_handle(cx); let queued_message_editors = &self.queued_message_editors; let queue_len = queued_message_editors.len(); let can_fast_track = self.can_fast_track_queue && queue_len > 0; v_flex() .id("message_queue_list") .max_h_40() .overflow_y_scroll() .children( queued_message_editors .iter() .enumerate() .map(|(index, editor)| { let is_next = index == 0; let (icon_color, tooltip_text) = if is_next { (Color::Accent, "Next in Queue") } else { (Color::Muted, "In Queue") }; let editor_focused = editor.focus_handle(cx).is_focused(_window); let keybinding_size = rems_from_px(12.); h_flex() .group("queue_entry") .w_full() .p_1p5() .gap_1() .bg(cx.theme().colors().editor_background) .when(index < queue_len - 1, |this| { this.border_b_1() .border_color(cx.theme().colors().border_variant) }) .child( div() .id("next_in_queue") .child( Icon::new(IconName::Circle) .size(IconSize::Small) .color(icon_color), ) .tooltip(Tooltip::text(tooltip_text)), ) .child(editor.clone()) .child(if editor_focused { h_flex() .gap_1() .min_w(rems_from_px(150.)) .justify_end() .child( IconButton::new(("edit", index), IconName::Pencil) .icon_size(IconSize::Small) .tooltip(|_window, cx| { Tooltip::with_meta( "Edit Queued Message", None, "Type anything to edit", cx, ) }) .on_click(cx.listener(move |this, _, window, cx| { this.move_queued_message_to_main_editor( index, None, None, window, cx, ); })), ) .child( Button::new(("send_now_focused", index), "Send Now") .label_size(LabelSize::Small) .style(ButtonStyle::Outlined) .key_binding( KeyBinding::for_action_in( &SendImmediately, &editor.focus_handle(cx), cx, ) .map(|kb| kb.size(keybinding_size)), ) .on_click(cx.listener(move |this, _, window, cx| { this.send_queued_message_at_index( index, true, window, cx, ); })), ) } else { h_flex() .when(!is_next, |this| this.visible_on_hover("queue_entry")) .gap_1() .min_w(rems_from_px(150.)) .justify_end() .child( IconButton::new(("delete", index), IconName::Trash) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { if is_next { Tooltip::for_action_in( "Remove Message from Queue", &RemoveFirstQueuedMessage, &focus_handle, cx, ) } else { Tooltip::simple( "Remove Message from Queue", cx, ) } } }) .on_click(cx.listener(move |this, _, _, cx| { this.remove_from_queue(index, cx); cx.notify(); })), ) .child( IconButton::new(("edit", index), IconName::Pencil) .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); move |_window, cx| { if is_next { Tooltip::for_action_in( "Edit", &EditFirstQueuedMessage, &focus_handle, cx, ) } else { Tooltip::simple("Edit", cx) } } }) .on_click(cx.listener(move |this, _, window, cx| { this.move_queued_message_to_main_editor( index, None, None, window, cx, ); })), ) .child( Button::new(("send_now", index), "Send Now") .label_size(LabelSize::Small) .when(is_next, |this| this.style(ButtonStyle::Outlined)) .when(is_next && message_editor.is_empty(cx), |this| { let action: Box = if can_fast_track { Box::new(Chat) } else { Box::new(SendNextQueuedMessage) }; this.key_binding( KeyBinding::for_action_in( action.as_ref(), &focus_handle.clone(), cx, ) .map(|kb| kb.size(keybinding_size)), ) }) .on_click(cx.listener(move |this, _, window, cx| { this.send_queued_message_at_index( index, true, window, cx, ); })), ) }) }), ) .into_any_element() } fn supports_split_token_display(&self, cx: &App) -> bool { self.as_native_thread(cx) .and_then(|thread| thread.read(cx).model()) .is_some_and(|model| model.supports_split_token_display()) } fn render_token_usage(&self, cx: &mut Context) -> Option { let thread = self.thread.read(cx); let usage = thread.token_usage()?; let show_split = self.supports_split_token_display(cx); let cost_label = if cx.has_flag::() { thread.cost().map(|cost| { let precision = if cost.amount > 0.0 && cost.amount < 0.01 { 4 } else { 2 }; format!("{:.prec$} {}", cost.amount, cost.currency, prec = precision) }) } else { None }; let progress_color = |ratio: f32| -> Hsla { if ratio >= 0.85 { cx.theme().status().warning } else { cx.theme().colors().text_muted } }; let used = crate::humanize_token_count(usage.used_tokens); let max = crate::humanize_token_count(usage.max_tokens); let input_tokens_label = crate::humanize_token_count(usage.input_tokens); let output_tokens_label = crate::humanize_token_count(usage.output_tokens); let progress_ratio = if usage.max_tokens > 0 { usage.used_tokens as f32 / usage.max_tokens as f32 } else { 0.0 }; let ring_size = px(16.0); let stroke_width = px(2.); let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self .as_native_thread(cx) .map(|thread| { let project_context = thread.read(cx).project_context().read(cx); let user_rules_count = project_context.user_rules.len(); let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); let project_entry_ids = project_context .worktrees .iter() .filter_map(|wt| wt.rules_file.as_ref()) .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) .collect::>(); let project_rules_count = project_entry_ids.len(); ( user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids, ) }) .unwrap_or_default(); let workspace = self.workspace.clone(); let max_output_tokens = self .as_native_thread(cx) .and_then(|thread| thread.read(cx).model()) .and_then(|model| model.max_output_tokens()) .unwrap_or(0); let input_max_label = crate::humanize_token_count(usage.max_tokens.saturating_sub(max_output_tokens)); let output_max_label = crate::humanize_token_count(max_output_tokens); let build_tooltip = { move |_window: &mut Window, cx: &mut App| { let percentage = percentage.clone(); let used = used.clone(); let max = max.clone(); let input_tokens_label = input_tokens_label.clone(); let output_tokens_label = output_tokens_label.clone(); let input_max_label = input_max_label.clone(); let output_max_label = output_max_label.clone(); let project_entry_ids = project_entry_ids.clone(); let workspace = workspace.clone(); let cost_label = cost_label.clone(); cx.new(move |_cx| TokenUsageTooltip { percentage, used, max, input_tokens: input_tokens_label, output_tokens: output_tokens_label, input_max: input_max_label, output_max: output_max_label, show_split, cost_label, separator_color: tooltip_separator_color, user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids, workspace, }) .into() } }; if show_split { let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens); let output_max_raw = max_output_tokens; let input_ratio = if input_max_raw > 0 { usage.input_tokens as f32 / input_max_raw as f32 } else { 0.0 }; let output_ratio = if output_max_raw > 0 { usage.output_tokens as f32 / output_max_raw as f32 } else { 0.0 }; Some( h_flex() .id("split_token_usage") .flex_shrink_0() .gap_1p5() .mr_1() .child( h_flex() .gap_0p5() .child( Icon::new(IconName::ArrowUp) .size(IconSize::XSmall) .color(Color::Muted), ) .child( CircularProgress::new( usage.input_tokens as f32, input_max_raw as f32, ring_size, cx, ) .stroke_width(stroke_width) .progress_color(progress_color(input_ratio)), ), ) .child( h_flex() .gap_0p5() .child( Icon::new(IconName::ArrowDown) .size(IconSize::XSmall) .color(Color::Muted), ) .child( CircularProgress::new( usage.output_tokens as f32, output_max_raw as f32, ring_size, cx, ) .stroke_width(stroke_width) .progress_color(progress_color(output_ratio)), ), ) .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { Some( h_flex() .id("circular_progress_tokens") .mt_px() .mr_1() .child( CircularProgress::new( usage.used_tokens as f32, usage.max_tokens as f32, ring_size, cx, ) .stroke_width(stroke_width) .progress_color(progress_color(progress_ratio)), ) .hoverable_tooltip(build_tooltip) .into_any_element(), ) } } fn fast_mode_available(&self, cx: &Context) -> bool { if !cx.is_staff() { return false; } self.as_native_thread(cx) .and_then(|thread| thread.read(cx).model()) .map(|model| model.supports_fast_mode()) .unwrap_or(false) } fn render_fast_mode_control(&self, cx: &mut Context) -> Option { if !self.fast_mode_available(cx) { return None; } let thread = self.as_native_thread(cx)?.read(cx); let (tooltip_label, color, icon) = if matches!(thread.speed(), Some(Speed::Fast)) { ("Disable Fast Mode", Color::Muted, IconName::FastForward) } else { ( "Enable Fast Mode", Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)), IconName::FastForwardOff, ) }; let focus_handle = self.message_editor.focus_handle(cx); Some( IconButton::new("fast-mode", icon) .icon_size(IconSize::Small) .icon_color(color) .tooltip(move |_, cx| { Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &focus_handle, cx) }) .on_click(cx.listener(move |this, _, _window, cx| { this.toggle_fast_mode(cx); })) .into_any_element(), ) } fn render_thinking_control(&self, cx: &mut Context) -> Option { let thread = self.as_native_thread(cx)?.read(cx); let model = thread.model()?; let supports_thinking = model.supports_thinking(); if !supports_thinking { return None; } let thinking = thread.thinking_enabled(); let (tooltip_label, icon, color) = if thinking { ( "Disable Thinking Mode", IconName::ThinkingMode, Color::Muted, ) } else { ( "Enable Thinking Mode", IconName::ThinkingModeOff, Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)), ) }; let focus_handle = self.message_editor.focus_handle(cx); let thinking_toggle = IconButton::new("thinking-mode", icon) .icon_size(IconSize::Small) .icon_color(color) .tooltip(move |_, cx| { Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) }) .on_click(cx.listener(move |this, _, _window, cx| { if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { let enable_thinking = !thread.thinking_enabled(); thread.set_thinking_enabled(enable_thinking, cx); let favorite_key = thread.model().map(|model| { (model.provider_id().0.to_string(), model.id().0.to_string()) }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { if let Some(agent) = settings.agent.as_mut() { if let Some(default_model) = agent.default_model.as_mut() { default_model.enable_thinking = enable_thinking; } if let Some((provider_id, model_id)) = &favorite_key { agent.update_favorite_model( provider_id, model_id, |favorite| favorite.enable_thinking = enable_thinking, ); } } }); }); } })); if model.supported_effort_levels().is_empty() { return Some(thinking_toggle.into_any_element()); } if !model.supported_effort_levels().is_empty() && !thinking { return Some(thinking_toggle.into_any_element()); } let left_btn = thinking_toggle; let right_btn = self.render_effort_selector( model.supported_effort_levels(), thread.thinking_effort().cloned(), cx, ); Some( SplitButton::new(left_btn, right_btn.into_any_element()) .style(SplitButtonStyle::Transparent) .into_any_element(), ) } fn render_effort_selector( &self, supported_effort_levels: Vec, selected_effort: Option, cx: &Context, ) -> impl IntoElement { let weak_self = cx.weak_entity(); let default_effort_level = supported_effort_levels .iter() .find(|effort_level| effort_level.is_default) .cloned(); let selected = selected_effort.and_then(|effort| { supported_effort_levels .iter() .find(|level| level.value == effort) .cloned() }); let label = selected .clone() .or(default_effort_level) .map_or("Select Effort".into(), |effort| effort.name); let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) } else { (Color::Muted, IconName::ChevronDown) }; let focus_handle = self.message_editor.focus_handle(cx); let show_cycle_row = supported_effort_levels.len() > 1; let tooltip = Tooltip::element({ move |_, cx| { let mut content = v_flex().gap_1().child( h_flex() .gap_2() .justify_between() .child(Label::new("Change Thinking Effort")) .child(KeyBinding::for_action_in( &ToggleThinkingEffortMenu, &focus_handle, cx, )), ); if show_cycle_row { content = content.child( h_flex() .pt_1() .gap_2() .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(Label::new("Cycle Thinking Effort")) .child(KeyBinding::for_action_in( &CycleThinkingEffort, &focus_handle, cx, )), ); } content.into_any_element() } }); PopoverMenu::new("effort-selector") .trigger_with_tooltip( ButtonLike::new_rounded_right("effort-selector-trigger") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), tooltip, ) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| { menu = menu.header("Change Thinking Effort"); for effort_level in supported_effort_levels.clone() { let is_selected = selected .as_ref() .is_some_and(|selected| selected.value == effort_level.value); let entry = ContextMenuEntry::new(effort_level.name) .toggleable(IconPosition::End, is_selected); menu.push_item(entry.handler({ let effort = effort_level.value.clone(); let weak_self = weak_self.clone(); move |_window, cx| { let effort = effort.clone(); weak_self .update(cx, |this, cx| { if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { thread.set_thinking_effort( Some(effort.to_string()), cx, ); let favorite_key = thread.model().map(|model| { ( model.provider_id().0.to_string(), model.id().0.to_string(), ) }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { if let Some(agent) = settings.agent.as_mut() { if let Some(default_model) = agent.default_model.as_mut() { default_model.effort = Some(effort.to_string()); } if let Some((provider_id, model_id)) = &favorite_key { agent.update_favorite_model( provider_id, model_id, |favorite| { favorite.effort = Some(effort.to_string()) }, ); } } }); }); } }) .ok(); } })); } menu })) }) .with_handle(self.thinking_effort_menu_handle.clone()) .offset(gpui::Point { x: px(0.0), y: px(-2.0), }) .anchor(gpui::Anchor::BottomLeft) } fn render_send_button(&self, cx: &mut Context) -> AnyElement { let message_editor = self.message_editor.read(cx); let is_editor_empty = message_editor.is_empty(cx); let focus_handle = message_editor.focus_handle(cx); let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; if self.is_loading_contents { div() .id("loading-message-content") .px_1() .tooltip(Tooltip::text("Loading Added Context…")) .child(loading_contents_spinner(IconSize::default())) .into_any_element() } else if is_generating && is_editor_empty { IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) .style(ButtonStyle::Tinted(TintColor::Error)) .tooltip(move |_window, cx| { Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) }) .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() } else { let send_icon = if is_generating { IconName::QueueMessage } else { IconName::Send }; IconButton::new("send-message", send_icon) .style(ButtonStyle::Filled) .map(|this| { if is_editor_empty && !is_generating { this.disabled(true).icon_color(Color::Muted) } else { this.icon_color(Color::Accent) } }) .tooltip(move |_window, cx| { if is_editor_empty && !is_generating { Tooltip::for_action("Type to Send", &Chat, cx) } else if is_generating { let focus_handle = focus_handle.clone(); Tooltip::element(move |_window, cx| { v_flex() .gap_1() .child( h_flex() .gap_2() .justify_between() .child(Label::new("Queue and Send")) .child(KeyBinding::for_action_in(&Chat, &focus_handle, cx)), ) .child( h_flex() .pt_1() .gap_2() .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) .child(Label::new("Send Immediately")) .child(KeyBinding::for_action_in( &SendImmediately, &focus_handle, cx, )), ) .into_any_element() })(_window, cx) } else { Tooltip::for_action("Send Message", &Chat, cx) } }) .on_click(cx.listener(|this, _, window, cx| { this.send(window, cx); })) .into_any_element() } } fn render_add_context_button(&mut self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.message_editor.focus_handle(cx); let weak_self = cx.weak_entity(); PopoverMenu::new("add-context-menu") .trigger_with_tooltip( IconButton::new("add-context", IconName::Plus) .icon_size(IconSize::Small) .icon_color(Color::Muted), { move |_window, cx| { Tooltip::for_action_in( "Add Context", &OpenAddContextMenu, &focus_handle, cx, ) } }, ) .anchor(gpui::Anchor::BottomLeft) .with_handle(self.add_context_menu_handle.clone()) .offset(gpui::Point { x: px(0.0), y: px(-2.0), }) .menu(move |window, cx| { weak_self .update(cx, |this, cx| this.build_add_context_menu(window, cx)) .ok() }) } fn build_add_context_menu( &self, window: &mut Window, cx: &mut Context, ) -> Entity { let message_editor = self.message_editor.clone(); let workspace = self.workspace.clone(); let session_capabilities = self.session_capabilities.read(); let supports_images = session_capabilities.supports_images(); let supports_embedded_context = session_capabilities.supports_embedded_context(); let has_editor_selection = workspace .upgrade() .and_then(|ws| { ws.read(cx) .active_item(cx) .and_then(|item| item.downcast::()) }) .is_some_and(|editor| { editor.update(cx, |editor, cx| { editor.has_non_empty_selection(&editor.display_snapshot(cx)) }) }); let has_terminal_selection = workspace .upgrade() .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| !panel.read(cx).terminal_selections(cx).is_empty()); let has_selection = has_editor_selection || has_terminal_selection; ContextMenu::build(window, cx, move |menu, _window, _cx| { menu.key_context("AddContextMenu") .header("Context") .item( ContextMenuEntry::new("Files & Directories") .icon(IconName::File) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .handler({ let message_editor = message_editor.clone(); move |window, cx| { message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { editor.insert_context_type("file", window, cx); }); } }), ) .item( ContextMenuEntry::new("Symbols") .icon(IconName::Code) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .handler({ let message_editor = message_editor.clone(); move |window, cx| { message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { editor.insert_context_type("symbol", window, cx); }); } }), ) .item( ContextMenuEntry::new("Threads") .icon(IconName::Thread) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .handler({ let message_editor = message_editor.clone(); move |window, cx| { message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { editor.insert_context_type("thread", window, cx); }); } }), ) .item( ContextMenuEntry::new("Rules") .icon(IconName::Reader) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .handler({ let message_editor = message_editor.clone(); move |window, cx| { message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { editor.insert_context_type("rule", window, cx); }); } }), ) .item( ContextMenuEntry::new("Image") .icon(IconName::Image) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .disabled(!supports_images) .handler({ let message_editor = message_editor.clone(); move |window, cx| { message_editor.focus_handle(cx).focus(window, cx); message_editor.update(cx, |editor, cx| { editor.add_images_from_picker(window, cx); }); } }), ) .item( ContextMenuEntry::new("Selection") .icon(IconName::CursorIBeam) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .disabled(!has_selection) .handler({ move |window, cx| { window.dispatch_action( zed_actions::agent::AddSelectionToThread.boxed_clone(), cx, ); } }), ) .item( ContextMenuEntry::new("Branch Diff") .icon(IconName::GitBranch) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .disabled(!supports_embedded_context) .handler({ move |window, cx| { message_editor.update(cx, |editor, cx| { editor.insert_branch_diff_crease(window, cx); }); } }), ) }) } fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { let following = self.is_following(cx); let tooltip_label = if following { if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { format!("Stop Following the {}", self.agent_id) } else { format!("Stop Following {}", self.agent_id) } } else { if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { format!("Follow the {}", self.agent_id) } else { format!("Follow {}", self.agent_id) } }; IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) .icon_color(Color::Muted) .toggle_state(following) .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) .tooltip(move |_window, cx| { if following { Tooltip::for_action(tooltip_label.clone(), &Follow, cx) } else { Tooltip::with_meta( tooltip_label.clone(), Some(&Follow), "Track the agent's location as it reads and edits files.", cx, ) } }) .on_click(cx.listener(move |this, _, window, cx| { this.toggle_following(window, cx); })) } } struct TokenUsageTooltip { percentage: String, used: String, max: String, input_tokens: String, output_tokens: String, input_max: String, output_max: String, show_split: bool, cost_label: Option, separator_color: Color, user_rules_count: usize, first_user_rules_id: Option, project_rules_count: usize, project_entry_ids: Vec, workspace: WeakEntity, } impl Render for TokenUsageTooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let separator_color = self.separator_color; let percentage = self.percentage.clone(); let used = self.used.clone(); let max = self.max.clone(); let input_tokens = self.input_tokens.clone(); let output_tokens = self.output_tokens.clone(); let input_max = self.input_max.clone(); let output_max = self.output_max.clone(); let show_split = self.show_split; let cost_label = self.cost_label.clone(); let user_rules_count = self.user_rules_count; let first_user_rules_id = self.first_user_rules_id; let project_rules_count = self.project_rules_count; let project_entry_ids = self.project_entry_ids.clone(); let workspace = self.workspace.clone(); ui::tooltip_container(cx, move |container, cx| { container .min_w_40() .child( Label::new("Context") .color(Color::Muted) .size(LabelSize::Small), ) .when(!show_split, |this| { this.child( h_flex() .gap_0p5() .child(Label::new(percentage.clone())) .child(Label::new("\u{2022}").color(separator_color).mx_1()) .child(Label::new(used.clone())) .child(Label::new("/").color(separator_color)) .child(Label::new(max.clone()).color(Color::Muted)), ) }) .when(show_split, |this| { this.child( v_flex() .gap_0p5() .child( h_flex() .gap_0p5() .child(Label::new("Input:").color(Color::Muted).mr_0p5()) .child(Label::new(input_tokens)) .child(Label::new("/").color(separator_color)) .child(Label::new(input_max).color(Color::Muted)), ) .child( h_flex() .gap_0p5() .child(Label::new("Output:").color(Color::Muted).mr_0p5()) .child(Label::new(output_tokens)) .child(Label::new("/").color(separator_color)) .child(Label::new(output_max).color(Color::Muted)), ), ) }) .when_some(cost_label, |this, cost_label| { this.child( v_flex() .mt_1p5() .pt_1p5() .gap_0p5() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Label::new("Cost") .color(Color::Muted) .size(LabelSize::Small), ) .child(Label::new(cost_label)), ) }) .when( user_rules_count > 0 || project_rules_count > 0, move |this| { this.child( v_flex() .mt_1p5() .pt_1p5() .pb_0p5() .gap_0p5() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Label::new("Rules") .color(Color::Muted) .size(LabelSize::Small), ) .child( v_flex() .mx_neg_1() .when(user_rules_count > 0, move |this| { this.child( Button::new( "open-user-rules", format!("{} user rules", user_rules_count), ) .end_icon( Icon::new(IconName::ArrowUpRight) .color(Color::Muted) .size(IconSize::XSmall), ) .on_click(move |_, window, cx| { window.dispatch_action( Box::new(OpenRulesLibrary { prompt_to_select: first_user_rules_id, }), cx, ); }), ) }) .when(project_rules_count > 0, move |this| { let workspace = workspace.clone(); let project_entry_ids = project_entry_ids.clone(); this.child( Button::new( "open-project-rules", format!( "{} project rules", project_rules_count ), ) .end_icon( Icon::new(IconName::ArrowUpRight) .color(Color::Muted) .size(IconSize::XSmall), ) .on_click(move |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| { let project = workspace.project().read(cx); let paths = project_entry_ids .iter() .flat_map(|id| { project.path_for_entry(*id, cx) }) .collect::>(); for path in paths { workspace .open_path( path, None, true, window, cx, ) .detach_and_log_err(cx); } }); }), ) }), ), ) }, ) }) } } impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { let max_content_width = AgentSettings::get_global(cx).max_content_width; let centered_container = move |content: AnyElement| { h_flex().w_full().justify_center().child( div() .when_some(max_content_width, |this, max_w| this.max_w(max_w)) .w_full() .child(content), ) }; list( self.list_state.clone(), cx.processor(move |this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); if let Some(entry) = entries.get(index) { let rendered = this.render_entry(index, entries.len(), entry, window, cx); centered_container(rendered.into_any_element()).into_any_element() } else if this.generating_indicator_in_list { let confirmation = entries .last() .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); let rendered = this.render_generating(confirmation, cx); centered_container(rendered.into_any_element()).into_any_element() } else { Empty.into_any() } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) .flex_grow() } fn render_entry( &self, entry_ix: usize, total_entries: usize, entry: &AgentThreadEntry, window: &Window, cx: &Context, ) -> AnyElement { let is_indented = entry.is_indented(); let is_first_indented = is_indented && self .thread .read(cx) .entries() .get(entry_ix.saturating_sub(1)) .is_none_or(|entry| !entry.is_indented()); let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.message_editor()) .cloned() else { return Empty.into_any_element(); }; let editing = self.editing_message == Some(entry_ix); let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; let has_checkpoint_button = message .checkpoint .as_ref() .is_some_and(|checkpoint| checkpoint.show); let is_subagent = self.is_subagent(); let can_rewind = self.thread.read(cx).supports_truncate(cx); let is_editable = can_rewind && message.id.is_some() && !is_subagent; let agent_name = if is_subagent { "subagents".into() } else { self.agent_id.clone() }; v_flex() .id(("user_message", entry_ix)) .map(|this| { if is_first_indented { this.pt_0p5() } else { this.pt_2() } }) .pb_3() .px_2() .gap_1p5() .w_full() .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() .px_3() .gap_2() .child(Divider::horizontal()) .child( Button::new("restore-checkpoint", "Restore Checkpoint") .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted)) .label_size(LabelSize::XSmall) .color(Color::Muted) .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) .on_click(cx.listener(move |this, _, _window, cx| { this.restore_checkpoint(&message_id, cx); })) ) .child(Divider::horizontal()) })) }) .child( div() .relative() .child( div() .py_3() .px_2() .rounded_md() .bg(cx.theme().colors().editor_background) .border_1() .when(is_indented, |this| { this.py_2().px_2().shadow_sm() }) .border_color(cx.theme().colors().border) .map(|this| { if !is_editable { if is_subagent { return this.border_dashed(); } return this; } if editing && editor_focus { return this.border_color(focus_border); } if editing && !editor_focus { return this.border_dashed() } this.shadow_md().hover(|s| { s.border_color(focus_border.opacity(0.8)) }) }) .text_xs() .child(editor.clone().into_any_element()) ) .when(editor_focus, |this| { let base_container = h_flex() .absolute() .top_neg_3p5() .right_3() .gap_1() .rounded_sm() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background) .overflow_hidden(); let is_loading_contents = self.is_loading_contents; if is_editable { this.child( base_container .child( IconButton::new("cancel", IconName::Close) .disabled(is_loading_contents) .icon_color(Color::Error) .icon_size(IconSize::XSmall) .on_click(cx.listener(Self::cancel_editing)) ) .child( if is_loading_contents { div() .id("loading-edited-message-content") .tooltip(Tooltip::text("Loading Added Context…")) .child(loading_contents_spinner(IconSize::XSmall)) .into_any_element() } else { IconButton::new("regenerate", IconName::Return) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .tooltip(Tooltip::text( "Editing will restart the thread from this point." )) .on_click(cx.listener({ let editor = editor.clone(); move |this, _, window, cx| { this.regenerate( entry_ix, editor.clone(), window, cx, ); } })).into_any_element() } ) ) } else { this.child( base_container .border_dashed() .child(IconButton::new("non_editable", IconName::PencilUnavailable) .icon_size(IconSize::Small) .icon_color(Color::Muted) .style(ButtonStyle::Transparent) .tooltip(Tooltip::element({ let agent_name = agent_name.clone(); move |_, _| { v_flex() .gap_1() .child(Label::new("Unavailable Editing")) .child( div().max_w_64().child( Label::new(format!( "Editing previous messages is not available for {} yet.", agent_name )) .size(LabelSize::Small) .color(Color::Muted), ), ) .into_any_element() } }))), ) } }), ) .into_any() } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks, indented: _, is_subagent_output: _, }) => { let mut is_blank = true; let is_last = entry_ix + 1 == total_entries; let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); let message_body = v_flex() .w_full() .gap_3() .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { block.markdown().and_then(|md| { let this_is_blank = md.read(cx).source().trim().is_empty(); is_blank = is_blank && this_is_blank; if this_is_blank { return None; } Some( self.render_markdown(md.clone(), style.clone(), cx) .into_any_element(), ) }) } AssistantMessageChunk::Thought { block } => { block.markdown().and_then(|md| { let this_is_blank = md.read(cx).source().trim().is_empty(); is_blank = is_blank && this_is_blank; if this_is_blank { return None; } Some( self.render_thinking_block( entry_ix, chunk_ix, md.clone(), window, cx, ) .into_any_element(), ) }) } }, )) .into_any(); if is_blank { Empty.into_any() } else { v_flex() .px_5() .py_1p5() .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) .child(self.render_message_context_menu(entry_ix, message_body, cx)) .when_some( self.entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.focus_handle(cx)), |this, handle| this.track_focus(&handle), ) .into_any() } } AgentThreadEntry::ToolCall(tool_call) => { let tool_call = self.render_any_tool_call( self.thread.read(cx).session_id(), entry_ix, tool_call, &self.focus_handle(cx), false, window, cx, ); if let Some(handle) = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.focus_handle(cx)) { tool_call.track_focus(&handle).into_any() } else { tool_call.into_any() } } AgentThreadEntry::CompletedPlan(entries) => { self.render_completed_plan(entries, window, cx) } }; let is_subagent_output = self.is_subagent() && matches!(entry, AgentThreadEntry::AssistantMessage(msg) if msg.is_subagent_output); let primary = if is_subagent_output { v_flex() .w_full() .child( h_flex() .id("subagent_output") .px_5() .py_1() .gap_2() .child(Divider::horizontal()) .child( h_flex() .gap_1() .child( Icon::new(IconName::ForwardArrowUp) .color(Color::Muted) .size(IconSize::Small), ) .child( Label::new("Subagent Output") .size(LabelSize::Custom(self.tool_name_font_size())) .color(Color::Muted), ), ) .child(Divider::horizontal()) .tooltip(Tooltip::text("Everything below this line was sent as output from this subagent to the main agent.")), ) .child(primary) .into_any_element() } else { primary }; let thread = self.thread.clone(); let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) } else { rems_from_px(0.0) }; div() .relative() .w_full() .pl_5() .bg(cx.theme().colors().panel_background.opacity(0.2)) .child( div() .absolute() .left(rems_from_px(18.0)) .top(line_top) .bottom_0() .w_px() .bg(cx.theme().colors().border.opacity(0.6)), ) .child(primary) .into_any_element() } else { primary }; let needs_confirmation = Self::is_waiting_for_confirmation(entry); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) .when(!needs_confirmation, |this| { this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) }) .into_any_element() } else { primary }; if let Some(editing_index) = self.editing_message && editing_index < entry_ix { let is_subagent = self.is_subagent(); let backdrop = div() .id(("backdrop", entry_ix)) .size_full() .absolute() .inset_0() .bg(cx.theme().colors().panel_background) .opacity(0.8) .block_mouse_except_scroll() .on_click(cx.listener(Self::cancel_editing)); div() .relative() .child(primary) .when(!is_subagent, |this| this.child(backdrop)) .into_any_element() } else { primary } } fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { h_flex() .key_context("AgentFeedbackMessageEditor") .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { this.thread_feedback.dismiss_comments(); cx.notify(); })) .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { this.submit_feedback_message(cx); })) .p_2() .mb_2() .mx_5() .gap_1() .rounded_md() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background) .child(div().w_full().child(editor)) .child( h_flex() .child( IconButton::new("dismiss-feedback-message", IconName::Close) .icon_color(Color::Error) .icon_size(IconSize::XSmall) .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.thread_feedback.dismiss_comments(); cx.notify(); })), ) .child( IconButton::new("submit-feedback-message", IconName::Return) .icon_size(IconSize::XSmall) .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.submit_feedback_message(cx); })), ), ) } fn render_thread_controls( &self, thread: &Entity, cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Open Thread as Markdown")) .on_click(cx.listener(move |this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { this.open_thread_as_markdown(workspace, window, cx) .detach_and_log_err(cx); } })); let scroll_to_recent_user_prompt = IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) .on_click(cx.listener(move |this, _, _, cx| { this.scroll_to_most_recent_user_prompt(cx); })); let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Scroll To Top")) .on_click(cx.listener(move |this, _, _, cx| { this.scroll_to_top(cx); })); let show_stats = AgentSettings::get_global(cx).show_turn_stats; let last_turn_clock = show_stats .then(|| { self.turn_fields .last_turn_duration .filter(|&duration| duration > STOPWATCH_THRESHOLD) .map(|duration| { Label::new(duration_alt_display(duration)) .size(LabelSize::Small) .color(Color::Muted) }) }) .flatten(); let last_turn_tokens_label = last_turn_clock .is_some() .then(|| { self.turn_fields .last_turn_tokens .filter(|&tokens| tokens > TOKEN_THRESHOLD) .map(|tokens| { Label::new(format!("{} tokens", crate::humanize_token_count(tokens))) .size(LabelSize::Small) .color(Color::Muted) }) }) .flatten(); let mut container = h_flex() .w_full() .py_2() .px_5() .gap_px() .opacity(0.6) .hover(|s| s.opacity(1.)) .justify_end() .when( last_turn_tokens_label.is_some() || last_turn_clock.is_some(), |this| { this.child( h_flex() .gap_1() .px_1() .when_some(last_turn_tokens_label, |this, label| this.child(label)) .when_some(last_turn_clock, |this, label| this.child(label)), ) }, ); let enable_thread_feedback = util::maybe!({ let project = thread.read(cx).project().read(cx); let user_store = project.user_store(); if let Some(configuration) = user_store.read(cx).current_organization_configuration() { if !configuration.is_agent_thread_feedback_enabled { return false; } } AgentSettings::get_global(cx).enable_feedback && self.thread.read(cx).connection().telemetry().is_some() }); if enable_thread_feedback { let feedback = self.thread_feedback.feedback; let tooltip_meta = || { SharedString::new( "Rating the thread sends all of your current conversation to the Zed team.", ) }; container = container .child( IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(match feedback { Some(ThreadFeedback::Positive) => Color::Accent, _ => Color::Ignored, }) .tooltip(move |window, cx| match feedback { Some(ThreadFeedback::Positive) => { Tooltip::text("Thanks for your feedback!")(window, cx) } _ => { Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) } }) .on_click(cx.listener(move |this, _, window, cx| { this.handle_feedback_click(ThreadFeedback::Positive, window, cx); })), ) .child( IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(match feedback { Some(ThreadFeedback::Negative) => Color::Accent, _ => Color::Ignored, }) .tooltip(move |window, cx| match feedback { Some(ThreadFeedback::Negative) => { Tooltip::text( "We appreciate your feedback and will use it to improve in the future.", )(window, cx) } _ => { Tooltip::with_meta( "Not Helpful Response", None, tooltip_meta(), cx, ) } }) .on_click(cx.listener(move |this, _, window, cx| { this.handle_feedback_click(ThreadFeedback::Negative, window, cx); })), ); } if let Some(project) = self.project.upgrade() && let Some(server_view) = self.server_view.upgrade() && cx.has_flag::() && project.read(cx).client().status().borrow().is_connected() { let button = if self.is_imported_thread(cx) { IconButton::new("sync-thread", IconName::ArrowCircle) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Sync with source thread")) .on_click(cx.listener(move |this, _, window, cx| { this.sync_thread(project.clone(), server_view.clone(), window, cx); })) } else { IconButton::new("share-thread", IconName::ArrowUpRight) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) .icon_color(Color::Ignored) .tooltip(Tooltip::text("Share Thread")) .on_click(cx.listener(move |this, _, window, cx| { this.share_thread(window, cx); })) }; container = container.child(button); } container .child(open_as_markdown) .child(scroll_to_recent_user_prompt) .child(scroll_to_top) .into_any_element() } pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { let entries = self.thread.read(cx).entries(); if entries.is_empty() { return; } // Find the most recent user message and scroll it to the top of the viewport. // (Fallback: if no user message exists, scroll to the bottom.) if let Some(ix) = entries .iter() .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) { self.list_state.scroll_to(ListOffset { item_ix: ix, offset_in_item: px(0.0), }); cx.notify(); } else { self.scroll_to_end(cx); } } pub fn scroll_to_end(&mut self, cx: &mut Context) { self.list_state.scroll_to_end(); cx.notify(); } fn handle_feedback_click( &mut self, feedback: ThreadFeedback, window: &mut Window, cx: &mut Context, ) { self.thread_feedback .submit(self.thread.clone(), feedback, window, cx); cx.notify(); } fn submit_feedback_message(&mut self, cx: &mut Context) { let thread = self.thread.clone(); self.thread_feedback.submit_comments(thread, cx); cx.notify(); } pub(crate) fn scroll_to_top(&mut self, cx: &mut Context) { self.list_state.scroll_to(ListOffset::default()); cx.notify(); } fn scroll_output_page_up( &mut self, _: &ScrollOutputPageUp, _window: &mut Window, cx: &mut Context, ) { let page_height = self.list_state.viewport_bounds().size.height; self.list_state.scroll_by(-page_height * 0.9); cx.notify(); } fn scroll_output_page_down( &mut self, _: &ScrollOutputPageDown, _window: &mut Window, cx: &mut Context, ) { let page_height = self.list_state.viewport_bounds().size.height; self.list_state.scroll_by(page_height * 0.9); cx.notify(); } fn scroll_output_line_up( &mut self, _: &ScrollOutputLineUp, window: &mut Window, cx: &mut Context, ) { self.list_state.scroll_by(-window.line_height() * 3.); cx.notify(); } fn scroll_output_line_down( &mut self, _: &ScrollOutputLineDown, window: &mut Window, cx: &mut Context, ) { self.list_state.scroll_by(window.line_height() * 3.); cx.notify(); } fn scroll_output_to_top( &mut self, _: &ScrollOutputToTop, _window: &mut Window, cx: &mut Context, ) { self.scroll_to_top(cx); } fn scroll_output_to_bottom( &mut self, _: &ScrollOutputToBottom, _window: &mut Window, cx: &mut Context, ) { self.scroll_to_end(cx); } fn scroll_output_to_previous_message( &mut self, _: &ScrollOutputToPreviousMessage, _window: &mut Window, cx: &mut Context, ) { let entries = self.thread.read(cx).entries(); let current_ix = self.list_state.logical_scroll_top().item_ix; if let Some(target_ix) = (0..current_ix) .rev() .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) { self.list_state.scroll_to(ListOffset { item_ix: target_ix, offset_in_item: px(0.), }); cx.notify(); } } fn scroll_output_to_next_message( &mut self, _: &ScrollOutputToNextMessage, _window: &mut Window, cx: &mut Context, ) { let entries = self.thread.read(cx).entries(); let current_ix = self.list_state.logical_scroll_top().item_ix; if let Some(target_ix) = (current_ix + 1..entries.len()) .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) { self.list_state.scroll_to(ListOffset { item_ix: target_ix, offset_in_item: px(0.), }); cx.notify(); } } pub fn open_thread_as_markdown( &self, workspace: Entity, window: &mut Window, cx: &mut App, ) -> Task> { let markdown_language_task = workspace .read(cx) .app_state() .languages .language_for_name("Markdown"); let thread = self.thread.read(cx); let thread_title = thread .title() .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()) .to_string(); let markdown = thread.to_markdown(cx); let project = workspace.read(cx).project().clone(); window.spawn(cx, async move |cx| { let markdown_language = markdown_language_task.await?; let buffer = project .update(cx, |project, cx| { project.create_buffer(Some(markdown_language), false, cx) }) .await?; buffer.update(cx, |buffer, cx| { buffer.set_text(markdown, cx); buffer.set_capability(language::Capability::ReadWrite, cx); }); workspace.update_in(cx, |workspace, window, cx| { let buffer = cx .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone())); workspace.add_item_to_active_pane( Box::new(cx.new(|cx| { let mut editor = Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); editor.set_breadcrumb_header(thread_title); editor.disable_mouse_wheel_zoom(); editor })), None, true, window, cx, ); })?; anyhow::Ok(()) }) } pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { let has_messages = self.list_state.item_count() > 0; let v2_empty_state = !has_messages; let mode = if v2_empty_state { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::Default, } } else { EditorMode::AutoHeight { min_lines: AgentSettings::get_global(cx).message_editor_min_lines, max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), } }; self.message_editor.update(cx, |editor, cx| { editor.set_mode(mode, cx); }); } /// Ensures the list item count includes (or excludes) an extra item for the generating indicator pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); if is_generating && !self.generating_indicator_in_list { let entries_count = self.thread.read(cx).entries().len(); self.list_state.splice(entries_count..entries_count, 1); self.generating_indicator_in_list = true; } else if !is_generating && self.generating_indicator_in_list { let entries_count = self.thread.read(cx).entries().len(); self.list_state.splice(entries_count..entries_count + 1, 0); self.generating_indicator_in_list = false; } } fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats .then(|| { self.turn_fields.turn_started_at.and_then(|started_at| { let elapsed = started_at.elapsed(); (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed)) }) }) .flatten(); let is_blocked_on_terminal_command = !confirmation && self.is_blocked_on_terminal_command(cx); let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls(); let turn_tokens_label = elapsed_label .is_some() .then(|| { self.turn_fields .turn_tokens .filter(|&tokens| tokens > TOKEN_THRESHOLD) .map(|tokens| crate::humanize_token_count(tokens)) }) .flatten(); let arrow_icon = if is_waiting { IconName::ArrowUp } else { IconName::ArrowDown }; h_flex() .id("generating-spinner") .py_2() .px(rems_from_px(22.)) .gap_2() .map(|this| { if confirmation { this.child( h_flex() .w_2() .justify_center() .child(GeneratingSpinnerElement::new(SpinnerVariant::Sand)), ) .child( div().min_w(rems(8.)).child( LoadingLabel::new("Awaiting Confirmation") .size(LabelSize::Small) .color(Color::Muted), ), ) } else if is_blocked_on_terminal_command { this } else { this.child( h_flex() .w_2() .justify_center() .child(GeneratingSpinnerElement::new(SpinnerVariant::Dots)), ) } }) .when_some(elapsed_label, |this, elapsed| { this.child( Label::new(elapsed) .size(LabelSize::Small) .color(Color::Muted), ) }) .when_some(turn_tokens_label, |this, tokens| { this.child( h_flex() .gap_0p5() .child( Icon::new(arrow_icon) .size(IconSize::XSmall) .color(Color::Muted), ) .child( Label::new(format!("{} tokens", tokens)) .size(LabelSize::Small) .color(Color::Muted), ), ) }) .into_any_element() } pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { let thinking_display = AgentSettings::get_global(cx).thinking_display; if !matches!( thinking_display, ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview ) { return; } let key = { let thread = self.thread.read(cx); if thread.status() != ThreadStatus::Generating { return; } let entries = thread.entries(); let last_ix = entries.len().saturating_sub(1); match entries.get(last_ix) { Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() { Some(AssistantMessageChunk::Thought { .. }) => { Some((last_ix, msg.chunks.len() - 1)) } _ => None, }, _ => None, } }; if let Some(key) = key { if self.auto_expanded_thinking_block != Some(key) { self.auto_expanded_thinking_block = Some(key); self.expanded_thinking_blocks.insert(key); cx.notify(); } } else if self.auto_expanded_thinking_block.is_some() { if thinking_display == ThinkingBlockDisplay::Auto { if let Some(key) = self.auto_expanded_thinking_block { if !self.user_toggled_thinking_blocks.contains(&key) { self.expanded_thinking_blocks.remove(&key); } } } self.auto_expanded_thinking_block = None; cx.notify(); } } pub(crate) fn clear_auto_expand_tracking(&mut self) { self.auto_expanded_thinking_block = None; } fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context) { let thinking_display = AgentSettings::get_global(cx).thinking_display; match thinking_display { ThinkingBlockDisplay::Auto => { let is_open = self.expanded_thinking_blocks.contains(&key) || self.user_toggled_thinking_blocks.contains(&key); if is_open { self.expanded_thinking_blocks.remove(&key); self.user_toggled_thinking_blocks.remove(&key); } else { self.expanded_thinking_blocks.insert(key); self.user_toggled_thinking_blocks.insert(key); } } ThinkingBlockDisplay::Preview => { let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key); let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); if is_user_expanded { self.user_toggled_thinking_blocks.remove(&key); self.expanded_thinking_blocks.remove(&key); } else if is_in_expanded_set { self.user_toggled_thinking_blocks.insert(key); } else { self.expanded_thinking_blocks.insert(key); self.user_toggled_thinking_blocks.insert(key); } } ThinkingBlockDisplay::AlwaysExpanded => { if self.user_toggled_thinking_blocks.contains(&key) { self.user_toggled_thinking_blocks.remove(&key); } else { self.user_toggled_thinking_blocks.insert(key); } } ThinkingBlockDisplay::AlwaysCollapsed => { if self.user_toggled_thinking_blocks.contains(&key) { self.user_toggled_thinking_blocks.remove(&key); self.expanded_thinking_blocks.remove(&key); } else { self.expanded_thinking_blocks.insert(key); self.user_toggled_thinking_blocks.insert(key); } } } cx.notify(); } fn render_thinking_block( &self, entry_ix: usize, chunk_ix: usize, chunk: Entity, window: &Window, cx: &Context, ) -> AnyElement { let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-card-header"); let key = (entry_ix, chunk_ix); let thinking_display = AgentSettings::get_global(cx).thinking_display; let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key); let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); let (is_open, is_constrained) = match thinking_display { ThinkingBlockDisplay::Auto => { let is_open = is_user_toggled || is_in_expanded_set; (is_open, false) } ThinkingBlockDisplay::Preview => { let is_open = is_user_toggled || is_in_expanded_set; let is_constrained = is_in_expanded_set && !is_user_toggled; (is_open, is_constrained) } ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false), ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false), }; let should_auto_scroll = self.auto_expanded_thinking_block == Some(key); let scroll_handle = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); if should_auto_scroll { if let Some(ref handle) = scroll_handle { handle.scroll_to_bottom(); } } let panel_bg = cx.theme().colors().panel_background; v_flex() .gap_1() .child( h_flex() .id(header_id) .group(&card_header_id) .relative() .w_full() .pr_1() .justify_between() .child( h_flex() .h(window.line_height() - px(2.)) .gap_1p5() .overflow_hidden() .child( Icon::new(IconName::ToolThink) .size(IconSize::Small) .color(Color::Muted), ) .child( div() .text_size(self.tool_name_font_size()) .text_color(cx.theme().colors().text_muted) .child("Thinking"), ), ) .child( Disclosure::new(("expand", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) .on_click(cx.listener( move |this, _event: &ClickEvent, _window, cx| { this.toggle_thinking_block_expansion(key, cx); }, )), ) .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { this.toggle_thinking_block_expansion(key, cx); })), ) .when(is_open, |this| { this.child( div() .when(is_constrained, |this| this.relative()) .child( div() .id(("thinking-content", chunk_ix)) .ml_1p5() .pl_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) .when(is_constrained, |this| this.max_h_64()) .when_some(scroll_handle, |this, scroll_handle| { this.track_scroll(&scroll_handle) }) .overflow_hidden() .child(self.render_markdown( chunk, MarkdownStyle::themed(MarkdownFont::Agent, window, cx), cx, )), ) .when(is_constrained, |this| { this.child( div() .absolute() .inset_0() .size_full() .bg(linear_gradient( 180., linear_color_stop(panel_bg.opacity(0.8), 0.), linear_color_stop(panel_bg.opacity(0.), 0.1), )) .block_mouse_except_scroll(), ) }), ) }) .into_any_element() } fn render_message_context_menu( &self, entry_ix: usize, message_body: AnyElement, cx: &Context, ) -> AnyElement { let entity = cx.entity(); let workspace = self.workspace.clone(); right_click_menu(format!("agent_context_menu-{}", entry_ix)) .trigger(move |_, _, _| message_body) .menu(move |window, cx| { let focus = window.focused(cx); let entity = entity.clone(); let workspace = workspace.clone(); ContextMenu::build(window, cx, move |menu, _, cx| { let this = entity.read(cx); let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; let chunks = this.thread.read(cx).entries().get(entry_ix).and_then( |entry| match &entry { AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), _ => None, }, ); let has_selection = chunks .map(|chunks| { chunks.iter().any(|chunk| { let md = match chunk { AssistantMessageChunk::Message { block } => block.markdown(), AssistantMessageChunk::Thought { block } => block.markdown(), }; md.map_or(false, |m| m.read(cx).selected_text().is_some()) }) }) .unwrap_or(false); let context_menu_link = chunks.and_then(|chunks| { chunks.iter().find_map(|chunk| { let md = match chunk { AssistantMessageChunk::Message { block } => block.markdown(), AssistantMessageChunk::Thought { block } => block.markdown(), }; md.and_then(|m| m.read(cx).context_menu_link().cloned()) }) }); let copy_this_agent_response = ContextMenuEntry::new("Copy This Agent Response").handler({ let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { let entries = this.thread.read(cx).entries(); if let Some(text) = Self::get_agent_message_content(entries, entry_ix, cx) { cx.write_to_clipboard(ClipboardItem::new_string(text)); } }); } }); let scroll_item = if is_at_top { ContextMenuEntry::new("Scroll to Bottom").handler({ let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { this.scroll_to_end(cx); }); } }) } else { ContextMenuEntry::new("Scroll to Top").handler({ let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { this.scroll_to_top(cx); }); } }) }; let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown") .handler({ let entity = entity.clone(); let workspace = workspace.clone(); move |window, cx| { if let Some(workspace) = workspace.upgrade() { entity .update(cx, |this, cx| { this.open_thread_as_markdown(workspace, window, cx) }) .detach_and_log_err(cx); } } }); menu.when_some(focus, |menu, focus| menu.context(focus)) .when_some(context_menu_link, |menu, url| { menu.entry("Copy Link", None, move |_, cx| { cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); }) .separator() }) .action_disabled_when( !has_selection, "Copy Selection", Box::new(markdown::CopyAsMarkdown), ) .item(copy_this_agent_response) .separator() .item(scroll_item) .item(open_thread_as_markdown) }) }) .into_any_element() } fn get_agent_message_content( entries: &[AgentThreadEntry], entry_index: usize, cx: &App, ) -> Option { let entry = entries.get(entry_index)?; if matches!(entry, AgentThreadEntry::UserMessage(_)) { return None; } let start_index = (0..entry_index) .rev() .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) .map(|i| i + 1) .unwrap_or(0); let end_index = (entry_index + 1..entries.len()) .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) .map(|i| i - 1) .unwrap_or(entries.len() - 1); let parts: Vec = (start_index..=end_index) .filter_map(|i| entries.get(i)) .filter_map(|entry| { if let AgentThreadEntry::AssistantMessage(message) = entry { let text: String = message .chunks .iter() .filter_map(|chunk| match chunk { AssistantMessageChunk::Message { block } => { let markdown = block.to_markdown(cx); if markdown.trim().is_empty() { None } else { Some(markdown.to_string()) } } AssistantMessageChunk::Thought { .. } => None, }) .collect::>() .join("\n\n"); if text.is_empty() { None } else { Some(text) } } else { None } }) .collect(); let text = parts.join("\n\n"); if text.is_empty() { None } else { Some(text) } } fn is_blocked_on_terminal_command(&self, cx: &App) -> bool { let thread = self.thread.read(cx); if !matches!(thread.status(), ThreadStatus::Generating) { return false; } let mut has_running_terminal_call = false; for entry in thread.entries().iter().rev() { match entry { AgentThreadEntry::UserMessage(_) => break, AgentThreadEntry::ToolCall(tool_call) if matches!( tool_call.status, ToolCallStatus::InProgress | ToolCallStatus::Pending ) => { if matches!(tool_call.kind, acp::ToolKind::Execute) { has_running_terminal_call = true; } else { return false; } } AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) => {} } } has_running_terminal_call } fn render_collapsible_command( &self, group: SharedString, is_preview: bool, command: Entity, window: &Window, cx: &Context, ) -> Div { // The label's markdown source is a fenced code block (```\n...\n```); // strip the fences so the copy button yields just the command text. let command_source = command.read(cx).source(); let command_text = command_source .strip_prefix("```\n") .and_then(|s| s.strip_suffix("\n```")) .unwrap_or(&command_source) .to_string(); let mut style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_buffer_font(cx); style.container_style.text.font_size = Some(rems_from_px(12.).into()); style.container_style.text.line_height = Some(rems_from_px(17.).into()); style.height_is_multiple_of_line_height = true; let header_bg = self.tool_card_header_bg(cx); let run_command_label = if is_preview { Some( h_flex().h_6().child( Label::new("Run Command") .buffer_font(cx) .size(LabelSize::XSmall) .color(Color::Muted), ), ) } else { None }; // Suppress the code block's built-in copy button so we don't stack two // copy buttons on top of each other; the outer button below is the one // we want, because it copies the unfenced command text. let markdown_element = self .render_markdown(command, style, cx) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, border: false, }); let copy_button = CopyButton::new("copy-command", command_text) .tooltip_label("Copy Command") .visible_on_hover(group.clone()); v_flex() .group(group) .relative() .p_1p5() .bg(header_bg) .when(is_preview, |this| this.pt_1().children(run_command_label)) .child(markdown_element) .child(div().absolute().top_1().right_1().child(copy_button)) } fn render_terminal_tool_call( &self, active_session_id: &acp::SessionId, entry_ix: usize, terminal: &Entity, tool_call: &ToolCall, focus_handle: &FocusHandle, is_subagent: bool, window: &Window, cx: &Context, ) -> AnyElement { let terminal_data = terminal.read(cx); let working_dir = terminal_data.working_dir(); let started_at = terminal_data.started_at(); let tool_failed = matches!( &tool_call.status, ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed ); let confirmation_options = match &tool_call.status { ToolCallStatus::WaitingForConfirmation { options, .. } => Some(options), _ => None, }; let needs_confirmation = confirmation_options.is_some(); let output = terminal_data.output(); let command_finished = output.is_some() && !matches!( tool_call.status, ToolCallStatus::InProgress | ToolCallStatus::Pending ); let truncated_output = output.is_some_and(|output| output.original_content_len > output.content.len()); let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); let command_failed = command_finished && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); let time_elapsed = if let Some(output) = output { output.ended_at.duration_since(started_at) } else { started_at.elapsed() }; let header_id = SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); let header_group = SharedString::from(format!( "terminal-tool-header-group-{}", terminal.entity_id() )); let header_bg = cx .theme() .colors() .element_background .blend(cx.theme().colors().editor_foreground.opacity(0.025)); let border_color = cx.theme().colors().border.opacity(0.6); let working_dir = working_dir .as_ref() .map(|path| path.display().to_string()) .unwrap_or_else(|| "current directory".to_string()); let command_element = self.render_collapsible_command( header_group.clone(), false, tool_call.label.clone(), window, cx, ); let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() .id(header_id) .pt_1() .pl_1p5() .pr_1() .flex_none() .gap_1() .justify_between() .rounded_t_md() .child( div() .id(("command-target-path", terminal.entity_id())) .w_full() .max_w_full() .overflow_x_scroll() .child( Label::new(working_dir) .buffer_font(cx) .size(LabelSize::XSmall) .color(Color::Muted), ), ) .child( Disclosure::new( SharedString::from(format!( "terminal-tool-disclosure-{}", terminal.entity_id() )), is_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&header_group) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this, _event, _window, cx| { if is_expanded { this.expanded_tool_calls.remove(&id); } else { this.expanded_tool_calls.insert(id.clone()); } cx.notify(); } })), ) .when(time_elapsed > Duration::from_secs(10), |header| { header.child( Label::new(format!("({})", duration_alt_display(time_elapsed))) .buffer_font(cx) .color(Color::Muted) .size(LabelSize::XSmall), ) }) .when(!command_finished && !needs_confirmation, |header| { header .gap_1p5() .child( Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Muted) .with_rotate_animation(2) ) .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border))) .child( IconButton::new( SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), IconName::Stop ) .icon_size(IconSize::Small) .icon_color(Color::Error) .tooltip(move |_window, cx| { Tooltip::with_meta( "Stop This Command", None, "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", cx, ) }) .on_click({ let terminal = terminal.clone(); cx.listener(move |this, _event, _window, cx| { terminal.update(cx, |terminal, cx| { terminal.stop_by_user(cx); }); if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop { this.cancel_generation(cx); } }) }), ) }) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { format!("Output exceeded terminal max lines and was \ truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) } else { format!( "Output is {} long, and to avoid unexpected token usage, \ only {} was sent back to the agent.", format_file_size(output.original_content_len as u64, true), format_file_size(output.content.len() as u64, true) ) } } else { "Output was truncated".to_string() }; header.child( h_flex() .id(("terminal-tool-truncated-label", terminal.entity_id())) .gap_1() .child( Icon::new(IconName::Info) .size(IconSize::XSmall) .color(Color::Ignored), ) .child( Label::new("Truncated") .color(Color::Muted) .size(LabelSize::XSmall), ) .tooltip(Tooltip::text(tooltip)), ) }) .when(tool_failed || command_failed, |header| { header.child( div() .id(("terminal-tool-error-code-indicator", terminal.entity_id())) .child( Icon::new(IconName::Close) .size(IconSize::Small) .color(Color::Error), ) .when_some(output.and_then(|o| o.exit_status), |this, status| { this.tooltip(Tooltip::text(format!( "Exited with code {}", status.code().unwrap_or(-1), ))) }), ) }) ; let terminal_view = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.terminal(terminal)); v_flex() .when(!is_subagent, |this| { this.my_1p5() .mx_5() .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) .rounded_md() }) .overflow_hidden() .child( v_flex() .group(&header_group) .bg(header_bg) .text_xs() .child(header) .child(command_element), ) .when(is_expanded && terminal_view.is_some(), |this| { this.child( div() .pt_2() .border_t_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) .bg(cx.theme().colors().editor_background) .rounded_b_md() .text_ui_sm(cx) .h_full() .children(terminal_view.map(|terminal_view| { let element = if terminal_view .read(cx) .content_mode(window, cx) .is_scrollable() { div().h_72().child(terminal_view).into_any_element() } else { terminal_view.into_any_element() }; div() .on_action(cx.listener(|_this, _: &NewTerminal, window, cx| { window.dispatch_action(NewThread.boxed_clone(), cx); cx.stop_propagation(); })) .child(element) .into_any_element() })), ) }) .when_some(confirmation_options, |this, options| { let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx); this.child(self.render_permission_buttons( self.thread.read(cx).session_id().clone(), is_first, options, entry_ix, tool_call.id.clone(), focus_handle, cx, )) }) .into_any() } fn is_first_tool_call( &self, active_session_id: &acp::SessionId, tool_call_id: &acp::ToolCallId, cx: &App, ) -> bool { self.conversation .read(cx) .pending_tool_call(active_session_id, cx) .map_or(false, |(pending_session_id, pending_tool_call_id, _)| { self.thread.read(cx).session_id() == &pending_session_id && tool_call_id == &pending_tool_call_id }) } fn render_any_tool_call( &self, active_session_id: &acp::SessionId, entry_ix: usize, tool_call: &ToolCall, focus_handle: &FocusHandle, is_subagent: bool, window: &Window, cx: &Context, ) -> Div { let has_terminals = tool_call.terminals().next().is_some(); div().w_full().map(|this| { if tool_call.is_subagent() { this.child( self.render_subagent_tool_call( active_session_id, entry_ix, tool_call, tool_call .subagent_session_info .as_ref() .map(|i| i.session_id.clone()), focus_handle, window, cx, ), ) } else if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( active_session_id, entry_ix, terminal, tool_call, focus_handle, is_subagent, window, cx, ) })) } else { this.child(self.render_tool_call( active_session_id, entry_ix, tool_call, focus_handle, is_subagent, window, cx, )) } }) } fn render_tool_call( &self, active_session_id: &acp::SessionId, entry_ix: usize, tool_call: &ToolCall, focus_handle: &FocusHandle, is_subagent: bool, window: &Window, cx: &Context, ) -> Div { let has_location = tool_call.locations.len() == 1; let card_header_id = SharedString::from("inner-tool-call-header"); let failed_or_canceled = match &tool_call.status { ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, _ => false, }; let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } ); let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute); let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); let (has_revealed_diff, tool_call_output_focus, tool_call_output_focus_handle) = tool_call .diffs() .next() .and_then(|diff| { let editor = self .entry_view_state .read(cx) .entry(entry_ix) .and_then(|entry| entry.editor_for_diff(diff))?; let has_revealed_diff = diff.read(cx).has_revealed_range(cx); let has_focus = editor.read(cx).is_focused(window); let focus_handle = editor.focus_handle(cx); Some((has_revealed_diff, has_focus, focus_handle)) }) .unwrap_or_else(|| (false, false, focus_handle.clone())); let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; let mut is_open = self.expanded_tool_calls.contains(&tool_call.id); is_open |= needs_confirmation; let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; let input_output_header = |label: SharedString| { Label::new(label) .size(LabelSize::XSmall) .color(Color::Muted) .buffer_font(cx) }; let tool_output_display = if is_open { match &tool_call.status { ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() .w_full() .children( tool_call .content .iter() .enumerate() .map(|(content_ix, content)| { div() .child(self.render_tool_call_content( active_session_id, entry_ix, content, content_ix, tool_call, use_card_layout, has_image_content, failed_or_canceled, focus_handle, window, cx, )) .into_any_element() }), ) .when(should_show_raw_input, |this| { let is_raw_input_expanded = self.expanded_tool_call_raw_inputs.contains(&tool_call.id); let input_header = if is_raw_input_expanded { "Raw Input:" } else { "View Raw Input" }; this.child( v_flex() .p_2() .gap_1() .border_t_1() .border_color(self.tool_card_border_color(cx)) .child( h_flex() .id("disclosure_container") .pl_0p5() .gap_1() .justify_between() .rounded_xs() .hover(|s| s.bg(cx.theme().colors().element_hover)) .child(input_output_header(input_header.into())) .child( Disclosure::new( ("raw-input-disclosure", entry_ix), is_raw_input_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown), ) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this: &mut Self, _, _, cx| { if this.expanded_tool_call_raw_inputs.contains(&id) { this.expanded_tool_call_raw_inputs.remove(&id); } else { this.expanded_tool_call_raw_inputs .insert(id.clone()); } cx.notify(); } })), ) .when(is_raw_input_expanded, |this| { this.children(tool_call.raw_input_markdown.clone().map( |input| { self.render_markdown( input, MarkdownStyle::themed( MarkdownFont::Agent, window, cx, ), cx, ) }, )) }), ) }) .child(self.render_permission_buttons( self.thread.read(cx).session_id().clone(), self.is_first_tool_call(active_session_id, &tool_call.id, cx), options, entry_ix, tool_call.id.clone(), focus_handle, cx, )) .into_any(), ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit && tool_call.content.is_empty() && self.as_native_connection(cx).is_some() => { self.render_diff_loading(cx) } ToolCallStatus::Pending | ToolCallStatus::InProgress | ToolCallStatus::Completed | ToolCallStatus::Failed | ToolCallStatus::Canceled => v_flex() .when(should_show_raw_input, |this| { this.mt_1p5().w_full().child( v_flex() .ml(rems(0.4)) .px_3p5() .pb_1() .gap_1() .border_l_1() .border_color(self.tool_card_border_color(cx)) .child(input_output_header("Raw Input:".into())) .children(tool_call.raw_input_markdown.clone().map(|input| { div().id(("tool-call-raw-input-markdown", entry_ix)).child( self.render_markdown( input, MarkdownStyle::themed(MarkdownFont::Agent, window, cx), cx, ), ) })) .child(input_output_header("Output:".into())), ) }) .children( tool_call .content .iter() .enumerate() .map(|(content_ix, content)| { div().id(("tool-call-output", entry_ix)).child( self.render_tool_call_content( active_session_id, entry_ix, content, content_ix, tool_call, use_card_layout, has_image_content, failed_or_canceled, focus_handle, window, cx, ), ) }), ) .into_any(), ToolCallStatus::Rejected => Empty.into_any(), } .into() } else { None }; v_flex() .map(|this| { if is_subagent { this } else if use_card_layout { this.my_1p5() .rounded_md() .border_1() .when(failed_or_canceled, |this| this.border_dashed()) .border_color(self.tool_card_border_color(cx)) .bg(cx.theme().colors().editor_background) .overflow_hidden() } else { this.my_1() } }) .when(!is_subagent, |this| { this.map(|this| { if has_location && !use_card_layout { this.ml_4() } else { this.ml_5() } }) .mr_5() }) .map(|this| { if is_terminal_tool { this.child(self.render_collapsible_command( card_header_id.clone(), true, tool_call.label.clone(), window, cx, )) } else { this.child( h_flex() .group(&card_header_id) .relative() .w_full() .justify_between() .when(use_card_layout, |this| { this.p_0p5() .rounded_t(rems_from_px(5.)) .bg(self.tool_card_header_bg(cx)) }) .child(self.render_tool_call_label( entry_ix, tool_call, is_edit, is_cancelled_edit, has_revealed_diff, use_card_layout, window, cx, )) .child( h_flex() .when(is_collapsible || failed_or_canceled, |this| { let diff_for_discard = if has_revealed_diff && is_cancelled_edit { tool_call.diffs().next().cloned() } else { None }; this.child( h_flex() .pr_0p5() .gap_1() .when(is_collapsible, |this| { this.child( Disclosure::new( ("expand-output", entry_ix), is_open, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this: &mut Self, _, _, cx: &mut Context| { if is_open { this.expanded_tool_calls .remove(&id); } else { this.expanded_tool_calls .insert(id.clone()); } cx.notify(); } })), ) }) .when(failed_or_canceled, |this| { if is_cancelled_edit && !has_revealed_diff { this.child( div() .id(entry_ix) .tooltip(Tooltip::text( "Interrupted Edit", )) .child( Icon::new(IconName::XCircle) .color(Color::Muted) .size(IconSize::Small), ), ) } else if is_cancelled_edit { this } else { this.child( Icon::new(IconName::Close) .color(Color::Error) .size(IconSize::Small), ) } }) .when_some(diff_for_discard, |this, diff| { let tool_call_id = tool_call.id.clone(); let is_discarded = self .discarded_partial_edits .contains(&tool_call_id); this.when(!is_discarded, |this| { this.child( IconButton::new( ("discard-partial-edit", entry_ix), IconName::Undo, ) .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::with_meta( "Discard Interrupted Edit", None, "You can discard this interrupted partial edit and restore the original file content.", cx, ) }) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); move |this, _, _window, cx| { let diff_data = diff.read(cx); let base_text = diff_data .base_text() .clone(); let buffer = diff_data.buffer().clone(); buffer.update( cx, |buffer, cx| { buffer.set_text( base_text.as_ref(), cx, ); }, ); this.discarded_partial_edits .insert( tool_call_id.clone(), ); cx.notify(); } })), ) }) }), ) }) .when(tool_call_output_focus, |this| { this.child( Button::new("open-file-button", "Open File") .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .key_binding( KeyBinding::for_action_in(&OpenExcerpts, &tool_call_output_focus_handle, cx) .map(|s| s.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action( Box::new(OpenExcerpts), cx, ) }), ) }), ) ) } }) .children(tool_output_display) } fn render_permission_buttons( &self, session_id: acp::SessionId, is_first: bool, options: &PermissionOptions, entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, cx: &Context, ) -> Div { match options { PermissionOptions::Flat(options) => self.render_permission_buttons_flat( session_id, is_first, options, entry_ix, tool_call_id, focus_handle, cx, ), PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown( is_first, choices, None, entry_ix, session_id, tool_call_id, focus_handle, cx, ), PermissionOptions::DropdownWithPatterns { choices, patterns, tool_name, } => self.render_permission_buttons_with_dropdown( is_first, choices, Some((patterns, tool_name)), entry_ix, session_id, tool_call_id, focus_handle, cx, ), } } fn render_permission_buttons_with_dropdown( &self, is_first: bool, choices: &[PermissionOptionChoice], patterns: Option<(&[PermissionPattern], &str)>, entry_ix: usize, session_id: acp::SessionId, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, cx: &Context, ) -> Div { let selection = self.permission_selections.get(&tool_call_id); let selected_index = selection .and_then(|s| s.choice_index()) .unwrap_or_else(|| choices.len().saturating_sub(1)); let dropdown_label: SharedString = if matches!(selection, Some(PermissionSelection::SelectedPatterns(_))) { "Always for selected commands".into() } else { choices .get(selected_index) .or(choices.last()) .map(|choice| choice.label()) .unwrap_or_else(|| "Only this time".into()) }; let dropdown = if let Some((pattern_list, tool_name)) = patterns { self.render_permission_granularity_dropdown_with_patterns( choices, pattern_list, tool_name, dropdown_label, entry_ix, tool_call_id.clone(), is_first, cx, ) } else { self.render_permission_granularity_dropdown( choices, dropdown_label, entry_ix, tool_call_id.clone(), selected_index, is_first, cx, ) }; h_flex() .w_full() .p_1() .gap_2() .justify_between() .border_t_1() .border_color(self.tool_card_border_color(cx)) .child( h_flex() .gap_0p5() .child( Button::new(("allow-btn", entry_ix), "Allow") .start_icon( Icon::new(IconName::Check) .size(IconSize::XSmall) .color(Color::Success), ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( KeyBinding::for_action_in( &AllowOnce as &dyn Action, focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) }) .on_click(cx.listener({ let session_id = session_id.clone(); let tool_call_id = tool_call_id.clone(); move |this, _, window, cx| { this.authorize_with_granularity( session_id.clone(), tool_call_id.clone(), true, window, cx, ); } })), ) .child( Button::new(("deny-btn", entry_ix), "Deny") .start_icon( Icon::new(IconName::Close) .size(IconSize::XSmall) .color(Color::Error), ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( KeyBinding::for_action_in( &RejectOnce as &dyn Action, focus_handle, cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) }) .on_click(cx.listener({ move |this, _, window, cx| { this.authorize_with_granularity( session_id.clone(), tool_call_id.clone(), false, window, cx, ); } })), ), ) .child(dropdown) } fn render_permission_granularity_dropdown( &self, choices: &[PermissionOptionChoice], current_label: SharedString, entry_ix: usize, tool_call_id: acp::ToolCallId, selected_index: usize, is_first: bool, cx: &Context, ) -> AnyElement { let menu_options: Vec<(usize, SharedString)> = choices .iter() .enumerate() .map(|(i, choice)| (i, choice.label())) .collect(); let permission_dropdown_handle = self.permission_dropdown_handle.clone(); PopoverMenu::new(("permission-granularity", entry_ix)) .with_handle(permission_dropdown_handle) .trigger( Button::new(("granularity-trigger", entry_ix), current_label) .end_icon( Icon::new(IconName::ChevronDown) .size(IconSize::XSmall) .color(Color::Muted), ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( KeyBinding::for_action_in( &crate::OpenPermissionDropdown as &dyn Action, &self.focus_handle(cx), cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) }), ) .menu(move |window, cx| { let tool_call_id = tool_call_id.clone(); let options = menu_options.clone(); Some(ContextMenu::build(window, cx, move |mut menu, _, _| { for (index, display_name) in options.iter() { let display_name = display_name.clone(); let index = *index; let tool_call_id_for_entry = tool_call_id.clone(); let is_selected = index == selected_index; menu = menu.toggleable_entry( display_name, is_selected, IconPosition::End, None, move |window, cx| { window.dispatch_action( SelectPermissionGranularity { tool_call_id: tool_call_id_for_entry.0.to_string(), index, } .boxed_clone(), cx, ); }, ); } menu })) }) .into_any_element() } fn render_permission_granularity_dropdown_with_patterns( &self, choices: &[PermissionOptionChoice], patterns: &[PermissionPattern], _tool_name: &str, current_label: SharedString, entry_ix: usize, tool_call_id: acp::ToolCallId, is_first: bool, cx: &Context, ) -> AnyElement { let default_choice_index = choices.len().saturating_sub(1); let menu_options: Vec<(usize, SharedString)> = choices .iter() .enumerate() .map(|(i, choice)| (i, choice.label())) .collect(); let pattern_options: Vec<(usize, SharedString)> = patterns .iter() .enumerate() .map(|(i, cp)| { ( i, SharedString::from(format!("Always for `{}` commands", cp.display_name)), ) }) .collect(); let pattern_count = patterns.len(); let permission_dropdown_handle = self.permission_dropdown_handle.clone(); let view = cx.entity().downgrade(); PopoverMenu::new(("permission-granularity", entry_ix)) .with_handle(permission_dropdown_handle.clone()) .anchor(gpui::Anchor::TopRight) .attach(gpui::Anchor::BottomRight) .trigger( Button::new(("granularity-trigger", entry_ix), current_label) .end_icon( Icon::new(IconName::ChevronDown) .size(IconSize::XSmall) .color(Color::Muted), ) .label_size(LabelSize::Small) .when(is_first, |this| { this.key_binding( KeyBinding::for_action_in( &crate::OpenPermissionDropdown as &dyn Action, &self.focus_handle(cx), cx, ) .map(|kb| kb.size(rems_from_px(12.))), ) }), ) .menu(move |window, cx| { let tool_call_id = tool_call_id.clone(); let options = menu_options.clone(); let patterns = pattern_options.clone(); let view = view.clone(); let dropdown_handle = permission_dropdown_handle.clone(); Some(ContextMenu::build_persistent( window, cx, move |menu, _window, cx| { let mut menu = menu; // Read fresh selection state from the view on each rebuild. let selection: Option = view.upgrade().and_then(|v| { let view = v.read(cx); view.permission_selections.get(&tool_call_id).cloned() }); let is_pattern_mode = matches!(selection, Some(PermissionSelection::SelectedPatterns(_))); // Granularity choices: "Always for terminal", "Only this time" for (index, display_name) in options.iter() { let display_name = display_name.clone(); let index = *index; let tool_call_id_for_entry = tool_call_id.clone(); let is_selected = !is_pattern_mode && selection .as_ref() .and_then(|s| s.choice_index()) .map_or(index == default_choice_index, |ci| ci == index); let view = view.clone(); menu = menu.toggleable_entry( display_name, is_selected, IconPosition::End, None, move |_window, cx| { view.update(cx, |this, cx| { this.permission_selections.insert( tool_call_id_for_entry.clone(), PermissionSelection::Choice(index), ); cx.notify(); }) .log_err(); }, ); } menu = menu.separator().header("Select Options…"); for (pattern_index, label) in patterns.iter() { let label = label.clone(); let pattern_index = *pattern_index; let tool_call_id_for_pattern = tool_call_id.clone(); let is_checked = selection .as_ref() .is_some_and(|s| s.is_pattern_checked(pattern_index)); let view = view.clone(); menu = menu.toggleable_entry( label, is_checked, IconPosition::End, None, move |_window, cx| { view.update(cx, |this, cx| { let selection = this .permission_selections .get_mut(&tool_call_id_for_pattern); match selection { Some(PermissionSelection::SelectedPatterns(_)) => { // Already in pattern mode — toggle. this.permission_selections .get_mut(&tool_call_id_for_pattern) .expect("just matched above") .toggle_pattern(pattern_index); } _ => { // First click: activate pattern mode // with all patterns checked. this.permission_selections.insert( tool_call_id_for_pattern.clone(), PermissionSelection::SelectedPatterns( (0..pattern_count).collect(), ), ); } } cx.notify(); }) .log_err(); }, ); } let any_patterns_checked = selection .as_ref() .is_some_and(|s| s.has_any_checked_patterns()); let dropdown_handle = dropdown_handle.clone(); menu = menu.custom_row(move |_window, _cx| { div() .py_1() .w_full() .child( Button::new("apply-patterns", "Apply") .full_width() .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .disabled(!any_patterns_checked) .on_click({ let dropdown_handle = dropdown_handle.clone(); move |_event, _window, cx| { dropdown_handle.hide(cx); } }), ) .into_any_element() }); menu }, )) }) .into_any_element() } fn render_permission_buttons_flat( &self, session_id: acp::SessionId, is_first: bool, options: &[acp::PermissionOption], entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, cx: &Context, ) -> Div { let mut seen_kinds: ArrayVec = ArrayVec::new(); div() .p_1() .border_t_1() .border_color(self.tool_card_border_color(cx)) .w_full() .v_flex() .gap_0p5() .children(options.iter().map(move |option| { let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { let (icon, action) = match option.kind { acp::PermissionOptionKind::AllowOnce => ( Icon::new(IconName::Check) .size(IconSize::XSmall) .color(Color::Success), Some(&AllowOnce as &dyn Action), ), acp::PermissionOptionKind::AllowAlways => ( Icon::new(IconName::CheckDouble) .size(IconSize::XSmall) .color(Color::Success), Some(&AllowAlways as &dyn Action), ), acp::PermissionOptionKind::RejectOnce => ( Icon::new(IconName::Close) .size(IconSize::XSmall) .color(Color::Error), Some(&RejectOnce as &dyn Action), ), acp::PermissionOptionKind::RejectAlways | _ => ( Icon::new(IconName::Close) .size(IconSize::XSmall) .color(Color::Error), None, ), }; let this = this.start_icon(icon); let Some(action) = action else { return this; }; if !is_first || seen_kinds.contains(&option.kind) { return this; } seen_kinds.push(option.kind).unwrap(); this.key_binding( KeyBinding::for_action_in(action, focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) }) .label_size(LabelSize::Small) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); let option_id = option.option_id.clone(); let option_kind = option.kind; let session_id = session_id.clone(); move |this, _, window, cx| { this.authorize_tool_call( session_id.clone(), tool_call_id.clone(), SelectedPermissionOutcome::new(option_id.clone(), option_kind), window, cx, ); } })) })) } fn render_diff_loading(&self, cx: &Context) -> AnyElement { let bar = |n: u64, width_class: &str| { let bg_color = cx.theme().colors().element_active; let base = h_flex().h_1().rounded_full(); let modified = match width_class { "w_4_5" => base.w_3_4(), "w_1_4" => base.w_1_4(), "w_2_4" => base.w_2_4(), "w_3_5" => base.w_3_5(), "w_2_5" => base.w_2_5(), _ => base.w_1_2(), }; modified.with_animation( ElementId::Integer(n), Animation::new(Duration::from_secs(2)).repeat(), move |tab, delta| { let delta = (delta - 0.15 * n as f32) / 0.7; let delta = 1.0 - (0.5 - delta).abs() * 2.; let delta = ease_in_out(delta.clamp(0., 1.)); let delta = 0.1 + 0.9 * delta; tab.bg(bg_color.opacity(delta)) }, ) }; v_flex() .p_3() .gap_1() .rounded_b_md() .bg(cx.theme().colors().editor_background) .child(bar(0, "w_4_5")) .child(bar(1, "w_1_4")) .child(bar(2, "w_2_4")) .child(bar(3, "w_3_5")) .child(bar(4, "w_2_5")) .into_any_element() } fn render_tool_call_label( &self, entry_ix: usize, tool_call: &ToolCall, is_edit: bool, has_failed: bool, has_revealed_diff: bool, use_card_layout: bool, window: &Window, cx: &Context, ) -> Div { let has_location = tool_call.locations.len() == 1; let is_file = tool_call.kind == acp::ToolKind::Edit && has_location; let is_subagent_tool_call = tool_call.is_subagent(); let file_icon = if has_location { FileIcons::get_icon(&tool_call.locations[0].path, cx) .map(|from_path| Icon::from_path(from_path).color(Color::Muted)) .unwrap_or(Icon::new(IconName::ToolPencil).color(Color::Muted)) } else { Icon::new(IconName::ToolPencil).color(Color::Muted) }; let tool_icon = if is_file && has_failed && has_revealed_diff { div() .id(entry_ix) .tooltip(Tooltip::text("Interrupted Edit")) .child(DecoratedIcon::new( file_icon, Some( IconDecoration::new( IconDecorationKind::Triangle, self.tool_card_header_bg(cx), cx, ) .color(cx.theme().status().warning) .position(gpui::Point { x: px(-2.), y: px(-2.), }), ), )) .into_any_element() } else if is_file { div().child(file_icon).into_any_element() } else if is_subagent_tool_call { Icon::new(self.agent_icon) .size(IconSize::Small) .color(Color::Muted) .into_any_element() } else { Icon::new(match tool_call.kind { acp::ToolKind::Read => IconName::ToolSearch, acp::ToolKind::Edit => IconName::ToolPencil, acp::ToolKind::Delete => IconName::ToolDeleteFile, acp::ToolKind::Move => IconName::ArrowRightLeft, acp::ToolKind::Search => IconName::ToolSearch, acp::ToolKind::Execute => IconName::ToolTerminal, acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, acp::ToolKind::Other | _ => IconName::ToolHammer, }) .size(IconSize::Small) .color(Color::Muted) .into_any_element() }; let gradient_overlay = { div() .absolute() .top_0() .right_0() .w_12() .h_full() .map(|this| { if use_card_layout { this.bg(linear_gradient( 90., linear_color_stop(self.tool_card_header_bg(cx), 1.), linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), )) } else { this.bg(linear_gradient( 90., linear_color_stop(cx.theme().colors().panel_background, 1.), linear_color_stop( cx.theme().colors().panel_background.opacity(0.2), 0., ), )) } }) }; h_flex() .relative() .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) .gap_1p5() .when(has_location || use_card_layout, |this| this.px_1()) .when(has_location, |this| { this.cursor(CursorStyle::PointingHand) .rounded(rems_from_px(3.)) // Concentric border radius .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) }) .overflow_hidden() .child(tool_icon) .child(if has_location { h_flex() .id(("open-tool-call-location", entry_ix)) .w_full() .map(|this| { if use_card_layout { this.text_color(cx.theme().colors().text) } else { this.text_color(cx.theme().colors().text_muted) } }) .child( self.render_markdown( tool_call.label.clone(), MarkdownStyle { prevent_mouse_interaction: true, ..MarkdownStyle::themed(MarkdownFont::Agent, window, cx) .with_muted_text(cx) }, cx, ), ) .tooltip(Tooltip::text("Go to File")) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() .w_full() .child(self.render_markdown( tool_call.label.clone(), MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx), cx, )) .into_any() }) .when(!is_edit, |this| this.child(gradient_overlay)) } fn open_tool_call_location( &self, entry_ix: usize, location_ix: usize, window: &mut Window, cx: &mut Context, ) -> Option<()> { let (tool_call_location, agent_location) = self .thread .read(cx) .entries() .get(entry_ix)? .location(location_ix)?; let project_path = self .project .upgrade()? .read(cx) .find_project_path(&tool_call_location.path, cx)?; let open_task = self .workspace .update(cx, |workspace, cx| { workspace.open_path(project_path, None, true, window, cx) }) .log_err()?; window .spawn(cx, async move |cx| { let item = open_task.await?; let Some(active_editor) = item.downcast::() else { return anyhow::Ok(()); }; active_editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); if snapshot.as_singleton().is_some() && let Some(anchor) = snapshot.anchor_in_excerpt(agent_location.position) { editor.change_selections(Default::default(), window, cx, |selections| { selections.select_anchor_ranges([anchor..anchor]); }) } else { let row = tool_call_location.line.unwrap_or_default(); editor.change_selections(Default::default(), window, cx, |selections| { selections.select_ranges([Point::new(row, 0)..Point::new(row, 0)]); }) } })?; anyhow::Ok(()) }) .detach_and_log_err(cx); None } fn render_tool_call_content( &self, session_id: &acp::SessionId, entry_ix: usize, content: &ToolCallContent, context_ix: usize, tool_call: &ToolCall, card_layout: bool, is_image_tool_call: bool, has_failed: bool, focus_handle: &FocusHandle, window: &Window, cx: &Context, ) -> AnyElement { match content { ToolCallContent::ContentBlock(content) => { if let Some(resource_link) = content.resource_link() { self.render_resource_link(resource_link, cx) } else if let Some(markdown) = content.markdown() { self.render_markdown_output( markdown.clone(), tool_call.id.clone(), context_ix, card_layout, window, cx, ) } else if let Some(image) = content.image() { let location = tool_call.locations.first().cloned(); self.render_image_output( entry_ix, image.clone(), location, card_layout, is_image_tool_call, cx, ) } else { Empty.into_any_element() } } ToolCallContent::Diff(diff) => { self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) } ToolCallContent::Terminal(terminal) => self.render_terminal_tool_call( session_id, entry_ix, terminal, tool_call, focus_handle, false, window, cx, ), } } fn render_resource_link( &self, resource_link: &acp::ResourceLink, cx: &Context, ) -> AnyElement { let uri: SharedString = resource_link.uri.clone().into(); let is_file = resource_link.uri.strip_prefix("file://"); let Some(project) = self.project.upgrade() else { return Empty.into_any_element(); }; let label: SharedString = if let Some(abs_path) = is_file { if let Some(project_path) = project .read(cx) .project_path_for_absolute_path(&Path::new(abs_path), cx) && let Some(worktree) = project .read(cx) .worktree_for_id(project_path.worktree_id, cx) { worktree .read(cx) .full_path(&project_path.path) .to_string_lossy() .to_string() .into() } else { abs_path.to_string().into() } } else { uri.clone() }; let button_id = SharedString::from(format!("item-{}", uri)); div() .ml(rems(0.4)) .pl_2p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) .overflow_hidden() .child( Button::new(button_id, label) .label_size(LabelSize::Small) .color(Color::Muted) .truncate(true) .when(is_file.is_none(), |this| { this.end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) .color(Color::Muted), ) }) .on_click(cx.listener({ let workspace = self.workspace.clone(); move |_, _, window, cx: &mut Context| { open_link(uri.clone(), &workspace, window, cx); } })), ) .into_any_element() } fn render_diff_editor( &self, entry_ix: usize, diff: &Entity, tool_call: &ToolCall, has_failed: bool, cx: &Context, ) -> AnyElement { let tool_progress = matches!( &tool_call.status, ToolCallStatus::InProgress | ToolCallStatus::Pending ); let revealed_diff_editor = if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix) && let Some(editor) = entry.editor_for_diff(diff) && diff.read(cx).has_revealed_range(cx) { Some(editor) } else { None }; let show_top_border = !has_failed || revealed_diff_editor.is_some(); v_flex() .h_full() .when(show_top_border, |this| { this.border_t_1() .when(has_failed, |this| this.border_dashed()) .border_color(self.tool_card_border_color(cx)) }) .child(if let Some(editor) = revealed_diff_editor { editor.into_any_element() } else if tool_progress && self.as_native_connection(cx).is_some() { self.render_diff_loading(cx) } else { Empty.into_any() }) .into_any() } fn render_markdown_output( &self, markdown: Entity, tool_call_id: acp::ToolCallId, context_ix: usize, card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); v_flex() .gap_2() .map(|this| { if card_layout { this.p_2().when(context_ix > 0, |this| { this.border_t_1() .border_color(self.tool_card_border_color(cx)) }) } else { this.ml(rems(0.4)) .px_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) } }) .text_xs() .text_color(cx.theme().colors().text_muted) .child(self.render_markdown( markdown, MarkdownStyle::themed(MarkdownFont::Agent, window, cx), cx, )) .when(!card_layout, |this| { this.child( IconButton::new(button_id, IconName::ChevronUp) .full_width() .style(ButtonStyle::Outlined) .icon_color(Color::Muted) .on_click(cx.listener({ move |this: &mut Self, _, _, cx: &mut Context| { this.expanded_tool_calls.remove(&tool_call_id); cx.notify(); } })), ) }) .into_any_element() } fn render_image_output( &self, entry_ix: usize, image: Arc, location: Option, card_layout: bool, show_dimensions: bool, cx: &Context, ) -> AnyElement { let dimensions_label = if show_dimensions { let format_name = match image.format() { gpui::ImageFormat::Png => "PNG", gpui::ImageFormat::Jpeg => "JPEG", gpui::ImageFormat::Webp => "WebP", gpui::ImageFormat::Gif => "GIF", gpui::ImageFormat::Svg => "SVG", gpui::ImageFormat::Bmp => "BMP", gpui::ImageFormat::Tiff => "TIFF", gpui::ImageFormat::Ico => "ICO", gpui::ImageFormat::Pnm => "PNM", }; let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) .with_guessed_format() .ok() .and_then(|reader| reader.into_dimensions().ok()); dimensions.map(|(w, h)| format!("{}×{} {}", w, h, format_name)) } else { None }; v_flex() .gap_2() .map(|this| { if card_layout { this } else { this.ml(rems(0.4)) .px_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) } }) .when(dimensions_label.is_some() || location.is_some(), |this| { this.child( h_flex() .w_full() .justify_between() .items_center() .children(dimensions_label.map(|label| { Label::new(label) .size(LabelSize::XSmall) .color(Color::Muted) .buffer_font(cx) })) .when_some(location, |this, _loc| { this.child( Button::new(("go-to-file", entry_ix), "Go to File") .label_size(LabelSize::Small) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })), ) }), ) }) .child( img(image) .max_w_96() .max_h_96() .object_fit(ObjectFit::ScaleDown), ) .into_any_element() } fn render_subagent_tool_call( &self, active_session_id: &acp::SessionId, entry_ix: usize, tool_call: &ToolCall, subagent_session_id: Option, focus_handle: &FocusHandle, window: &Window, cx: &Context, ) -> Div { let subagent_thread_view = subagent_session_id.and_then(|session_id| { self.server_view .upgrade() .and_then(|server_view| server_view.read(cx).as_connected()) .and_then(|connected| connected.threads.get(&session_id)) }); let content = self.render_subagent_card( active_session_id, entry_ix, subagent_thread_view, tool_call, focus_handle, window, cx, ); v_flex().mx_5().my_1p5().gap_3().child(content) } fn render_subagent_card( &self, active_session_id: &acp::SessionId, entry_ix: usize, thread_view: Option<&Entity>, tool_call: &ToolCall, focus_handle: &FocusHandle, window: &Window, cx: &Context, ) -> AnyElement { let thread = thread_view .as_ref() .map(|view| view.read(cx).thread.clone()); let subagent_session_id = thread .as_ref() .map(|thread| thread.read(cx).session_id().clone()); let action_log = thread.as_ref().map(|thread| thread.read(cx).action_log()); let changed_buffers = action_log .map(|log| log.read(cx).changed_buffers(cx)) .unwrap_or_default(); let is_pending_tool_call = thread_view .as_ref() .and_then(|tv| { let sid = tv.read(cx).thread.read(cx).session_id(); self.conversation.read(cx).pending_tool_call(sid, cx) }) .is_some(); let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let files_changed = changed_buffers.len(); let diff_stats = DiffStats::all_files(&changed_buffers, cx); let is_running = matches!( tool_call.status, ToolCallStatus::Pending | ToolCallStatus::InProgress | ToolCallStatus::WaitingForConfirmation { .. } ); let is_failed = matches!( tool_call.status, ToolCallStatus::Failed | ToolCallStatus::Rejected ); let is_cancelled = matches!(tool_call.status, ToolCallStatus::Canceled) || tool_call.content.iter().any(|c| match c { ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => { markdown.read(cx).source() == "User canceled" } _ => false, }); let thread_title = thread .as_ref() .and_then(|t| t.read(cx).title()) .filter(|t| !t.is_empty()); let tool_call_label = tool_call.label.read(cx).source().to_string(); let has_tool_call_label = !tool_call_label.is_empty(); let has_title = thread_title.is_some() || has_tool_call_label; let has_no_title_or_canceled = !has_title || is_failed || is_cancelled; let title: SharedString = if let Some(thread_title) = thread_title { thread_title } else if !tool_call_label.is_empty() { tool_call_label.into() } else if is_cancelled { "Subagent Canceled".into() } else if is_failed { "Subagent Failed".into() } else { "Spawning Agent…".into() }; let card_header_id = format!("subagent-header-{}", entry_ix); let status_icon = format!("status-icon-{}", entry_ix); let diff_stat_id = format!("subagent-diff-{}", entry_ix); let icon = h_flex().w_4().justify_center().child(if is_running { SpinnerLabel::new() .size(LabelSize::Small) .into_any_element() } else if is_cancelled { div() .id(status_icon) .child( Icon::new(IconName::Circle) .size(IconSize::Small) .color(Color::Custom( cx.theme().colors().icon_disabled.opacity(0.5), )), ) .tooltip(Tooltip::text("Subagent Cancelled")) .into_any_element() } else if is_failed { div() .id(status_icon) .child( Icon::new(IconName::Close) .size(IconSize::Small) .color(Color::Error), ) .tooltip(Tooltip::text("Subagent Failed")) .into_any_element() } else { Icon::new(IconName::Check) .size(IconSize::Small) .color(Color::Success) .into_any_element() }); let has_expandable_content = thread .as_ref() .map_or(false, |thread| !thread.read(cx).entries().is_empty()); let tooltip_meta_description = if is_expanded { "Click to Collapse" } else { "Click to Preview" }; let error_message = self.subagent_error_message(&tool_call.status, tool_call, cx); v_flex() .w_full() .rounded_md() .border_1() .when(has_no_title_or_canceled, |this| this.border_dashed()) .border_color(self.tool_card_border_color(cx)) .overflow_hidden() .child( h_flex() .group(&card_header_id) .h_8() .p_1() .w_full() .justify_between() .when(!has_no_title_or_canceled, |this| { this.bg(self.tool_card_header_bg(cx)) }) .child( h_flex() .id(format!("subagent-title-{}", entry_ix)) .px_1() .min_w_0() .size_full() .gap_2() .justify_between() .rounded_sm() .overflow_hidden() .child( h_flex() .min_w_0() .w_full() .gap_1p5() .child(icon) .child( Label::new(title.to_string()) .size(LabelSize::Custom(self.tool_name_font_size())) .truncate(), ) .when(files_changed > 0, |this| { this.child( Label::new(format!( "— {} {} changed", files_changed, if files_changed == 1 { "file" } else { "files" } )) .size(LabelSize::Custom(self.tool_name_font_size())) .color(Color::Muted), ) .child( DiffStat::new( diff_stat_id.clone(), diff_stats.lines_added as usize, diff_stats.lines_removed as usize, ) .label_size(LabelSize::Custom( self.tool_name_font_size(), )), ) }), ) .when(!has_no_title_or_canceled && !is_pending_tool_call, |this| { this.tooltip(move |_, cx| { Tooltip::with_meta( title.to_string(), None, tooltip_meta_description, cx, ) }) }) .when(has_expandable_content && !is_pending_tool_call, |this| { this.cursor_pointer() .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( div().visible_on_hover(card_header_id).child( Icon::new(if is_expanded { IconName::ChevronUp } else { IconName::ChevronDown }) .color(Color::Muted) .size(IconSize::Small), ), ) .on_click(cx.listener({ let tool_call_id = tool_call.id.clone(); move |this, _, _, cx| { if this.expanded_tool_calls.contains(&tool_call_id) { this.expanded_tool_calls.remove(&tool_call_id); } else { this.expanded_tool_calls .insert(tool_call_id.clone()); } let expanded = this.expanded_tool_calls.contains(&tool_call_id); telemetry::event!("Subagent Toggled", expanded); cx.notify(); } })) }), ) .when(is_running && subagent_session_id.is_some(), |buttons| { buttons.child( IconButton::new(format!("stop-subagent-{}", entry_ix), IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .tooltip(Tooltip::text("Stop Subagent")) .when_some( thread_view .as_ref() .map(|view| view.read(cx).thread.clone()), |this, thread| { this.on_click(cx.listener( move |_this, _event, _window, cx| { telemetry::event!("Subagent Stopped"); thread.update(cx, |thread, cx| { thread.cancel(cx).detach(); }); }, )) }, ), ) }), ) .when_some(thread_view, |this, thread_view| { let thread = &thread_view.read(cx).thread; let tv_session_id = thread.read(cx).session_id(); let pending_tool_call = self .conversation .read(cx) .pending_tool_call(tv_session_id, cx); let nav_session_id = tv_session_id.clone(); let fullscreen_toggle = h_flex() .id(entry_ix) .py_1() .w_full() .justify_center() .border_t_1() .when(is_failed, |this| this.border_dashed()) .border_color(self.tool_card_border_color(cx)) .cursor_pointer() .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( Icon::new(IconName::Maximize) .color(Color::Muted) .size(IconSize::Small), ) .tooltip(Tooltip::text("Make Subagent Full Screen")) .on_click(cx.listener(move |this, _event, window, cx| { telemetry::event!("Subagent Maximized"); this.server_view .update(cx, |this, cx| { this.navigate_to_thread(nav_session_id.clone(), window, cx); }) .ok(); })); if is_running && let Some((_, subagent_tool_call_id, _)) = pending_tool_call { if let Some((entry_ix, tool_call)) = thread.read(cx).tool_call(&subagent_tool_call_id) { this.child(Divider::horizontal().color(DividerColor::Border)) .child(thread_view.read(cx).render_any_tool_call( active_session_id, entry_ix, tool_call, focus_handle, true, window, cx, )) .child(fullscreen_toggle) } else { this } } else { this.when(is_expanded, |this| { this.child(self.render_subagent_expanded_content( thread_view, tool_call, window, cx, )) .when_some(error_message, |this, message| { this.child( Callout::new() .severity(Severity::Error) .icon(IconName::XCircle) .title(message), ) }) .child(fullscreen_toggle) }) } }) .into_any_element() } fn render_subagent_expanded_content( &self, thread_view: &Entity, tool_call: &ToolCall, window: &Window, cx: &Context, ) -> impl IntoElement { const MAX_PREVIEW_ENTRIES: usize = 8; let subagent_view = thread_view.read(cx); let session_id = subagent_view.thread.read(cx).session_id().clone(); let is_canceled_or_failed = matches!( tool_call.status, ToolCallStatus::Canceled | ToolCallStatus::Failed | ToolCallStatus::Rejected ); let editor_bg = cx.theme().colors().editor_background; let overlay = { div() .absolute() .inset_0() .size_full() .bg(linear_gradient( 180., linear_color_stop(editor_bg.opacity(0.5), 0.), linear_color_stop(editor_bg.opacity(0.), 0.1), )) .block_mouse_except_scroll() }; let entries = subagent_view.thread.read(cx).entries(); let total_entries = entries.len(); let mut entry_range = if let Some(info) = tool_call.subagent_session_info.as_ref() { info.message_start_index ..info .message_end_index .map(|i| (i + 1).min(total_entries)) .unwrap_or(total_entries) } else { 0..total_entries }; entry_range.start = entry_range .end .saturating_sub(MAX_PREVIEW_ENTRIES) .max(entry_range.start); let start_ix = entry_range.start; let scroll_handle = self .subagent_scroll_handles .borrow_mut() .entry(subagent_view.session_id.clone()) .or_default() .clone(); scroll_handle.scroll_to_bottom(); let rendered_entries: Vec = entries .get(entry_range) .unwrap_or_default() .iter() .enumerate() .map(|(i, entry)| { let actual_ix = start_ix + i; subagent_view.render_entry(actual_ix, total_entries, entry, window, cx) }) .collect(); v_flex() .w_full() .border_t_1() .when(is_canceled_or_failed, |this| this.border_dashed()) .border_color(self.tool_card_border_color(cx)) .overflow_hidden() .child( div() .pb_1() .min_h_0() .id(format!("subagent-entries-{}", session_id)) .track_scroll(&scroll_handle) .children(rendered_entries), ) .h_56() .child(overlay) .into_any_element() } fn subagent_error_message( &self, status: &ToolCallStatus, tool_call: &ToolCall, cx: &App, ) -> Option { if matches!(status, ToolCallStatus::Failed) { tool_call.content.iter().find_map(|content| { if let ToolCallContent::ContentBlock(block) = content { if let acp_thread::ContentBlock::Markdown { markdown } = block { let source = markdown.read(cx).source().to_string(); if !source.is_empty() { if source == "User canceled" { return None; } else { return Some(SharedString::from(source)); } } } } None }) } else { None } } fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() .element_background .blend(cx.theme().colors().editor_foreground.opacity(0.025)) } fn tool_card_border_color(&self, cx: &Context) -> Hsla { cx.theme().colors().border.opacity(0.8) } fn tool_name_font_size(&self) -> Rems { rems_from_px(13.) } pub(crate) fn render_thread_error( &mut self, window: &mut Window, cx: &mut Context, ) -> Option
{ let content = match self.thread_error.as_ref()? { ThreadError::Other { message, .. } => { self.render_any_thread_error(message.clone(), window, cx) } ThreadError::Refusal => self.render_refusal_error(cx), ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } ThreadError::PaymentRequired => self.render_payment_required_error(cx), ThreadError::RateLimitExceeded { provider } => self.render_error_callout( "Rate Limit Reached", format!( "{provider}'s rate limit was reached. Zed will retry automatically. \ You can also wait a moment and try again." ) .into(), true, true, cx, ), ThreadError::ServerOverloaded { provider } => self.render_error_callout( "Provider Unavailable", format!( "{provider}'s servers are temporarily unavailable. Zed will retry \ automatically. If the problem persists, check the provider's status page." ) .into(), true, true, cx, ), ThreadError::PromptTooLarge => self.render_prompt_too_large_error(cx), ThreadError::NoApiKey { provider } => self.render_error_callout( "API Key Missing", format!( "No API key is configured for {provider}. \ Add your key via the Agent Panel settings to continue." ) .into(), false, true, cx, ), ThreadError::StreamError { provider } => self.render_error_callout( "Connection Interrupted", format!( "The connection to {provider}'s API was interrupted. Zed will retry \ automatically. If the problem persists, check your network connection." ) .into(), true, true, cx, ), ThreadError::InvalidApiKey { provider } => self.render_error_callout( "Invalid API Key", format!( "The API key for {provider} is invalid or has expired. \ Update your key via the Agent Panel settings to continue." ) .into(), false, false, cx, ), ThreadError::PermissionDenied { provider } => self.render_error_callout( "Permission Denied", format!( "{provider}'s API rejected the request due to insufficient permissions. \ Check that your API key has access to this model." ) .into(), false, false, cx, ), ThreadError::RequestFailed => self.render_error_callout( "Request Failed", "The request could not be completed after multiple attempts. \ Try again in a moment." .into(), true, false, cx, ), ThreadError::MaxOutputTokens => self.render_error_callout( "Output Limit Reached", "The model stopped because it reached its maximum output length. \ You can ask it to continue where it left off." .into(), false, false, cx, ), ThreadError::NoModelSelected => self.render_error_callout( "No Model Selected", "Select a model from the model picker below to get started.".into(), false, false, cx, ), ThreadError::ApiError { provider } => self.render_error_callout( "API Error", format!( "{provider}'s API returned an unexpected error. \ If the problem persists, try switching models or restarting Zed." ) .into(), true, true, cx, ), }; Some(div().child(content)) } fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { let model_or_agent_name = self.current_model_name(cx); let refusal_message = format!( "{} refused to respond to this prompt. \ This can happen when a model believes the prompt violates its content policy \ or safety guidelines, so rephrasing it can sometimes address the issue.", model_or_agent_name ); Callout::new() .severity(Severity::Error) .title("Request Refused") .icon(IconName::XCircle) .description(refusal_message.clone()) .actions_slot(self.create_copy_button(&refusal_message)) .dismiss_action(self.dismiss_error_button(cx)) } fn render_authentication_required_error( &self, error: SharedString, cx: &mut Context, ) -> Callout { Callout::new() .severity(Severity::Error) .title("Authentication Required") .icon(IconName::XCircle) .description(error.clone()) .actions_slot( h_flex() .gap_0p5() .child(self.authenticate_button(cx)) .child(self.create_copy_button(error)), ) .dismiss_action(self.dismiss_error_button(cx)) } fn render_payment_required_error(&self, cx: &mut Context) -> Callout { const ERROR_MESSAGE: &str = "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; Callout::new() .severity(Severity::Error) .icon(IconName::XCircle) .title("Free Usage Exceeded") .description(ERROR_MESSAGE) .actions_slot( h_flex() .gap_0p5() .child(self.upgrade_button(cx)) .child(self.create_copy_button(ERROR_MESSAGE)), ) .dismiss_action(self.dismiss_error_button(cx)) } fn render_error_callout( &self, title: &'static str, message: SharedString, show_retry: bool, show_copy: bool, cx: &mut Context, ) -> Callout { let can_resume = show_retry && self.thread.read(cx).can_retry(cx); let show_actions = can_resume || show_copy; Callout::new() .severity(Severity::Error) .icon(IconName::XCircle) .title(title) .description(message.clone()) .when(show_actions, |callout| { callout.actions_slot( h_flex() .gap_0p5() .when(can_resume, |this| this.child(self.retry_button(cx))) .when(show_copy, |this| { this.child(self.create_copy_button(message.clone())) }), ) }) .dismiss_action(self.dismiss_error_button(cx)) } fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { const MESSAGE: &str = "This conversation is too long for the model's context window. \ Start a new thread or remove some attached files to continue."; Callout::new() .severity(Severity::Error) .icon(IconName::XCircle) .title("Context Too Large") .description(MESSAGE) .actions_slot( h_flex() .gap_0p5() .child(self.new_thread_button(cx)) .child(self.create_copy_button(MESSAGE)), ) .dismiss_action(self.dismiss_error_button(cx)) } fn retry_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("retry", "Retry") .label_size(LabelSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _, _, cx| { this.retry_generation(cx); })) } fn new_thread_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("new_thread", "New Thread") .label_size(LabelSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _, window, cx| { this.clear_thread_error(cx); window.dispatch_action(NewThread.boxed_clone(), cx); })) } fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(cx.listener({ move |this, _, _, cx| { this.clear_thread_error(cx); cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); } })) } fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("authenticate", "Authenticate") .label_size(LabelSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { this.message_editor.update(cx, |editor, cx| { editor.set_message(message, window, cx); }); } let connection = this.thread.read(cx).connection().clone(); window.defer(cx, |window, cx| { ConversationView::handle_auth_required( server_view, AuthRequired::new(), agent_name, connection, window, cx, ); }) } })) } fn current_model_name(&self, cx: &App) -> SharedString { // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") // For ACP agents, use the agent name (e.g., "Claude Agent", "Gemini CLI") // This provides better clarity about what refused the request if self.as_native_connection(cx).is_some() { self.model_selector .clone() .and_then(|selector| selector.read(cx).active_model(cx)) .map(|model| model.name.clone()) .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") self.agent_id.0.clone() } } fn render_any_thread_error( &mut self, error: SharedString, window: &mut Window, cx: &mut Context<'_, Self>, ) -> Callout { let can_resume = self.thread.read(cx).can_retry(cx); let markdown = if let Some(markdown) = &self.thread_error_markdown { markdown.clone() } else { let markdown = cx.new(|cx| Markdown::new(error.clone(), None, None, cx)); self.thread_error_markdown = Some(markdown.clone()); markdown }; let markdown_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx).with_muted_text(cx); let description = self .render_markdown(markdown, markdown_style, cx) .into_any_element(); Callout::new() .severity(Severity::Error) .icon(IconName::XCircle) .title("An Error Happened") .description_slot(description) .actions_slot( h_flex() .gap_0p5() .when(can_resume, |this| { this.child( IconButton::new("retry", IconName::RotateCw) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Retry Generation")) .on_click(cx.listener(|this, _, _window, cx| { this.retry_generation(cx); })), ) }) .child(self.create_copy_button(error.to_string())), ) .dismiss_action(self.dismiss_error_button(cx)) } fn render_markdown( &self, markdown: Entity, style: MarkdownStyle, cx: &App, ) -> MarkdownElement { render_agent_markdown(markdown, style, &self.workspace, &self.project, cx) } fn create_copy_button(&self, message: impl Into) -> impl IntoElement { let message = message.into(); CopyButton::new("copy-error-message", message).tooltip_label("Copy Error Message") } fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { IconButton::new("dismiss", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Dismiss")) .on_click(cx.listener({ move |this, _, _, cx| { this.clear_thread_error(cx); cx.notify(); } })) } fn render_resume_notice(_cx: &Context) -> AnyElement { let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off."; Callout::new() .border_position(ui::BorderPosition::Bottom) .severity(Severity::Info) .icon(IconName::Info) .title("Resumed Session") .description(description) .into_any_element() } fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { Callout::new() .icon(IconName::Warning) .severity(Severity::Warning) .title("Codex on Windows") .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") .actions_slot( Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({ move |_, _, _window, cx| { #[cfg(windows)] _window.dispatch_action( zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), cx, ); cx.notify(); } })), ) .dismiss_action( IconButton::new("dismiss", IconName::Close) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss Warning")) .on_click(cx.listener({ move |this, _, _, cx| { this.show_codex_windows_warning = false; cx.notify(); } })), ) } fn render_skill_loading_errors(&self, cx: &mut Context) -> Vec { self.skill_loading_errors .iter() .enumerate() .map(|(index, error)| { let abs_path = error.path.clone(); let workspace = self.workspace.clone(); let path_label = error.path.display().to_string(); let target = error.clone(); Callout::new() .icon(IconName::Warning) .severity(Severity::Warning) .title("Skill failed to load") .description(format!("{}\n{path_label}", error.message)) .actions_slot( Button::new(("open-skill-file", index), "Open File").on_click(cx.listener( move |_, _, window, cx| { let abs_path = abs_path.clone(); workspace .update(cx, |workspace, cx| { workspace .open_abs_path( abs_path, workspace::OpenOptions::default(), window, cx, ) .detach_and_log_err(cx); }) .ok(); }, )), ) .dismiss_action( IconButton::new(("dismiss-skill-error", index), IconName::Close) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss")) .on_click(cx.listener(move |this, _, _, cx| { this.skill_loading_errors.retain(|e| *e != target); this.dismissed_skill_loading_errors.insert(target.clone()); cx.notify(); })), ) }) .collect() } fn render_external_source_prompt_warning(&self, cx: &mut Context) -> Callout { Callout::new() .icon(IconName::Warning) .severity(Severity::Warning) .title("Review before sending") .description("This prompt was pre-filled by an external link. Read it carefully before you send it.") .dismiss_action( IconButton::new("dismiss-external-source-prompt-warning", IconName::Close) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss Warning")) .on_click(cx.listener({ move |this, _, _, cx| { this.show_external_source_prompt_warning = false; cx.notify(); } })), ) } fn render_multi_root_callout(&self, cx: &mut Context) -> Option { if self.multi_root_callout_dismissed { return None; } if self.as_native_connection(cx).is_some() { return None; } let project = self.project.upgrade()?; let worktree_count = project.read(cx).visible_worktrees(cx).count(); if worktree_count <= 1 { return None; } let work_dirs = self.thread.read(cx).work_dirs()?; let active_dir = work_dirs .ordered_paths() .next() .and_then(|p| p.file_name()) .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "one folder".to_string()); let description = format!( "This agent only operates on \"{}\". Other folders in this workspace are not accessible to it.", active_dir ); Some( Callout::new() .severity(Severity::Warning) .icon(IconName::Warning) .title("External Agents currently don't support multi-root workspaces") .description(description) .border_position(ui::BorderPosition::Bottom) .dismiss_action( IconButton::new("dismiss-multi-root-callout", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Dismiss")) .on_click(cx.listener(|this, _, _, cx| { this.multi_root_callout_dismissed = true; cx.notify(); })), ), ) } fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { let server_view = self.server_view.clone(); let has_version = !version.is_empty(); let title = if has_version { "New version available" } else { "Agent update available" }; let button_label = if has_version { format!("Update to v{}", version) } else { "Reconnect".to_string() }; v_flex().w_full().justify_end().child( h_flex() .p_2() .pr_3() .w_full() .gap_1p5() .border_t_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().element_background) .child( h_flex() .flex_1() .gap_1p5() .child( Icon::new(IconName::Download) .color(Color::Accent) .size(IconSize::Small), ) .child(Label::new(title).size(LabelSize::Small)), ) .child( Button::new("update-button", button_label) .label_size(LabelSize::Small) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(move |_, window, cx| { server_view .update(cx, |view, cx| view.reset(window, cx)) .ok(); }), ), ) } fn render_token_limit_callout(&self, cx: &mut Context) -> Option { if self.token_limit_callout_dismissed || self.as_native_thread(cx).is_none() { return None; } let token_usage = self.thread.read(cx).token_usage()?; let ratio = token_usage.ratio(); let (severity, icon, title) = match ratio { acp_thread::TokenUsageRatio::Normal => return None, acp_thread::TokenUsageRatio::Warning => ( Severity::Warning, IconName::Warning, "Thread reaching the token limit soon", ), acp_thread::TokenUsageRatio::Exceeded => ( Severity::Error, IconName::XCircle, "Thread reached the token limit", ), }; let description = "To continue, start a new thread from a summary."; Some( Callout::new() .severity(severity) .icon(icon) .title(title) .description(description) .actions_slot( h_flex().gap_0p5().child( Button::new("start-new-thread", "Start New Thread") .label_size(LabelSize::Small) .on_click(cx.listener(|this, _, window, cx| { let session_id = this.thread.read(cx).session_id().clone(); window.dispatch_action( crate::NewNativeAgentThreadFromSummary { from_session_id: session_id, } .boxed_clone(), cx, ); })), ), ) .dismiss_action(self.dismiss_error_button(cx)), ) } fn open_permission_dropdown( &mut self, _: &crate::OpenPermissionDropdown, window: &mut Window, cx: &mut Context, ) { let menu_handle = self.permission_dropdown_handle.clone(); window.defer(cx, move |window, cx| { menu_handle.toggle(window, cx); }); } fn open_add_context_menu( &mut self, _action: &OpenAddContextMenu, window: &mut Window, cx: &mut Context, ) { let menu_handle = self.add_context_menu_handle.clone(); window.defer(cx, move |window, cx| { menu_handle.toggle(window, cx); }); } fn toggle_fast_mode(&mut self, cx: &mut Context) { if !self.fast_mode_available(cx) { return; } let Some(thread) = self.as_native_thread(cx) else { return; }; thread.update(cx, |thread, cx| { let new_speed = thread .speed() .map(|speed| speed.toggle()) .unwrap_or(Speed::Fast); thread.set_speed(new_speed, cx); let favorite_key = thread .model() .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { if let Some(agent) = settings.agent.as_mut() { if let Some(default_model) = agent.default_model.as_mut() { default_model.speed = Some(new_speed); } if let Some((provider_id, model_id)) = &favorite_key { agent.update_favorite_model(provider_id, model_id, |favorite| { favorite.speed = Some(new_speed) }); } } }); }); } fn cycle_thinking_effort(&mut self, cx: &mut Context) { let Some(thread) = self.as_native_thread(cx) else { return; }; let (effort_levels, current_effort) = { let thread_ref = thread.read(cx); let Some(model) = thread_ref.model() else { return; }; if !model.supports_thinking() || !thread_ref.thinking_enabled() { return; } let effort_levels = model.supported_effort_levels(); if effort_levels.is_empty() { return; } let current_effort = thread_ref.thinking_effort().cloned(); (effort_levels, current_effort) }; let current_index = current_effort.and_then(|current| { effort_levels .iter() .position(|level| level.value == current) }); let next_index = match current_index { Some(index) => (index + 1) % effort_levels.len(), None => 0, }; let next_effort = effort_levels[next_index].value.to_string(); thread.update(cx, |thread, cx| { thread.set_thinking_effort(Some(next_effort.clone()), cx); let favorite_key = thread .model() .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { if let Some(agent) = settings.agent.as_mut() { if let Some(default_model) = agent.default_model.as_mut() { default_model.effort = Some(next_effort.clone()); } if let Some((provider_id, model_id)) = &favorite_key { agent.update_favorite_model(provider_id, model_id, |favorite| { favorite.effort = Some(next_effort) }); } } }); }); } fn toggle_thinking_effort_menu( &mut self, _action: &ToggleThinkingEffortMenu, window: &mut Window, cx: &mut Context, ) { let menu_handle = self.thinking_effort_menu_handle.clone(); window.defer(cx, move |window, cx| { menu_handle.toggle(window, cx); }); } } impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_messages = self.list_state.item_count() > 0; let list_state = self.list_state.clone(); let conversation = v_flex() .when(self.resumed_without_history, |this| { this.child(Self::render_resume_notice(cx)) }) .map(|this| { if has_messages { this.flex_1() .size_full() .child(self.render_entries(cx)) .vertical_scrollbar_for(&list_state, window, cx) .into_any() } else { this.into_any() } }); v_flex() .key_context("AcpThread") .track_focus(&self.focus_handle) .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| { if this.parent_session_id.is_none() { this.cancel_generation(cx); } })) .on_action(cx.listener(|this, _: &workspace::GoBack, window, cx| { if let Some(parent_session_id) = this.thread.read(cx).parent_session_id().cloned() { this.server_view .update(cx, |view, cx| { view.navigate_to_thread(parent_session_id, window, cx); }) .ok(); } })) .on_action(cx.listener(Self::keep_all)) .on_action(cx.listener(Self::reject_all)) .on_action(cx.listener(Self::undo_last_reject)) .on_action(cx.listener(Self::allow_always)) .on_action(cx.listener(Self::allow_once)) .on_action(cx.listener(Self::reject_once)) .on_action(cx.listener(Self::handle_authorize_tool_call)) .on_action(cx.listener(Self::handle_select_permission_granularity)) .on_action(cx.listener(Self::handle_toggle_command_pattern)) .on_action(cx.listener(Self::open_permission_dropdown)) .on_action(cx.listener(Self::open_add_context_menu)) .on_action(cx.listener(Self::scroll_output_page_up)) .on_action(cx.listener(Self::scroll_output_page_down)) .on_action(cx.listener(Self::scroll_output_line_up)) .on_action(cx.listener(Self::scroll_output_line_down)) .on_action(cx.listener(Self::scroll_output_to_top)) .on_action(cx.listener(Self::scroll_output_to_bottom)) .on_action(cx.listener(Self::scroll_output_to_previous_message)) .on_action(cx.listener(Self::scroll_output_to_next_message)) .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| { this.toggle_fast_mode(cx); })) .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { thread.set_thinking_enabled(!thread.thinking_enabled(), cx); }); } })) .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } this.cycle_thinking_effort(cx); })) .on_action( cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } this.toggle_thinking_effort_menu(action, window, cx); }), ) .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { this.send_queued_message_at_index(0, true, window, cx); })) .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| { this.remove_from_queue(0, cx); cx.notify(); })) .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| { this.move_queued_message_to_main_editor(0, None, None, window, cx); })) .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| { this.local_queued_messages.clear(); this.sync_queue_flag_to_native_thread(cx); this.can_fast_track_queue = false; cx.notify(); })) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( acp::SessionConfigOptionCategory::Mode, window, cx, ) }); if handled { return; } } if let Some(profile_selector) = this.profile_selector.clone() { profile_selector.read(cx).menu_handle().toggle(window, cx); } else if let Some(mode_selector) = this.mode_selector.clone() { mode_selector.read(cx).menu_handle().toggle(window, cx); } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( acp::SessionConfigOptionCategory::Mode, false, cx, ) }); if handled { return; } } if let Some(profile_selector) = this.profile_selector.clone() { profile_selector.update(cx, |profile_selector, cx| { profile_selector.cycle_profile(cx); }); } else if let Some(mode_selector) = this.mode_selector.clone() { mode_selector.update(cx, |mode_selector, cx| { mode_selector.cycle_mode(window, cx); }); } })) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( acp::SessionConfigOptionCategory::Model, window, cx, ) }); if handled { return; } } if let Some(model_selector) = this.model_selector.clone() { model_selector .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); } })) .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { if this.thread.read(cx).status() != ThreadStatus::Idle { return; } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( acp::SessionConfigOptionCategory::Model, true, cx, ) }); if handled { return; } } if let Some(model_selector) = this.model_selector.clone() { model_selector.update(cx, |model_selector, cx| { model_selector.cycle_favorite_models(window, cx); }); } })) .size_full() .children(self.render_subagent_titlebar(cx)) .child(conversation) .children(self.render_multi_root_callout(cx)) .children(self.render_skill_loading_errors(cx)) .children(self.render_activity_bar(window, cx)) .when(self.show_external_source_prompt_warning, |this| { this.child(self.render_external_source_prompt_warning(cx)) }) .when(self.show_codex_windows_warning, |this| { this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_thread_retry_status_callout()) .children(self.render_thread_error(window, cx)) .when_some( match has_messages { true => None, false => self.new_server_version_available.clone(), }, |this, version| this.child(self.render_new_version_callout(&version, cx)), ) .children(self.render_token_limit_callout(cx)) .child(self.render_message_editor(window, cx)) } } pub(crate) fn open_link( url: SharedString, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let Some(workspace) = workspace.upgrade() else { cx.open_url(&url); return; }; if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { workspace.update(cx, |workspace, cx| match mention { MentionUri::File { abs_path } => { let project = workspace.project(); let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) else { return; }; workspace .open_path(path, None, true, window, cx) .detach_and_log_err(cx); } MentionUri::PastedImage { .. } => {} MentionUri::Directory { abs_path } => { let project = workspace.project(); let Some(entry_id) = project.update(cx, |project, cx| { let path = project.find_project_path(abs_path, cx)?; project.entry_for_path(&path, cx).map(|entry| entry.id) }) else { return; }; project.update(cx, |_, cx| { cx.emit(project::Event::RevealInProjectPanel(entry_id)); }); } MentionUri::Symbol { abs_path: path, line_range, .. } | MentionUri::Selection { abs_path: Some(path), line_range, } => { let project = workspace.project(); let Some(path) = project.update(cx, |project, cx| project.find_project_path(path, cx)) else { return; }; let item = workspace.open_path(path, None, true, window, cx); window .spawn(cx, async move |cx| { let Some(editor) = item.await?.downcast::() else { return Ok(()); }; let range = Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0); editor .update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, |s| s.select_ranges(vec![range]), ); }) .ok(); anyhow::Ok(()) }) .detach_and_log_err(cx); } MentionUri::Selection { abs_path: None, .. } => {} MentionUri::Thread { id, name } => { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.open_thread(id, None, Some(name.into()), window, cx) }); } } MentionUri::Rule { id, .. } => { let PromptId::User { uuid } = id else { return; }; window.dispatch_action( Box::new(OpenRulesLibrary { prompt_to_select: Some(uuid.0), }), cx, ) } MentionUri::Fetch { url } => { cx.open_url(url.as_str()); } MentionUri::Diagnostics { .. } => {} MentionUri::TerminalSelection { .. } => {} MentionUri::GitDiff { .. } => {} MentionUri::MergeConflict { .. } => {} }) } else { cx.open_url(&url); } }