first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+630 -92
View File
@@ -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, &current_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;