diff --git a/assets/settings/default.json b/assets/settings/default.json index 7bfb1f2cdb68856d66073e8629d9921602d806d8..cabeeaf2007dee5d7bd15aa295ffb265d55f183f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1106,6 +1106,10 @@ // // Default: true "expand_terminal_card": true, + // How thinking blocks should be displayed by default in the agent panel. + // + // Default: automatic + "thinking_display": "automatic", // Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. // Note that this only applies to the stop button, not to ctrl+c inside the terminal. // diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index ec22fa11da00f6f41dfbdcee283ea983fbeac1af..b04db5ffd886b0b4a3704d8c1443e0ef7358f3ec 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -597,6 +597,7 @@ mod tests { show_turn_stats: false, new_thread_location: Default::default(), sidebar_side: Default::default(), + thinking_display: Default::default(), } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index ec0a46af0636877210d26b2c45660baae648ffea..7d83be6ebcfe935d6650a85108966d02c7c95eec 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition, - SidebarSide, ToolPermissionMode, + SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, }; pub use crate::agent_profile::*; @@ -48,6 +48,7 @@ pub struct AgentSettings { pub enable_feedback: bool, pub expand_edit_card: bool, pub expand_terminal_card: bool, + pub thinking_display: ThinkingBlockDisplay, pub cancel_generation_on_terminal_stop: bool, pub use_modifier_to_send: bool, pub message_editor_min_lines: usize, @@ -448,6 +449,7 @@ impl Settings for AgentSettings { enable_feedback: agent.enable_feedback.unwrap(), expand_edit_card: agent.expand_edit_card.unwrap(), expand_terminal_card: agent.expand_terminal_card.unwrap(), + thinking_display: agent.thinking_display.unwrap(), cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(), use_modifier_to_send: agent.use_modifier_to_send.unwrap(), message_editor_min_lines: agent.message_editor_min_lines.unwrap(), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 531f0cfa6259e651d473e08135a68f7f44cac48d..f8e73a69870ea693270990cb1253ea777331e094 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -673,6 +673,7 @@ mod tests { show_turn_stats: false, new_thread_location: Default::default(), sidebar_side: Default::default(), + thinking_display: Default::default(), }; cx.update(|cx| { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 316650a32b2f0179d9e63ea26bb5ad22cccd8021..80567646d1abc6be566b91d705b53bad50abc416 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -43,7 +43,7 @@ use prompt_store::{PromptId, PromptStore}; use crate::DEFAULT_THREAD_TITLE; use crate::message_editor::SessionCapabilities; use rope::Point; -use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; +use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay}; use std::path::Path; use std::sync::Arc; use std::time::Instant; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index a295cb562f0d46b8d979412ced39abf5f94d1ad9..77b2b1f82c21cfbe424d047f80b1a554f15e10db 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -253,6 +253,7 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, + user_toggled_thinking_blocks: HashSet<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -495,6 +496,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, + user_toggled_thinking_blocks: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -5155,9 +5157,13 @@ impl ThreadView { .into_any_element() } - /// If the last entry's last chunk is a streaming thought block, auto-expand it. - /// Also collapses the previously auto-expanded block when a new one starts. pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { + // Only auto-expand thinking blocks in Automatic mode. + // AlwaysExpanded shows them open by default; AlwaysCollapsed keeps them closed. + if AgentSettings::get_global(cx).thinking_display != ThinkingBlockDisplay::Automatic { + return; + } + let key = { let thread = self.thread.read(cx); if thread.status() != ThreadStatus::Generating { @@ -5178,30 +5184,59 @@ impl ThreadView { if let Some(key) = key { if self.auto_expanded_thinking_block != Some(key) { - if let Some(old_key) = self.auto_expanded_thinking_block.replace(key) { - self.expanded_thinking_blocks.remove(&old_key); - } + self.auto_expanded_thinking_block = Some(key); self.expanded_thinking_blocks.insert(key); cx.notify(); } } else if self.auto_expanded_thinking_block.is_some() { - // The last chunk is no longer a thought (model transitioned to responding), - // so collapse the previously auto-expanded block. - self.collapse_auto_expanded_thinking_block(); + self.auto_expanded_thinking_block = None; cx.notify(); } } - fn collapse_auto_expanded_thinking_block(&mut self) { - if let Some(key) = self.auto_expanded_thinking_block.take() { - self.expanded_thinking_blocks.remove(&key); - } - } - pub(crate) fn clear_auto_expand_tracking(&mut self) { self.auto_expanded_thinking_block = None; } + fn toggle_thinking_block_expansion(&mut self, key: (usize, usize), cx: &mut Context) { + let thinking_display = AgentSettings::get_global(cx).thinking_display; + + match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + if is_user_expanded { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else if is_in_expanded_set { + self.user_toggled_thinking_blocks.insert(key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysExpanded => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + } else { + self.user_toggled_thinking_blocks.insert(key); + } + } + ThinkingBlockDisplay::AlwaysCollapsed => { + if self.user_toggled_thinking_blocks.contains(&key) { + self.user_toggled_thinking_blocks.remove(&key); + self.expanded_thinking_blocks.remove(&key); + } else { + self.expanded_thinking_blocks.insert(key); + self.user_toggled_thinking_blocks.insert(key); + } + } + } + + cx.notify(); + } + fn render_thinking_block( &self, entry_ix: usize, @@ -5215,7 +5250,21 @@ impl ThreadView { let key = (entry_ix, chunk_ix); - let is_open = self.expanded_thinking_blocks.contains(&key); + let thinking_display = AgentSettings::get_global(cx).thinking_display; + let is_user_toggled = self.user_toggled_thinking_blocks.contains(&key); + let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key); + + let (is_open, is_constrained) = match thinking_display { + ThinkingBlockDisplay::Automatic => { + let is_open = is_user_toggled || is_in_expanded_set; + let is_constrained = is_in_expanded_set && !is_user_toggled; + (is_open, is_constrained) + } + ThinkingBlockDisplay::AlwaysExpanded => (!is_user_toggled, false), + ThinkingBlockDisplay::AlwaysCollapsed => (is_user_toggled, false), + }; + + let should_auto_scroll = self.auto_expanded_thinking_block == Some(key); let scroll_handle = self .entry_view_state @@ -5223,6 +5272,14 @@ impl ThreadView { .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + if should_auto_scroll { + if let Some(ref handle) = scroll_handle { + handle.scroll_to_bottom(); + } + } + + let panel_bg = cx.theme().colors().panel_background; + v_flex() .gap_1() .child( @@ -5255,42 +5312,51 @@ impl ThreadView { .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), + .on_click(cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); + }, + )), ) - .on_click(cx.listener(move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.toggle_thinking_block_expansion(key, cx); })), ) .when(is_open, |this| { this.child( div() - .id(("thinking-content", chunk_ix)) - .ml_1p5() - .pl_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )), + .when(is_constrained, |this| this.relative()) + .child( + div() + .id(("thinking-content", chunk_ix)) + .ml_1p5() + .pl_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .when(is_constrained, |this| this.max_h_64()) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )), + ) + .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() diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 0c77957bc6a1dab2af47164cbd1f46c5dc679d37..158cdc78d963847c3f2d814279e1417b723f7c2c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -66,6 +66,34 @@ pub enum SidebarSide { Right, } +/// How thinking blocks should be displayed by default in the agent panel. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingBlockDisplay { + /// Thinking blocks auto-expand with a height constraint during streaming, + /// then remain in their constrained state when complete. Users can click + /// to fully expand or collapse. + #[default] + Automatic, + /// Thinking blocks are always fully expanded by default (no height constraint). + AlwaysExpanded, + /// Thinking blocks are always collapsed by default. + AlwaysCollapsed, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -159,6 +187,10 @@ pub struct AgentSettingsContent { /// /// Default: true pub expand_terminal_card: Option, + /// How thinking blocks should be displayed by default in the agent panel. + /// + /// Default: automatic + pub thinking_display: Option, /// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. /// Note that this only applies to the stop button, not to ctrl+c inside the terminal. /// diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 37e3e78baceccde480801c84cfe6462c8356c5ed..83895cf6f3493fb6bddf6963e72b5467f382dd0a 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7323,6 +7323,28 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Thinking Display", + description: "How thinking blocks should be displayed by default. 'Automatic' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.", + field: Box::new(SettingField { + json_path: Some("agent.thinking_display"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .thinking_display + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .thinking_display = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cancel Generation On Terminal Stop", description: "Whether clicking the stop button on a running terminal tool should also cancel the agent's generation. Note that this only applies to the stop button, not to ctrl+c inside the terminal.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index a14d7d3c883cad98dc8f5d462c52f20819a6c7ae..568d00115dcb760fcdacb264a9dd8d36829df9a4 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -524,6 +524,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown)