Files
zed/crates/agent_ui/src/conversation_view/thread_view.rs
T
yenru0 fe62a99bf5 gwen: Purge remaining Zed branding from user-facing surfaces
Sweep every user-visible product string to Gwen: app menus (Quit,
Hide, Gwen Repository), feedback links (repo/issues now point at
github.com/kk376/zed, email to yenru@yrkaierx.com), move-to-Applications
and update dialogs, provider and settings copy, CLI help, installer and
desktop/flatpak/snap/plist metadata.

Rename user-facing settings values with back-compat aliases: base_keymap
"Gwen" (accepts "Zed"), fonts ".GwenMono"/".GwenSans" (accept
".ZedMono"/".ZedSans"), icon theme "Gwen (Default)". Fix F10
app-menu keymap arguments to "Gwen" and align the Windows installer
AppMutex with release_channel::app_identifier(). Internal identifiers,
wire protocol names, upstream service URLs and legal attribution are
intentionally kept.
2026-09-29 09:24:34 +09:00

13084 lines
525 KiB
Rust

use crate::{
DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
agent_configuration::configure_context_server_modal::default_markdown_style,
conversation_view::thread_search_bar::{ThreadSearchBar, ThreadSearchBarEvent},
open_abs_path_at_point,
thread_metadata_store::{ThreadId, ThreadMetadataStore},
};
use agent_client_protocol::schema::v1 as acp;
use std::cell::RefCell;
use acp_thread::{
Elicitation, ElicitationEntryId, ElicitationStatus, PlanEntry, SandboxAuthorizationDetails,
SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason, decode_path_escapes,
};
use agent::{
SandboxStatusKey, SandboxStatusRefresh, SkillLoadingIssue, SkillLoadingIssueKind,
SkillLoadingIssuesUpdated, ThreadSandbox, VerifiedSandboxStatus,
};
use agent_settings::UserAgentsMd;
use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
use editor::actions::OpenExcerpts;
use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
use crate::completion_provider::{AvailableSkill, PromptLocalCommand, pluralize};
use crate::message_editor::SharedSessionCapabilities;
use crate::ui::{
SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip, TerminalSandboxWarning,
TerminalToolHeader,
};
use crate::unicode_confusables;
use db::kvp::KeyValueStore;
use gpui::List;
use gpui::Stateful;
use gpui::TaskExt;
use heapless::Vec as ArrayVec;
use language_model::{
FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId,
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, Speed,
};
use notifications::status_toast::StatusToast;
use settings::{update_settings_file, update_settings_file_with_completion};
use ui::{
ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton,
SplitButtonStyle, Tab, ToggleState,
};
use util::markdown::{source_position_from_fragment, split_local_url_fragment};
use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME};
use super::elicitation::{
ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation,
};
use super::*;
const DATA_RETENTION_LEARN_MORE_URL: &str = "https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models";
#[derive(Default)]
struct ThreadFeedbackState {
feedback: Option<ThreadFeedback>,
comments_editor: Option<Entity<Editor>>,
}
impl ThreadFeedbackState {
pub fn submit(
&mut self,
thread: Entity<AcpThread>,
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<AcpThread>, 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<Editor> {
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<Self>) -> 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<AcpThreadViewEvent> for ThreadView {}
/// `cat -n`-style numbered code block, already stripped of its line-number
/// prefixes and ready to render. Line numbers are guaranteed to be contiguous
/// starting at `first_number`, so we only store the first number and the line
/// count rather than allocating a per-line `Vec`.
struct ParsedCatNumberedCode {
code: String,
first_number: u32,
line_count: usize,
}
fn parse_cat_numbered_markdown_code_block(markdown: &str) -> Option<ParsedCatNumberedCode> {
let (_tag, code) = parse_single_fenced_code_block(markdown)?;
parse_cat_numbered_code(code)
}
fn parse_single_fenced_code_block(markdown: &str) -> Option<(&str, &str)> {
let first_non_backtick = markdown.find(|character| character != '`')?;
if first_non_backtick < 3 {
return None;
}
let fence = &markdown[..first_non_backtick];
let after_opening_fence = &markdown[first_non_backtick..];
let tag_end = after_opening_fence.find('\n')?;
let tag = &after_opening_fence[..tag_end];
let after_tag = &after_opening_fence[tag_end + 1..];
let closing_fence = format!("\n{fence}\n");
let code = after_tag.strip_suffix(&closing_fence)?;
Some((tag, code))
}
/// Walks `code` exactly once: for each line it validates and strips the
/// `NNN\t` prefix, then pushes the line's content into the accumulating
/// code buffer (with `\n` between lines, no trailing newline). Verifies that
/// the line numbers form a contiguous, increasing sequence.
fn parse_cat_numbered_code(code: &str) -> Option<ParsedCatNumberedCode> {
if code.is_empty() {
return None;
}
let mut output = String::with_capacity(code.len());
let mut first_number = None;
let mut expected_number = None;
let mut line_count: usize = 0;
for raw_line in code.split_inclusive('\n') {
let line = strip_line_ending(raw_line);
let (number, text) = parse_cat_numbered_line(line)?;
if let Some(expected) = expected_number {
if number != expected {
return None;
}
} else {
first_number = Some(number);
}
expected_number = number.checked_add(1);
if line_count > 0 {
output.push('\n');
}
output.push_str(text);
line_count += 1;
}
Some(ParsedCatNumberedCode {
code: output,
first_number: first_number?,
line_count,
})
}
fn strip_line_ending(line: &str) -> &str {
let without_lf = line.strip_suffix('\n').unwrap_or(line);
without_lf.strip_suffix('\r').unwrap_or(without_lf)
}
fn parse_cat_numbered_line(line: &str) -> Option<(u32, &str)> {
let (prefix, text) = line.split_once('\t')?;
let number = prefix.trim();
if number.is_empty()
|| !prefix
.chars()
.all(|character| character == ' ' || character.is_ascii_digit())
{
return None;
}
Some((number.parse().ok()?, text))
}
fn render_cat_numbered_code_block(
parsed: ParsedCatNumberedCode,
language: Option<Arc<Language>>,
markdown_style: MarkdownStyle,
copy_button_id: String,
cx: &App,
) -> AnyElement {
use std::fmt::Write as _;
let ParsedCatNumberedCode {
code,
first_number,
line_count,
} = parsed;
// Line numbers are contiguous (verified during parsing), so the largest
// line number is `first_number + line_count - 1`. Sizing the gutter to
// that number's digit count means every rendered line contributes exactly
// `gutter_width` bytes to the gutter, plus a newline between adjacent
// lines.
let last_number = first_number
.saturating_add(u32::try_from(line_count.saturating_sub(1)).unwrap_or(u32::MAX));
let gutter_width = last_number.to_string().len().max(1);
let gutter_capacity = line_count * gutter_width + line_count.saturating_sub(1);
let mut gutter = String::with_capacity(gutter_capacity);
for i in 0..line_count {
if i > 0 {
gutter.push('\n');
}
let line_number = first_number.saturating_add(u32::try_from(i).unwrap_or(u32::MAX));
// Writes to a `String` are infallible, so the `Result` can be ignored.
let _ = write!(&mut gutter, "{line_number:>gutter_width$}");
}
let mut code_text_style = markdown_style.base_text_style.clone();
code_text_style.refine(&markdown_style.code_block.text);
let mut gutter_text_style = code_text_style.clone();
gutter_text_style.color = cx.theme().colors().text_muted;
let gutter_len = gutter.len();
let gutter = StyledText::new(gutter).with_runs(vec![gutter_text_style.to_run(gutter_len)]);
// Share `code` between syntax highlighting, the rendered `StyledText`, and
// the copy button via a single `SharedString` (cheap `Arc` clones) instead
// of cloning the underlying `String`.
let code: SharedString = code.into();
let code_runs = highlight_code_runs(&code, language.as_ref(), code_text_style, &markdown_style);
let code_text = StyledText::new(code.clone()).with_runs(code_runs);
let code_block_id = format!("read-file-code-block-{copy_button_id}");
let code_scroll_id = format!("read-file-code-scroll-{copy_button_id}");
let mut container = div()
.id(code_block_id)
.group("read-file-code-block")
.relative()
.w_full()
.whitespace_nowrap();
container.style().refine(&markdown_style.code_block);
// `overflow_x_scroll` only actually scrolls when the container is laid out
// as a flex container: in GPUI the default `Display` is `Block`, and a
// block-level child fills its parent's content width instead of overflowing
// it, so there is nothing for the scroll viewport to scroll. Using `flex()`
// on the scroll wrapper plus `flex_none()` on the inner item lets the inner
// item take its natural width (the unwrapped code), which is what overflows.
// `restrict_scroll_to_axis` then keeps vertical wheel events flowing through
// to the outer thread scroller. This mirrors the standard markdown
// code-block path in `crates/markdown/src/markdown.rs`.
let code_scroll = div()
.id(code_scroll_id)
.flex()
.flex_1()
.min_w_0()
.overflow_x_scroll()
.restrict_scroll_to_axis()
.child(div().flex_none().child(code_text));
container
.child(
h_flex()
.items_start()
.min_w_0()
.w_full()
.child(div().flex_none().pr_3().child(gutter))
.child(code_scroll),
)
.child(
h_flex()
.w_4()
.absolute()
.top_0()
.right_0()
.justify_end()
.visible_on_hover("read-file-code-block")
.child(CopyButton::new(copy_button_id, code).tooltip_label("Copy Code")),
)
.into_any_element()
}
fn highlight_code_runs(
code: &str,
language: Option<&Arc<Language>>,
code_text_style: TextStyle,
markdown_style: &MarkdownStyle,
) -> Vec<TextRun> {
if code.is_empty() {
return Vec::new();
}
let Some(language) = language else {
return vec![code_text_style.to_run(code.len())];
};
let mut runs = Vec::new();
let mut offset = 0;
for (range, highlight_id) in language.highlight_text(&Rope::from(code), 0..code.len()) {
if range.start > offset {
runs.push(code_text_style.to_run(range.start - offset));
}
let mut run_style = code_text_style.clone();
if let Some(highlight) = markdown_style.syntax.get(highlight_id).cloned() {
run_style = run_style.highlight(highlight);
}
runs.push(run_style.to_run(range.len()));
offset = range.end;
}
if offset < code.len() {
runs.push(code_text_style.to_run(code.len() - offset));
}
runs
}
#[cfg(test)]
mod numbered_code_block_tests {
use super::*;
#[test]
fn parses_cat_numbered_markdown_code_block() {
let parsed = parse_cat_numbered_markdown_code_block(
"```rs zed/crates/example.rs\n 2\tfn main() {\n 3\t println!(\"hi\");\n 4\t}\n```\n",
)
.expect("cat-numbered block should parse");
assert_eq!(parsed.line_count, 3);
assert_eq!(parsed.first_number, 2);
assert_eq!(parsed.code, "fn main() {\n println!(\"hi\");\n}");
}
#[test]
fn parses_cat_numbered_code_with_crlf_line_endings() {
let parsed = parse_cat_numbered_code(" 1\tline one\r\n 2\tline two\r\n")
.expect("crlf-terminated cat-numbered code should parse");
assert_eq!(parsed.line_count, 2);
assert_eq!(parsed.first_number, 1);
assert_eq!(parsed.code, "line one\nline two");
}
#[test]
fn rejects_non_cat_numbered_code_block() {
assert!(parse_cat_numbered_markdown_code_block("```rs\nfn main() {}\n```\n").is_none());
}
#[test]
fn rejects_non_contiguous_cat_numbers() {
assert!(
parse_cat_numbered_markdown_code_block(
"```rs\n 2\tlet a = 1;\n 4\tlet b = 2;\n```\n"
)
.is_none()
);
}
}
/// 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<usize>),
}
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<usize> {
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<acp::SessionId>,
pub thread: Entity<AcpThread>,
pub(crate) conversation: Entity<super::Conversation>,
pub server_view: WeakEntity<ConversationView>,
pub agent_icon: IconName,
pub agent_icon_from_external_svg: Option<SharedString>,
pub agent_id: AgentId,
pub agent_display_name: SharedString,
pub focus_handle: FocusHandle,
pub workspace: WeakEntity<Workspace>,
pub entry_view_state: Entity<EntryViewState>,
pub title_editor: Entity<Editor>,
pub config_options_view: Option<Entity<ConfigOptionsView>>,
pub mode_selector: Option<Entity<ModeSelector>>,
pub model_selector: Option<Entity<ModelSelectorPopover>>,
pub profile_selector: Option<Entity<ProfileSelector>>,
pub permission_dropdown_handle: PopoverMenuHandle<ContextMenu>,
pub thread_retry_status: Option<RetryStatus>,
pub(super) thread_error: Option<ThreadError>,
pub thread_error_markdown: Option<Entity<Markdown>>,
pub token_limit_callout_dismissed: bool,
pub last_token_limit_telemetry: Option<acp_thread::TokenUsageRatio>,
thread_feedback: ThreadFeedbackState,
pub list_state: ListState,
pub session_capabilities: SharedSessionCapabilities,
pub expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
collapsed_sandbox_authorization_details: HashSet<acp::ToolCallId>,
collapsed_sandbox_network_details: HashSet<acp::ToolCallId>,
/// Sandbox escalation prompts whose "surprising Unicode" warning the user
/// has explicitly acknowledged. Until a prompt's tool call is in this set,
/// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`].
acknowledged_confusable_warnings: HashSet<acp::ToolCallId>,
pub subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
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<usize>,
pub message_queue: MessageQueue,
pub turn_fields: TurnFields,
pub discarded_partial_edits: HashSet<acp::ToolCallId>,
pub is_loading_contents: bool,
pub new_server_version_available: Option<SharedString>,
pub resumed_without_history: bool,
pub(crate) permission_selections: HashMap<acp::ToolCallId, PermissionSelection>,
elicitation_form_states: HashMap<ElicitationEntryId, ElicitationFormState>,
pub _cancel_task: Option<Task<()>>,
_save_task: Option<Task<()>>,
_draft_resolve_task: Option<Task<()>>,
_sandbox_status_refresh_task: Option<Task<()>>,
pub hovered_edited_file_buttons: Option<usize>,
pub in_flight_prompt: Option<Vec<acp::ContentBlock>>,
pub _subscriptions: Vec<Subscription>,
pub message_editor: Entity<MessageEditor>,
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
pub fast_mode_menu_handle: PopoverMenuHandle<ContextMenu>,
pub project: WeakEntity<Project>,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Cloned from the parent `ConversationView` so the cache is shared and the
/// snapshot stays in sync via the parent's project-event subscription.
pub(crate) code_span_resolver: AgentCodeSpanResolver,
pub show_external_source_prompt_warning: bool,
pub show_codex_windows_warning: bool,
sandbox_status: Option<VerifiedSandboxStatus>,
sandbox_status_key: Option<SandboxStatusKey>,
pending_sandbox_status_key: Option<SandboxStatusKey>,
pub multi_root_callout_dismissed: bool,
pub generating_indicator_in_list: bool,
pub skill_loading_issues: Vec<SkillLoadingIssue>,
/// Issues the user has explicitly dismissed. Each entry is matched against
/// emitted issues by full equality; when an issue no longer appears in the
/// latest replacement list (because the underlying file was fixed/removed), it's
/// dropped from this set so a future regression of the same kind would
/// re-show.
dismissed_skill_loading_issues: HashSet<SkillLoadingIssue>,
pub(crate) thread_search_bar: Option<Entity<super::thread_search_bar::ThreadSearchBar>>,
pub(crate) thread_search_visible: bool,
}
impl Focusable for ThreadView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ThreadView {
pub(crate) fn activation_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<Task<()>>,
pub last_turn_duration: Option<Duration>,
pub last_turn_tokens: Option<u64>,
pub turn_generation: usize,
pub turn_started_at: Option<Instant>,
pub turn_tokens: Option<u64>,
}
/// How a tool call is rendered relative to its surroundings.
///
/// `Standalone` draws its own border/margin/location header. `Embedded` is
/// hosted by a container that provides its own framing (e.g. the subagent
/// card). `Floating` is like `Embedded`, but used for the floating
/// awaiting-permission row above the message editor: the tool call's content
/// is height-capped and scrollable so the row can never grow to consume the
/// entire panel and squeeze the conversation list out of view.
#[derive(Copy, Clone, PartialEq, Eq)]
enum ToolCallLayout {
Standalone,
Embedded,
Floating,
}
impl ToolCallLayout {
/// Stable discriminant used to disambiguate element ids when the same tool
/// call is rendered in more than one layout at once (e.g. inline in the
/// list *and* in the floating awaiting-permission row).
fn id_str(self) -> &'static str {
match self {
ToolCallLayout::Standalone => "standalone",
ToolCallLayout::Embedded => "embedded",
ToolCallLayout::Floating => "floating",
}
}
}
fn full_path_for_empty_project_path(file: &dyn language::File, cx: &App) -> Option<String> {
if file.path().file_name().is_some() {
return None;
}
let full_path = file.full_path(cx).display().to_string();
(!full_path.is_empty()).then_some(full_path)
}
fn skill_issue_file_label(path: &std::path::Path) -> String {
let file_name = path.file_name().and_then(|name| name.to_str());
let parent_name = path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str());
match (parent_name, file_name) {
(Some(parent_name), Some(file_name)) => format!("{parent_name}/{file_name}"),
(_, Some(file_name)) => file_name.to_string(),
_ => path.display().to_string(),
}
}
pub fn open_markdown_in_workspace(
title: String,
markdown: String,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
let markdown_language_task = workspace
.read(cx)
.app_state()
.languages
.language_for_name("Markdown");
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(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(title);
editor.disable_mouse_wheel_zoom();
editor
})),
None,
true,
window,
cx,
);
})?;
anyhow::Ok(())
})
}
impl ThreadView {
pub(crate) fn new(
root_thread_id: ThreadId,
thread: Entity<AcpThread>,
conversation: Entity<super::Conversation>,
server_view: WeakEntity<ConversationView>,
agent_icon: IconName,
agent_icon_from_external_svg: Option<SharedString>,
agent_id: AgentId,
agent_display_name: SharedString,
workspace: WeakEntity<Workspace>,
entry_view_state: Entity<EntryViewState>,
config_options_view: Option<Entity<ConfigOptionsView>>,
mode_selector: Option<Entity<ModeSelector>>,
model_selector: Option<Entity<ModelSelectorPopover>>,
profile_selector: Option<Entity<ProfileSelector>>,
list_state: ListState,
session_capabilities: SharedSessionCapabilities,
resumed_without_history: bool,
project: WeakEntity<Project>,
code_span_resolver: AgentCodeSpanResolver,
thread_store: Option<Entity<ThreadStore>>,
initial_content: Option<AgentInitialContent>,
mut subscriptions: Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let session_id = thread.read(cx).session_id().clone();
let parent_session_id = thread.read(cx).parent_session_id().cloned();
let has_slash_completions = session_capabilities.read().has_slash_completions();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_slash_completions);
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,
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";
if let Some(project) = project.upgrade() {
subscriptions.push(cx.subscribe(&project, {
let resolver = code_span_resolver.clone();
move |_this: &mut Self, _project, event: &project::Event, cx| {
if matches!(
event,
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeUpdatedEntries(_, _)
) {
resolver.clear_cache();
cx.notify();
}
}
}));
}
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
// issues 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 issues
// once a user resolves them.
if let Some(native_connection) = thread
.read(cx)
.connection()
.clone()
.downcast::<agent::NativeAgentConnection>()
{
let project_id = thread.read(cx).project().entity_id();
subscriptions.push(cx.subscribe(
&native_connection.0,
move |this: &mut Self, _agent, event: &SkillLoadingIssuesUpdated, cx| {
if event.project_id != project_id {
return;
}
// Drop dismissals for issues 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_issues
.retain(|dismissed| event.issues.contains(dismissed));
// Show only issues that haven't been dismissed.
this.skill_loading_issues = event
.issues
.iter()
.filter(|issue| !this.dismissed_skill_loading_issues.contains(issue))
.cloned()
.collect();
cx.notify();
},
));
// A "no model selected" error is stale as soon as the thread has a
// usable model
if let Some(native_thread) = native_connection.thread(thread.read(cx).session_id(), cx)
{
subscriptions.push(cx.subscribe(
&native_thread,
|this: &mut Self, _thread, _event: &agent::ModelChanged, cx| {
if matches!(this.thread_error, Some(ThreadError::NoModelSelected)) {
this.clear_thread_error(cx);
}
},
));
}
}
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,
agent_display_name,
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_call_raw_inputs: HashSet::default(),
collapsed_sandbox_authorization_details: HashSet::default(),
collapsed_sandbox_network_details: HashSet::default(),
acknowledged_confusable_warnings: 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,
message_queue: MessageQueue::default(),
turn_fields: TurnFields::default(),
discarded_partial_edits: HashSet::default(),
is_loading_contents: false,
new_server_version_available: None,
permission_selections: HashMap::default(),
elicitation_form_states: HashMap::default(),
_cancel_task: None,
_save_task: None,
_draft_resolve_task: None,
_sandbox_status_refresh_task: None,
hovered_edited_file_buttons: None,
in_flight_prompt: None,
message_editor,
add_context_menu_handle: PopoverMenuHandle::default(),
thinking_effort_menu_handle: PopoverMenuHandle::default(),
fast_mode_menu_handle: PopoverMenuHandle::default(),
project,
code_span_resolver,
show_external_source_prompt_warning,
show_codex_windows_warning,
sandbox_status: None,
sandbox_status_key: None,
pending_sandbox_status_key: None,
multi_root_callout_dismissed: false,
generating_indicator_in_list: false,
skill_loading_issues: Vec::new(),
dismissed_skill_loading_issues: HashSet::default(),
thread_search_bar: None,
thread_search_visible: false,
};
this.sync_generating_indicator(cx);
this.sync_editor_mode(cx);
this.sync_existing_elicitation_states(window, 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>) {
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<MessageEditor>,
event: &MessageEditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
// 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);
if let Some(project) = self.project.upgrade() {
connection.refresh_skills_for_project(project, cx);
}
}
}
match event {
MessageEditorEvent::Send => self.send(window, cx),
MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
MessageEditorEvent::Cancel => {
if !self.close_thread_search(window, cx) {
self.cancel_generation(cx);
}
}
MessageEditorEvent::Focus => {
self.cancel_editing(&Default::default(), window, cx);
}
MessageEditorEvent::LostFocus => {}
MessageEditorEvent::SlashAutocompleteOpened => {}
MessageEditorEvent::LocalCommandInvoked(command) => {
self.run_local_command(*command, window, cx);
}
MessageEditorEvent::InputAttempted { .. } => {}
MessageEditorEvent::Edited => {}
}
}
fn run_local_command(
&mut self,
command: PromptLocalCommand,
window: &mut Window,
cx: &mut Context<Self>,
) {
match command {
PromptLocalCommand::ThumbsUp => {
self.handle_feedback_click(ThreadFeedback::Positive, window, cx);
self.show_local_command_toast("Thanks for your feedback!", cx);
}
PromptLocalCommand::ThumbsDown => {
self.handle_feedback_click(ThreadFeedback::Negative, window, cx);
}
}
}
fn show_local_command_toast(&self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
// Shown after positive feedback, replacing the inline button state.
let Some(workspace) = self.workspace.upgrade() else {
return;
};
workspace.update(cx, |workspace, cx| {
let toast = StatusToast::new(message, cx, |this, _cx| {
this.icon(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
});
workspace.toggle_status_toast(toast, cx);
});
}
pub(crate) fn as_native_connection(
&self,
cx: &App,
) -> Option<Rc<agent::NativeAgentConnection>> {
let acp_thread = self.thread.read(cx);
acp_thread.connection().clone().downcast()
}
pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
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<MessageEditor>,
cx: &mut App,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
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<String> {
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<Arc<str>> {
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<MessageEditor> {
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.message_queue.is_empty()
}
// events
pub fn handle_entry_view_event(
&mut self,
_: &Entity<EntryViewState>,
event: &EntryViewEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match &event.view_event {
ViewEvent::NewDiff(tool_call_id) => {
if AgentSettings::get_global(cx).expand_edit_card {
self.entry_view_state.update(cx, |state, _cx| {
state.expand_tool_call(tool_call_id.clone());
});
}
}
ViewEvent::NewTerminal(tool_call_id) => {
if AgentSettings::get_global(cx).expand_terminal_card {
self.entry_view_state.update(cx, |state, _cx| {
state.expand_tool_call(tool_call_id.clone());
});
}
}
ViewEvent::TerminalMovedToBackground(tool_call_id) => {
self.entry_view_state.update(cx, |state, _cx| {
state.collapse_tool_call(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.client_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.client_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::LocalCommandInvoked(_)) => {}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {}
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<Self>,
) {
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::<Editor>() 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<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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;
if is_editor_empty {
if let Some(entry) = self.message_queue.try_fast_track(is_generating) {
self.dispatch_queued_entry(entry, window, cx);
}
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(|available_command| available_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 server_view = self.server_view.clone();
move |window, cx| {
ConversationView::handle_auth_required(
server_view.clone(),
AuthRequired::new(),
connection,
window,
cx,
);
}
});
cx.notify();
return;
}
}
// A built-in command (e.g. `/compact`): run the bare command without
// echoing it as a user message, and queue any trailing text the user
// typed so it isn't silently dropped.
let native_command =
leading_native_command(text, self.session_capabilities.read().available_commands());
if let Some(command_name) = native_command {
cx.emit(AcpThreadViewEvent::Interacted);
self.send_command_queueing_remainder(message_editor, command_name, window, cx);
return;
}
cx.emit(AcpThreadViewEvent::Interacted);
self.send_impl(message_editor, window, cx)
}
/// Sends a bare `/command` turn and queues everything the user typed after
/// it as a follow-up message. The queued remainder auto-processes when the
/// command turn stops, so e.g. `/compact do X` compacts and then runs `do X`
/// rather than discarding it.
fn send_command_queueing_remainder(
&mut self,
message_editor: Entity<MessageEditor>,
command_name: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Resolve the editor contents before clearing it: the resolve task
// reads the editor lazily, so clearing first would wipe the contents.
let contents = self.resolve_message_contents(&message_editor, cx);
self.thread_error.take();
self.thread_feedback.clear();
self.editing_message.take();
cx.spawn_in(window, async move |this, cx| {
let (mut content, tracked_buffers) = contents.await?;
cx.update(|window, cx| {
message_editor.update(cx, |message_editor, cx| {
message_editor.clear(window, cx);
});
})?;
// Strip the leading `/command` from the first text block; whatever
// remains (including any later mention blocks) becomes the queued
// follow-up message.
if let Some(acp::ContentBlock::Text(text_content)) = content.first_mut() {
text_content.text = strip_leading_command(&text_content.text, &command_name);
}
if matches!(
content.first(),
Some(acp::ContentBlock::Text(text)) if text.text.trim().is_empty()
) {
content.remove(0);
}
let command_block =
acp::ContentBlock::Text(acp::TextContent::new(format!("/{command_name}")));
this.update_in(cx, |this, window, cx| {
// Queue the remainder first, then start the command turn; the
// queue auto-processes when the command turn stops.
if !content.is_empty() {
this.add_to_queue(content, tracked_buffers, window, cx);
}
this.send_content(
Task::ready(Ok(Some((vec![command_block], Vec::new())))),
true,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
pub fn send_impl(
&mut self,
message_editor: Entity<MessageEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let contents = self.resolve_message_contents(&message_editor, cx);
self.thread_error.take();
self.thread_feedback.clear();
self.editing_message.take();
// Sending a message is active engagement: un-freeze the queue if it
// was paused by a manual stop.
self.message_queue.resume();
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, false, window, cx);
}
pub fn send_content(
&mut self,
contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
is_native_command: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
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 = crate::agent_sidebar_side(cx);
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::<Vec<_>>()
.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
);
if is_native_command {
thread.send_command(contents, cx)
} else {
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<Self>) {
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<MessageEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let thread = self.thread.clone();
self.message_queue.pause();
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<ThreadError>,
cx: &mut Context<Self>,
) {
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<Self>) {
let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
match error {
ThreadError::ZedPaymentRequired => (
"payment_required",
None,
"You reached your free usage limit. Upgrade to Gwen 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::DataRetentionConsentRequired => {
let message = format!(
"{} is not available with Zero Data Retention.",
self.current_model_name(cx)
);
("data_retention_consent_required", 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::NoCredentials { provider } => (
"no_api_key",
None,
format!("No credentials configured for {provider}.").into(),
),
ThreadError::StreamError { provider } => (
"stream_error",
None,
format!("Connection to {provider}'s API was interrupted.").into(),
),
ThreadError::AuthenticationFailed { provider } => (
"invalid_api_key",
None,
format!("Authentication with {provider} failed.").into(),
),
ThreadError::PermissionDenied { provider, message } => (
"permission_denied",
None,
message.clone().unwrap_or_else(|| {
format!(
"{provider}'s API rejected the request due to insufficient permissions."
)
.into()
}),
),
ThreadError::ProviderRejection { message } => {
("provider_rejection", None, message.clone())
}
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>) {
self.thread_retry_status.take();
self.thread_error.take();
self.message_queue.pause();
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>) {
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<MessageEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.is_loading_contents {
return;
}
let thread = self.thread.clone();
let Some(client_id) = thread.update(cx, |thread, _| {
thread
.entries()
.get(entry_ix)?
.user_message()?
.client_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`.
//
// Subagent edits never show up as diffs in the parent thread's entries (they
// are only forwarded to the parent's action log), so treat any earlier
// subagent tool call as potentially having edits. Keeping all edits is a
// no-op when the subagent didn't make any.
let has_earlier_edits = thread.read_with(cx, |thread, _| {
thread.entries().iter().take(entry_ix).any(|entry| {
entry.diffs().next().is_some()
|| matches!(
entry,
AgentThreadEntry::ToolCall(tool_call) if tool_call.is_subagent()
)
})
});
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(client_id, cx))
.await?;
this.update_in(cx, |thread, window, cx| {
cx.emit(AcpThreadViewEvent::Interacted);
thread.send_impl(message_editor, window, cx);
thread.activation_focus_handle(cx).focus(window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
// message queueing
fn queue_message(
&mut self,
message_editor: Entity<MessageEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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, window, cx);
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<acp::ContentBlock>,
tracked_buffers: Vec<Entity<Buffer>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// The ID must be allocated up front so the editor event subscription
// can capture it before the entry (which owns the subscription) exists.
let id = self.message_queue.next_id();
let editor = cx.new(|cx| {
let mut editor = MessageEditor::new(
self.workspace.clone(),
self.project.clone(),
None,
self.session_capabilities.clone(),
self.agent_id.clone(),
"",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: Some(10),
},
window,
cx,
);
editor.set_read_only(true, cx);
editor.set_message(content.clone(), window, cx);
editor
});
let subscription =
cx.subscribe_in(&editor, window, move |this, _editor, event, window, cx| {
this.handle_queue_editor_event(id, event, window, cx);
});
self.message_queue.enqueue(QueueEntry {
id,
content,
tracked_buffers,
steer: false,
editor,
_subscription: subscription,
});
self.sync_queue_flag_to_native_thread(cx);
cx.notify();
}
fn handle_queue_editor_event(
&mut self,
id: QueueEntryId,
event: &MessageEditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
MessageEditorEvent::InputAttempted {
attempt,
cursor_offset,
} => {
self.move_queued_message_to_main_editor(
id,
Some(attempt.clone()),
Some(*cursor_offset),
window,
cx,
);
}
MessageEditorEvent::LostFocus => {
self.save_queued_message(id, cx);
}
MessageEditorEvent::Cancel | MessageEditorEvent::Send => {
window.focus(&self.message_editor.focus_handle(cx), cx);
}
MessageEditorEvent::SendImmediately => {
self.send_queued_message_now(id, window, cx);
}
_ => {}
}
}
fn save_queued_message(&mut self, id: QueueEntryId, cx: &mut Context<Self>) {
let Some(entry) = self.message_queue.entry_by_id(id) else {
return;
};
let contents_task = entry
.editor
.update(cx, |editor, cx| editor.contents(false, cx));
cx.spawn(async move |this, cx| {
let (content, tracked_buffers) = contents_task.await?;
this.update(cx, |this, cx| {
if let Some(entry) = this.message_queue.entry_by_id_mut(id) {
entry.content = content;
entry.tracked_buffers = tracked_buffers;
}
cx.notify();
})?;
Ok::<(), anyhow::Error>(())
})
.detach_and_log_err(cx);
}
pub fn remove_from_queue(
&mut self,
id: QueueEntryId,
cx: &mut Context<Self>,
) -> Option<QueueEntry> {
let removed = self.message_queue.remove(id);
if removed.is_some() {
self.sync_queue_flag_to_native_thread(cx);
}
removed
}
fn toggle_queue_entry_steer(&mut self, id: QueueEntryId, cx: &mut Context<Self>) {
self.message_queue.toggle_steer(id);
self.sync_queue_flag_to_native_thread(cx);
cx.notify();
}
pub fn sync_queue_flag_to_native_thread(&self, cx: &mut Context<Self>) {
if let Some(native_thread) = self.as_native_thread(cx) {
// By default queued messages wait for the turn to fully complete.
// Only a "steering" front message ends the turn at the next boundary.
let end_at_boundary = self.message_queue.front_wants_steer();
native_thread.update(cx, |thread, _| {
thread.set_end_turn_at_next_boundary(end_at_boundary);
});
}
}
pub fn send_queued_message_now(
&mut self,
id: QueueEntryId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let is_generating = self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
if let Some(entry) = self.message_queue.send_now(id, is_generating) {
self.dispatch_queued_entry(entry, window, cx);
}
}
/// The shared "actually send this entry" path, used by fast-track,
/// auto-processing on Stopped, and "Send Now". The entry must already have
/// been removed from the queue.
pub fn dispatch_queued_entry(
&mut self,
entry: QueueEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.sync_queue_flag_to_native_thread(cx);
cx.emit(AcpThreadViewEvent::Interacted);
self.message_editor.focus_handle(cx).focus(window, cx);
let content = entry.content;
let tracked_buffers = entry.tracked_buffers;
// A queued message can itself be a built-in command (e.g. the user typed
// `/compact` while a turn was generating). Detect that so we run it as a
// command turn without echoing it as a user message, matching the
// non-queued path.
let is_native_command = content
.first()
.and_then(|block| match block {
acp::ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
})
.and_then(|text| {
leading_native_command(text, self.session_capabilities.read().available_commands())
})
.is_some();
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, is_native_command, window, cx);
}
pub fn move_queued_message_to_main_editor(
&mut self,
id: QueueEntryId,
attempt: Option<InputAttempt>,
cursor_offset: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(queued_message) = self.remove_from_queue(id, 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
}
fn handle_message_editor_move_up(
&mut self,
_: &zed_actions::editor::MoveUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.message_editor.read(cx).is_empty(cx) {
cx.propagate();
return;
}
let Some(last_id) = self.message_queue.last_id() else {
cx.propagate();
return;
};
self.move_queued_message_to_main_editor(last_id, None, None, window, cx);
}
// editor methods
pub fn expand_message_editor(
&mut self,
_: &ExpandMessageEditor,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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>) {
self.editor_expanded = is_expanded;
self.sync_editor_mode(cx);
cx.notify();
}
pub fn handle_title_editor_event(
&mut self,
title_editor: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
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;
}
self.apply_renamed_title(SharedString::from(new_title), 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);
});
}
}
_ => {}
}
}
/// Renames the thread, mirroring the editor text and persisting the new
/// title. Used by callers outside of the title editor (e.g. the sidebar's
/// inline rename) so that they go through the same persistence path as
/// the in-thread title editor.
pub fn rename(&mut self, title: SharedString, window: &mut Window, cx: &mut Context<Self>) {
if self.title_editor.read(cx).text(cx) != title.as_ref() {
self.title_editor.update(cx, |editor, cx| {
editor.set_text(title.clone(), window, cx);
});
}
self.apply_renamed_title(title, cx);
}
fn apply_renamed_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
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);
});
}
self.thread.update(cx, |thread, cx| {
if thread.can_set_title(cx) {
thread.set_title(title, cx).detach_and_log_err(cx);
}
});
}
pub fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
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>,
) {
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>) {
if self.pending_allow_blocked_by_confusables(cx) {
return;
}
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
}
pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_allow_blocked_by_confusables(cx) {
return;
}
self.authorize_pending_with_granularity(true, window, cx);
}
/// Whether the currently pending permission prompt is blocked by an
/// unacknowledged surprising-Unicode warning, so the keyboard allow
/// shortcuts must be ignored (mirroring the disabled allow buttons).
fn pending_allow_blocked_by_confusables(&self, cx: &Context<Self>) -> bool {
let session_id = self.thread.read(cx).session_id().clone();
let Some((_, tool_call_id, _)) = self
.conversation
.read(cx)
.pending_tool_call(&session_id, cx)
else {
return false;
};
self.thread.read(cx).entries().iter().any(|entry| {
matches!(
entry,
AgentThreadEntry::ToolCall(call)
if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx)
)
})
}
pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
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<Self>,
) -> 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 has_pending_request_elicitation(&self, cx: &App) -> bool {
self.server_view
.read_with(cx, |server_view, cx| {
server_view
.request_elicitation_store()
.is_some_and(|store| {
store.read(cx).elicitations().iter().any(|elicitation| {
matches!(elicitation.status, ElicitationStatus::Pending { .. })
})
})
})
.unwrap_or(false)
}
pub fn sync_elicitation_state_for_entry(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let elicitation_id = {
let thread = self.thread.read(cx);
let Some(AgentThreadEntry::Elicitation(elicitation_id)) = thread.entries().get(index)
else {
return;
};
elicitation_id.clone()
};
let thread = self.thread.read(cx);
let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| {
(
elicitation_id.clone(),
matches!(elicitation.status, ElicitationStatus::Pending { .. }),
match &elicitation.request.mode {
acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()),
_ => None,
},
)
});
let Some((id, is_pending, schema)) = entry else {
return;
};
if is_pending
&& let Some(schema) = schema
&& !self.elicitation_form_states.contains_key(&id)
{
self.elicitation_form_states
.insert(id, ElicitationFormState::new(&schema, window, cx));
} else if !is_pending {
self.elicitation_form_states.remove(&id);
}
}
fn sync_existing_elicitation_states(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let entry_count = self.thread.read(cx).entries().len();
for index in 0..entry_count {
self.sync_elicitation_state_for_entry(index, window, cx);
}
}
#[cfg(test)]
pub(crate) fn has_elicitation_form_state(&self, id: &ElicitationEntryId) -> bool {
self.elicitation_form_states.contains_key(id)
}
fn submit_elicitation(
&mut self,
elicitation_id: ElicitationEntryId,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let mode = self
.thread
.read(cx)
.elicitation(&elicitation_id)
.map(|(_, elicitation)| elicitation.request.mode.clone());
let Some(mode) = mode else {
return;
};
match mode {
acp::ElicitationMode::Form(mode) => {
let Some(state) = self.elicitation_form_states.get_mut(&elicitation_id) else {
return;
};
let Some(submission) = state.begin_submission(cx) else {
return;
};
let schema = mode.requested_schema;
let validation_task = cx.background_spawn(async move {
let result = submission.validate(&schema);
(submission, result)
});
cx.notify();
cx.spawn(async move |this, cx| {
let (submission, result) = validation_task.await;
this.update(cx, |this, cx| {
let is_current = this
.elicitation_form_states
.get_mut(&elicitation_id)
.is_some_and(|state| {
state.validation_matches_current_values(&submission, cx)
});
if !is_current {
cx.notify();
return;
}
match result {
Ok(content) => {
this.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(
acp::ElicitationAction::Accept(
acp::ElicitationAcceptAction::new().content(content),
),
),
cx,
);
}
Err(errors) => {
if let Some(state) =
this.elicitation_form_states.get_mut(&elicitation_id)
{
state.set_errors(errors);
}
cx.notify();
}
}
})
.log_err();
})
.detach();
}
acp::ElicitationMode::Url(_) => {
self.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
acp::ElicitationAcceptAction::new(),
)),
cx,
);
}
_ => {}
}
}
fn decline_elicitation(
&mut self,
elicitation_id: ElicitationEntryId,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
cx,
);
}
fn cancel_elicitation(
&mut self,
elicitation_id: ElicitationEntryId,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
cx,
);
}
fn dismiss_url_elicitation(
&mut self,
elicitation_id: ElicitationEntryId,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.elicitation_form_states.remove(&elicitation_id);
self.thread.update(cx, |thread, cx| {
thread.cancel_elicitation(&elicitation_id, cx);
});
cx.notify();
}
fn respond_to_elicitation(
&mut self,
elicitation_id: ElicitationEntryId,
response: acp::CreateElicitationResponse,
cx: &mut Context<Self>,
) {
let session_id = self.session_id.clone();
self.elicitation_form_states.remove(&elicitation_id);
self.conversation.update(cx, |conversation, cx| {
conversation.respond_to_elicitation(session_id, elicitation_id, response, cx);
});
cx.notify();
}
fn handle_authorize_tool_call(
&mut self,
action: &AuthorizeToolCall,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) -> 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<Self>,
) -> 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<Self>) {
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<Self>) {
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).next().is_some();
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<Self>,
) {
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<Buffer>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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
pub fn restore_checkpoint(&mut self, client_id: &ClientUserMessageId, cx: &mut Context<Self>) {
self.thread
.update(cx, |thread, cx| {
thread.restore_checkpoint(client_id.clone(), cx)
})
.detach_and_log_err(cx);
}
pub fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
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<Self>) {
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);
}
fn callout_border_position(&self) -> CalloutBorderPosition {
if self.list_state.item_count() > 0 {
CalloutBorderPosition::Top
} else {
CalloutBorderPosition::Bottom
}
}
pub fn render_thread_retry_status_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
let state = self.thread_retry_status.as_ref()?;
if let Some(fallback_model) = acp_thread::refusal_fallback_model_from_meta(&state.meta) {
return Some(
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.title(state.last_error.clone())
.description(format!("Retrying with {fallback_model}"))
.dismiss_action(
IconButton::new("dismiss-refusal-fallback", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(cx.listener(|this, _, _, cx| {
this.thread_retry_status = None;
cx.notify();
})),
),
);
}
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()
.border_position(self.callout_border_position())
.icon(IconName::Warning)
.severity(Severity::Warning)
.title(state.last_error.clone())
.description(retry_message),
)
}
fn activity_bar_bg(&self, cx: &Context<Self>) -> 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<Self>,
) -> Option<AnyElement> {
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).collect::<Vec<_>>();
let plan = thread.plan();
let queue_is_empty = !self.has_queued_messages();
let awaiting_permission = self
.render_main_agent_awaiting_permission(window, cx)
.or_else(|| self.render_subagents_awaiting_permission(cx));
let has_awaiting_permission = awaiting_permission.is_some();
if changed_buffers.is_empty()
&& plan.is_empty()
&& queue_is_empty
&& !has_awaiting_permission
{
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;
// Drop shadows have no opaque surface to blend into on a transparent
// window, so they render as a dark halo; only apply them when opaque.
let opaque_window =
cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque;
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_1()
.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()
.when(opaque_window, |this| {
this.shadow(vec![
gpui::BoxShadow::new(px(1.), px(-1.), gpui::black().opacity(0.12))
.blur_radius(px(2.)),
])
})
.when_some(awaiting_permission, |this, element| this.child(element))
.when(
has_awaiting_permission
&& (!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<ActionLog>,
telemetry: ActionLogTelemetry,
changed_buffers: &[(Entity<Buffer>, Entity<BufferDiff>)],
pending_edits: bool,
cx: &Context<Self>,
) -> 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 fallback_full_path =
full_path_for_empty_project_path(file.as_ref(), cx);
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()
})
.or_else(|| {
fallback_full_path.as_ref().map(|path| {
Label::new(path.clone())
.size(LabelSize::XSmall)
.buffer_font(cx)
.ml_1()
})
});
let full_path = fallback_full_path
.unwrap_or_else(|| 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(diff.read(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<Buffer>,
action_log: &Entity<ActionLog>,
telemetry: &ActionLogTelemetry,
pending_edits: bool,
editor_bg_color: Hsla,
cx: &Context<Self>,
) -> 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<Self>) -> Option<AnyElement> {
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(),
)
}
pub(crate) fn render_main_agent_awaiting_permission(
&self,
window: &Window,
cx: &Context<Self>,
) -> Option<AnyElement> {
if self.is_subagent() {
return None;
}
let active_session_id = self.thread.read(cx).session_id().clone();
let conversation = self.conversation.read(cx);
let tool_call_id = conversation.pending_tool_call_for_session(&active_session_id, cx)?;
let pending_count = conversation.pending_tool_call_count_for_session(&active_session_id);
let thread = self.thread.read(cx);
let (entry_ix, tool_call) = thread.tool_call(&tool_call_id)?;
let scroll_icon = if self.list_state.item_is_above_viewport(entry_ix)? {
IconName::ArrowUp
} else if self.list_state.item_is_below_viewport(entry_ix)? {
IconName::ArrowDown
} else {
return None;
};
let focus_handle = self.focus_handle(cx);
let card = self.render_any_tool_call(
&active_session_id,
entry_ix,
tool_call,
&focus_handle,
ToolCallLayout::Floating,
window,
cx,
);
let label: SharedString = if pending_count > 1 {
format!("Awaiting Confirmation ({pending_count})").into()
} else {
"Awaiting Confirmation".into()
};
let header = h_flex()
.p_1p5()
.pl_2()
.w_full()
.gap_1p5()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
h_flex()
.gap_1p5()
.child(
h_flex()
.w_2()
.justify_center()
.child(GeneratingSpinnerElement::new(SpinnerVariant::Sand)),
)
.child(Label::new(label).size(LabelSize::Small).color(Color::Muted)),
)
.child(
Button::new("main-agent-permission-scroll-to", "Scroll")
.label_size(LabelSize::Small)
.end_icon(
Icon::new(scroll_icon)
.size(IconSize::XSmall)
.color(Color::Default),
)
.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();
})),
);
Some(v_flex().child(header).child(card).into_any())
}
fn render_message_queue_summary(
&self,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let queue_count = self.message_queue.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_f32))),
)
.on_click(cx.listener(|this, _, _, cx| {
this.clear_queue(cx);
})),
)
.into_any_element()
}
fn clear_queue(&mut self, cx: &mut Context<Self>) {
self.message_queue.clear();
self.sync_queue_flag_to_native_thread(cx);
cx.notify();
}
fn render_plan_summary(
&self,
plan: &Plan,
window: &mut Window,
cx: &Context<Self>,
) -> 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<Self>,
) -> 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<Self>,
) -> 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_context_compaction(
&self,
entry_ix: usize,
compaction: &acp_thread::ContextCompaction,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let is_compacting = compaction.is_in_progress();
let summary = &compaction.summary;
let error = compaction.error.clone();
let has_details = !summary.is_empty() || error.is_some();
let is_expanded = self
.entry_view_state
.read(cx)
.is_compaction_expanded(entry_ix);
let details = (is_expanded && has_details).then_some((summary, error));
let id = format!("context-compaction-{entry_ix}");
let header_label = match &compaction.status {
acp_thread::ContextCompactionStatus::InProgress => "Compacting Context…",
acp_thread::ContextCompactionStatus::Completed => "Context Compacted",
acp_thread::ContextCompactionStatus::Failed => "Compaction Failed",
acp_thread::ContextCompactionStatus::Canceled => "Compaction Canceled",
acp_thread::ContextCompactionStatus::Other(_) => "Context Compaction",
};
let chevron_end = if is_expanded {
IconName::ChevronUp
} else {
IconName::ChevronDown
};
let header = h_flex()
.gap_1()
.w_full()
.child(Divider::horizontal())
.child(
Button::new(id, header_label)
.label_size(LabelSize::Small)
.loading(is_compacting)
.disabled(!has_details)
.start_icon(
Icon::new(IconName::Compact)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.when(has_details, |this| {
this.end_icon(
Icon::new(chevron_end)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.on_click(cx.listener(
move |this, _event: &ClickEvent, window, cx| {
this.toggle_compaction_expansion(entry_ix, window, cx);
},
))
}),
)
.child(Divider::horizontal());
div()
.px_5()
.w_full()
.child(
v_flex()
.pt_1p5()
.mb_1p5()
.gap_1p5()
.border_1()
.border_color(gpui::transparent_black())
.rounded_sm()
.child(header)
.when_some(details, |this, (summary, error)| {
this.border_color(self.tool_card_border_color(cx))
.bg(cx.theme().colors().editor_background.opacity(0.2))
.when(!summary.is_empty(), |this| {
this.child(
v_flex()
.id(("compaction-summary", entry_ix))
.p_2()
.gap_2()
.text_ui(cx)
.children(summary.iter().enumerate().map(
|(content_ix, content)| {
self.render_output_content_block(
entry_ix, content_ix, content, None, true,
window, cx,
)
},
)),
)
})
.when_some(error, |this, error| {
let mut style =
MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
style.base_text_style.color = Color::Error.color(cx);
this.child(
div()
.id(("compaction-error", entry_ix))
.p_2()
.child(self.render_markdown(error, style, cx)),
)
})
.child(
h_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
IconButton::new(
("compaction-summary-collapse", entry_ix),
IconName::ChevronUp,
)
.full_width()
.on_click(
cx.listener(
move |this, _event: &ClickEvent, window, cx| {
this.entry_view_state.update(
cx,
|state, _cx| {
state.collapse_compaction(entry_ix);
},
);
this.refresh_thread_search(window, cx);
cx.notify();
},
),
),
),
)
}),
)
.into_any()
}
pub(super) fn toggle_compaction_expansion(
&mut self,
entry_ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// If tail following is active and the entry is not yet expanded, we'll
// want to anchor the list's scroll position, to prevent it from
// automatically scrolling to the end of the compaction context element,
// which would feel off, as we assume the user is trying to read it from
// top to bottom.
if self.list_state.is_following_tail()
&& !self
.entry_view_state
.read(cx)
.is_compaction_expanded(entry_ix)
{
self.list_state.pause_following_tail();
}
self.entry_view_state.update(cx, |state, _cx| {
state.toggle_compaction_expansion(entry_ix);
});
self.list_state.remeasure_items(entry_ix..entry_ix + 1);
self.refresh_thread_search(window, cx);
cx.notify();
}
fn render_edits_summary(
&self,
changed_buffers: &[(Entity<Buffer>, Entity<BufferDiff>)],
expanded: bool,
pending_edits: bool,
cx: &Context<Self>,
) -> 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.iter().cloned(), 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_f32))),
)
.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_f32))),
)
.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<Self>) -> Option<Div> {
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<Self>,
) -> 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()
.py_2()
.bg(editor_bg_color)
.justify_center()
.on_action(cx.listener(Self::handle_message_editor_move_up))
.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(max_content_width.is_none(), |this| this.w_full())
.min_w_0()
.when(fills_container, |this| this.h_full())
.px_2()
.flex_shrink_1()
.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()
.min_w_0()
.flex_none()
.flex_wrap()
.justify_between()
.child(
h_flex()
.min_w_0()
.flex_wrap()
.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()
.min_w_0()
.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_queue_steer_button(
&self,
entry_id: QueueEntryId,
index: usize,
is_next: bool,
steer_on: bool,
cx: &Context<Self>,
) -> impl IntoElement {
let focus_handle = self.message_editor.focus_handle(cx);
Button::new(("steer", index), "Steer")
.label_size(LabelSize::Small)
.toggle_state(steer_on)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.when(is_next, |this| {
this.key_binding(
KeyBinding::for_action_in(&ToggleSteerFirstQueuedMessage, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12_f32))),
)
})
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Steer",
None,
"Interrupt the agent at its next step to send this message. \
When off, queued messages wait for the agent to finish.",
cx,
)
})
.on_click(cx.listener(move |this, _, _, cx| {
this.toggle_queue_entry_steer(entry_id, cx);
}))
}
fn render_message_queue_entries(
&self,
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let message_editor = self.message_editor.read(cx);
let focus_handle = message_editor.focus_handle(cx);
let queue_len = self.message_queue.len();
let can_fast_track = self.message_queue.can_fast_track();
let is_native = self.as_native_thread(cx).is_some();
v_flex()
.id("message_queue_list")
.max_h_40()
.overflow_y_scroll()
.children(self.message_queue.iter().enumerate().map(|(index, entry)| {
let entry_id = entry.id;
let editor = &entry.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_f32);
let steer_on = entry.steer;
let min_width = rems_from_px(160_f32);
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(min_width)
.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(
entry_id, None, None, window, cx,
);
})),
)
.when(is_native, |row| {
row.child(self.render_queue_steer_button(
entry_id, index, is_next, steer_on, 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_now(entry_id, window, cx);
})),
)
} else {
h_flex()
.when(!is_next, |this| this.visible_on_hover("queue_entry"))
.gap_1()
.min_w(min_width)
.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(entry_id, 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(
entry_id, None, None, window, cx,
);
})),
)
.when(is_native, |row| {
row.child(self.render_queue_steer_button(
entry_id, index, is_next, steer_on, 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<dyn gpui::Action> = 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_now(entry_id, 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<Self>) -> Option<impl IntoElement> {
let thread = self.thread.read(cx);
let usage = thread.token_usage()?;
let show_split = self.supports_split_token_display(cx);
let cost_label = 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)
});
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 (project_rules_count, project_entry_ids) = self
.as_native_thread(cx)
.map(|thread| {
let project_context = thread.read(cx).project_context().read(cx);
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::<Vec<_>>();
let project_rules_count = project_entry_ids.len();
(project_rules_count, project_entry_ids)
})
.unwrap_or_default();
let global_agents_md_loaded = UserAgentsMd::global(cx)
.and_then(|md| md.content())
.is_some();
let workspace = self.workspace.clone();
let (max_input_tokens, max_output_tokens) = self
.as_native_thread(cx)
.map(|thread| {
let thread = thread.read(cx);
(
thread.input_token_capacity().unwrap_or(usage.max_tokens),
thread
.model()
.and_then(|model| model.max_output_tokens())
.unwrap_or(0),
)
})
.unwrap_or((usage.max_tokens, 0));
let input_max_label = crate::humanize_token_count(max_input_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,
global_agents_md_loaded,
project_rules_count,
project_entry_ids,
workspace,
})
.into()
}
};
if show_split {
let input_max_raw = max_input_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<Self>) -> bool {
self.as_native_thread(cx)
.and_then(|thread| thread.read(cx).model())
.map(|model| model.supports_fast_mode())
.unwrap_or(false)
}
fn refresh_sandbox_status(&mut self, cx: &mut Context<Self>) -> Option<VerifiedSandboxStatus> {
let thread = self.as_native_thread(cx)?;
let (key, refresh) =
thread.update(cx, |thread, cx| thread.refresh_verified_sandbox_status(cx))?;
if self.sandbox_status_key.as_ref() == Some(&key) {
return self.sandbox_status.clone();
}
match refresh {
SandboxStatusRefresh::Ready(status) => {
self.sandbox_status = Some(status.clone());
self.sandbox_status_key = Some(key);
self.pending_sandbox_status_key = None;
Some(status)
}
SandboxStatusRefresh::Pending(task) => {
if self.pending_sandbox_status_key.as_ref() != Some(&key) {
self.sandbox_status = None;
self.sandbox_status_key = None;
self.pending_sandbox_status_key = Some(key.clone());
self._sandbox_status_refresh_task = Some(cx.spawn(async move |this, cx| {
let status = task.await;
this.update(cx, |this, cx| {
if this.pending_sandbox_status_key.as_ref() == Some(&key) {
this.sandbox_status = Some(status);
this.sandbox_status_key = Some(key);
this.pending_sandbox_status_key = None;
cx.notify();
}
})
.ok();
}));
}
None
}
}
}
pub fn render_sandbox_status(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
let status = self.refresh_sandbox_status(cx)?;
let settings_sandbox = status.settings_sandbox.clone();
let thread_sandbox = status.thread_sandbox.clone();
let baseline = status.baseline_writable_paths;
// The lock is struck only when the *merged* result is unsandboxed (the
// agent runs with ambient permissions). A layer that is merely wide open
// but still sandboxed keeps the closed lock.
let (icon, icon_color) = if settings_sandbox
.clone()
.merge(thread_sandbox.clone())
.is_unsandboxed()
{
(IconName::LockOff, Color::Muted)
} else {
(IconName::Lock, Color::Default)
};
let tooltip = match (settings_sandbox, thread_sandbox) {
// No sandbox at all because the user turned it off in settings: the
// per-thread layer is moot, so don't show it.
(ThreadSandbox::Unsandboxed, _) => SandboxStatusTooltip::disabled_in_settings(),
// Sandboxed by settings, but disabled for this thread: show the
// settings scope (greyed) for context above the disabled status.
(ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => {
let settings = augment_settings_sandbox_policy(&settings_policy, baseline);
SandboxStatusTooltip::disabled_for_thread(sandbox_section(
"Defined in your settings:",
&settings,
true,
))
}
(
ThreadSandbox::Sandboxed(settings_policy),
ThreadSandbox::Sandboxed(thread_policy),
) => {
let settings = augment_settings_sandbox_policy(&settings_policy, baseline);
let thread = SandboxPolicyDisplay::from_policy(&thread_policy);
// Omit the per-thread section when it grants nothing extra.
let thread = (!sandbox_policy_grants_nothing(&thread))
.then(|| sandbox_section("Allowed for this thread:", &thread, false));
SandboxStatusTooltip::enabled(
sandbox_section("Defined in your settings:", &settings, true),
thread,
)
}
};
Some(
h_flex()
.gap_1()
.child(
IconButton::new("sandbox-status", icon)
.icon_size(IconSize::Small)
.icon_color(icon_color)
.tooltip(Tooltip::element(move |_window, _cx| {
tooltip.clone().into_any_element()
}))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
target: None,
}),
cx,
);
}),
)
.child(Divider::vertical())
.into_any_element(),
)
}
fn render_fast_mode_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if !self.fast_mode_available(cx) {
return None;
}
let thread = self.as_native_thread(cx)?.read(cx);
let is_fast = matches!(thread.speed(), Some(Speed::Fast));
let model_identity = thread
.model()
.map(|model| (model.provider_id(), model.id()));
let (tooltip_label, color, icon, new_speed) = if is_fast {
(
"Disable Fast Mode",
Color::Accent,
IconName::FastForward,
Speed::Standard,
)
} else {
(
"Enable Fast Mode",
Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
IconName::FastForwardOff,
Speed::Fast,
)
};
let focus_handle = self.message_editor.focus_handle(cx);
let pending_confirmation = (!is_fast)
.then(|| self.pending_fast_mode_confirmation(cx))
.flatten();
let icon_button = IconButton::new("fast-mode", icon)
.icon_size(IconSize::Small)
.icon_color(color);
if let Some((provider_id, model_id, confirmation)) = pending_confirmation {
let weak_self = cx.entity().downgrade();
let tooltip_focus = focus_handle;
return Some(
PopoverMenu::new("fast-mode-warning")
.with_handle(self.fast_mode_menu_handle.clone())
.trigger_with_tooltip(icon_button, move |_, cx| {
Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &tooltip_focus, cx)
})
.menu(move |window, cx| {
let weak_self = weak_self.clone();
let confirmation = confirmation.clone();
let provider_id = provider_id.clone();
let model_id = model_id.clone();
Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
let message = confirmation.message.clone();
menu.custom_row(move |_window, _cx| {
div()
.max_w_72()
.child(Label::new(confirmation.title.clone()))
.child(Label::new(message.clone()).color(Color::Muted))
.into_any_element()
})
.separator()
.item(ContextMenuEntry::new("Enable Now").handler({
let weak_self = weak_self.clone();
move |_window, cx| {
weak_self
.update(cx, |this, cx| {
this.apply_fast_mode_speed(Speed::Fast, cx);
})
.log_err();
}
}))
.item(
ContextMenuEntry::new("Enable and Don't Show Again").handler({
let weak_self = weak_self.clone();
let provider_id = provider_id.clone();
let model_id = model_id;
move |_window, cx| {
weak_self
.update(cx, |this, cx| {
this.apply_fast_mode_speed(Speed::Fast, cx);
})
.log_err();
set_fast_mode_warning_dismissed(
&provider_id,
&model_id,
cx,
);
}
}),
)
}))
})
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
.anchor(gpui::Anchor::BottomLeft)
.into_any_element(),
);
}
let _ = model_identity;
Some(
icon_button
.tooltip(move |_, cx| {
Tooltip::for_action_in(tooltip_label, &ToggleFastMode, &focus_handle, cx)
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.apply_fast_mode_speed(new_speed, cx);
}))
.into_any_element(),
)
}
fn pending_fast_mode_confirmation(
&self,
cx: &App,
) -> Option<(
LanguageModelProviderId,
LanguageModelId,
FastModeConfirmation,
)> {
let thread = self.as_native_thread(cx)?.read(cx);
let model = thread.model()?;
let provider_id = model.provider_id();
let model_id = model.id();
let confirmation = LanguageModelRegistry::read_global(cx)
.provider(&provider_id)
.and_then(|provider| provider.fast_mode_confirmation(cx))?;
if fast_mode_warning_dismissed(&provider_id, &model_id, cx) {
return None;
}
Some((provider_id, model_id, confirmation))
}
fn render_thinking_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let thread = self.as_native_thread(cx)?.read(cx);
let model = thread.model()?;
let supports_thinking = model.supports_thinking();
if !supports_thinking {
return None;
}
// A toggle would be dishonest for models that always think: only
// offer the effort selector.
if !model.supports_disabling_thinking() {
let effort_levels = model.supported_effort_levels();
if effort_levels.is_empty() {
return None;
}
return Some(
self.render_effort_selector(
effort_levels,
thread.thinking_effort().cloned(),
true,
cx,
)
.into_any_element(),
);
}
let thinking = thread.thinking_enabled();
let (tooltip_label, icon, color) = if thinking {
(
"Disable Thinking Mode",
IconName::ThinkingMode,
Color::Accent,
)
} 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(),
false,
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<LanguageModelEffortLevel>,
selected_effort: Option<String>,
standalone: bool,
cx: &Context<Self>,
) -> 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()
}
});
let trigger = if standalone {
ButtonLike::new("effort-selector-trigger").child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::ThinkingMode)
.size(IconSize::Small)
.color(Color::Accent),
)
.child(Label::new(label).size(LabelSize::Small).color(label_color))
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
)
} else {
ButtonLike::new_rounded_right("effort-selector-trigger")
.child(Label::new(label).size(LabelSize::Small).color(label_color))
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
};
PopoverMenu::new("effort-selector")
.trigger_with_tooltip(
trigger.selected_style(ButtonStyle::Tinted(TintColor::Accent)),
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<Self>) -> 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<Self>) -> 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<Self>,
) -> Entity<ContextMenu> {
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 available_skills = session_capabilities.completion_skills();
drop(session_capabilities);
let has_editor_selection = workspace
.upgrade()
.and_then(|ws| {
ws.read(cx)
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
})
.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::<TerminalPanel>(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")
.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);
});
}
}),
)
.when(!available_skills.is_empty(), |this| {
this.submenu_with_colored_icon("Skills", IconName::Sparkle, Color::Muted, {
let message_editor = message_editor.clone();
let available_skills = available_skills.clone();
move |mut menu, _window, _cx| {
for skill in &available_skills {
menu = menu
.item(Self::skill_menu_entry(skill, message_editor.clone()));
}
menu
}
})
})
.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 skill_menu_entry(
skill: &AvailableSkill,
message_editor: Entity<crate::message_editor::MessageEditor>,
) -> ContextMenuEntry {
let label = format!("{} ({})", skill.name, skill.source);
let skill = skill.clone();
ContextMenuEntry::new(label)
.icon(IconName::Sparkle)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.handler(move |window, cx| {
message_editor.focus_handle(cx).focus(window, cx);
message_editor.update(cx, |editor, cx| {
editor.insert_skill_crease(&skill, window, cx);
});
})
}
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> 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<String>,
separator_color: Color,
global_agents_md_loaded: bool,
project_rules_count: usize,
project_entry_ids: Vec<ProjectEntryId>,
workspace: WeakEntity<Workspace>,
}
impl Render for TokenUsageTooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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 global_agents_md_loaded = self.global_agents_md_loaded;
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(
global_agents_md_loaded || 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(global_agents_md_loaded, {
let workspace = workspace.clone();
move |this| {
this.child(
Button::new(
"open-global-agents-md",
"1 global rule",
)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.on_click(move |_, window, cx| {
workspace
.update(cx, |workspace, cx| {
workspace
.open_abs_path(
paths::agents_file()
.clone(),
workspace::OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
.detach_and_log_err(cx);
})
.log_err();
}),
)
}
})
.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_count,
pluralize(
"project rule",
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::<Vec<_>>();
for path in paths {
workspace
.open_path(
path, None, true, window,
cx,
)
.detach_and_log_err(cx);
}
});
}),
)
}),
),
)
},
)
})
}
}
/// A display-ready snapshot of a sandbox policy for the status tooltip.
///
/// The opaque `HostFilesystemLocation`s in a policy are stringified up front,
/// when this is built, so the tooltip state (which outlives the build and is
/// captured by the lazy tooltip closure) never holds the locations' fds open.
#[derive(Clone)]
struct SandboxPolicyDisplay {
fs: SandboxFsDisplay,
network: SandboxNetPolicy,
}
/// The filesystem write-access portion of a [`SandboxPolicyDisplay`].
#[derive(Clone)]
enum SandboxFsDisplay {
Unrestricted,
Restricted(Vec<WritableEntryDisplay>),
}
/// A single writable entry to display in the sandbox tooltip: either a real host
/// location (already stringified for display) or the Linux-only host-isolated
/// `/tmp` overlay, which has no backing host path and is purely a label.
#[derive(Clone)]
enum WritableEntryDisplay {
Path(String),
// Only ever constructed on Linux (the bwrap `--tmpfs /tmp` overlay), so the
// variant is gated to match and avoid dead-code warnings elsewhere.
#[cfg(target_os = "linux")]
IsolatedTmp,
}
impl SandboxPolicyDisplay {
/// Display a policy verbatim (used for the per-thread overrides, which carry
/// no implicit baseline grants). Takes the policy by reference and stringifies
/// its locations immediately, so no fd is retained past this call.
fn from_policy(policy: &SandboxPolicy) -> Self {
let fs = match &policy.fs {
SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
SandboxFsPolicy::Restricted { writable_paths, .. } => SandboxFsDisplay::Restricted(
writable_paths
.iter()
.map(|location| {
WritableEntryDisplay::Path(location.untrusted_path_display().to_string())
})
.collect(),
),
};
SandboxPolicyDisplay {
fs,
network: policy.network.clone(),
}
}
}
/// Fold the always-granted baseline writable paths (the project's worktree
/// roots, derived from the same source the terminal tool uses) and, on Linux,
/// the host-isolated `/tmp` overlay into a settings policy for display. These
/// are part of what the sandbox grants whenever it's active but aren't
/// persistent-settings entries, so they're shown in the "from your settings"
/// section rather than stored. A no-op when the fs is unrestricted (rendered as
/// "All paths"), since there's nothing to scope.
fn augment_settings_sandbox_policy(
policy: &SandboxPolicy,
baseline: Vec<PathBuf>,
) -> SandboxPolicyDisplay {
let fs = match &policy.fs {
SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
SandboxFsPolicy::Restricted { writable_paths, .. } => {
// Dedup by display string. We deliberately don't open the locations'
// fds to dedup by inode here: this is a display-only tooltip and the
// string is the location's identity for that purpose. The string can
// only diverge from the captured inode while a symlink-swap is
// actively in progress, and in that case the bind validator refuses
// to run the command at all (see the `sandbox` crate) — so showing the
// requested path is always safe, and not worth a blocking syscall on
// the render path.
let mut merged: Vec<String> = Vec::new();
let baseline_paths = baseline.iter().map(|path| path.display().to_string());
let granted_paths = writable_paths
.iter()
.map(|location| location.untrusted_path_display().to_string());
for path in baseline_paths.chain(granted_paths) {
if !merged.contains(&path) {
merged.push(path);
}
}
// `mut` is only needed on Linux, where the isolated `/tmp` entry is
// pushed below.
#[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
let mut entries: Vec<WritableEntryDisplay> =
merged.into_iter().map(WritableEntryDisplay::Path).collect();
// The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the
// bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a
// real host path, so it can't be a captured location.
#[cfg(target_os = "linux")]
entries.push(WritableEntryDisplay::IsolatedTmp);
SandboxFsDisplay::Restricted(entries)
}
};
SandboxPolicyDisplay {
fs,
network: policy.network.clone(),
}
}
fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection {
let write_empty = fs_grants_nothing(&policy.fs);
let network_empty = network_grants_nothing(&policy.network);
let mut section = SandboxSection::new(title.to_string());
if show_empty || !write_empty {
section =
section.group(SandboxGroup::new("Write Access").rows(sandbox_fs_rows(&policy.fs)));
}
if show_empty || !network_empty {
section = section
.group(SandboxGroup::new("Network Access").rows(sandbox_network_rows(&policy.network)));
}
section
}
/// Whether a policy grants nothing worth surfacing, used to decide whether to
/// show the per-thread overrides section at all.
fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool {
fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network)
}
fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool {
matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty())
}
fn network_grants_nothing(network: &SandboxNetPolicy) -> bool {
match network {
SandboxNetPolicy::Blocked => true,
SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains.is_empty(),
SandboxNetPolicy::Unrestricted => false,
}
}
/// Rows for the write-access group: a message for the "all"/"none" cases, or one
/// row per granted path.
fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec<SandboxRow> {
match fs {
SandboxFsDisplay::Unrestricted => vec![SandboxRow::message(
"All paths except protected Git metadata",
)],
SandboxFsDisplay::Restricted(entries) if entries.is_empty() => {
vec![SandboxRow::message("None")]
}
SandboxFsDisplay::Restricted(entries) => entries
.iter()
.map(|entry| match entry {
// The display string was captured up front.
WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)),
#[cfg(target_os = "linux")]
WritableEntryDisplay::IsolatedTmp => {
SandboxRow::path(PathBuf::from("/tmp (isolated)"))
}
})
.collect(),
}
}
/// Rows for the network-access group: a message for the "all"/"none" cases, or
/// one row per allowed domain.
fn sandbox_network_rows(network: &SandboxNetPolicy) -> Vec<SandboxRow> {
match network {
SandboxNetPolicy::Unrestricted => vec![SandboxRow::message("All domains (unrestricted)")],
SandboxNetPolicy::Blocked => vec![SandboxRow::message("None")],
SandboxNetPolicy::Restricted { allowed_domains } if allowed_domains.is_empty() => {
vec![SandboxRow::message("None")]
}
SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains
.iter()
.map(|domain| SandboxRow::domain(domain.clone()))
.collect(),
}
}
impl ThreadView {
fn render_entries(&mut self, cx: &mut Context<Self>) -> 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 = this.thread.read(cx).is_waiting_for_confirmation()
|| this.has_pending_request_elicitation(cx);
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_1()
}
fn render_entry(
&self,
entry_ix: usize,
total_entries: usize,
entry: &AgentThreadEntry,
window: &Window,
cx: &Context<Self>,
) -> 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 mut assistant_message_is_blank = false;
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;
// Drop shadows render as a dark halo on transparent windows.
let opaque_window = cx.theme().window_background_appearance()
== gpui::WindowBackgroundAppearance::Opaque;
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.client_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.client_id.clone().map(|client_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(&client_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().when(opaque_window, |this| {
this.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.when(opaque_window, |this| 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 message_body = v_flex()
.w_full()
.gap_3()
.children(chunks.iter().enumerate().filter_map(
|(chunk_ix, chunk)| match chunk {
AssistantMessageChunk::Message { block, .. } => {
let this_is_blank = !block.visible_content(cx);
is_blank = is_blank && this_is_blank;
(!this_is_blank).then(|| {
div()
.id(("assistant-message-chunk", chunk_ix))
.child(self.render_message_content(
entry_ix, chunk_ix, block, window, cx,
))
.into_any_element()
})
}
AssistantMessageChunk::Thought { block, .. } => {
let this_is_blank = !block.visible_content(cx);
is_blank = is_blank && this_is_blank;
(!this_is_blank).then(|| {
self.render_thinking_block(
entry_ix, chunk_ix, block, window, cx,
)
.into_any_element()
})
}
},
))
.into_any();
assistant_message_is_blank = is_blank;
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(message_body)
.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) => {
// A canceled tool call that produced visible output is still worth
// showing, but one that was canceled before producing anything just
// renders as a useless "Canceled" card — hide those entirely.
if matches!(tool_call.status, ToolCallStatus::Canceled) {
let has_visible_content =
tool_call.content.iter().any(|content| match content {
ToolCallContent::ContentBlock(block) => block.visible_content(cx),
ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true,
});
if !has_visible_content {
return Empty.into_any();
}
}
let tool_call = self.render_any_tool_call(
self.thread.read(cx).session_id(),
entry_ix,
tool_call,
&self.focus_handle(cx),
ToolCallLayout::Standalone,
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::Elicitation(elicitation_id) => {
let thread = self.thread.read(cx);
if let Some((_, elicitation)) = thread.elicitation(elicitation_id)
&& should_render_elicitation(elicitation)
{
let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx);
if let Some(handle) = self
.entry_view_state
.read(cx)
.entry(entry_ix)
.and_then(|entry| entry.focus_handle(cx))
{
elicitation.track_focus(&handle).into_any()
} else {
elicitation.into_any()
}
} else {
Empty.into_any()
}
}
AgentThreadEntry::CompletedPlan(entries) => {
self.render_completed_plan(entries, window, cx)
}
AgentThreadEntry::ContextCompaction(compaction) => {
self.render_context_compaction(entry_ix, compaction, 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_f32)
} else {
rems_from_px(0.0_f32)
};
div()
.relative()
.w_full()
.pl_5()
.bg(cx.theme().colors().panel_background.opacity(0.2))
.child(
div()
.absolute()
.left(rems_from_px(18.0_f32))
.top(line_top)
.bottom_0()
.w_px()
.bg(cx.theme().colors().border.opacity(0.6)),
)
.child(primary)
.into_any_element()
} else {
primary
};
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
let is_turn_end = Self::entry_is_finalized_turn_end(thread.read(cx).entries(), entry_ix)
.unwrap_or(!is_generating);
let primary = if is_turn_end && !assistant_message_is_blank {
let user_message_index = thread
.read(cx)
.entries()
.iter()
.take(entry_ix)
.rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)));
v_flex()
.w_full()
.child(primary)
.child(self.render_thread_controls(
&thread,
entry_ix,
Some(entry_ix),
entry_ix + 1 == total_entries,
user_message_index,
cx,
))
.into_any_element()
} else {
primary
};
let is_assistant = matches!(entry, AgentThreadEntry::AssistantMessage(_));
let comments_editor = self.thread_feedback.comments_editor.clone();
let primary = if entry_ix + 1 == total_entries {
let last_assistant_index = thread
.read(cx)
.entries()
.iter()
.rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_)));
v_flex()
.w_full()
.child(primary)
.when(!is_assistant, |this| {
this.child(self.render_thread_controls(
&thread,
entry_ix,
last_assistant_index,
true,
None,
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_elicitation(
&self,
entry_ix: usize,
elicitation: &Elicitation,
_window: &Window,
cx: &Context<Self>,
) -> Div {
ElicitationCard::new(
entry_ix,
elicitation,
self.agent_display_name.clone(),
self.elicitation_form_states.get(&elicitation.id),
self.elicitation_card_handlers(cx),
)
.render(cx)
}
fn elicitation_card_handlers(&self, cx: &Context<Self>) -> ElicitationCardHandlers {
let view = cx.entity().downgrade();
ElicitationCardHandlers::new(
{
let view = view.clone();
move |elicitation_id, window, cx| {
view.update(cx, |this, cx| {
this.submit_elicitation(elicitation_id, window, cx);
})
.log_err();
}
},
{
let view = view.clone();
move |elicitation_id, window, cx| {
view.update(cx, |this, cx| {
this.decline_elicitation(elicitation_id, window, cx);
})
.log_err();
}
},
{
let view = view.clone();
move |elicitation_id, window, cx| {
view.update(cx, |this, cx| {
this.cancel_elicitation(elicitation_id, window, cx);
})
.log_err();
}
},
{
let view = view.clone();
move |elicitation_id, window, cx| {
view.update(cx, |this, cx| {
this.dismiss_url_elicitation(elicitation_id, window, cx);
})
.log_err();
}
},
move |_elicitation_id, url, _window, cx| cx.open_url(&url),
{
let view = view.clone();
move |elicitation_id, field_name, value, cx| {
view.update(cx, |this, cx| {
if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
form.set_boolean(&field_name, value);
cx.notify();
}
})
.log_err();
}
},
{
let view = view.clone();
move |elicitation_id, field_name, value, cx| {
view.update(cx, |this, cx| {
if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
form.set_single_select(&field_name, value);
cx.notify();
}
})
.log_err();
}
},
move |elicitation_id, field_name, value, selected, cx| {
view.update(cx, |this, cx| {
if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
form.set_multi_select(&field_name, value, selected);
cx.notify();
}
})
.log_err();
},
)
}
fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> 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);
})),
),
)
}
/// A turn ends when no further assistant output (message or tool call)
/// follows before the next user message, and it's finalized once a user
/// message follows it.
fn entry_is_finalized_turn_end(entries: &[AgentThreadEntry], entry_ix: usize) -> Option<bool> {
if !matches!(
entries.get(entry_ix),
Some(AgentThreadEntry::AssistantMessage(_))
) {
return Some(false);
}
for entry in &entries[entry_ix + 1..] {
match entry {
AgentThreadEntry::UserMessage(_) => return Some(true),
AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::ToolCall(_) => {
return Some(false);
}
_ => {}
}
}
None
}
fn render_thread_controls(
&self,
thread: &Entity<AcpThread>,
entry_ix: usize,
copy_response_index: Option<usize>,
is_thread_bottom: bool,
user_message_index: Option<usize>,
cx: &Context<Self>,
) -> impl IntoElement {
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
let needs_confirmation = thread.read(cx).is_waiting_for_confirmation()
|| self.has_pending_request_elicitation(cx);
if is_thread_bottom && (is_generating || needs_confirmation) {
return Empty.into_any_element();
}
let copy_response_button = copy_response_index.map(|response_index| {
IconButton::new(("copy_agent_response", entry_ix), IconName::Copy)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Copy This Agent Response"))
.on_click(cx.listener(move |this, _, _, cx| {
let entries = this.thread.read(cx).entries();
if let Some(text) = Self::get_agent_message_content(entries, response_index, cx)
{
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}))
});
let scroll_to_recent_user_prompt = IconButton::new(
("scroll_to_recent_user_prompt", entry_ix),
IconName::UserArrowUp,
)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Scroll to User Message"))
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_user_message_index(user_message_index, cx);
}));
let scroll_to_top = is_thread_bottom.then(|| {
IconButton::new(("scroll_to_top", entry_ix), IconName::ArrowUp)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Scroll to Top"))
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_top(cx);
}))
});
let show_stats = is_thread_bottom && 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 feedback_buttons = is_thread_bottom
.then(|| {
(self.is_subagent() && self.is_thread_feedback_enabled(cx)).then(|| {
let feedback = self.thread_feedback.feedback;
let tooltip_meta =
"Rating the thread sends all of your current conversation to the Gwen team.";
h_flex()
.child(
IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Positive) => Color::Accent,
_ => Color::Muted,
})
.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)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Negative) => Color::Accent,
_ => Color::Muted,
})
.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);
})),
)
})
})
.flatten();
let separator_dots = || {
Label::new("•")
.size(LabelSize::Small)
.color(Color::Muted)
.alpha(0.5)
};
h_flex()
.w_full()
.py_1p5()
.px_4()
.justify_end()
.opacity(0.4)
.hover(|s| s.opacity(1.))
.when(
last_turn_tokens_label.is_some() || last_turn_clock.is_some(),
|this| {
this.child(
h_flex()
.px_1()
.gap_1()
.when_some(last_turn_tokens_label, |this, label| {
this.child(label).child(separator_dots())
})
.when_some(last_turn_clock, |this, label| {
this.child(label).child(separator_dots())
}),
)
},
)
.when_some(feedback_buttons, |this, buttons| this.child(buttons))
.when_some(copy_response_button, |this, button| this.child(button))
.child(scroll_to_recent_user_prompt)
.when_some(scroll_to_top, |this, button| this.child(button))
.into_any_element()
}
fn is_thread_feedback_enabled(&self, cx: &App) -> bool {
util::maybe!({
let project = self.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()
})
}
// The local slash commands the message editor should currently expose.
// Kept in sync with the availability of the corresponding actions via
// `sync_local_commands`.
fn available_local_commands(&self, cx: &App) -> Vec<PromptLocalCommand> {
let mut commands = Vec::new();
if self.is_thread_feedback_enabled(cx) {
commands.push(PromptLocalCommand::ThumbsUp);
commands.push(PromptLocalCommand::ThumbsDown);
}
commands
}
// Pushes the current set of available local commands to the message
// editor so they appear in its slash-command popup.
pub(crate) fn sync_local_commands(&self, cx: &App) {
let commands = self.available_local_commands(cx);
self.message_editor.read(cx).set_local_commands(commands);
}
fn render_request_elicitations(&self, cx: &Context<Self>) -> Vec<AnyElement> {
let server_view = self.server_view.clone();
let handlers_view = server_view.clone();
server_view
.read_with(cx, |server_view, cx| {
let Some(connection) = server_view.request_elicitation_connection() else {
return Vec::new();
};
server_view.render_request_elicitations(&connection, handlers_view, cx)
})
.unwrap_or_default()
}
pub(crate) fn scroll_to_user_message_index(
&mut self,
user_message_index: Option<usize>,
cx: &mut Context<Self>,
) {
let entries = self.thread.read(cx).entries();
if entries.is_empty() {
return;
}
// Scroll to the provided user message, or fall back to the most recent one.
// (Fallback: if no user message exists, scroll to the bottom.)
if let Some(ix) = user_message_index.or_else(|| {
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>) {
self.list_state.scroll_to_end();
cx.notify();
}
fn handle_feedback_click(
&mut self,
feedback: ThreadFeedback,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.thread_feedback
.submit(self.thread.clone(), feedback, window, cx);
cx.notify();
}
fn submit_feedback_message(&mut self, cx: &mut Context<Self>) {
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>) {
self.list_state.scroll_to(ListOffset::default());
cx.notify();
}
fn scroll_output_page_up(
&mut self,
_: &ScrollOutputPageUp,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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>,
) {
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>,
) {
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>,
) {
self.scroll_to_top(cx);
}
fn scroll_output_to_bottom(
&mut self,
_: &ScrollOutputToBottom,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.scroll_to_end(cx);
}
fn scroll_output_to_previous_message(
&mut self,
_: &ScrollOutputToPreviousMessage,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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();
}
}
fn refresh_thread_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.thread_search_visible {
return;
}
if let Some(bar) = self.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.update_matches(window, cx));
}
}
/// Hides the thread search bar, clears its highlights, and returns focus to
/// the message editor. Returns `true` if the search bar was visible.
pub(crate) fn close_thread_search(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !self.thread_search_visible {
return false;
}
if let Some(bar) = self.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.clear_highlights(cx));
}
self.thread_search_visible = false;
self.message_editor.focus_handle(cx).focus(window, cx);
cx.notify();
true
}
pub(crate) fn toggle_search(
&mut self,
_: &crate::ToggleSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.thread_search_bar.is_none() {
let thread = self.thread.clone();
let view = cx.entity().downgrade();
let on_activate =
Arc::new(move |entry_ix: usize, _window: &mut Window, cx: &mut App| {
// Avoid re-entering `ThreadView` when search navigation is forwarded
// from a `ThreadView` action handler.
let view = view.clone();
cx.defer(move |cx| {
view.update(cx, |this, cx| {
this.list_state.scroll_to(gpui::ListOffset {
item_ix: entry_ix,
offset_in_item: gpui::px(0.),
});
cx.notify();
})
.ok();
});
});
let search_bar = cx.new(|cx| {
ThreadSearchBar::new(
thread,
self.entry_view_state.clone(),
on_activate,
window,
cx,
)
});
self._subscriptions.push(cx.subscribe_in(
&search_bar,
window,
|this, _bar, event, window, cx| {
if matches!(event, ThreadSearchBarEvent::Dismissed) {
this.thread_search_visible = false;
this.message_editor.focus_handle(cx).focus(window, cx);
cx.notify();
}
},
));
self.thread_search_bar = Some(search_bar);
}
// Re-focus an open bar unless it already owns focus.
let search_bar_focused = self
.thread_search_bar
.as_ref()
.is_some_and(|bar| bar.focus_handle(cx).contains_focused(window, cx));
if self.thread_search_visible && search_bar_focused {
if let Some(bar) = &self.thread_search_bar {
bar.update(cx, |bar, cx| bar.clear_highlights(cx));
}
self.thread_search_visible = false;
self.message_editor.focus_handle(cx).focus(window, cx);
cx.notify();
} else {
self.thread_search_visible = true;
if let Some(bar) = self.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.focus_and_refresh(window, cx));
}
cx.notify();
}
}
pub fn open_thread_as_markdown(
&self,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
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);
open_markdown_in_workspace(thread_title, markdown, workspace, window, cx)
}
pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context<Self>) {
let has_messages = self.list_state.item_count() > 0;
let v2_empty_state = !has_messages;
if !has_messages {
self.editor_expanded = false;
}
let mode = if self.editor_expanded {
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
}
} else 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 thread = self.thread.read(cx);
let is_generating =
matches!(thread.status(), ThreadStatus::Generating) && !thread.is_compacting();
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_f32))
.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<Self>) {
let thread = self.thread.clone();
let changed = self.entry_view_state.update(cx, |state, cx| {
let thread = thread.read(cx);
if thread.status() != ThreadStatus::Generating {
return false;
}
state.auto_expand_streaming_thought(thread, cx)
});
if changed {
cx.notify();
}
}
pub(crate) fn clear_auto_expand_tracking(&mut self, cx: &mut Context<Self>) {
self.entry_view_state.update(cx, |state, _cx| {
state.clear_auto_expand_tracking();
});
}
fn toggle_thinking_block_expansion(
&mut self,
key: (usize, usize),
window: &mut Window,
cx: &mut Context<Self>,
) {
self.entry_view_state.update(cx, |state, cx| {
state.toggle_thinking_block_expansion(key, cx);
});
self.refresh_thread_search(window, cx);
cx.notify();
}
fn render_message_content(
&self,
entry_ix: usize,
chunk_ix: usize,
content: &acp_thread::MessageContent,
window: &Window,
cx: &Context<Self>,
) -> Div {
v_flex().w_full().gap_3().children(
content
.blocks()
.iter()
.enumerate()
.filter(|(_, block)| block.visible_content(cx))
.map(|(block_ix, block)| {
let content = self.render_output_content_block(
entry_ix, block_ix, block, None, false, window, cx,
);
div()
.id(("message-content-block", block_ix))
.debug_selector(move || {
format!("message-content-{entry_ix}-{chunk_ix}-{block_ix}")
})
.child(self.render_message_context_menu(
entry_ix,
block.markdown().cloned(),
content,
cx,
))
}),
)
}
fn render_thinking_block(
&self,
entry_ix: usize,
chunk_ix: usize,
chunk: &acp_thread::MessageContent,
window: &Window,
cx: &Context<Self>,
) -> 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 entry_view_state = self.entry_view_state.read(cx);
let (is_open, is_constrained) = entry_view_state.thinking_block_state(key, cx);
let should_auto_scroll = entry_view_state.is_auto_expanded_thinking_block(key);
let scroll_handle = entry_view_state
.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()
.id(("thinking-block", chunk_ix))
.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, window, cx);
})),
)
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.toggle_thinking_block_expansion(key, window, cx);
}))
.map(|header| {
self.render_message_context_menu(entry_ix, None, header.into_any(), 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_message_content(
entry_ix, chunk_ix, chunk, window, 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,
markdown: Option<Entity<Markdown>>,
message_body: AnyElement,
cx: &Context<Self>,
) -> 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();
let markdown = markdown.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 markdown = markdown.as_ref().map(|markdown| markdown.read(cx));
let context_menu_link =
markdown.and_then(|markdown| markdown.context_menu_link().cloned());
let selected_text = markdown
.and_then(|markdown| markdown.context_menu_selected_text().cloned());
let selected_markdown = markdown
.and_then(|markdown| markdown.context_menu_selected_markdown().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()
})
.when_some(selected_text, |menu, selected_text| {
menu.entry("Copy", Some(Box::new(markdown::Copy)), move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
selected_text.to_string(),
));
})
})
.when_some(selected_markdown, |menu, selected_markdown| {
menu.entry(
"Copy as Markdown",
Some(Box::new(markdown::CopyAsMarkdown)),
move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
selected_markdown.to_string(),
));
},
)
})
.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<String> {
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<String> = (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)
}
}
AssistantMessageChunk::Thought { .. } => None,
})
.collect::<Vec<_>>()
.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::Elicitation(_)
| AgentThreadEntry::AssistantMessage(_)
| AgentThreadEntry::CompletedPlan(_)
| AgentThreadEntry::ContextCompaction(_) => {}
}
}
has_running_terminal_call
}
fn render_collapsible_command(
&self,
group: SharedString,
is_preview: bool,
command: Entity<Markdown>,
window: &Window,
cx: &Context<Self>,
) -> 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_agent_buffer_font(cx);
style.container_style.text.font_size = Some(rems_from_px(12_f32).into());
style.container_style.text.line_height = Some(rems_from_px(17_f32).into());
style.height_is_multiple_of_line_height = true;
// Soft-wrap the command instead of horizontally scrolling it: the card is
// narrow, and in scroll mode a long command wraps anyway but its wrapped
// lines don't pick up the code block's left padding. Wrap mode lays the
// text out as a normal block inside the padded content box, so every
// line (wrapped or not) is padded consistently.
style.code_block_overflow_x_scroll = false;
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,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
});
let copy_button_id = SharedString::from(format!("{group}-copy-command"));
let copy_button = CopyButton::new(copy_button_id, 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<acp_thread::Terminal>,
tool_call: &ToolCall,
focus_handle: &FocusHandle,
layout: ToolCallLayout,
window: &Window,
cx: &Context<Self>,
) -> 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(|output| {
output
.exit_status
.exit_code
.is_some_and(|exit_code| exit_code != 0)
|| output.exit_status.signal.is_some()
});
let time_elapsed = if let Some(output) = output {
output.ended_at.duration_since(started_at)
} else {
started_at.elapsed()
};
let header_group = SharedString::from(format!(
"terminal-tool-header-group-{}",
terminal.entity_id()
));
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
.entry_view_state
.read(cx)
.is_tool_call_expanded(&tool_call.id);
let truncated_tooltip = truncated_output.then(|| {
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()
}
});
let header = TerminalToolHeader::new(
terminal.entity_id().to_string(),
header_group,
working_dir,
is_expanded,
)
.elapsed(time_elapsed)
.running(!command_finished && !needs_confirmation)
.on_toggle_expand(cx.listener({
let id = tool_call.id.clone();
move |this, _event, window, cx| {
this.entry_view_state.update(cx, |state, _cx| {
state.toggle_tool_call_expansion(&id);
});
this.refresh_thread_search(window, cx);
cx.notify();
}
}))
.when(terminal_data.is_process_backed(), |header| {
header.on_stop({
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_some(truncated_tooltip, |header, tooltip| {
header.truncated(tooltip)
})
.when(tool_failed || command_failed, |header| {
header.failed(output.and_then(|output| output.exit_status.exit_code))
})
.when_some(tool_call.sandbox_not_applied.as_ref(), |header, reason| {
header.sandbox_warning(self.sandbox_not_applied_warning(reason, cx))
})
.command_slot(command_element);
let terminal_view = self
.entry_view_state
.read(cx)
.entry(entry_ix)
.and_then(|entry| entry.terminal(terminal));
v_flex()
.when(layout == ToolCallLayout::Standalone, |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(header)
.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().terminal_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);
let allow_disabled = self.sandbox_confusables_block_allow(tool_call, 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,
allow_disabled,
cx,
))
})
.into_any()
}
fn sandbox_not_applied_warning(
&self,
reason: &SandboxNotAppliedReason,
cx: &Context<Self>,
) -> TerminalSandboxWarning {
// (title, detail line, docs section slug)
let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) =
match reason {
SandboxNotAppliedReason::ErrorLinuxWsl(error) => (
"Couldn't create a sandbox".into(),
error.user_facing_message().into(),
Some(error.docs_section()),
),
SandboxNotAppliedReason::DisabledForThisThread => {
// The grant only exists because an earlier command failed to
// create a sandbox; surface that same explanation here.
let thread_error = self.find_thread_sandbox_error(cx);
let detail = thread_error
.as_ref()
.map(|error| {
SharedString::from(format!(
"Allowed for this thread after the sandbox failed: {}",
error.user_facing_message()
))
})
.unwrap_or_else(|| {
"Unsandboxed execution is allowed for the rest of this thread.".into()
});
let docs_section = thread_error.as_ref().map(|error| error.docs_section());
("Ran without sandbox".into(), detail, docs_section)
}
};
TerminalSandboxWarning {
title,
detail,
docs_url: zed_urls::sandboxing_docs(docs_section, cx).into(),
}
}
/// Find the first terminal tool call in the thread whose sandbox couldn't be
/// created, so a later "disabled for this thread" warning can reuse the same
/// explanation of *why* the sandbox failed.
fn find_thread_sandbox_error(&self, cx: &App) -> Option<acp_thread::LinuxWslSandboxError> {
self.thread.read(cx).entries().iter().find_map(|entry| {
if let AgentThreadEntry::ToolCall(tool_call) = entry
&& let Some(SandboxNotAppliedReason::ErrorLinuxWsl(error)) =
&tool_call.sandbox_not_applied
{
return Some(error.clone());
}
None
})
}
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,
layout: ToolCallLayout,
window: &Window,
cx: &Context<Self>,
) -> Stateful<Div> {
let has_terminals = tool_call.terminals().next().is_some();
// Give every tool-call subtree a unique element-id prefix derived from
// the globally-unique tool call id and the layout. This single wrapper
// is what keeps all the `entry_ix`-keyed element ids inside the card
// collision-free, even when the same tool call is rendered in multiple
// places at once (inline list + floating awaiting-permission row) or
// when subagent entries are inlined into the parent view's element tree.
let container_id = ElementId::Name(SharedString::from(format!(
"tool-call-{}-{}",
tool_call.id.0,
layout.id_str()
)));
div().w_full().id(container_id).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,
layout,
window,
cx,
)
}))
} else {
this.child(self.render_tool_call(
active_session_id,
entry_ix,
tool_call,
focus_handle,
layout,
window,
cx,
))
}
})
}
fn render_tool_call(
&self,
active_session_id: &acp::SessionId,
entry_ix: usize,
tool_call: &ToolCall,
focus_handle: &FocusHandle,
layout: ToolCallLayout,
window: &Window,
cx: &Context<Self>,
) -> Div {
let has_location = tool_call.locations.len() == 1;
let card_header_id = SharedString::from(format!("inner-tool-call-header-{entry_ix}"));
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 should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
let has_content = !tool_call.content.is_empty()
|| (should_show_raw_input && tool_call.raw_input.is_some());
let is_collapsible = has_content && !needs_confirmation;
let mut is_open = self
.entry_view_state
.read(cx)
.is_tool_call_expanded(&tool_call.id);
is_open |= needs_confirmation;
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 { .. } => {
let confirmation_content = 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,
failed_or_canceled,
focus_handle,
window,
cx,
))
.into_any_element()
},
))
.when_some(
tool_call.sandbox_authorization_details.as_ref(),
|this, details| {
this.child(self.render_sandbox_authorization_details(
entry_ix,
&tool_call.id,
details,
window,
cx,
))
},
)
.when_some(
tool_call.sandbox_fallback_authorization_details.as_ref(),
|this, details| {
this.child(
self.render_sandbox_fallback_authorization_details(details, cx),
)
},
)
.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,
)
},
))
}),
)
});
confirmation_content.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)| {
let output_id = SharedString::from(format!(
"tool-call-output-{entry_ix}-{content_ix}"
));
div()
.id(output_id.clone())
.debug_selector(move || output_id.to_string())
.child(self.render_tool_call_content(
active_session_id,
entry_ix,
content,
content_ix,
tool_call,
use_card_layout,
failed_or_canceled,
focus_handle,
window,
cx,
))
}),
)
.when(!use_card_layout, |this| {
let button_id =
SharedString::from(format!("tool_output-collapse-{:?}", tool_call.id));
let tool_call_id = tool_call.id.clone();
this.child(
div()
.ml(rems(0.4))
.px_3p5()
.pt_2()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
.child(
IconButton::new(button_id, IconName::ChevronUp)
.full_width()
.style(ButtonStyle::Outlined)
.icon_color(Color::Muted)
.on_click(cx.listener({
move |this: &mut Self,
_,
window,
cx: &mut Context<Self>| {
this.entry_view_state.update(cx, |state, _cx| {
state.collapse_tool_call(&tool_call_id);
});
this.refresh_thread_search(window, cx);
cx.notify();
}
})),
),
)
})
.into_any(),
ToolCallStatus::Rejected => Empty.into_any(),
}
.into()
} else {
None
};
let permission_buttons =
if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status {
Some(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,
self.sandbox_confusables_block_allow(tool_call, cx),
cx,
))
} else {
None
};
let body = v_flex()
.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_f32))
.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,
_,
window,
cx: &mut Context<Self>| {
this.entry_view_state.update(
cx,
|state, _cx| {
state
.toggle_tool_call_expansion(
&id,
);
},
);
this.refresh_thread_search(window, cx);
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_f32))),
)
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(OpenExcerpts),
cx,
)
}),
)
}),
)
)
}
})
.children(tool_output_display);
v_flex()
.map(|this| {
if matches!(layout, ToolCallLayout::Embedded | ToolCallLayout::Floating) {
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(layout == ToolCallLayout::Standalone, |this| {
this.map(|this| {
if has_location && !use_card_layout {
this.ml_4()
} else {
this.ml_5()
}
})
.mr_5()
})
.map(|this| {
if layout == ToolCallLayout::Floating {
this.child(
div()
.id(("floating-tool-call-body", entry_ix))
.max_h_40()
.overflow_y_scroll()
.child(body),
)
} else {
this.child(body)
}
})
.children(permission_buttons)
}
/// A small "Learn more" link to the sandboxing docs, deep-linked to
/// `section` when provided. Shared by the sandbox warning and the two
/// sandbox approval prompts so the user can always reach an explanation of
/// what they're being asked about.
fn render_sandbox_docs_link(
&self,
id: &'static str,
section: Option<&str>,
cx: &Context<Self>,
) -> AnyElement {
let url = zed_urls::sandboxing_docs(section, cx);
Button::new(id, "View Sandboxing Docs")
.label_size(LabelSize::Small)
.color(Color::Muted)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.tooltip({
let url = url.clone();
move |_, cx| Tooltip::with_meta("Open Docs", None, url.clone(), cx)
})
.on_click(move |_, _, cx| cx.open_url(&url))
.into_any_element()
}
fn render_sandbox_authorization_details(
&self,
entry_ix: usize,
tool_call_id: &acp::ToolCallId,
details: &SandboxAuthorizationDetails,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
let has_write = details.allow_fs_write_all || !details.write_paths.is_empty();
// The dedicated Windows-drive warning prompt is only ever sent while the
// warning is enabled, so key the banner on the prompt itself. Keeping it
// visible even after the "Don't show again" checkbox flips the setting
// avoids the card disappearing out from under the user mid-decision.
let has_windows_fs_warning = details.warn_windows_fs;
if !has_network
&& !has_write
&& !details.unsandboxed
&& details.reason.is_empty()
&& !has_windows_fs_warning
{
return Empty.into_any_element();
}
let confusable_findings = if Self::confusable_warning_enabled(cx) {
Self::sandbox_confusable_findings(details)
} else {
Vec::new()
};
let network_section = has_network.then(|| {
let summary = if details.network_all_hosts {
"any host".to_string()
} else {
format!(
"{} {}",
details.network_hosts.len(),
if details.network_hosts.len() == 1 {
"host"
} else {
"hosts"
}
)
};
let has_host_list = !details.network_all_hosts && !details.network_hosts.is_empty();
let is_open = !self
.collapsed_sandbox_network_details
.contains(tool_call_id);
let mut hosts = details.network_hosts.clone();
hosts.sort();
v_flex()
.child(
h_flex()
.id(("sandbox-network-details-header", entry_ix))
// Align text with the allow/deny button icons below,
// which sit at p_1 (container) + Base04 (button) ≈ px_2.
.px_2()
.py_1()
.justify_between()
.when(has_host_list, |this| {
this.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, _event, _window, cx| {
if this
.collapsed_sandbox_network_details
.remove(&tool_call_id)
{
cx.notify();
return;
}
this.collapsed_sandbox_network_details
.insert(tool_call_id.clone());
cx.notify();
}
}))
})
.child(
h_flex()
.gap_1()
.child(
Label::new("Network access")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new("•")
.size(LabelSize::XSmall)
.color(Color::Disabled),
)
.child(
Label::new(summary)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.when(has_host_list, |this| {
this.child(
Disclosure::new(("sandbox-network-details", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown),
)
}),
)
.when(has_host_list && is_open, |this| {
this.child(v_flex().children(hosts.iter().enumerate().map(
|(host_ix, host)| {
h_flex()
.min_w_0()
.px_2()
.py_1p5()
.bg(cx.theme().colors().editor_background)
.when(host_ix < hosts.len() - 1, |this| {
this.border_b_1().border_color(cx.theme().colors().border)
})
.child(
Label::new(host.clone())
.size(LabelSize::XSmall)
.buffer_font(cx),
)
},
)))
})
});
let write_section = has_write.then(|| {
let summary = if details.allow_fs_write_all {
"unrestricted except Git metadata".to_string()
} else {
format!(
"{} {}",
details.write_paths.len(),
if details.write_paths.len() == 1 {
"path"
} else {
"paths"
}
)
};
let has_path_list = !details.allow_fs_write_all && !details.write_paths.is_empty();
let is_open = !self
.collapsed_sandbox_authorization_details
.contains(tool_call_id);
let mut paths = details.write_paths.clone();
// Sort by the path that is actually granted (the resolved canonical
// when present, else the requested path).
paths.sort_by(|a, b| a.canonical_or_requested().cmp(b.canonical_or_requested()));
v_flex()
.child(
h_flex()
.id(("sandbox-authorization-details-header", entry_ix))
.px_2()
.py_1()
.justify_between()
.when(has_path_list, |this| {
this.cursor_pointer()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, _event, _window, cx| {
if this
.collapsed_sandbox_authorization_details
.remove(&tool_call_id)
{
cx.notify();
return;
}
this.collapsed_sandbox_authorization_details
.insert(tool_call_id.clone());
cx.notify();
}
}))
})
.child(
h_flex()
.gap_1()
.child(
Label::new("Write Access")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new("•")
.size(LabelSize::XSmall)
.color(Color::Disabled),
)
.child(
Label::new(summary)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.when(has_path_list, |this| {
this.child(
Disclosure::new(
("sandbox-authorization-details", entry_ix),
is_open,
)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown),
)
}),
)
.when(has_path_list && is_open, |this| {
this.child(v_flex().children(paths.iter().enumerate().map(
|(path_ix, path)| {
self.render_sandbox_authorization_path_row(entry_ix, path_ix, path, cx)
},
)))
})
});
let unsandboxed_section = details.unsandboxed.then(|| {
h_flex()
.px_2()
.py_1()
.gap_1p5()
.child(
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
)
.child(
Label::new("Runs without the OS sandbox")
.size(LabelSize::Small)
.color(Color::Muted),
)
});
let reason_section = (!details.reason.is_empty()).then(|| {
v_flex()
.px_2()
.py_1()
.gap_0p5()
.child(
Label::new("Reason")
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(details.reason.clone()).size(LabelSize::Small))
});
// The command stays in the tool-call title above; here we show what the
// command is asking for (paths / domains) and the agent's reason.
v_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when(has_windows_fs_warning, |this| {
this.child(self.render_sandbox_windows_fs_warning(cx))
})
.when(!confusable_findings.is_empty(), |this| {
this.child(self.render_sandbox_confusable_warning(
tool_call_id,
&confusable_findings,
window,
cx,
))
})
.children(network_section)
.children(write_section)
.children(unsandboxed_section)
.children(reason_section)
.when(!has_windows_fs_warning, |this| {
// The Windows-drive warning banner carries its own docs link, so
// skip the default one that every other sandbox prompt appends.
this.child(
h_flex()
.px_1()
.py_0p5()
.child(self.render_sandbox_docs_link(
"sandbox-authorization-docs-link",
None,
cx,
)),
)
})
.into_any_element()
}
/// Scan the hosts and paths in a sandbox escalation request for surprising
/// Unicode characters (homoglyphs, invisible characters, bidi overrides).
/// Returns, for each offending value, the display string shown to the user
/// and the distinct suspicious characters it contains. Hosts are decoded from
/// Punycode first, so the display string is the Unicode form the user should
/// scrutinize. Empty when nothing is surprising.
fn sandbox_confusable_findings(
details: &SandboxAuthorizationDetails,
) -> Vec<(String, Vec<unicode_confusables::SuspiciousChar>)> {
let mut findings = Vec::new();
for host in &details.network_hosts {
let (decoded, suspicious) = unicode_confusables::scan_host(host);
if !suspicious.is_empty() {
findings.push((decoded, suspicious));
}
}
for granted in &details.write_paths {
// Scan both the requested path and the resolved target (when they
// differ), so a confusable in either the shown request or the real
// grant destination is surfaced.
let requested = granted.requested.display().to_string();
let resolved = granted.canonical_or_requested().display().to_string();
for display in [requested, resolved]
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
{
let suspicious = unicode_confusables::scan(&display);
if !suspicious.is_empty() {
findings.push((display, suspicious));
}
}
}
findings
}
/// Whether the surprising-Unicode warning is enabled in settings (on by
/// default). When off, prompts neither show the banner nor gate their allow
/// buttons on it.
fn confusable_warning_enabled(cx: &App) -> bool {
AgentSettings::get_global(cx)
.sandbox_permissions
.warn_confusable_unicode
}
/// Whether this tool call's sandbox escalation shows surprising Unicode that
/// the user hasn't acknowledged yet. While true, the prompt's allow buttons
/// stay disabled so the user can't grant access to a lookalike target
/// without first ticking the acknowledgement checkbox.
fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool {
if !Self::confusable_warning_enabled(cx) {
return false;
}
let Some(details) = tool_call.sandbox_authorization_details.as_ref() else {
return false;
};
if self
.acknowledged_confusable_warnings
.contains(&tool_call.id)
{
return false;
}
!Self::sandbox_confusable_findings(details).is_empty()
}
/// Red banner warning that a requested domain or path contains surprising
/// Unicode characters, with a checkbox the user must tick to unlock the
/// allow buttons. See [`Self::sandbox_confusables_block_allow`].
fn render_sandbox_confusable_warning(
&self,
tool_call_id: &acp::ToolCallId,
findings: &[(String, Vec<unicode_confusables::SuspiciousChar>)],
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id);
let line_height = window.line_height();
v_flex()
.w_full()
.p_2()
.gap_2()
.border_t_1()
.border_color(cx.theme().status().error_border)
.bg(cx.theme().status().error_background.opacity(0.15))
.child(
h_flex()
.w_full()
.gap_1p5()
.items_start()
.child(
h_flex()
.h(line_height)
.flex_none()
.justify_center()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Error),
),
)
.child(
v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map(
|(value, suspicious)| {
v_flex()
.min_w_0()
.gap_0p5()
.child(
Label::new(format!(
"“{value}” contains potentially surprising Unicode characters"
))
.size(LabelSize::Small)
.color(Color::Error),
)
.child(v_flex().min_w_0().pl_2().children(
suspicious.iter().map(|character| {
Label::new(format!("• {}", character.description()))
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx)
}),
))
},
)),
)
.child(
IconButton::new("configure-confusable-warning", IconName::Settings)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Configure unicode confusables warning"))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
target: None,
}),
cx,
);
}),
),
)
.child(
Checkbox::new(
SharedString::from(format!("confusable-ack-{}", tool_call_id.0)),
if acknowledged {
ToggleState::Selected
} else {
ToggleState::Unselected
},
)
.label("I understand and wish to proceed")
.label_size(LabelSize::Small)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, state: &ToggleState, _window, cx| {
if *state == ToggleState::Selected {
this.acknowledged_confusable_warnings
.insert(tool_call_id.clone());
} else {
this.acknowledged_confusable_warnings.remove(&tool_call_id);
}
cx.notify();
}
})),
)
.into_any_element()
}
/// Whether the Windows-drive (DrvFs) weaker-guarantee warning is enabled in
/// settings (on by default). Windows-only in effect: `warn_windows_fs` is
/// never set on other platforms.
fn ntfs_warning_enabled(cx: &App) -> bool {
AgentSettings::get_global(cx)
.sandbox_permissions
.warn_ntfs_grants
}
/// Informational banner shown on a sandbox approval prompt when the command
/// will write to a file on a Windows drive (reached inside WSL via DrvFs),
/// whose sandbox-integrity guarantees are weaker than the distro's native
/// filesystem. Unlike the confusable-Unicode banner this does not gate the
/// allow buttons: the approval itself is the acknowledgement. A settings gear
/// links to where the warning can be suppressed.
fn render_sandbox_windows_fs_warning(&self, cx: &Context<Self>) -> AnyElement {
v_flex()
.w_full()
.p_2()
.gap_1()
.border_t_1()
.border_color(cx.theme().status().warning_border)
.bg(cx.theme().status().warning_background.opacity(0.15))
.child(
h_flex()
.w_full()
.gap_1p5()
.items_start()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(
v_flex()
.min_w_0()
.flex_1()
.gap_0p5()
.child(
Label::new("This command can write to a file on a Windows drive")
.size(LabelSize::Small)
.color(Color::Warning),
)
.child(
Label::new(
"Sandboxes with write access to a location on a Windows \
drive may not provide full filesystem isolation.",
)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(h_flex().child(self.render_sandbox_docs_link(
"sandbox-windows-fs-docs-link",
Some("windows"),
cx,
))),
)
.child(
IconButton::new("configure-ntfs-warning", IconName::Settings)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Configure Windows-drive warning"))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
target: None,
}),
cx,
);
}),
),
)
.child(
Checkbox::new(
"sandbox-windows-fs-dont-warn",
if Self::ntfs_warning_enabled(cx) {
ToggleState::Unselected
} else {
ToggleState::Selected
},
)
.label("Don't show this warning again")
.label_size(LabelSize::Small)
.on_click(cx.listener(|this, state: &ToggleState, _window, cx| {
let disable = *state == ToggleState::Selected;
let fs = this.thread.read(cx).project().read(cx).fs().clone();
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.sandbox_permissions
.get_or_insert_default()
.warn_ntfs_grants = Some(!disable);
});
cx.notify();
})),
)
.into_any_element()
}
fn render_sandbox_fallback_authorization_details(
&self,
details: &SandboxFallbackAuthorizationDetails,
cx: &Context<Self>,
) -> AnyElement {
// The command itself is shown in the tool-call header (a collapsible
// command), so here we only explain *why* the sandbox couldn't be
// created — the user needs both to decide whether to run unsandboxed.
if details.reason.is_empty() {
return Empty.into_any_element();
}
h_flex()
.p_1p5()
.gap_1p5()
.items_start()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
)
.child(
v_flex()
.min_w_0()
.gap_0p5()
.child(
Label::new("Couldn't create a sandbox")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Label::new(details.reason.clone()).size(LabelSize::Small))
.child(self.render_sandbox_docs_link(
"sandbox-fallback-docs-link",
details.docs_section.as_deref(),
cx,
)),
)
.into_any_element()
}
fn render_sandbox_authorization_path_row(
&self,
entry_ix: usize,
path_ix: usize,
granted: &settings::GrantedWritePath,
cx: &Context<Self>,
) -> Stateful<Div> {
// The path that is actually granted is the resolved canonical target.
// When the request went through a symlink to a *different* target, both
// paths are shown, each explicitly captioned, so it's unmistakable which
// string was requested and which location write access is really granted
// to.
let granted_path = granted.canonical_or_requested();
let requested_path = granted.requested.clone();
// Grants are stored in the request's own namespace (a Windows path stays
// `C:\...`, a WSL path stays `/...`), so a genuine symlink/junction
// redirect is just a plain inequality between the request and its
// resolved canonical.
let is_redirected = granted
.resolved
.as_deref()
.is_some_and(|resolved| resolved != requested_path.as_path());
let granted_display = granted_path.display().to_string();
let requested_display = requested_path.display().to_string();
let captioned_path = |caption: SharedString, path: String, cx: &Context<Self>| {
v_flex()
.min_w_0()
.gap_0p5()
.child(
Label::new(caption)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(path).size(LabelSize::Small).buffer_font(cx))
};
v_flex()
.id(format!("sandbox-authorization-path-{entry_ix}-{path_ix}"))
.min_w_0()
.gap_1()
.px_2()
.py_1p5()
.bg(cx.theme().colors().editor_background)
.map(|this| {
if is_redirected {
this.child(captioned_path("Source".into(), requested_display, cx))
.child(
Icon::new(IconName::ArrowDown)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(captioned_path("Target".into(), granted_display, cx))
} else {
// Not a genuine redirect: show what the user asked for (e.g.
// the `C:\...` path), not the internal Linux canonical.
this.child(captioned_path("Write Path".into(), requested_display, cx))
}
})
.child(Divider::horizontal())
}
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,
// When true, the "allow" choices are disabled (e.g. an unacknowledged
// surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled.
allow_disabled: bool,
cx: &Context<Self>,
) -> Div {
match options {
PermissionOptions::Flat(options) => self.render_permission_buttons_flat(
session_id,
is_first,
options,
entry_ix,
tool_call_id,
focus_handle,
allow_disabled,
cx,
),
PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
is_first,
choices,
None,
entry_ix,
session_id,
tool_call_id,
focus_handle,
allow_disabled,
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,
allow_disabled,
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,
allow_disabled: bool,
cx: &Context<Self>,
) -> 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")
.disabled(allow_disabled)
.start_icon(
Icon::new(IconName::Check)
.size(IconSize::XSmall)
.color(Color::Success),
)
.label_size(LabelSize::Small)
.when(is_first && !allow_disabled, |this| {
this.key_binding(
KeyBinding::for_action_in(
&AllowOnce as &dyn Action,
focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12_f32))),
)
})
.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_f32))),
)
})
.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<Self>,
) -> 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_f32))),
)
}),
)
.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<Self>,
) -> 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_f32))),
)
}),
)
.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<PermissionSelection> = 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,
allow_disabled: bool,
cx: &Context<Self>,
) -> Div {
let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = 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| {
// The sandbox-fallback prompt offers a "Retry" option
// that re-attempts creating the sandbox; it isn't an
// allow/deny choice, so give it its own icon and no
// keybinding.
let is_retry = option.option_id.0.as_ref()
== acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID;
let (icon, action) = if is_retry {
(
Icon::new(IconName::RotateCcw)
.size(IconSize::XSmall)
.color(Color::Muted),
None,
)
} else {
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),
if option.option_id.0.as_ref()
== acp_thread::SandboxPermission::AllowThread.as_id()
{
None
} else {
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,
),
}
};
// An "allow" choice is disabled while a surprising-Unicode
// warning is unacknowledged; "deny"/"retry" stay enabled.
let is_allow = matches!(
option.kind,
acp::PermissionOptionKind::AllowOnce
| acp::PermissionOptionKind::AllowAlways
) && !is_retry;
let disabled = allow_disabled && is_allow;
let this = this.start_icon(icon).disabled(disabled);
let Some(action) = action else {
return this;
};
if !is_first || disabled || 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_f32))),
)
})
.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<Self>) -> 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 tool_call_icon_tooltip(
tool_name: Option<&SharedString>,
interrupted_edit: bool,
) -> Option<SharedString> {
let tool_name = tool_name.filter(|name| !name.trim().is_empty());
match (tool_name, interrupted_edit) {
(Some(name), true) => Some(format!("Interrupted Edit\nTool: {name}").into()),
(Some(name), false) => Some(format!("Tool: {name}").into()),
(None, true) => Some("Interrupted Edit".into()),
(None, false) => None,
}
}
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<Self>,
) -> 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 interrupted_edit = is_file && has_failed && has_revealed_diff;
let tool_icon = if interrupted_edit {
div()
.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_f32)) // Concentric border radius
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
})
.overflow_hidden()
.child(
div()
.id(("tool-call-icon", entry_ix))
.flex_none()
.when_some(
Self::tool_call_icon_tooltip(
tool_call.tool_name.as_ref(),
interrupted_edit,
),
|this, tooltip| this.tooltip(Tooltip::text(tooltip)),
)
.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<Self>,
) -> 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| {
if let Some(project_path) = project_path {
workspace.open_path(project_path, None, true, window, cx)
} else {
workspace.open_abs_path(
tool_call_location.path.clone(),
OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
}
})
.log_err()?;
window
.spawn(cx, async move |cx| {
let item = open_task.await?;
let Some(active_editor) = item.downcast::<Editor>() 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,
has_failed: bool,
focus_handle: &FocusHandle,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
match content {
ToolCallContent::ContentBlock(content) => self.render_output_content_block(
entry_ix,
context_ix,
content,
Some(tool_call),
card_layout,
window,
cx,
),
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,
ToolCallLayout::Standalone,
window,
cx,
),
}
}
fn render_output_content_block(
&self,
entry_ix: usize,
context_ix: usize,
content: &acp_thread::ContentBlock,
tool_call: Option<&ToolCall>,
card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
if let Some(markdown) = content.markdown() {
if let Some(tool_call) = tool_call {
self.render_markdown_output(
markdown.clone(),
entry_ix,
context_ix,
tool_call,
card_layout,
window,
cx,
)
} else {
self.render_markdown(
markdown.clone(),
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
cx,
)
.into_any()
}
} else if let Some((resource, _)) = content.embedded_resource() {
if tool_call.is_some() {
self.render_embedded_resource_output(resource, context_ix, card_layout, cx)
} else {
self.render_embedded_resource_label(resource)
}
} else if let Some(resource_link) = content.resource_link() {
self.render_resource_link(resource_link, cx)
} else if let Some((image, _)) = content.image() {
let location = tool_call.and_then(|tool_call| tool_call.locations.first().cloned());
self.render_image_output(entry_ix, image.clone(), location, card_layout, cx)
} else {
Empty.into_any_element()
}
}
fn render_embedded_resource_output(
&self,
resource: &acp::EmbeddedResource,
context_ix: usize,
card_layout: bool,
cx: &Context<Self>,
) -> AnyElement {
v_flex()
.gap_1()
.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))
}
})
.child(self.render_embedded_resource_label(resource))
.into_any_element()
}
fn render_embedded_resource_label(&self, resource: &acp::EmbeddedResource) -> AnyElement {
let uri = match &resource.resource {
acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(),
acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(),
_ => "",
};
if uri.is_empty() {
Empty.into_any_element()
} else {
Label::new(uri.to_string())
.size(LabelSize::XSmall)
.color(Color::Muted)
.into_any_element()
}
}
fn render_resource_link(
&self,
resource_link: &acp::ResourceLink,
cx: &Context<Self>,
) -> 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 {
// Split off an optional `#L<line>` fragment so the path still resolves.
let (abs_path, fragment) = abs_path
.split_once('#')
.map_or((abs_path, None), |(path, fragment)| (path, Some(fragment)));
let path_label = 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()
} else {
abs_path.to_string()
};
match fragment {
Some(fragment) => format!("{path_label}#{fragment}").into(),
None => path_label.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<Self>| {
open_link(uri.clone(), &workspace, window, cx);
}
})),
)
.into_any_element()
}
fn render_diff_editor(
&self,
entry_ix: usize,
diff: &Entity<acp_thread::Diff>,
tool_call: &ToolCall,
has_failed: bool,
cx: &Context<Self>,
) -> 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<Markdown>,
entry_ix: usize,
context_ix: usize,
tool_call: &ToolCall,
card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let markdown_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
let output = self
.render_numbered_read_file_output(
markdown.clone(),
entry_ix,
context_ix,
tool_call,
markdown_style.clone(),
cx,
)
.unwrap_or_else(|| {
self.render_markdown(markdown, markdown_style, cx)
.into_any()
});
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(output)
.into_any_element()
}
fn render_numbered_read_file_output(
&self,
markdown: Entity<Markdown>,
entry_ix: usize,
context_ix: usize,
tool_call: &ToolCall,
markdown_style: MarkdownStyle,
cx: &Context<Self>,
) -> Option<AnyElement> {
let is_read_file = tool_call
.tool_name
.as_ref()
.is_some_and(|tool_name| tool_name.as_ref() == "read_file");
if !is_read_file {
return None;
}
let markdown = markdown.read(cx);
let parsed = parse_cat_numbered_markdown_code_block(markdown.source())?;
let language = markdown.first_code_block_language();
Some(render_cat_numbered_code_block(
parsed,
language,
markdown_style,
format!("copy-read-file-output-{entry_ix}-{context_ix}"),
cx,
))
}
fn render_image_output(
&self,
entry_ix: usize,
image: Arc<gpui::Image>,
location: Option<acp::ToolCallLocation>,
card_layout: bool,
cx: &Context<Self>,
) -> AnyElement {
v_flex()
.debug_selector(|| "agent-output-image".into())
.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_some(location, |this, _loc| {
this.child(
h_flex().w_full().justify_end().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<acp::SessionId>,
focus_handle: &FocusHandle,
window: &Window,
cx: &Context<Self>,
) -> 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<ThreadView>>,
tool_call: &ToolCall,
focus_handle: &FocusHandle,
window: &Window,
cx: &Context<Self>,
) -> 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).collect::<Vec<_>>())
.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
.entry_view_state
.read(cx)
.is_tool_call_expanded(&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(block) => {
block.text_content(cx) == Some("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, _, window, cx| {
let expanded =
this.entry_view_state.update(cx, |state, _cx| {
state.toggle_tool_call_expansion(&tool_call_id);
state.is_tool_call_expanded(&tool_call_id)
});
this.refresh_thread_search(window, cx);
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,
ToolCallLayout::Embedded,
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<ThreadView>,
tool_call: &ToolCall,
window: &Window,
cx: &Context<Self>,
) -> 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<AnyElement> = 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()
// Include the tool call id so the same subagent session
// rendered in multiple parent cards gets distinct element
// ids for its inlined entries (avoids duplicate a11y ids).
.id(format!(
"subagent-entries-{}-{}",
session_id, tool_call.id.0
))
.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<SharedString> {
if matches!(status, ToolCallStatus::Failed) {
tool_call.content.iter().find_map(|content| {
if let ToolCallContent::ContentBlock(block) = content {
if let Some(source) = block.text_content(cx).filter(|source| !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<Self>) -> Hsla {
cx.theme()
.colors()
.element_background
.blend(cx.theme().colors().editor_foreground.opacity(0.025))
}
fn tool_card_border_color(&self, cx: &Context<Self>) -> Hsla {
cx.theme().colors().border.opacity(0.8)
}
fn tool_name_font_size(&self) -> Rems {
rems_from_px(13_f32)
}
fn provider_by_name(name: &SharedString, cx: &App) -> Option<Arc<dyn LanguageModelProvider>> {
LanguageModelRegistry::read_global(cx)
.providers()
.into_iter()
.find(|provider| provider.name().0 == *name)
}
pub(crate) fn render_thread_error(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Div> {
let callout = 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::DataRetentionConsentRequired => {
self.render_data_retention_consent_error(cx)
}
ThreadError::AuthenticationRequired(error) => {
self.render_authentication_required_error(error.clone(), cx)
}
ThreadError::ZedPaymentRequired => self.render_zed_payment_required_error(cx),
ThreadError::RateLimitExceeded { provider } => self.render_error_callout(
"Rate Limit Reached",
format!(
"{provider}'s rate limit was reached. Gwen 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. Gwen 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::NoCredentials { provider } => {
let message = Self::provider_by_name(provider, cx)
.map(|provider| provider.missing_credentials_error_message())
.unwrap_or_else(|| {
format!("No credentials are configured for {provider}.").into()
});
self.render_error_callout("Credentials Missing", message, false, true, cx)
}
ThreadError::StreamError { provider } => self.render_error_callout(
"Connection Interrupted",
format!(
"The connection to {provider}'s API was interrupted. Gwen will retry \
automatically. If the problem persists, check your network connection."
)
.into(),
true,
true,
cx,
),
ThreadError::AuthenticationFailed { provider } => {
let message = Self::provider_by_name(provider, cx)
.map(|provider| provider.authentication_error_message())
.unwrap_or_else(|| format!("Could not authenticate with {provider}.").into());
self.render_error_callout("Authentication Failed", message, false, false, cx)
}
ThreadError::PermissionDenied { provider, message } => {
let message: SharedString = message.clone().unwrap_or_else(|| {
format!("{provider} rejected the request due to insufficient permissions.")
.into()
});
self.render_error_callout("Permission Denied", message, false, false, cx)
}
ThreadError::ProviderRejection { message } => {
self.render_error_callout("Request Failed", message.clone(), 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_model_not_available_error(cx)
.unwrap_or_else(|| {
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 Gwen."
)
.into(),
true,
true,
cx,
),
};
Some(div().child(callout.border_position(self.callout_border_position())))
}
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<Self>,
) -> 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_zed_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
const ERROR_MESSAGE: &str =
"You reached your free usage limit. Upgrade to Gwen 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<Self>,
) -> 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_model_not_available_error(&self, cx: &mut Context<Self>) -> Option<Callout> {
let thread = self.as_native_thread(cx)?;
let has_authenticated_provider =
LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx);
let (title, description): (SharedString, SharedString) =
match thread.read(cx).thread_model() {
agent::ThreadModel::Ready(_) => return None,
agent::ThreadModel::Unresolved(selected_model) => {
if let Some(provider) = LanguageModelRegistry::global(cx)
.read(cx)
.provider(&&selected_model.provider)
{
if !provider.is_authenticated(cx) {
(
format!("Failed to authenticate with {} provider", provider.name())
.into(),
"Open the settings to configure the selected provider".into(),
)
} else {
(
format!("Model {} was not found", selected_model.model.0).into(),
"You may need to reconfigure authentication for this provider"
.into(),
)
}
} else {
(
format!("Provider {} was not found", selected_model.provider).into(),
"Open the settings to configure providers".into(),
)
}
}
agent::ThreadModel::Unset => {
if has_authenticated_provider {
(
"No model selected".into(),
"Choose a different model or configure other providers to get started"
.into(),
)
} else {
(
"No model selected".into(),
"Configure a provider to get started".into(),
)
}
}
};
let callout = Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title(title)
.description(description)
.actions_slot(
h_flex()
.gap_1()
.child(self.open_llm_providers_settings_button(cx))
.when(has_authenticated_provider, |this| {
this.child(self.open_model_selector_button(cx))
}),
)
.dismiss_action(self.dismiss_error_button(cx));
Some(callout)
}
fn open_llm_providers_settings_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("configure-llm-provider", "Configure Provider")
.label_size(LabelSize::Small)
.style(ButtonStyle::Filled)
.on_click(cx.listener(|this, _, window, cx| {
this.clear_thread_error(cx);
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: "llm_providers".to_string(),
target: None,
}),
cx,
);
}))
}
fn open_model_selector_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("open-model-selector", "Select Model")
.label_size(LabelSize::Small)
.style(ButtonStyle::Filled)
.key_binding(KeyBinding::for_action(&ToggleModelSelector, cx))
.on_click(cx.listener(|this, _, window, cx| {
this.clear_thread_error(cx);
window.dispatch_action(ToggleModelSelector.boxed_clone(), cx);
}))
}
fn render_prompt_too_large_error(&self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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();
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(),
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<Markdown>,
style: MarkdownStyle,
cx: &App,
) -> MarkdownElement {
let list_state = self.list_state.clone();
render_agent_markdown(
markdown,
style,
&self.workspace,
&self.code_span_resolver,
cx,
)
// Zooming a diagram grows/shrinks its block; pause tail-following so the
// viewport stays put instead of snapping back to the bottom. The list
// resumes following on its own once the content returns to the bottom.
.on_mermaid_zoom(move |_window, _cx| {
list_state.pause_following_tail();
})
}
fn create_copy_button(&self, message: impl Into<String>) -> 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<Self>) -> 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<Self>) -> 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(CalloutBorderPosition::Bottom)
.severity(Severity::Info)
.icon(IconName::Info)
.title("Resumed Session")
.description(description)
.into_any_element()
}
fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
Callout::new()
.border_position(self.callout_border_position())
.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_issues(&self, cx: &mut Context<Self>) -> Vec<Callout> {
let border_position = self.callout_border_position();
let description_warnings = self
.skill_loading_issues
.iter()
.filter(|issue| issue.kind == SkillLoadingIssueKind::DescriptionTooLong)
.cloned()
.collect::<Vec<_>>();
let long_description_warning =
self.render_skill_description_warnings(description_warnings, cx);
let other_warnings = self
.skill_loading_issues
.iter()
.filter(|issue| issue.kind != SkillLoadingIssueKind::DescriptionTooLong)
.enumerate()
.map(|(index, issue)| {
let abs_path = issue.path.clone();
let workspace = self.workspace.clone();
let path_label = issue.path.display().to_string();
let target = issue.clone();
let title = match issue.kind {
SkillLoadingIssueKind::LoadFailed => "Skill Failed to Load",
SkillLoadingIssueKind::DescriptionTooLong => unreachable!(),
SkillLoadingIssueKind::CatalogBudgetExceeded => {
"Skill Omitted from Model Catalog"
}
};
Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.title(title)
.description(format!("{}\n{path_label}", issue.message))
.actions_slot(
Button::new(("open-skill-file", index), "Open Skill")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small)
.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-issue", index), IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(cx.listener(move |this, _, _, cx| {
this.skill_loading_issues.retain(|issue| *issue != target);
this.dismissed_skill_loading_issues.insert(target.clone());
cx.notify();
})),
)
})
.collect::<Vec<_>>();
long_description_warning
.into_iter()
.chain(other_warnings)
.map(|callout| callout.border_position(border_position))
.collect()
}
fn render_skill_description_warnings(
&self,
description_warnings: Vec<SkillLoadingIssue>,
cx: &mut Context<Self>,
) -> Option<Callout> {
if description_warnings.is_empty() {
return None;
}
let warning_count = description_warnings.len();
let title = if warning_count == 1 {
"1 Skill Loaded with a Long Description".to_string()
} else {
format!("{warning_count} Skills Loaded with Long Descriptions")
};
let rows = description_warnings
.iter()
.enumerate()
.map(|(index, issue)| {
let abs_path = issue.path.clone();
let workspace = self.workspace.clone();
let full_path = issue.path.display().to_string();
let file_label = skill_issue_file_label(&issue.path);
ButtonLike::new(("skill-description-warning-file", index))
.full_width()
.child(
h_flex()
.w_full()
.gap_1()
.child(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(file_label).size(LabelSize::Small)),
)
.tooltip(move |_, cx| {
Tooltip::with_meta("Open Skill", None, full_path.clone(), cx)
})
.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();
}))
.into_any_element()
})
.collect::<Vec<_>>();
let callout = Callout::new()
.icon(IconName::Warning)
.severity(Severity::Warning)
.title(title)
.description_slot(
v_flex()
.gap_1()
.child(
Label::new(format!(
"Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} characters; longer ones may consume more model-context tokens."
))
.size(LabelSize::Small)
.color(Color::Muted),
)
.children(rows),
);
let targets = description_warnings;
Some(
callout.dismiss_action(
IconButton::new("dismiss-skill-description-warnings", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(cx.listener(move |this, _, _, cx| {
this.skill_loading_issues
.retain(|issue| !targets.contains(issue));
for target in &targets {
this.dismissed_skill_loading_issues.insert(target.clone());
}
cx.notify();
})),
),
)
}
fn render_external_source_prompt_warning(&self, cx: &mut Context<Self>) -> Callout {
Callout::new()
.border_position(self.callout_border_position())
.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 submit it to the model.")
.dismiss_action(
IconButton::new("dismiss-external-source-prompt-warning", IconName::Close)
.icon_size(IconSize::Small)
.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<Self>) -> Option<Callout> {
if self.multi_root_callout_dismissed {
return None;
}
if self.as_native_connection(cx).is_some() {
return None;
}
if self
.thread
.read(cx)
.connection()
.supports_session_additional_directories()
{
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());
Some(
Callout::new()
.severity(Severity::Warning)
.icon(IconName::Warning)
.title("This agent doesn't currently support multi-root workspaces")
.description(format!(
"It currently only operates by default on \"{}\".",
active_dir
))
.border_position(self.callout_border_position())
.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<Self>) -> 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_b_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<Self>) -> Option<Callout> {
if self.token_limit_callout_dismissed || self.as_native_thread(cx).is_none() {
return None;
}
let token_usage = self.thread.read(cx).token_usage()?;
// When auto-compaction is available (the model's context window is large
// enough), the thread is compacted automatically before it reaches the
// limit, so there's no need to warn the user. Models with a context
// window that's too small can't be auto-compacted, so we fall back to
// the normal warning.
if token_usage.max_tokens >= agent::MIN_COMPACTION_CONTEXT_WINDOW {
return None;
}
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, run /compact or start a new thread and @-mention this one";
Some(
Callout::new()
.border_position(self.callout_border_position())
.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)),
)
}
/// Returns the model to offer as a downgrade target when the current model
/// requires data retention consent (e.g. Opus 4.8 for Fable).
fn data_retention_fallback_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
let thread = self.as_native_thread(cx)?;
let model = thread.read(cx).model()?.clone();
let fallback_id = model.refusal_fallback_model_id()?;
LanguageModelRegistry::read_global(cx)
.available_models(cx)
.find(|fallback| {
fallback.provider_id() == model.provider_id()
&& fallback.id().0.as_ref() == fallback_id
})
}
fn render_data_retention_consent_error(&self, cx: &mut Context<Self>) -> Callout {
let fallback_model = self.data_retention_fallback_model(cx);
Callout::new()
.severity(Severity::Warning)
.icon(IconName::Warning)
.title(format!(
"Note: {} cannot be offered with Zero Data Retention.",
self.current_model_name(cx)
))
.description_slot(
h_flex()
.gap_1()
.child(
Label::new("Anthropic will retain inference logs.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Button::new("data-retention-learn-more", "Learn More")
.label_size(LabelSize::Small)
.on_click(|_, _, cx| {
cx.open_url(DATA_RETENTION_LEARN_MORE_URL);
}),
),
)
.actions_slot(
h_flex()
.gap_0p5()
.when_some(fallback_model, |this, fallback| {
this.child(
Button::new(
"switch-data-retention-fallback",
format!("Switch to {}", fallback.name().0),
)
.label_size(LabelSize::Small)
.on_click(cx.listener(|this, _, _, cx| {
this.switch_to_data_retention_fallback_and_resend(cx);
})),
)
})
.child(
Button::new("accept-data-retention", "Accept")
.label_size(LabelSize::Small)
.style(ButtonStyle::Tinted(TintColor::Warning))
.on_click(cx.listener(|this, _, _, cx| {
this.accept_data_retention_and_resend(cx);
})),
),
)
.dismiss_action(self.dismiss_error_button(cx))
}
fn accept_data_retention_and_resend(&mut self, cx: &mut Context<Self>) {
let fs = self.thread.read(cx).project().read(cx).fs().clone();
// Resume the failed turn only once the in-memory settings reflect
// consent, otherwise the resent request would be rejected again.
let completion = update_settings_file_with_completion(fs, cx, |settings, _| {
settings
.telemetry
.get_or_insert_default()
.anthropic_retention = Some(true);
});
cx.spawn(async move |this, cx| {
completion.await??;
this.update(cx, |this, cx| this.retry_generation(cx))?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn switch_to_data_retention_fallback_and_resend(&mut self, cx: &mut Context<Self>) {
let Some(fallback) = self.data_retention_fallback_model(cx) else {
return;
};
let model_id = acp_thread::AgentModelId::new(format!(
"{}/{}",
fallback.provider_id().0,
fallback.id().0
));
let session_id = self.thread.read(cx).session_id().clone();
let Some(selector) = self
.thread
.read(cx)
.connection()
.model_selector(&session_id)
else {
return;
};
let select = selector.select_model(model_id, cx);
cx.spawn(async move |this, cx| {
select.await?;
this.update(cx, |this, cx| this.retry_generation(cx))?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn open_permission_dropdown(
&mut self,
_: &crate::OpenPermissionDropdown,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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, window: &mut Window, cx: &mut Context<Self>) {
if !self.fast_mode_available(cx) {
return;
}
let Some(thread) = self.as_native_thread(cx) else {
return;
};
let current_speed = thread.read(cx).speed().unwrap_or_default();
let new_speed = current_speed.toggle();
if new_speed == Speed::Fast && self.pending_fast_mode_confirmation(cx).is_some() {
let menu_handle = self.fast_mode_menu_handle.clone();
window.defer(cx, move |window, cx| {
menu_handle.toggle(window, cx);
});
return;
}
self.apply_fast_mode_speed(new_speed, cx);
}
fn apply_fast_mode_speed(&mut self, new_speed: Speed, cx: &mut Context<Self>) {
let Some(thread) = self.as_native_thread(cx) else {
return;
};
thread.update(cx, |thread, cx| {
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_native_agent_thinking_effort(&mut self, cx: &mut Context<Self>) {
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)
});
}
}
});
});
}
}
impl Render for ThreadView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Keep the message editor's local slash commands in sync with the
// current availability of feedback/sharing, which can change between
// renders (settings, connection state, feature flags).
self.sync_local_commands(cx);
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, _: &super::thread_search_bar::DismissThreadSearch, window, cx| {
this.close_thread_search(window, cx);
},
))
// Esc can arrive as `editor::Cancel` from the query editor.
.on_action(
cx.listener(|this, _: &editor::actions::Cancel, window, cx| {
if !this.close_thread_search(window, cx) {
cx.propagate();
}
}),
)
.on_action(cx.listener(
|this, action: &super::thread_search_bar::SelectNextThreadMatch, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.select_next_match(action, window, cx));
}
},
))
.on_action(cx.listener(
|this, action: &super::thread_search_bar::SelectPreviousThreadMatch, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.select_prev_match(action, window, cx));
}
},
))
.on_action(
cx.listener(|this, _: &search::ToggleCaseSensitive, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| {
bar.toggle_case_sensitive(&search::ToggleCaseSensitive, window, cx)
});
}
}),
)
.on_action(
cx.listener(|this, _: &search::ToggleWholeWord, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| {
bar.toggle_whole_word(&search::ToggleWholeWord, window, cx)
});
}
}),
)
.on_action(cx.listener(|this, _: &search::ToggleRegex, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| {
bar.toggle_regex(&search::ToggleRegex, window, cx)
});
}
}))
.on_action(
cx.listener(|this, action: &search::FocusSearch, window, cx| {
if !this.thread_search_visible {
cx.propagate();
return;
}
if let Some(bar) = this.thread_search_bar.clone() {
bar.update(cx, |bar, cx| bar.focus_search(action, window, 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(Self::toggle_search))
.on_action(cx.listener(|this, _: &ToggleFastMode, window, cx| {
this.toggle_fast_mode(window, 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| {
let model_allows_disabling = thread
.model()
.is_none_or(|model| model.supports_disabling_thinking());
if model_allows_disabling {
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;
}
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::ThoughtLevel,
false,
cx,
)
});
if handled {
return;
}
}
this.cycle_native_agent_thinking_effort(cx);
}))
.on_action(
cx.listener(|this, _: &ToggleThinkingEffortMenu, 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::ThoughtLevel,
window,
cx,
)
});
if handled {
return;
}
}
let menu_handle = this.thinking_effort_menu_handle.clone();
window.defer(cx, move |window, cx| {
menu_handle.toggle(window, cx);
});
}),
)
.on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
if let Some(id) = this.message_queue.first_id() {
this.send_queued_message_now(id, window, cx);
}
}))
.on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
if let Some(id) = this.message_queue.first_id() {
this.remove_from_queue(id, cx);
cx.notify();
}
}))
.on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
if let Some(id) = this.message_queue.first_id() {
this.move_queued_message_to_main_editor(id, None, None, window, cx);
}
}))
.on_action(
cx.listener(|this, _: &ToggleSteerFirstQueuedMessage, _, cx| {
if this.as_native_thread(cx).is_none() {
return;
}
if let Some(id) = this.message_queue.first_id() {
this.toggle_queue_entry_steer(id, cx);
}
}),
)
.on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
this.clear_queue(cx);
}))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
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))
.when_some(
self.thread_search_visible
.then(|| self.thread_search_bar.clone())
.flatten(),
|this, bar| this.child(bar),
)
.child(conversation)
.children(self.render_multi_root_callout(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_skill_loading_issues(cx))
.children(self.render_thread_retry_status_callout(cx))
.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))
.children(self.render_request_elicitations(cx))
.child(self.render_message_editor(window, cx))
}
}
pub(crate) fn open_link(
url: SharedString,
workspace: &WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) {
let Some(workspace) = workspace.upgrade() else {
cx.open_url(&url);
return;
};
let path_style = workspace.read(cx).path_style(cx);
let (relative_path, fragment) = split_local_url_fragment(&url);
if let Some(fragment) = fragment
&& !relative_path.is_empty()
&& !path_style.is_absolute(relative_path)
{
let project = workspace.read(cx).project().clone();
let decoded_path = decode_path_escapes(relative_path);
let abs_path = project.update(cx, |project, cx| {
let resolve_path = |path: &str| {
let project_path = project.find_project_path(path, cx)?;
project.entry_for_path(&project_path, cx)?;
project.absolute_path(&project_path, cx)
};
resolve_path(&decoded_path).or_else(|| resolve_path(relative_path))
});
if let Some(abs_path) = abs_path {
let point = fragment
.strip_prefix('L')
.and_then(source_position_from_fragment)
.map(|(row, _)| Point::new(row, 0));
workspace.update(cx, |workspace, cx| {
open_abs_path_at_point(workspace, abs_path, point, window, cx);
});
return;
}
}
if let Some(mention) = MentionUri::parse_hyperlink(&url, path_style).log_err() {
// Percent escapes in bare paths are ambiguous: prefer the decoded
// interpretation, falling back to the literal one (e.g. a file
// actually named `a%20b.rs`) only when the decoded path doesn't
// resolve in the project but the literal one does.
let resolves_in_project = |mention: &MentionUri, cx: &App| {
mention.abs_path().is_some_and(|abs_path| {
let project = workspace.read(cx).project().read(cx);
project
.find_project_path(abs_path, cx)
.is_some_and(|path| project.entry_for_path(&path, cx).is_some())
})
};
let mention = match MentionUri::parse_hyperlink_literal(&url, path_style) {
Some(literal)
if !resolves_in_project(&mention, cx) && resolves_in_project(&literal, cx) =>
{
literal
}
_ => mention,
};
workspace.update(cx, |workspace, cx| match mention {
MentionUri::File { abs_path } => {
open_abs_path_at_point(workspace, abs_path, None, window, 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,
..
} => {
open_abs_path_at_point(
workspace,
path,
Some(Point::new(*line_range.start(), 0)),
window,
cx,
);
}
MentionUri::Selection {
abs_path: Some(path),
line_range,
column,
} => {
open_abs_path_at_point(
workspace,
path,
Some(Point::new(*line_range.start(), column.unwrap_or(0))),
window,
cx,
);
}
MentionUri::Selection { abs_path: None, .. } => {}
MentionUri::Thread { id, name } => {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.open_thread(id, None, Some(name.into()), window, cx)
});
}
}
MentionUri::Fetch { url } => {
cx.open_url(url.as_str());
}
MentionUri::Diagnostics { .. } => {}
MentionUri::TerminalSelection { .. } => {}
MentionUri::GitDiff { .. } => {}
MentionUri::MergeConflict { .. } => {}
MentionUri::Rule { name, .. } => {
crate::ui::open_migrated_rule(workspace, &name, window, cx);
}
MentionUri::Skill {
skill_file_path, ..
} => {
workspace
.open_abs_path(
skill_file_path,
workspace::OpenOptions {
focus: Some(true),
..Default::default()
},
window,
cx,
)
.detach_and_log_err(cx);
}
})
} else {
workspace.update(cx, |workspace, cx| {
workspace.open_url_or_file(&url, None, window, cx);
});
}
}
/// Returns the name of the leading built-in (native-category) slash command —
/// e.g. `compact` for `/compact` or `/compact summarize the API work` — whether
/// or not the user typed any trailing text after it. Built-in commands ignore
/// trailing arguments, so the caller sends the bare command and queues any
/// remainder rather than discarding it. Commands from MCP servers and ACP
/// agents are excluded: their trailing text is a real argument the agent
/// consumes.
///
/// Native commands run a turn that produces its own thread entry, so the typed
/// command is never echoed as a user message (see `send_command_queueing_remainder`).
fn leading_native_command(
text: &str,
available_commands: &[acp::AvailableCommand],
) -> Option<String> {
let rest = text.trim_start().strip_prefix('/')?;
let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let name = &rest[..name_end];
let is_native = available_commands.iter().any(|command| {
command.name == name
&& acp_thread::command_category_from_meta(&command.meta)
== Some(acp_thread::CommandCategory::Native)
});
is_native.then(|| name.to_string())
}
/// Removes a leading `/command_name` token from `text`, returning the trimmed
/// remainder. Falls back to the trimmed input if the prefix isn't present.
fn strip_leading_command(text: &str, command_name: &str) -> String {
let trimmed = text.trim_start();
trimmed
.strip_prefix('/')
.and_then(|rest| rest.strip_prefix(command_name))
.map(|rest| rest.trim_start().to_string())
.unwrap_or_else(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use project::{FakeFs, Project};
use serde_json::json;
use std::path::Path;
use util::path;
use workspace::MultiWorkspace;
#[test]
fn test_tool_call_icon_tooltip() {
for (name, interrupted_edit, expected) in [
(None, false, None),
(Some(" \t\n"), false, None),
(Some("read_file"), false, Some("Tool: read_file")),
(
Some(" mcp__server__**read_file**<raw> "),
false,
Some("Tool: mcp__server__**read_file**<raw> "),
),
(None, true, Some("Interrupted Edit")),
(Some(" \t\n"), true, Some("Interrupted Edit")),
(
Some("edit_file"),
true,
Some("Interrupted Edit\nTool: edit_file"),
),
] {
let name = name.map(SharedString::from);
assert_eq!(
ThreadView::tool_call_icon_tooltip(name.as_ref(), interrupted_edit).as_deref(),
expected,
);
}
}
fn native_command(name: &str) -> acp::AvailableCommand {
acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category(
acp_thread::CommandCategory::Native,
))
}
fn mcp_command(name: &str) -> acp::AvailableCommand {
acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category(
acp_thread::CommandCategory::Mcp,
))
}
#[test]
fn test_leading_native_command_matches_bare_and_with_remainder() {
let commands = [native_command("compact"), mcp_command("deploy")];
// Native command with trailing text.
assert_eq!(
leading_native_command("/compact summarize the API work", &commands),
Some("compact".to_string())
);
// Leading/trailing whitespace is tolerated.
assert_eq!(
leading_native_command(" /compact do x ", &commands),
Some("compact".to_string())
);
// Bare native command (no remainder) is still recognized, so it runs as
// a command turn (without echoing a user message) rather than being sent
// to the model as a normal prompt.
assert_eq!(
leading_native_command("/compact", &commands),
Some("compact".to_string())
);
assert_eq!(
leading_native_command("/compact ", &commands),
Some("compact".to_string())
);
// MCP/ACP commands are not native: their trailing text is a real
// argument the agent consumes, and they echo as normal user messages.
assert_eq!(leading_native_command("/deploy prod", &commands), None);
assert_eq!(leading_native_command("/deploy", &commands), None);
// Unknown command, or not a slash command at all.
assert_eq!(leading_native_command("/unknown foo", &commands), None);
assert_eq!(leading_native_command("just a message", &commands), None);
}
#[test]
fn test_strip_leading_command() {
assert_eq!(strip_leading_command("/compact do x", "compact"), "do x");
assert_eq!(
strip_leading_command(" /compact do x ", "compact"),
"do x "
);
// No matching prefix: returns the trimmed input unchanged.
assert_eq!(strip_leading_command("hello", "compact"), "hello");
}
#[gpui::test]
async fn test_open_link_bare_path(cx: &mut gpui::TestAppContext) {
crate::test_support::init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({"src": {"main.rs": "first\nsecond\nthird\n"}}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let workspace_weak = workspace.downgrade();
// Relative path — call from multi_workspace so the inner workspace entity is not locked
multi_workspace.update_in(cx, |_, window, cx| {
open_link("src/main.rs".into(), &workspace_weak, window, cx);
});
cx.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
let active = workspace
.active_item(cx)
.and_then(|item| item.project_path(cx))
.expect("file should be open");
assert!(*active.path == *"src/main.rs");
});
multi_workspace.update_in(cx, |_, window, cx| {
open_link("src/main.rs#L2".into(), &workspace_weak, window, cx);
});
cx.run_until_parked();
let editor = workspace.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
.expect("file should be open in an editor")
});
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
});
// Absolute path
let abs_path: SharedString = path!("/project/src/main.rs").to_string().into();
multi_workspace.update_in(cx, |_, window, cx| {
open_link(abs_path, &workspace_weak, window, cx);
});
cx.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
let active = workspace
.active_item(cx)
.and_then(|item| item.project_path(cx))
.expect("file should be open");
assert!(*active.path == *"src/main.rs");
});
}
#[gpui::test]
async fn test_open_link_percent_escape_disambiguation(cx: &mut gpui::TestAppContext) {
crate::test_support::init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"a%20b.rs": "literal\nsecond\n",
"a b.rs": "decoded\nsecond\n",
"c d.rs": "first\nsecond\n",
"e%20f.rs": "first\nsecond\n",
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let workspace_weak = workspace.downgrade();
let open_link_and_active_path = |url: String, cx: &mut gpui::VisualTestContext| {
multi_workspace.update_in(cx, |_, window, cx| {
open_link(url.into(), &workspace_weak, window, cx);
});
cx.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
.and_then(|item| item.project_path(cx))
.expect("file should be open")
.path
})
};
// Both interpretations exist: the decoded one wins.
let path = open_link_and_active_path(path!("/project/a%20b.rs").to_string(), cx);
assert_eq!(*path, *"a b.rs");
// Only the decoded file exists.
let path = open_link_and_active_path(path!("/project/c%20d.rs").to_string(), cx);
assert_eq!(*path, *"c d.rs");
// Only the literally-named file exists: fall back to it.
let path = open_link_and_active_path(path!("/project/e%20f.rs").to_string(), cx);
assert_eq!(*path, *"e%20f.rs");
let path = open_link_and_active_path("a%20b.rs#L2".to_string(), cx);
assert_eq!(*path, *"a b.rs");
let path = open_link_and_active_path("c%20d.rs#L2".to_string(), cx);
assert_eq!(*path, *"c d.rs");
let path = open_link_and_active_path("e%20f.rs#L2".to_string(), cx);
assert_eq!(*path, *"e%20f.rs");
let editor = workspace.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
.expect("file should be open in an editor")
});
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
});
}
#[gpui::test]
async fn test_open_link_out_of_project_path(cx: &mut gpui::TestAppContext) {
crate::test_support::init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}}))
.await;
fs.insert_tree(path!("/outside"), json!({"notes.md": "one\ntwo\nthree\n"}))
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let workspace_weak = workspace.downgrade();
// A nonexistent out-of-project path opens nothing, not even an
// empty buffer.
multi_workspace.update_in(cx, |_, window, cx| {
open_link(
path!("/outside/missing.md").to_string().into(),
&workspace_weak,
window,
cx,
);
});
cx.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
assert!(
workspace.active_item(cx).is_none(),
"nothing should open for a nonexistent path"
);
});
// An existing out-of-project file opens at the linked line.
multi_workspace.update_in(cx, |_, window, cx| {
open_link(
format!("{}:2", path!("/outside/notes.md")).into(),
&workspace_weak,
window,
cx,
);
});
cx.run_until_parked();
let editor = workspace.read_with(cx, |workspace, cx| {
let item = workspace.active_item(cx).expect("file should be open");
let project_path = item.project_path(cx).expect("item should have a path");
let abs_path = workspace
.project()
.read(cx)
.absolute_path(&project_path, cx);
assert_eq!(
abs_path.as_deref(),
Some(Path::new(path!("/outside/notes.md")))
);
item.downcast::<Editor>().expect("should be an editor")
});
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
});
}
}
const FAST_MODE_WARNING_NAMESPACE: &str = "fast-mode-warning-dismissed";
fn fast_mode_warning_id(
provider_id: &LanguageModelProviderId,
model_id: &LanguageModelId,
) -> String {
format!("{}:{}", provider_id.0, model_id.0)
}
fn fast_mode_warning_dismissed(
provider_id: &LanguageModelProviderId,
model_id: &LanguageModelId,
cx: &App,
) -> bool {
KeyValueStore::global(cx)
.scoped(FAST_MODE_WARNING_NAMESPACE)
.read(&fast_mode_warning_id(provider_id, model_id))
.log_err()
.flatten()
.is_some()
}
fn set_fast_mode_warning_dismissed(
provider_id: &LanguageModelProviderId,
model_id: &LanguageModelId,
cx: &mut App,
) {
let key = fast_mode_warning_id(provider_id, model_id);
let kvp = KeyValueStore::global(cx);
cx.background_spawn(async move {
kvp.scoped(FAST_MODE_WARNING_NAMESPACE)
.write(key, "1".to_string())
.await
.log_err();
})
.detach();
}
pub(crate) fn reset_fast_mode_warnings(cx: &mut App) {
let kvp = KeyValueStore::global(cx);
cx.background_spawn(async move {
kvp.scoped(FAST_MODE_WARNING_NAMESPACE)
.delete_all()
.await
.log_err();
})
.detach();
}