Add ACP agent backend and terminal controls
This commit is contained in:
@@ -20,6 +20,7 @@ use instant::{Duration, Instant};
|
||||
use parking_lot::FairMutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
|
||||
const SIDECAR_POSITION_ID: &str = "model_sidecar_panel";
|
||||
|
||||
@@ -29,6 +30,7 @@ use galaxy_core::ui::color::{coloru_with_opacity, Opacity};
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
|
||||
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::blocklist::prompt::PromptIconButtonTheme;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIInputEvent, BlocklistAIInputModel,
|
||||
@@ -53,6 +55,8 @@ use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::context_chips::display_chip::{udi_font_size, udi_icon_size};
|
||||
use crate::context_chips::spacing;
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::persistence::model::AgentBackend;
|
||||
use crate::settings::AISettings;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::{MenuPositioning, MenuPositioningProvider};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
|
||||
@@ -482,6 +486,24 @@ impl ProfileModelSelector {
|
||||
}
|
||||
});
|
||||
}
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
let changes_active_conversation = matches!(
|
||||
event,
|
||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
|
||||
);
|
||||
if changes_active_conversation
|
||||
&& event.terminal_surface_id() == Some(me.terminal_view_id)
|
||||
{
|
||||
me.is_model_menu_open = false;
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| {
|
||||
me.handle_appearance_change(ctx);
|
||||
});
|
||||
@@ -655,6 +677,24 @@ impl ProfileModelSelector {
|
||||
self.is_locked_for_cloud_followup(app) || self.is_locked_for_non_oz_run(app)
|
||||
}
|
||||
|
||||
fn is_acp_model_managed(&self, app: &AppContext) -> bool {
|
||||
if self.ambient_agent_view_model.is_some() {
|
||||
return false;
|
||||
}
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
if let Some(conversation_id) = history.active_conversation_id(self.terminal_view_id) {
|
||||
return history
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|conversation| {
|
||||
matches!(conversation.agent_backend(), AgentBackend::Acp(_))
|
||||
});
|
||||
}
|
||||
|
||||
cfg!(unix)
|
||||
&& FeatureFlag::AgentClientProtocol.is_enabled()
|
||||
&& *AISettings::as_ref(app).acp_enabled.value()
|
||||
}
|
||||
|
||||
/// True when a non-Oz harness is selected.
|
||||
fn is_third_party_harness(&self, app: &AppContext) -> bool {
|
||||
self.ambient_agent_view_model.as_ref().is_some_and(|m| {
|
||||
@@ -1595,6 +1635,7 @@ impl ProfileModelSelector {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let llm_preferences = LLMPreferences::as_ref(app);
|
||||
let is_acp_model_managed = self.is_acp_model_managed(app);
|
||||
|
||||
// Allow editing if composing an ambient agent query, or if the user has edit access
|
||||
// in a shared session (i.e., not a viewer, or is an executor).
|
||||
@@ -1617,7 +1658,9 @@ impl ProfileModelSelector {
|
||||
.is_agent_in_control_or_tagged_in();
|
||||
drop(terminal_model);
|
||||
|
||||
let model_display_name = if self.is_third_party_harness(app) {
|
||||
let model_display_name = if is_acp_model_managed {
|
||||
"Managed by ACP".to_owned()
|
||||
} else if self.is_third_party_harness(app) {
|
||||
self.harness_model_display_name(app)
|
||||
} else if is_lrc {
|
||||
llm_preferences
|
||||
@@ -1674,7 +1717,8 @@ impl ProfileModelSelector {
|
||||
// Only show chevron icon if the user can click to open the menu (i.e. has edit access)
|
||||
// and the InlineMenuHeaders feature flag is not enabled
|
||||
// (when enabled, clicking opens the inline model selector instead of a dropdown).
|
||||
if has_edit_access && !FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
if has_edit_access && !is_acp_model_managed && !FeatureFlag::InlineMenuHeaders.is_enabled()
|
||||
{
|
||||
let chevron_icon = Icon::ChevronDown
|
||||
.to_galaxyui_icon(Fill::Solid(text_color))
|
||||
.finish();
|
||||
@@ -1702,7 +1746,7 @@ impl ProfileModelSelector {
|
||||
let is_locked_for_followup = self.is_locked_for_cloud_followup(app);
|
||||
let is_locked_for_non_oz = self.is_locked_for_non_oz_run(app);
|
||||
let is_locked = is_locked_for_followup || is_locked_for_non_oz;
|
||||
let can_interact = has_edit_access && !is_locked;
|
||||
let can_interact = has_edit_access && !is_locked && !is_acp_model_managed;
|
||||
|
||||
let hoverable = Hoverable::new(self.model_mouse_state.clone(), move |state| {
|
||||
if state.is_hovered() && can_interact {
|
||||
@@ -1730,7 +1774,9 @@ impl ProfileModelSelector {
|
||||
stack.finish()
|
||||
} else if state.is_hovered() {
|
||||
// Non-Oz runs lock silently — skip the tooltip entirely.
|
||||
let tooltip_text: Option<&str> = if is_locked_for_followup {
|
||||
let tooltip_text: Option<&str> = if is_acp_model_managed {
|
||||
Some("Model selection is managed by the ACP agent")
|
||||
} else if is_locked_for_followup {
|
||||
Some(MODEL_LOCKED_FOR_FOLLOWUP_TOOLTIP)
|
||||
} else if is_locked_for_non_oz {
|
||||
None
|
||||
@@ -1803,6 +1849,19 @@ impl TypedActionView for ProfileModelSelector {
|
||||
type Action = ProfileModelSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
let is_model_action = matches!(
|
||||
action,
|
||||
ProfileModelSelectorAction::SelectModel(_)
|
||||
| ProfileModelSelectorAction::SelectAutoModel
|
||||
| ProfileModelSelectorAction::SelectReasoningModel(_)
|
||||
| ProfileModelSelectorAction::SelectHarnessModel { .. }
|
||||
| ProfileModelSelectorAction::ToggleModelMenu
|
||||
);
|
||||
if is_model_action && self.is_acp_model_managed(ctx) {
|
||||
self.set_model_menu_visibility(false, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
match action {
|
||||
ProfileModelSelectorAction::SelectProfile(profile_id) => {
|
||||
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
|
||||
@@ -1891,6 +1950,7 @@ impl View for ProfileModelSelector {
|
||||
let theme = appearance.theme();
|
||||
let profiles_model = AIExecutionProfilesModel::as_ref(app);
|
||||
let has_multiple_profiles = profiles_model.has_multiple_profiles();
|
||||
let is_acp_model_managed = self.is_acp_model_managed(app);
|
||||
|
||||
// Check if user is a viewer in a shared session
|
||||
let is_viewer = self
|
||||
@@ -1914,12 +1974,14 @@ impl View for ProfileModelSelector {
|
||||
compact_row.add_child(profile_button_with_save_position);
|
||||
}
|
||||
|
||||
let model_button_with_save_position = SavePosition::new(
|
||||
ChildView::new(&self.model_compact_button).finish(),
|
||||
"profile_model_selector_model_compact_button",
|
||||
)
|
||||
.finish();
|
||||
compact_row.add_child(model_button_with_save_position);
|
||||
if !is_acp_model_managed {
|
||||
let model_button_with_save_position = SavePosition::new(
|
||||
ChildView::new(&self.model_compact_button).finish(),
|
||||
"profile_model_selector_model_compact_button",
|
||||
)
|
||||
.finish();
|
||||
compact_row.add_child(model_button_with_save_position);
|
||||
}
|
||||
|
||||
let compact_layout = compact_row.finish();
|
||||
|
||||
@@ -1965,7 +2027,7 @@ impl View for ProfileModelSelector {
|
||||
stack.add_positioned_overlay_child(profile_menu, positioning);
|
||||
}
|
||||
|
||||
if self.is_model_menu_open {
|
||||
if self.is_model_menu_open && !is_acp_model_managed {
|
||||
let model_menu = ChildView::new(&self.model_dropdown).finish();
|
||||
let positioning = self.get_menu_positioning(app, false);
|
||||
stack.add_positioned_overlay_child(model_menu, positioning);
|
||||
@@ -1977,7 +2039,8 @@ impl View for ProfileModelSelector {
|
||||
// The popup overflows the viewport on wasm mobile.
|
||||
let is_wasm_mobile = warpui::platform::is_mobile_device();
|
||||
|
||||
if !is_wasm_mobile
|
||||
if !is_acp_model_managed
|
||||
&& !is_wasm_mobile
|
||||
&& (is_udi_enabled
|
||||
|| self
|
||||
.input_model
|
||||
|
||||
+105
-33
@@ -163,38 +163,37 @@ impl SshWarpifyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref INTERACTIVE_SSH: Regex = Regex::new(r"^ssh\s+").expect("interactive SSH regex invalid");
|
||||
|
||||
/// Matches "gcloud compute ssh" for connecting to GCP VMs.
|
||||
static ref GCLOUD_REGEX: Regex = Regex::new(r"^gcloud\s+compute\s+ssh\s.+").expect("gcloud SSH regex invalid");
|
||||
|
||||
/// Matches "eb ssh" for connecting to AWS Elastic Beanstalk VMs.
|
||||
static ref ELASTIC_BEANSTALK_REGEX: Regex = Regex::new(r"^eb\s+ssh\s.+").expect("elastic beanstalk SSH regex invalid");
|
||||
|
||||
/// Matches "doctl compute ssh" for connecting to a digital ocean droplet.
|
||||
static ref DIGITAL_OCEAN_DROPLET_REGEX: Regex = Regex::new(r"^doctl\s+compute\s+ssh\s.+").expect("digital ocean SSH regex invalid");
|
||||
}
|
||||
|
||||
impl SshWarpifyCommand {
|
||||
pub fn matches(command: &str) -> Option<SshWarpifyCommand> {
|
||||
let command = if let Some(suffix) = command.strip_prefix("command ") {
|
||||
suffix
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if INTERACTIVE_SSH.is_match(command) {
|
||||
Some(SshWarpifyCommand::Ssh)
|
||||
} else if GCLOUD_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud))
|
||||
} else if ELASTIC_BEANSTALK_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk))
|
||||
} else if DIGITAL_OCEAN_DROPLET_REGEX.is_match(command) {
|
||||
Some(SshWarpifyCommand::SshLike(
|
||||
SshLikeCommand::DigitalOceanDroplet,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
let tokens = normalized_command_tokens(command)?;
|
||||
match tokens.as_slice() {
|
||||
[command, arguments @ ..] if command == "ssh" && !arguments.is_empty() => {
|
||||
Some(SshWarpifyCommand::Ssh)
|
||||
}
|
||||
[command, compute, ssh, arguments @ ..]
|
||||
if command == "gcloud"
|
||||
&& compute == "compute"
|
||||
&& ssh == "ssh"
|
||||
&& !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud))
|
||||
}
|
||||
[command, ssh, arguments @ ..]
|
||||
if command == "eb" && ssh == "ssh" && !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk))
|
||||
}
|
||||
[command, compute, ssh, arguments @ ..]
|
||||
if command == "doctl"
|
||||
&& compute == "compute"
|
||||
&& ssh == "ssh"
|
||||
&& !arguments.is_empty() =>
|
||||
{
|
||||
Some(SshWarpifyCommand::SshLike(
|
||||
SshLikeCommand::DigitalOceanDroplet,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,9 +215,7 @@ pub fn parse_interactive_ssh_command(command: &str) -> Option<InteractiveSshComm
|
||||
}
|
||||
|
||||
fn parse_ssh_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
let Ok(tokens) = shell_words::split(command) else {
|
||||
return None;
|
||||
};
|
||||
let tokens = normalized_command_tokens(command)?;
|
||||
|
||||
// Cases: "", "ls", "ssh-add-key"
|
||||
if tokens.is_empty() || tokens[0] != "ssh" {
|
||||
@@ -227,6 +224,81 @@ fn parse_ssh_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
Some(tokens)
|
||||
}
|
||||
|
||||
/// Returns shell tokens with safe, non-executing prefixes removed and the
|
||||
/// executable reduced to its basename. This lets SSH detection recognize the
|
||||
/// command forms users commonly launch from a shell without treating an
|
||||
/// argument that merely contains "ssh" as an SSH process.
|
||||
fn normalized_command_tokens(command: &str) -> Option<Vec<String>> {
|
||||
let tokens = shell_words::split(command.trim_start()).ok()?;
|
||||
let mut command_index = 0;
|
||||
|
||||
while tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| is_environment_assignment(token))
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
|
||||
if tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| executable_name(token) == "command")
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
|
||||
if tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| executable_name(token) == "env")
|
||||
{
|
||||
command_index += 1;
|
||||
while let Some(token) = tokens.get(command_index) {
|
||||
if is_environment_assignment(token)
|
||||
|| matches!(
|
||||
token.as_str(),
|
||||
"-i" | "--ignore-environment" | "-0" | "--null"
|
||||
)
|
||||
{
|
||||
command_index += 1;
|
||||
} else if token == "--" {
|
||||
command_index += 1;
|
||||
while tokens
|
||||
.get(command_index)
|
||||
.is_some_and(|token| is_environment_assignment(token))
|
||||
{
|
||||
command_index += 1;
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let command_name = executable_name(tokens.get(command_index)?);
|
||||
let mut normalized = tokens[command_index..].to_vec();
|
||||
normalized[0] = command_name;
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn is_environment_assignment(token: &str) -> bool {
|
||||
let Some((name, _)) = token.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = name.chars();
|
||||
chars
|
||||
.next()
|
||||
.is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
|
||||
&& chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn executable_name(executable: &str) -> String {
|
||||
let file_name = executable.rsplit(['/', '\\']).next().unwrap_or(executable);
|
||||
file_name
|
||||
.strip_suffix(".exe")
|
||||
.unwrap_or(file_name)
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Creates an sftp command that copies a given local file into the pwd in the warpified ssh session.
|
||||
pub fn transfer_file_sftp_command(
|
||||
local_file_path: String,
|
||||
|
||||
@@ -133,3 +133,38 @@ fn ssh_interactive_shell_parsing() {
|
||||
== Some("localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_interactive_shell_parsing_normalizes_safe_shell_prefixes() {
|
||||
for command in [
|
||||
" ssh user@host",
|
||||
"/usr/bin/ssh user@host",
|
||||
"GALAXY_TEST=1 ssh user@host",
|
||||
"env GALAXY_TEST=1 ssh user@host",
|
||||
"command /usr/bin/ssh user@host",
|
||||
"/usr/bin/env -i GALAXY_TEST=1 /usr/bin/ssh user@host",
|
||||
"/usr/bin/env -- GALAXY_TEST=1 /usr/bin/ssh user@host",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_interactive_ssh_command(command).and_then(|parsed| parsed.host),
|
||||
Some("user@host".to_owned()),
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_interactive_shell_parsing_does_not_match_ssh_arguments_or_similar_names() {
|
||||
for command in [
|
||||
"echo /usr/bin/ssh user@host",
|
||||
"GALAXY_TEST=/usr/bin/ssh cargo test",
|
||||
"env GALAXY_TEST=1 cargo test",
|
||||
"/usr/bin/ssh-add user@host",
|
||||
"sh -c 'echo ssh user@host'",
|
||||
] {
|
||||
assert!(
|
||||
parse_interactive_ssh_command(command).is_none(),
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,6 +936,7 @@ impl TerminalView {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
Reference in New Issue
Block a user