diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0bcb8254c8b8123eef3faaa913bb360de8dcc76d..36c9fb40c4a573e09da05618a29c1898cced60ad 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1032,6 +1032,7 @@ pub struct AcpThread { connection: Rc, token_usage: Option, prompt_capabilities: acp::PromptCapabilities, + available_commands: Vec, _observe_prompt_capabilities: Task>, terminals: HashMap>, pending_terminal_output: HashMap>>, @@ -1220,6 +1221,7 @@ impl AcpThread { session_id, token_usage: None, prompt_capabilities, + available_commands: Vec::new(), _observe_prompt_capabilities: task, terminals: HashMap::default(), pending_terminal_output: HashMap::default(), @@ -1239,6 +1241,10 @@ impl AcpThread { self.prompt_capabilities.clone() } + pub fn available_commands(&self) -> &[acp::AvailableCommand] { + &self.available_commands + } + pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> { self.draft_prompt.as_deref() } @@ -1419,7 +1425,10 @@ impl AcpThread { acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate { available_commands, .. - }) => cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)), + }) => { + self.available_commands = available_commands.clone(); + cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)); + } acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { current_mode_id, .. diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 149ed2e2fc0f9b22244e0d69deebf5aa7bb7d4c5..7c9acfdf27d5b750afe4b8817af7f657f5fcdecc 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -812,7 +812,7 @@ impl ConversationView { let agent_id = self.agent.agent_id(); let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::new( thread.read(cx).prompt_capabilities(), - vec![], + thread.read(cx).available_commands().to_vec(), ))); let action_log = thread.read(cx).action_log().clone(); @@ -1448,40 +1448,24 @@ impl ConversationView { self.emit_token_limit_telemetry_if_needed(thread, cx); } AcpThreadEvent::AvailableCommandsUpdated(available_commands) => { - let mut available_commands = available_commands.clone(); + if let Some(thread_view) = self.thread_view(&thread_id) { + let has_commands = !available_commands.is_empty(); - if thread - .read(cx) - .connection() - .auth_methods() - .iter() - .any(|method| method.id().0.as_ref() == "claude-login") - { - available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); - available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); - } - - let has_commands = !available_commands.is_empty(); - if let Some(active) = self.active_thread() { - active.update(cx, |active, _cx| { - active - .session_capabilities - .write() - .set_available_commands(available_commands); - }); - } - - let agent_display_name = self - .agent_server_store - .read(cx) - .agent_display_name(&self.agent.agent_id()) - .unwrap_or_else(|| self.agent.agent_id().0.to_string().into()); + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0.to_string().into()); - if let Some(active) = self.active_thread() { let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); - active.update(cx, |active, cx| { - active.message_editor.update(cx, |editor, cx| { + + thread_view.update(cx, |thread_view, cx| { + thread_view + .session_capabilities + .write() + .set_available_commands(available_commands.clone()); + thread_view.message_editor.update(cx, |editor, cx| { editor.set_placeholder_text(&new_placeholder, window, cx); }); }); @@ -2348,9 +2332,9 @@ impl ConversationView { } } + #[cfg(feature = "audio")] fn play_notification_sound(&self, window: &Window, cx: &mut App) { - let settings = AgentSettings::get_global(cx); - let _visible = window.is_window_active() + let visible = window.is_window_active() && if let Some(mw) = window.root::().flatten() { self.agent_panel_visible(&mw, cx) } else { @@ -2358,8 +2342,8 @@ impl ConversationView { .upgrade() .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) }; - #[cfg(feature = "audio")] - if settings.play_sound_when_agent_done.should_play(_visible) { + let settings = AgentSettings::get_global(cx); + if settings.play_sound_when_agent_done.should_play(visible) { Audio::play_sound(Sound::AgentDone, cx); } } @@ -2989,6 +2973,166 @@ pub(crate) mod tests { }); } + #[derive(Clone)] + struct RestoredAvailableCommandsConnection; + + impl AgentConnection for RestoredAvailableCommandsConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("restored-available-commands") + } + + fn telemetry_id(&self) -> SharedString { + "restored-available-commands".into() + } + + fn new_session( + self: Rc, + project: Entity, + _work_dirs: PathList, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self, + project, + "RestoredAvailableCommandsConnection", + SessionId::new("new-session"), + cx, + ); + Task::ready(Ok(thread)) + } + + fn supports_load_session(&self) -> bool { + true + } + + fn load_session( + self: Rc, + session_id: acp::SessionId, + project: Entity, + _work_dirs: PathList, + _title: Option, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self, + project, + "RestoredAvailableCommandsConnection", + session_id, + cx, + ); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::AvailableCommandsUpdate( + acp::AvailableCommandsUpdate::new(vec![acp::AvailableCommand::new( + "help", "Get help", + )]), + ), + cx, + ) + }) + .expect("available commands update should succeed"); + + Task::ready(Ok(thread)) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _id: Option, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + + fn into_any(self: Rc) -> Rc { + self + } + } + + #[gpui::test] + async fn test_restored_threads_keep_available_commands(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], 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 thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + let conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)), + connection_store, + Agent::Custom { id: "Test".into() }, + Some(SessionId::new("restored-session")), + None, + None, + None, + workspace.downgrade(), + project, + Some(thread_store), + None, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + let message_editor = message_editor(&conversation_view, cx); + let editor = + message_editor.update(cx, |message_editor, _cx| message_editor.editor().clone()); + let placeholder = editor.update(cx, |editor, cx| editor.placeholder_text(cx)); + + active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { + let available_commands = view + .session_capabilities + .read() + .available_commands() + .to_vec(); + assert_eq!(available_commands.len(), 1); + assert_eq!(available_commands[0].name.as_str(), "help"); + assert_eq!(available_commands[0].description.as_str(), "Get help"); + }); + + assert_eq!( + placeholder, + Some("Message Test — @ to include context, / for commands".to_string()) + ); + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("/help", window, cx); + }); + + let contents_result = message_editor + .update(cx, |editor, cx| editor.contents(false, cx)) + .await; + + assert!(contents_result.is_ok()); + } + #[gpui::test] async fn test_resume_thread_uses_session_cwd_when_inside_project(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 25af09832f3473aa690c7b205e1b56bab86e9709..9f9b5dff00536953b76a50b65a4ab64e427bc554 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -344,7 +344,8 @@ impl ThreadView { ) -> Self { let id = thread.read(cx).session_id().clone(); - let placeholder = placeholder_text(agent_display_name.as_ref(), false); + let has_commands = !session_capabilities.read().available_commands().is_empty(); + let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); let history_subscription = history.as_ref().map(|h| { cx.observe(h, |this, history, cx| {