first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,21 +1,43 @@
|
||||
mod cloud_mode_v2_view;
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
pub(super) mod view;
|
||||
|
||||
pub use data_source::*;
|
||||
pub use view::*;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::SkillReference;
|
||||
pub use cloud_mode_v2_view::{CloudModeV2SlashCommandView, Section as CloudModeV2Section};
|
||||
pub use data_source::*;
|
||||
pub use view::{CloseReason, InlineSlashCommandView, SlashCommandsEvent};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::{SingletonEntity, ViewContext};
|
||||
use galaxyui::{AppContext, SingletonEntity, ViewContext};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_management::telemetry::AgentManagementTelemetryEvent;
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
AgentViewEntryOrigin, DismissalStrategy, EphemeralMessage, ENTER_OR_EXIT_CONFIRMATION_WINDOW,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, SlashCommandRequest};
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
use crate::ai::blocklist::handoff::PendingCloudLaunch;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIHistoryModel, InputTypeAutoDetectionSource, PendingAttachment, QueuedQuery,
|
||||
QueuedQueryId, QueuedQueryModel, QueuedQueryOrigin, SlashCommandRequest,
|
||||
};
|
||||
use crate::ai::conversation_rename::rename_conversation;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
|
||||
use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY};
|
||||
@@ -24,16 +46,21 @@ use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::telemetry::SlashCommandAcceptedDetails;
|
||||
use crate::settings::AISettings;
|
||||
use crate::tab::SelectedTabColor;
|
||||
use crate::terminal::input::decorations::InputBackgroundJobOptions;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuAction, InlineMenuType};
|
||||
use crate::terminal::input::message_bar::Message;
|
||||
use crate::terminal::input::models::InlineModelSelectorTab;
|
||||
use crate::terminal::input::slash_command_model::{
|
||||
SlashCommandEntryState, UpdatedSlashCommandModel,
|
||||
};
|
||||
use crate::terminal::input::{
|
||||
CompletionsTrigger, Event, Input, InputSuggestionsMode, UserQueryMenuAction,
|
||||
CompletionsTrigger, Event, Input, InputAction, InputSuggestionsMode, UserQueryMenuAction,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::color_dot;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
||||
use crate::workspace::{ForkedConversationDestination, ToastStack, WorkspaceAction};
|
||||
@@ -94,16 +121,61 @@ impl SlashCommandTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn open_file_command_path(
|
||||
session: &Session,
|
||||
current_dir: &str,
|
||||
raw_arg: &str,
|
||||
) -> (PathBuf, Option<LineAndColumnArg>) {
|
||||
let parsed_path = CleanPathResult::with_line_and_column_number(raw_arg.trim());
|
||||
// The argument may contain shell-escaped characters (e.g. `\ ` for spaces) from auto-suggest.
|
||||
// Unescape them so the path matches the actual filesystem entry.
|
||||
let unescaped_path = session.shell_family().unescape(&parsed_path.path);
|
||||
// Expand `~` to the user's home directory.
|
||||
let expanded_path = shellexpand::tilde(&unescaped_path);
|
||||
|
||||
let shell_path = session
|
||||
.convert_directory_to_typed_path_buf(current_dir.to_owned())
|
||||
.join(session.convert_directory_to_typed_path_buf(expanded_path.into_owned()))
|
||||
.normalize();
|
||||
let file_path = session
|
||||
.maybe_convert_to_native_path(&shell_path.to_path())
|
||||
.unwrap_or_else(|err| {
|
||||
log::warn!("unable to convert /open-file path to native path: {err:?}");
|
||||
PathBuf::from(shell_path.to_string_lossy().into_owned())
|
||||
});
|
||||
|
||||
(file_path, parsed_path.line_and_column_num)
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn is_slash_command_available(&self, command: &StaticCommand, ctx: &AppContext) -> bool {
|
||||
let slash_command_data_source = if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
let Some(data_source) = self.cloud_mode_composer_slash_command_data_source.as_ref()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
data_source
|
||||
} else {
|
||||
&self.slash_command_data_source
|
||||
};
|
||||
slash_command_data_source
|
||||
.as_ref(ctx)
|
||||
.command_is_active(command, ctx)
|
||||
}
|
||||
|
||||
pub(super) fn select_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
trigger: SlashCommandTrigger,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !self.is_slash_command_available(command, ctx) {
|
||||
return;
|
||||
}
|
||||
if command.argument.as_ref().is_none() {
|
||||
self.execute_slash_command(
|
||||
command, None, trigger, /*is_queued_prompt*/ false, ctx,
|
||||
command, None, trigger, /*is_queued_prompt*/ false, None, None, ctx,
|
||||
);
|
||||
} else if command
|
||||
.argument
|
||||
@@ -123,6 +195,8 @@ impl Input {
|
||||
argument.as_ref(),
|
||||
trigger,
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
@@ -186,7 +260,7 @@ impl Input {
|
||||
if detected_command.command.auto_enter_ai_mode
|
||||
|| !FeatureFlag::AgentView.is_enabled()
|
||||
{
|
||||
self.enter_ai_mode(ctx);
|
||||
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
|
||||
}
|
||||
|
||||
if detected_command.command.name == commands::EDIT.name
|
||||
@@ -196,7 +270,7 @@ impl Input {
|
||||
.is_some_and(|argument| argument.is_empty())
|
||||
&& self.suggestions_mode_model.as_ref(ctx).is_closed()
|
||||
{
|
||||
self.open_completion_suggestions(CompletionsTrigger::Keybinding, ctx);
|
||||
self.open_completion_suggestions(CompletionsTrigger::SlashCommandAutoOpen, ctx);
|
||||
}
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
@@ -213,7 +287,7 @@ impl Input {
|
||||
}
|
||||
|
||||
// Skill commands always require AI mode
|
||||
self.enter_ai_mode(ctx);
|
||||
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,12 +365,15 @@ impl Input {
|
||||
/// the agent was busy.
|
||||
///
|
||||
/// Returns `true` if execution was 'handled' (whether or not it resulted in success or failure).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn execute_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
argument: Option<&String>,
|
||||
trigger: SlashCommandTrigger,
|
||||
is_queued_prompt: bool,
|
||||
queued_conversation_id: Option<AIConversationId>,
|
||||
queued_query_id: Option<QueuedQueryId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
fn show_error_toast(message: String, ctx: &mut ViewContext<Input>) {
|
||||
@@ -399,8 +476,20 @@ impl Input {
|
||||
_create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => {
|
||||
ctx.emit(Event::CreateDockerSandbox);
|
||||
}
|
||||
_conversations if command.name == commands::CONVERSATIONS.name => {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
conversations if command.name == commands::CONVERSATIONS.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
if let Some(view) = self.cloud_mode_v2_history_menu_view.clone() {
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.arm_initial_buffer_sync(ctx);
|
||||
});
|
||||
}
|
||||
ctx.dispatch_typed_action_deferred(InputAction::OpenInlineHistoryMenu);
|
||||
return true;
|
||||
} else if FeatureFlag::AgentView.is_enabled() {
|
||||
self.open_conversation_menu(ctx);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenConversationsPalette);
|
||||
@@ -420,7 +509,69 @@ impl Input {
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned()));
|
||||
}
|
||||
_create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
|
||||
_ if command.name == commands::RENAME_CONVERSATION.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/rename-conversation requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
rename_conversation(conversation_id, argument.cloned().unwrap_or_default(), ctx);
|
||||
}
|
||||
set_tab_color if command.name == commands::SET_TAB_COLOR.name => {
|
||||
let supported_options = || {
|
||||
color_dot::TAB_COLOR_OPTIONS
|
||||
.iter()
|
||||
.map(|c| c.to_string().to_ascii_lowercase())
|
||||
.chain(std::iter::once("none".to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
|
||||
let Some(arg) = argument
|
||||
.map(|name| name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
else {
|
||||
show_error_toast(
|
||||
format!(
|
||||
"Please provide a color after /set-tab-color ({})",
|
||||
supported_options()
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
let color = if arg.eq_ignore_ascii_case("none") {
|
||||
SelectedTabColor::Cleared
|
||||
} else {
|
||||
let parsed = arg
|
||||
.parse::<AnsiColorIdentifier>()
|
||||
.ok()
|
||||
.filter(|c| color_dot::TAB_COLOR_OPTIONS.contains(c));
|
||||
match parsed {
|
||||
Some(c) => SelectedTabColor::Color(c),
|
||||
None => {
|
||||
show_error_toast(
|
||||
format!(
|
||||
"Unknown tab color '{arg}'. Use one of: {}.",
|
||||
supported_options()
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabColor(color));
|
||||
}
|
||||
create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
|
||||
// If the user included args after the slash command, treat them as repo paths/URLs.
|
||||
let repos = argument
|
||||
.map(|arg| {
|
||||
@@ -450,9 +601,6 @@ impl Input {
|
||||
#[cfg(feature = "local_fs")]
|
||||
match argument {
|
||||
Some(args) if !args.is_empty() => {
|
||||
use galaxy_util::path::CleanPathResult;
|
||||
use shellexpand::tilde;
|
||||
|
||||
let Some(session_id) = self.active_block_session_id() else {
|
||||
return false;
|
||||
};
|
||||
@@ -480,20 +628,14 @@ impl Input {
|
||||
.active_block_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.current_working_directory())
|
||||
.map(std::path::PathBuf::from);
|
||||
.map(str::to_owned);
|
||||
|
||||
let Some(current_dir) = current_dir else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let parsed_path = CleanPathResult::with_line_and_column_number(args.trim());
|
||||
// The argument may contain shell-escaped characters (e.g. `\ ` for
|
||||
// spaces) from auto-suggest. Unescape them so the path matches the
|
||||
// actual filesystem entry.
|
||||
let unescaped_path = session.shell_family().unescape(&parsed_path.path);
|
||||
// Expand `~` to the user's home directory.
|
||||
let expanded_path = tilde(&unescaped_path);
|
||||
let file_path = current_dir.join(&*expanded_path);
|
||||
let (file_path, line_col) =
|
||||
open_file_command_path(&session, ¤t_dir, args);
|
||||
|
||||
match std::fs::metadata(&file_path) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
@@ -502,7 +644,7 @@ impl Input {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenCodeInWarp {
|
||||
path: file_path,
|
||||
layout: external_editor::settings::EditorLayout::SplitPane,
|
||||
line_col: parsed_path.line_and_column_num,
|
||||
line_col,
|
||||
});
|
||||
}
|
||||
Ok(_) => {
|
||||
@@ -627,11 +769,82 @@ impl Input {
|
||||
if !FeatureFlag::ListSkills.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.apply_v2_slash_section_filter(CloudModeV2Section::Skills, ctx);
|
||||
return true;
|
||||
}
|
||||
// Open the skill selector menu for invocation - skill command will be inserted into buffer
|
||||
self.open_invoke_skill_selector(ctx);
|
||||
}
|
||||
_models if command.name == commands::MODEL.name => {
|
||||
self.open_model_selector(ctx);
|
||||
host if command.name == commands::HOST.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
// Only open the host selector when a default host is configured.
|
||||
if self
|
||||
.host_selector()
|
||||
.is_none_or(|h| !h.as_ref(ctx).has_default_host())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_host_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
harness if command.name == commands::HARNESS.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
// Defensive: the command is registered only when the V2 flag is on and its
|
||||
// availability requires CLOUD_MODE_V2_COMPOSER, so this branch should be unreachable.
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_harness_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
environment if command.name == commands::ENVIRONMENT.name => {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.open_v2_environment_selector(ctx);
|
||||
return true;
|
||||
}
|
||||
models if command.name == commands::MODEL.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
self.clear_buffer_and_reset_undo_stack(ctx);
|
||||
self.agent_input_footer.update(ctx, |footer, ctx| {
|
||||
footer.open_v2_model_selector(ctx);
|
||||
});
|
||||
return true;
|
||||
} else if trigger.is_keybinding() {
|
||||
// A keybinding may carry a pre-existing prompt in the buffer; open
|
||||
// like the model chip so the prompt is parked for search and
|
||||
// restored when a model is selected (or the selector is dismissed).
|
||||
self.open_model_selector_and_snapshot_prompt(
|
||||
InlineModelSelectorTab::BaseAgent,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
// Typed `/model`: the buffer holds the consumable command text.
|
||||
// Just switch into the model selector; `set_mode` snapshots the
|
||||
// buffer so it's restored on dismiss but cleared on selection.
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::ModelSelector, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
_profiles if command.name == commands::PROFILE.name => {
|
||||
if !FeatureFlag::InlineProfileSelector.is_enabled() {
|
||||
@@ -640,7 +853,11 @@ impl Input {
|
||||
|
||||
self.open_profile_selector(ctx);
|
||||
}
|
||||
_prompts if command.name == commands::PROMPTS.name => {
|
||||
prompts if command.name == commands::PROMPTS.name => {
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
self.apply_v2_slash_section_filter(CloudModeV2Section::Prompts, ctx);
|
||||
return true;
|
||||
}
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
self.open_prompts_menu(ctx);
|
||||
} else {
|
||||
@@ -698,24 +915,53 @@ impl Input {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
|
||||
}
|
||||
}
|
||||
_context if command.name == commands::CONTEXT.name => {
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let conversation = history
|
||||
.as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id);
|
||||
if conversation.is_none() {
|
||||
show_error_toast(
|
||||
"Cannot show context: no active conversation".to_owned(),
|
||||
ctx,
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
move_to_cloud if command.name == commands::MOVE_TO_CLOUD.name => {
|
||||
if !AISettings::as_ref(ctx).is_cloud_handoff_enabled(ctx) {
|
||||
return false;
|
||||
}
|
||||
let prompt = argument
|
||||
.map(|argument| argument.trim())
|
||||
.filter(|argument| !argument.is_empty())
|
||||
.map(str::to_owned);
|
||||
if let Some(prompt) = prompt {
|
||||
// `/handoff query` auto-submits, same as `& query`.
|
||||
let attachments = self.collect_cloud_launch_attachments(ctx);
|
||||
let launch = PendingCloudLaunch {
|
||||
prompt,
|
||||
attachments,
|
||||
};
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::OpenLocalToCloudHandoffPane {
|
||||
launch: Some(launch),
|
||||
environment_id: None,
|
||||
entry_point: HandoffEntryPoint::SlashCommand,
|
||||
},
|
||||
);
|
||||
} else if self.source_conversation_has_content(ctx) {
|
||||
// Empty `/handoff` with a non-empty source conversation:
|
||||
// dispatch the immediate empty-prompt handoff (continue /
|
||||
// snapshot rehydration); the workspace synthesizes the
|
||||
// launch and collects attachments.
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::OpenLocalToCloudHandoffPane {
|
||||
launch: None,
|
||||
environment_id: None,
|
||||
entry_point: HandoffEntryPoint::SlashCommand,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleContextView);
|
||||
// Empty `/handoff` with no source content — surface a toast
|
||||
// so the user knows why nothing happened. The chip falls
|
||||
// back to `&` compose mode here; the slash-command flow
|
||||
// does not because it has no compose-draft state to seed.
|
||||
show_error_toast(
|
||||
"Nothing to hand off — start a conversation first.".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
_settings if command.name == commands::SETTINGS.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleSettingsView);
|
||||
}
|
||||
_fork if command.name == commands::FORK.name => {
|
||||
fork if command.name == commands::FORK.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
@@ -725,11 +971,15 @@ impl Input {
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::NewTab
|
||||
} else {
|
||||
ForkedConversationDestination::SplitPane
|
||||
};
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
// Move any pending attachments out of the source input so they travel with the
|
||||
// initial prompt into the forked pane and no longer linger on the original input.
|
||||
// Only drain them when a non-empty prompt will actually be sent; the fork drops
|
||||
// attachments when there is no initial prompt, which would silently discard them.
|
||||
let initial_attachments =
|
||||
self.maybe_take_attachments_for_initial_prompt(argument, ctx);
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
@@ -737,6 +987,7 @@ impl Input {
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
@@ -744,7 +995,55 @@ impl Input {
|
||||
self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx);
|
||||
return true;
|
||||
}
|
||||
_fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
continue_locally if command.name == commands::CONTINUE_LOCALLY.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/continue-locally requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
if !conversation_is_cloud_oz_for_slash_command(conversation_id, ctx) {
|
||||
show_error_toast(
|
||||
"/continue-locally is only available for cloud Oz conversations".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::SlashCommandContinueLocally,
|
||||
ctx
|
||||
);
|
||||
|
||||
// Move any pending attachments out of the source input so they travel with the
|
||||
// initial prompt into the continued local pane and no longer linger on the
|
||||
// original input. Only drain them when a non-empty prompt will actually be sent;
|
||||
// the fork drops attachments when there is no initial prompt, which would
|
||||
// silently discard them.
|
||||
let initial_attachments =
|
||||
self.maybe_take_attachments_for_initial_prompt(argument, ctx);
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
fork_from_exchange: None,
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
@@ -757,11 +1056,8 @@ impl Input {
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::SplitPane
|
||||
} else {
|
||||
ForkedConversationDestination::CurrentPane
|
||||
};
|
||||
let destination =
|
||||
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
@@ -769,27 +1065,50 @@ impl Input {
|
||||
summarize_after_fork: true,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
initial_attachments: vec![],
|
||||
destination,
|
||||
});
|
||||
}
|
||||
_compact_and if command.name == commands::COMPACT_AND.name => {
|
||||
if self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
.is_none()
|
||||
{
|
||||
show_error_toast(
|
||||
"/compact-and requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
compact_and if command.name == commands::COMPACT_AND.name => {
|
||||
let conversation_id = if is_queued_prompt {
|
||||
let Some(conversation_id) = queued_conversation_id else {
|
||||
log::error!("Queued /compact-and missing conversation id");
|
||||
return true;
|
||||
};
|
||||
conversation_id
|
||||
} else {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/compact-and requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
conversation_id
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
});
|
||||
if is_queued_prompt {
|
||||
let Some(queued_query_id) = queued_query_id else {
|
||||
log::error!("Queued /compact-and missing queued query id");
|
||||
return true;
|
||||
};
|
||||
self.execute_queued_compact_and(
|
||||
conversation_id,
|
||||
queued_query_id,
|
||||
argument.cloned(),
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
let summarize = WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
};
|
||||
ctx.dispatch_typed_action(&summarize);
|
||||
}
|
||||
}
|
||||
_queue if command.name == commands::QUEUE.name => {
|
||||
let Some(conversation_id) = self
|
||||
@@ -807,17 +1126,34 @@ impl Input {
|
||||
};
|
||||
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let is_in_progress = history
|
||||
// An empty conversation defaults to `InProgress` even though nothing is
|
||||
// running, so exclude it here to auto-send rather than queue.
|
||||
let should_queue = history
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|c| c.status().is_in_progress() || c.status().is_blocked());
|
||||
.is_some_and(|c| {
|
||||
!c.is_empty() && (c.status().is_in_progress() || c.status().is_blocked())
|
||||
});
|
||||
|
||||
if is_in_progress {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::QueuePromptForConversation {
|
||||
prompt,
|
||||
if should_queue {
|
||||
let attachments = self.ai_context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.take_pending_attachments(ctx)
|
||||
});
|
||||
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new_with_attachments(
|
||||
prompt,
|
||||
QueuedQueryOrigin::QueueSlashCommand,
|
||||
attachments,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
self.submit_queued_prompt(prompt, ctx);
|
||||
// Not in progress: submit immediately as a regular (non-queued) user query so
|
||||
// the live staging is sent and reset, rather than treated as a queued-row fire.
|
||||
self.submit_user_query_now(prompt, ctx);
|
||||
}
|
||||
}
|
||||
_open_repo if command.name == commands::OPEN_REPO.name => {
|
||||
@@ -826,10 +1162,8 @@ impl Input {
|
||||
}
|
||||
self.open_repos_menu(ctx);
|
||||
}
|
||||
_command_that_just_sends_ai_request_with_prefix
|
||||
if command.name == commands::COMPACT.name
|
||||
|| command.name == commands::PLAN.name
|
||||
|| command.name == commands::ORCHESTRATE.name =>
|
||||
command_that_just_sends_ai_request_with_prefix
|
||||
if slash_command_is_submitted_as_prompt(command) =>
|
||||
{
|
||||
// These slash commands just send AI requests with the slash command text as a
|
||||
// prefix, and special handling is done downstream as an implementation detail
|
||||
@@ -897,9 +1231,17 @@ impl Input {
|
||||
self.suggestions_mode_model.as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::SlashCommands
|
||||
) {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(true, ctx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -908,20 +1250,28 @@ impl Input {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
if !self.is_slash_command_available(&command, ctx) {
|
||||
return false;
|
||||
}
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::cmd_or_ctrl_enter(),
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(_)
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) =>
|
||||
{
|
||||
false
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
let reference = detected_skill.reference.clone();
|
||||
let user_query = detected_skill.argument.clone();
|
||||
self.execute_skill_command(
|
||||
reference, user_query, /*is_queued_prompt*/ false, ctx,
|
||||
)
|
||||
self.execute_skill_command(reference, user_query, None, None, ctx)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
@@ -929,6 +1279,41 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_v2_slash_section_filter(
|
||||
&mut self,
|
||||
section: CloudModeV2Section,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text("/", ctx);
|
||||
});
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.set_section_filter(Some(section), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn maybe_clear_v2_slash_section_filter(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
if !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
}
|
||||
let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() else {
|
||||
return false;
|
||||
};
|
||||
let has_filter = view.as_ref(ctx).has_section_filter();
|
||||
if !has_filter {
|
||||
return false;
|
||||
}
|
||||
view.update(ctx, |v, ctx| {
|
||||
v.set_section_filter(None, ctx);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Executes a slash command on `enter` keypress.
|
||||
///
|
||||
/// If the slash command menu is open, then "accepts" the slash command:
|
||||
@@ -948,9 +1333,17 @@ impl Input {
|
||||
self.suggestions_mode_model.as_ref(ctx).mode(),
|
||||
InputSuggestionsMode::SlashCommands
|
||||
) {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
if let Some(view) = self.cloud_mode_v2_slash_commands_view.clone() {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.inline_slash_commands_view.update(ctx, |view, ctx| {
|
||||
view.accept_selected_item(false, ctx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -958,24 +1351,169 @@ impl Input {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
if !self.is_slash_command_available(&command, ctx) {
|
||||
return false;
|
||||
}
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::input(),
|
||||
/*is_queued_prompt*/ false,
|
||||
None,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(_)
|
||||
if self.is_cloud_mode_input_v2_composing(ctx) =>
|
||||
{
|
||||
false
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
let reference = detected_skill.reference.clone();
|
||||
let user_query = detected_skill.argument.clone();
|
||||
self.execute_skill_command(
|
||||
reference, user_query, /*is_queued_prompt*/ false, ctx,
|
||||
)
|
||||
self.execute_skill_command(reference, user_query, None, None, ctx)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::DisabledUntilEmptyBuffer => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains pending attachments from the input's context model, but only when `argument`
|
||||
/// contains a non-empty prompt. Forked conversations drop attachments when there is no
|
||||
/// initial prompt to send, so draining them unconditionally would silently discard them;
|
||||
/// leaving them staged in the source input instead loses nothing.
|
||||
fn maybe_take_attachments_for_initial_prompt(
|
||||
&mut self,
|
||||
argument: Option<&String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<PendingAttachment> {
|
||||
if argument.is_none_or(|argument| argument.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.ai_context_model.update(ctx, |context_model, ctx| {
|
||||
context_model.take_pending_attachments(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a queued `/compact-and` summary and stores its follow-up on the original conversation.
|
||||
pub(super) fn execute_queued_compact_and(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
queued_query_id: QueuedQueryId,
|
||||
initial_prompt: Option<String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let followup_attachments = QueuedQueryModel::as_ref(ctx)
|
||||
.attachments_for(conversation_id, queued_query_id)
|
||||
.to_vec();
|
||||
self.ai_controller.update(ctx, move |controller, ctx| {
|
||||
controller.send_queued_slash_command_request(
|
||||
SlashCommandRequest::Summarize { prompt: None },
|
||||
queued_query_id,
|
||||
Some(conversation_id),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let Some(initial_prompt) = initial_prompt.filter(|prompt| !prompt.trim().is_empty()) else {
|
||||
return;
|
||||
};
|
||||
QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.append(
|
||||
conversation_id,
|
||||
QueuedQuery::new_with_attachments(
|
||||
initial_prompt,
|
||||
QueuedQueryOrigin::CompactAndSlashCommand,
|
||||
followup_attachments,
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether executing the static slash `command` submits its text to the conversation as an AI
|
||||
/// prompt (handled downstream like a normal user query) rather than performing an immediate
|
||||
/// local action.
|
||||
///
|
||||
/// This is the single source of truth for the "reiterated as a prompt vs handled immediately"
|
||||
/// distinction: only `/compact`, `/plan`, and `/orchestrate` are sent as prompts (mirroring the
|
||||
/// `command_that_just_sends_ai_request_with_prefix` arm in [`Input::execute_slash_command`]).
|
||||
/// Every other slash command emits an immediate action (forking, switching model, opening a
|
||||
/// menu, etc.), so callers gating prompt queuing or shared-session forwarding should treat those
|
||||
/// as "run now".
|
||||
pub(crate) fn slash_command_is_submitted_as_prompt(command: &StaticCommand) -> bool {
|
||||
command.name == commands::COMPACT.name
|
||||
|| command.name == commands::PLAN.name
|
||||
|| command.name == commands::ORCHESTRATE.name
|
||||
}
|
||||
|
||||
/// Returns true when the conversation with `conversation_id` is associated with an Oz
|
||||
/// `AmbientAgentTask`. Callers deciding between `/fork` and `/continue-locally` should also
|
||||
/// check the same `CLOUD_AGENT` context that gates `/continue-locally`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn conversation_is_cloud_oz_for_slash_command(
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(task_id) = conversation.task_id() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
|
||||
// Permissive: not yet fetched. Matches the data-source default so the command isn't
|
||||
// wrongly blocked while the task fetch is in flight.
|
||||
return true;
|
||||
};
|
||||
|
||||
match task
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|s| s.harness.as_ref())
|
||||
{
|
||||
Some(config) => config.harness_type == Harness::Oz,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tooltip and slash command name for the fork button, returned as a unit so
|
||||
/// callers rendering the button and callers inserting the command always agree.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) struct ForkButtonAction {
|
||||
pub tooltip: &'static str,
|
||||
pub command_name: &'static str,
|
||||
}
|
||||
|
||||
/// Returns the tooltip and slash command for the fork button given an optional
|
||||
/// conversation ID. Uses `/continue-locally` for Oz conversations when `/fork`
|
||||
/// is unavailable in the current cloud-agent context, and `/fork` otherwise.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn fork_button_action(
|
||||
conversation_id: Option<AIConversationId>,
|
||||
is_cloud_agent_context: bool,
|
||||
ctx: &AppContext,
|
||||
) -> ForkButtonAction {
|
||||
if is_cloud_agent_context
|
||||
&& conversation_id.is_some_and(|id| conversation_is_cloud_oz_for_slash_command(id, ctx))
|
||||
{
|
||||
ForkButtonAction {
|
||||
tooltip: "Continue locally",
|
||||
command_name: commands::CONTINUE_LOCALLY.name,
|
||||
}
|
||||
} else {
|
||||
ForkButtonAction {
|
||||
tooltip: "Fork conversation",
|
||||
command_name: commands::FORK.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user