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.
13084 lines
525 KiB
Rust
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();
|
|
}
|