Files
galaxy/app/src/terminal/input/slash_commands/mod.rs
T
rkw6086 a078287f4b Fix Local Agent Execution And Auth Checks
- Gate server requests on available credentials
- Run local child agents directly without a parent run ID
- Include command IDs in Bedrock context and recognize transfer tools
2026-07-28 10:43:59 -05:00

1640 lines
69 KiB
Rust

mod cloud_mode_v2_view;
mod data_source;
mod search_item;
pub(super) mod 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::*;
#[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::{AppContext, SingletonEntity, ViewContext};
pub use view::{CloseReason, InlineSlashCommandView, SlashCommandsEvent};
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,
};
#[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};
use crate::search::slash_command_menu::static_commands::Availability;
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, 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};
use crate::TelemetryEvent;
#[derive(Debug, Clone)]
pub enum AcceptSlashCommandOrSavedPrompt {
SlashCommand {
id: SlashCommandId,
},
SavedPrompt {
id: SyncId,
},
/// A skill selected from browse or search. Contains name (for display/insertion) and path/bundled_skill_id (for execution).
Skill {
reference: SkillReference,
name: String,
},
}
impl InlineMenuAction for AcceptSlashCommandOrSavedPrompt {
const MENU_TYPE: InlineMenuType = InlineMenuType::SlashCommands;
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum SlashCommandTrigger {
Input { cmd_or_ctrl_enter: bool },
Keybinding,
}
impl SlashCommandTrigger {
fn cmd_or_ctrl_enter() -> Self {
Self::Input {
cmd_or_ctrl_enter: true,
}
}
pub fn input() -> Self {
Self::Input {
cmd_or_ctrl_enter: false,
}
}
pub(super) fn keybinding() -> Self {
Self::Keybinding
}
pub fn is_keybinding(&self) -> bool {
matches!(self, Self::Keybinding)
}
fn is_cmd_or_ctrl_enter(&self) -> bool {
matches!(
self,
Self::Input {
cmd_or_ctrl_enter: true
}
)
}
}
#[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, None, None, ctx,
);
} else if command
.argument
.as_ref()
.is_some_and(|arg| arg.should_execute_on_selection)
{
// TODO (zachbai): this is a hack for Oz launch. Caller
// should probably be invoking `execute_slash_command` in this case.
let argument = if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
let trimmed = self.buffer_text(ctx).trim().to_owned();
(!trimmed.is_empty()).then_some(trimmed)
} else {
None
};
self.execute_slash_command(
command,
argument.as_ref(),
trigger,
/*is_queued_prompt*/ false,
None,
None,
ctx,
);
} else {
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(&format!("{} ", command.name), ctx);
});
}
}
pub(super) fn close_slash_commands_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
pub(super) fn handle_slash_command_model_event(
&mut self,
event: &UpdatedSlashCommandModel,
ctx: &mut ViewContext<Self>,
) {
// Refresh decorations if the slash command detection state changed, since
// detected commands affect syntax highlighting.
let new_state = self.slash_command_model.as_ref(ctx).state();
if event.old_state.is_detected_command() != new_state.is_detected_command() {
let _ = self
.debounce_input_background_tx
.try_send(InputBackgroundJobOptions::default().with_command_decoration());
}
match self.slash_command_model.as_ref(ctx).state().clone() {
SlashCommandEntryState::None | SlashCommandEntryState::DisabledUntilEmptyBuffer => {
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
self.close_slash_commands_menu(ctx);
}
}
SlashCommandEntryState::Composing { .. } => {
if self.suggestions_mode_model.as_ref(ctx).is_closed() {
self.open_slash_commands_menu(ctx);
} else if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
self.slash_command_model.update(ctx, |model, ctx| {
model.disable(ctx);
});
}
}
SlashCommandEntryState::SlashCommand(detected_command) => {
// If there is only one result (or zero, but that should be impossible if there is
// a valid command in the input) OR if the user has started typing arguments, hide
// the menu.
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands()
&& (self
.inline_slash_commands_view
.as_ref(ctx)
.result_count(ctx)
< 2
|| detected_command.argument.is_some())
{
self.close_slash_commands_menu(ctx);
}
if detected_command.command.auto_enter_ai_mode
|| !FeatureFlag::AgentView.is_enabled()
{
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
}
if detected_command.command.name == commands::EDIT.name
&& detected_command
.argument
.as_ref()
.is_some_and(|argument| argument.is_empty())
&& self.suggestions_mode_model.as_ref(ctx).is_closed()
{
self.open_completion_suggestions(CompletionsTrigger::SlashCommandAutoOpen, ctx);
}
}
SlashCommandEntryState::SkillCommand(detected_skill) => {
// Hide the menu once the user has started typing the prompt
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands()
&& (self
.inline_slash_commands_view
.as_ref(ctx)
.result_count(ctx)
< 2
|| detected_skill.argument.is_some())
{
self.close_slash_commands_menu(ctx);
}
// Skill commands always require AI mode
self.enter_ai_mode(Some(InputTypeAutoDetectionSource::SlashCommand), ctx);
}
}
}
pub(crate) fn handle_slash_commands_menu_event(
&mut self,
event: &SlashCommandsEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SlashCommandsEvent::Close(reason) => {
if reason.is_manual_dismissal() {
self.slash_command_model.update(ctx, |model, ctx| {
model.disable(ctx);
});
}
self.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
ctx.notify();
}
SlashCommandsEvent::SelectedSavedPrompt { id } => {
let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(id).cloned() else {
log::warn!("Tried to execute workflow for id {id:?} but it does not exist");
return;
};
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_fullscreen();
send_telemetry_from_ctx!(
TelemetryEvent::SlashCommandAccepted {
command_details: SlashCommandAcceptedDetails::SavedPrompt,
is_in_agent_view,
},
ctx
);
self.show_workflows_info_box_on_workflow_selection(
WorkflowType::Cloud(Box::new(workflow)),
WorkflowSource::WarpAI,
WorkflowSelectionSource::SlashMenu,
None,
ctx,
);
}
SlashCommandsEvent::SelectedStaticCommand {
id,
cmd_or_ctrl_enter,
} => {
let Some(command) = COMMAND_REGISTRY.get_command(id) else {
return;
};
self.select_slash_command(
command,
SlashCommandTrigger::Input {
cmd_or_ctrl_enter: *cmd_or_ctrl_enter,
},
ctx,
);
}
SlashCommandsEvent::SelectedSkill { name, reference: _ } => {
// Insert /{skill-name} into the buffer
self.editor.update(ctx, |editor, ctx| {
editor.set_buffer_text(format!("/{name} ").as_str(), ctx);
});
self.close_slash_commands_menu(ctx);
}
}
}
/// Executes the given `command` with `argument`, if any.
///
/// When `is_queued_prompt` is true, this is the first send of a previously queued prompt:
/// the input buffer is left alone so the user doesn't lose anything they've typed while
/// 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>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
});
}
// Safety net: commands whose availability requires AI should not execute when AI is
// globally disabled. They're normally filtered out of the slash command menu, but this
// protects keybinding-triggered execution where a bound key may still address the command.
if command.availability.contains(Availability::AI_ENABLED)
&& !AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
{
show_error_toast(format!("{} requires AI to be enabled", command.name), ctx);
return true;
}
// Handle the slash command action based on its kind
match command.name {
_add_mcp if command.name == commands::ADD_MCP.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddMCPPane);
}
_add_prompt if command.name == commands::ADD_PROMPT.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddPromptPane);
}
_add_rule if command.name == commands::ADD_RULE.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddRulePane);
}
_agent_or_new
if command.name == commands::NEW.name || command.name == commands::AGENT.name =>
{
if !self
.ai_context_model
.as_ref(ctx)
.can_start_new_conversation()
{
self.ephemeral_message_model.update(ctx, |model, ctx| {
let appearance = Appearance::handle(ctx).as_ref(ctx);
let message = Message::from_text(
"cannot start new conversation while terminal command is running",
)
.with_text_color(appearance.theme().ansi_fg_red());
model.show_ephemeral_message(
EphemeralMessage::new(
message,
DismissalStrategy::Timer(ENTER_OR_EXIT_CONFIRMATION_WINDOW),
),
ctx,
);
});
return true;
}
// Keybindings can be triggered reflexively while users are already in an active
// conversation, so we gate only this path behind a second-press confirmation.
// Typed `/agent`/`/new` and slash-menu execution stay single-step by design.
if trigger.is_keybinding() && self.agent_view_controller.as_ref(ctx).is_active() {
let should_start_new_conversation =
self.agent_view_controller.update(ctx, |controller, ctx| {
controller
.should_start_new_conversation_for_keybinding(command.name, ctx)
});
if !should_start_new_conversation {
// Keep the current input/conversation untouched on first press; only the
// ephemeral confirmation prompt should change.
return true;
}
}
let prompt = argument.and_then(|argument| {
let trimmed = argument.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
});
ctx.emit(Event::EnterAgentView {
initial_prompt: prompt,
conversation_id: None,
origin: AgentViewEntryOrigin::SlashCommand { trigger },
});
}
_cloud_agent if command.name == commands::CLOUD_AGENT.name => {
let prompt = argument.and_then(|argument| {
let trimmed = argument.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
});
ctx.emit(Event::EnterCloudAgentView {
initial_prompt: prompt,
});
}
_create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => {
ctx.emit(Event::CreateDockerSandbox);
}
_ 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);
}
}
_rename_tab if command.name == commands::RENAME_TAB.name => {
let Some(name) = argument
.map(|name| name.trim())
.filter(|name| !name.is_empty())
else {
show_error_toast(
"Please provide a tab name after /rename-tab".to_owned(),
ctx,
);
return true;
};
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned()));
}
_ 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);
}
_ 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));
}
_ 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| {
arg.split_whitespace()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
ctx.emit(Event::TriggerEnvironmentSetup { repos });
}
_create_project if command.name == commands::CREATE_NEW_PROJECT.name => {
if argument.is_none_or(|args| args.is_empty()) {
show_error_toast(
"Please describe the project you want to create after /create-new-project"
.to_owned(),
ctx,
);
return true;
}
let args = argument.expect("args are Some()");
self.initiate_create_new_project(args.to_owned(), ctx);
}
_edit if command.name == commands::EDIT.name => {
#[cfg(feature = "local_fs")]
match argument {
Some(args) if !args.is_empty() => {
let Some(session_id) = self.active_block_session_id() else {
return false;
};
let Some(session) = self.sessions.as_ref(ctx).get(session_id) else {
return false;
};
if !session.is_local() {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"The /open-file command is only available for local sessions"
.to_owned(),
),
window_id,
ctx,
);
});
return false;
}
let current_dir = self
.active_block_metadata
.as_ref()
.and_then(|metadata| metadata.current_working_directory())
.map(str::to_owned);
let Some(current_dir) = current_dir else {
return false;
};
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() => {
use crate::util::file::external_editor;
ctx.dispatch_typed_action(&TerminalAction::OpenCodeInWarp {
path: file_path,
layout: external_editor::settings::EditorLayout::SplitPane,
line_col,
});
}
Ok(_) => {
show_error_toast(
"The /open-file command only works for files, not directories"
.to_owned(),
ctx,
);
return true;
}
Err(_) => {
show_error_toast(
format!("File not found: {}", file_path.display()),
ctx,
);
return true;
}
}
}
_ => {
use crate::server::telemetry::PaletteSource;
ctx.emit(Event::OpenFilesPalette {
source: PaletteSource::Keybinding,
});
}
}
#[cfg(not(feature = "local_fs"))]
{
show_error_toast(
"The /open-file command is not supported in this build".to_owned(),
ctx,
);
return true;
}
}
_export_to_clipboard if command.name == commands::EXPORT_TO_CLIPBOARD.name => {
let history = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history
.as_ref(ctx)
.active_conversation(self.terminal_view_id)
else {
show_error_toast("No active conversation to export".to_owned(), ctx);
return true;
};
let action_model = self.ai_action_model.as_ref(ctx);
let conversation_text = conversation.export_to_markdown(Some(action_model));
ctx.clipboard()
.write(ClipboardContent::plain_text(conversation_text));
// Show a toast to confirm the export
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(String::from(
"Conversation exported to clipboard",
));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
_export_to_file if command.name == commands::EXPORT_TO_FILE.name => {
#[cfg(not(target_family = "wasm"))]
{
self.export_conversation_to_file(
argument.map(|filename| filename.to_owned()),
ctx,
);
}
#[cfg(target_family = "wasm")]
{
show_error_toast(
"Export conversation to file unsupported in web".to_owned(),
ctx,
);
return true;
}
}
_index if command.name == commands::INDEX.name => {
ctx.dispatch_typed_action(&TerminalAction::IndexProjectSpeedbump);
}
_init if command.name == commands::INIT.name => {
ctx.dispatch_typed_action(&TerminalAction::InitProject);
}
_changelog if command.name == commands::CHANGELOG.name => {
if !FeatureFlag::Changelog.is_enabled() {
return false;
}
ctx.dispatch_typed_action(&WorkspaceAction::ViewLatestChangelog);
}
_feedback if command.name == commands::FEEDBACK.name => {
ctx.dispatch_typed_action(&WorkspaceAction::SendFeedback);
}
_open_code_review if command.name == commands::OPEN_CODE_REVIEW.name => {
ctx.dispatch_typed_action(&TerminalAction::ToggleCodeReviewPane {
entrypoint: CodeReviewPaneEntrypoint::SlashCommand,
});
}
_open_mcp_servers if command.name == commands::OPEN_MCP_SERVERS.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenViewMCPPane);
}
_open_settings_file if command.name == commands::OPEN_SETTINGS_FILE.name => {
if !FeatureFlag::SettingsFile.is_enabled() || !cfg!(feature = "local_fs") {
return false;
}
ctx.dispatch_typed_action(&WorkspaceAction::OpenSettingsFile);
}
_open_project_rules if command.name == commands::OPEN_PROJECT_RULES.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenProjectRulesPane);
}
_open_rules if command.name == commands::OPEN_RULES.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenRulesPane);
}
_edit_skill if command.name == commands::EDIT_SKILL.name => {
if !FeatureFlag::ListSkills.is_enabled() {
return false;
}
// Open the skill selector menu - user will select a skill from the inline menu
self.open_skill_selector(ctx);
}
_invoke_skill if command.name == commands::INVOKE_SKILL.name => {
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);
}
_ 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;
}
_ 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;
}
_ 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;
}
_ 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() {
return false;
}
self.open_profile_selector(ctx);
}
_ 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 {
return false;
}
}
_rewind if command.name == commands::REWIND.name => {
self.open_rewind_menu(ctx);
}
_pr_comments if command.name == commands::PR_COMMENTS.name => {
if !FeatureFlag::PRCommentsSlashCommand.is_enabled() {
return false;
}
let Some(repo_path) = self
.active_session_path_if_local(ctx)
.map(|path| path.to_path_buf())
.map(|path| path.to_string_lossy().to_string())
else {
log::error!("Expected a valid working directory since /pr-comments is only available from the terminal");
return false;
};
self.ai_controller.update(ctx, move |controller, ctx| {
controller.send_slash_command_request(
SlashCommandRequest::FetchReviewComments { repo_path },
ctx,
)
});
}
_usage if command.name == commands::USAGE.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenBillingAndUsagePane);
}
_cost if command.name == commands::COST.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 conversation cost: no active conversation".to_owned(),
ctx,
);
} else if conversation.is_some_and(|c| c.is_empty()) {
show_error_toast(
"Cannot show conversation cost: conversation is empty".to_owned(),
ctx,
);
} else if conversation.is_some_and(|c| !c.status().is_done()) {
show_error_toast(
"Cannot show conversation cost: conversation is in progress".to_owned(),
ctx,
);
} else {
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
}
}
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
_ 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 {
// 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,
);
}
}
_ if command.name == commands::FORK.name => {
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
show_error_toast("/fork requires an active conversation".to_owned(), ctx);
return true;
};
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,
fork_from_exchange: None,
summarize_after_fork: false,
summarization_prompt: None,
initial_prompt: argument.cloned(),
initial_attachments,
destination,
});
}
_fork_from if command.name == commands::FORK_FROM.name => {
self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx);
return true;
}
#[cfg(not(target_family = "wasm"))]
_ 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,
});
}
_ if command.name == commands::FORK_AND_COMPACT.name => {
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
show_error_toast(
"/fork-and-compact requires an active conversation".to_owned(),
ctx,
);
return true;
};
let destination =
ForkedConversationDestination::for_fork_trigger(trigger.is_cmd_or_ctrl_enter());
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
conversation_id,
fork_from_exchange: None,
summarize_after_fork: true,
summarization_prompt: None,
initial_prompt: argument.cloned(),
initial_attachments: vec![],
destination,
});
}
_ 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
};
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
.ai_context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
else {
show_error_toast("/queue requires an active conversation".to_owned(), ctx);
return true;
};
let Some(prompt) = argument.filter(|a| !a.is_empty()).cloned() else {
show_error_toast("/queue requires a prompt argument".to_owned(), ctx);
return true;
};
let history = BlocklistAIHistoryModel::handle(ctx);
// 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.is_empty() && (c.status().is_in_progress() || c.status().is_blocked())
});
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 {
// 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 => {
if !FeatureFlag::InlineRepoMenu.is_enabled() {
return false;
}
self.open_repos_menu(ctx);
}
_context if command.name == commands::CONTEXT.name => {
// Debug command: dump the full context window (system prompt, rules,
// messages) to the clipboard so the user can inspect what the model sees.
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::CloudModel;
let history = BlocklistAIHistoryModel::handle(ctx);
// Extract data from conversation while the borrow is active,
// then drop it before using ctx mutably.
let context_data = {
let Some(conversation) = history
.as_ref(ctx)
.active_conversation(self.terminal_view_id)
else {
show_error_toast("No active conversation.".to_owned(), ctx);
return true;
};
let summary = conversation.progressive_summary().map(str::to_string);
let messages: Vec<String> = conversation
.bedrock_message_history()
.iter()
.enumerate()
.map(|(i, msg)| {
let content_str = match &msg.content {
crate::ai::bedrock::convert::MessageContent::Text(t) => {
if t.len() > 500 {
format!("{}... ({} chars total)", &t[..500], t.len())
} else {
t.clone()
}
}
crate::ai::bedrock::convert::MessageContent::ToolUse {
name,
tool_use_id,
..
} => {
format!("ToolUse(name={name}, id={tool_use_id})")
}
crate::ai::bedrock::convert::MessageContent::ToolResult {
tool_use_id,
content,
is_error,
} => {
let truncated = if content.len() > 200 {
format!("{}...", &content[..200])
} else {
content.clone()
};
format!(
"ToolResult(id={tool_use_id}, err={is_error}): {truncated}"
)
}
crate::ai::bedrock::convert::MessageContent::MultiPart(parts) => {
format!("MultiPart({} parts)", parts.len())
}
};
format!("[{i}] {:?}: {content_str}", msg.role)
})
.collect();
let msg_count = messages.len();
(summary, messages, msg_count)
};
let (summary, messages, msg_count) = context_data;
// Global rules (no lifetime conflict since CloudModel is separate)
let global_rules: Vec<(String, String)> = CloudModel::as_ref(ctx)
.get_all_objects_of_type::<GenericStringObjectId, CloudAIFactModel>()
.filter_map(|fact| {
let AIFact::Memory(AIMemory { name, content, .. }) =
fact.model().string_model.clone();
if content.is_empty() {
None
} else {
Some((name.unwrap_or_default(), content))
}
})
.collect();
let mut output = String::new();
output.push_str("=== GLOBAL RULES ===");
output.push('\n');
if global_rules.is_empty() {
output.push_str("(none)\n");
} else {
for (name, content) in &global_rules {
output.push_str(&format!("### {name}\n{content}\n\n"));
}
}
output.push('\n');
output.push_str("=== PROGRESSIVE SUMMARY ===");
output.push('\n');
match &summary {
Some(s) => {
output.push_str(s);
output.push('\n');
}
None => output.push_str("(none)\n"),
}
output.push('\n');
output.push_str(&format!("=== MESSAGE HISTORY ({msg_count} messages) ===\n"));
for line in &messages {
output.push_str(line);
output.push('\n');
}
ctx.clipboard().write(ClipboardContent::plain_text(output));
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(
"Full context has been copied to the clipboard.".to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
_ 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
// of handling user queries with specific slash command prefixes.
return false;
}
_ => {
debug_assert!(
false,
"Attempted to execute slash command with no handler: {}",
command.name
);
return false;
}
}
// Leave the buffer alone when re-sending a queued prompt (the user may have typed
// new input while the agent was busy).
if !is_queued_prompt {
self.editor.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
}
// If the command must be executed in AI mode, and we're not already in an agent view,
// enter the agent view.
if FeatureFlag::AgentView.is_enabled()
&& command.auto_enter_ai_mode
&& !self.agent_view_controller.as_ref(ctx).is_active()
{
self.agent_view_controller.update(ctx, |controller, ctx| {
let _ = controller.try_enter_agent_view(
None,
AgentViewEntryOrigin::SlashCommand {
trigger: SlashCommandTrigger::input(),
},
ctx,
);
});
}
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(ctx).is_active();
send_telemetry_from_ctx!(
TelemetryEvent::SlashCommandAccepted {
command_details: SlashCommandAcceptedDetails::StaticCommand {
command_name: command.name.to_owned(),
},
is_in_agent_view,
},
ctx
);
true
}
/// Handles cmd+enter (Mac) / ctrl+enter (Linux/Windows) for slash commands.
///
/// Returns `true` if the keypress was handled.
pub(super) fn maybe_handle_cmd_or_ctrl_shift_enter_for_slash_command(
&mut self,
ctx: &mut ViewContext<Self>,
) -> bool {
// If slash command menu is open, accept the selected item with cmd_or_ctrl_enter=true.
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::SlashCommands
) {
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;
}
// If no menu but slash command detected in buffer, execute with cmd_or_ctrl_enter=true
match self.slash_command_model.as_ref(ctx).state() {
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, None, None, ctx)
}
SlashCommandEntryState::None
| SlashCommandEntryState::Composing { .. }
| SlashCommandEntryState::DisabledUntilEmptyBuffer => false,
}
}
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:
/// * If the slash command does not take arguments, executes it
/// * If the slash command does take arguments, inserts it into the input.
///
/// If the slash command menu is not open, then "executes" the slash command in the input, if
/// there is one.
///
/// Returns `true` if the enter keypress was 'handled', else upstream enter keypress handling
/// logic should continue.
pub(super) fn maybe_handle_enter_for_slash_command(
&mut self,
ctx: &mut ViewContext<Self>,
) -> bool {
if matches!(
self.suggestions_mode_model.as_ref(ctx).mode(),
InputSuggestionsMode::SlashCommands
) {
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;
}
match self.slash_command_model.as_ref(ctx).state() {
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, 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;