Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,680 @@
#[cfg(not(target_family = "wasm"))]
use command::r#async::Command;
#[cfg(not(target_family = "wasm"))]
use std::process::Stdio;
use std::{path::PathBuf, sync::Arc, time::Duration};
use super::static_prompt_suggestions::static_suggested_query;
#[cfg(not(target_family = "wasm"))]
use crate::ai::agent::PassiveSuggestionTrigger;
use crate::ai::agent::{AIAgentExchangeId, CancellationReason};
use crate::ai::blocklist::controller::{
response_stream::ResponseStreamId, BlocklistAIController, BlocklistAIControllerEvent,
};
use crate::ai::blocklist::{
read_local_file_context, BlocklistAIHistoryModel, BlocklistAIPermissions,
};
use crate::ai::paths::host_native_absolute_path;
use crate::ai::predict::generate_am_query_suggestions::{
GenerateAMQuerySuggestionsRequest, GenerateAMQuerySuggestionsResponse, Suggestion,
};
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::network::NetworkStatus;
use crate::report_error;
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::PromptSuggestionFallbackReason;
use crate::settings::AISettings;
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::block::BlockId;
use crate::terminal::model::session::{active_session::ActiveSession, SessionType};
use crate::terminal::model::terminal_model::TerminalModel;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::view::{AgentModePromptSuggestion, PromptSuggestion};
use crate::workspaces::user_workspaces::UserWorkspaces;
use chrono::Utc;
use parking_lot::FairMutex;
use serde_json::json;
use warp_core::features::FeatureFlag;
use warpui::r#async::{FutureExt as AsyncFutureExt, SpawnedFutureHandle, Timer};
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
const NUM_TOP_BLOCK_LINES: usize = 100;
const NUM_BOTTOM_BLOCK_LINES: usize = 200;
const PASSIVE_CODE_DIFF_LONG_FILE_LINE_LIMIT: usize = 2000;
const PASSIVE_CODE_DIFF_LONG_FILE_BYTE_LIMIT: usize = 100_000;
const PASSIVE_CODE_DIFF_TOTAL_LINE_LIMIT: usize = 2500;
const PASSIVE_CODE_DIFF_TOTAL_BYTE_LIMIT: usize = 150_000;
const PASSIVE_CODE_DIFF_FILE_READING_TIMEOUT: Duration = Duration::from_secs(2);
const PASSIVE_CODE_DIFF_AI_QUERY_TIMEOUT: Duration = Duration::from_secs(25);
#[derive(Clone, Debug)]
pub enum PassiveSuggestionsEvent {
PromptSuggestionsGenerated {
prompt_suggestion: AgentModePromptSuggestion,
block_id: BlockId,
command: String,
request_duration_ms: u64,
},
PassiveCodeDiffRequestStarted {
prompt_suggestion_id: String,
code_exchange_id: Option<AIAgentExchangeId>,
block_id: BlockId,
},
PassiveCodeDiffFailed {
reason: PromptSuggestionFallbackReason,
},
}
pub struct PassiveSuggestionsModel {
active_session: ModelHandle<ActiveSession>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ai_controller: ModelHandle<BlocklistAIController>,
terminal_view_id: EntityId,
prompt_suggestions_future_handle: Option<SpawnedFutureHandle>,
unit_test_generation_future_handle: Option<SpawnedFutureHandle>,
code_diff_preflight_future_handle: Option<SpawnedFutureHandle>,
code_diff_timeout_future_handle: Option<SpawnedFutureHandle>,
pending_unit_test_stream_id: Option<ResponseStreamId>,
pending_code_diff_stream_id: Option<ResponseStreamId>,
}
impl PassiveSuggestionsModel {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ai_controller: ModelHandle<BlocklistAIController>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
me.handle_model_event(event, ctx);
});
ctx.subscribe_to_model(&ai_controller, |me, event, _ctx| {
me.handle_controller_event(event, _ctx);
});
Self {
active_session,
terminal_model,
ai_controller,
terminal_view_id,
prompt_suggestions_future_handle: None,
unit_test_generation_future_handle: None,
code_diff_preflight_future_handle: None,
code_diff_timeout_future_handle: None,
pending_unit_test_stream_id: None,
pending_code_diff_stream_id: None,
}
}
pub fn is_passive_code_diff_being_generated(&self) -> bool {
self.pending_code_diff_stream_id.is_some()
}
pub fn abort_pending_requests(
&mut self,
ctx: &mut ModelContext<Self>,
) -> Vec<ResponseStreamId> {
let mut aborted_stream_ids = Vec::new();
if let Some(handle) = self.prompt_suggestions_future_handle.take() {
handle.abort();
}
if let Some(handle) = self.unit_test_generation_future_handle.take() {
handle.abort();
}
if let Some(handle) = self.code_diff_preflight_future_handle.take() {
handle.abort();
}
if let Some(handle) = self.code_diff_timeout_future_handle.take() {
handle.abort();
}
if let Some(stream_id) = self.pending_unit_test_stream_id.take() {
self.ai_controller.update(ctx, |controller, ctx| {
controller.try_cancel_pending_response_stream(
&stream_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
aborted_stream_ids.push(stream_id);
}
if let Some(stream_id) = self.pending_code_diff_stream_id.take() {
self.ai_controller.update(ctx, |controller, ctx| {
controller.try_cancel_pending_response_stream(
&stream_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
aborted_stream_ids.push(stream_id);
}
aborted_stream_ids
}
fn handle_model_event(&mut self, event: &ModelEvent, ctx: &mut ModelContext<Self>) {
match event {
ModelEvent::AfterBlockStarted { .. } => {
self.abort_pending_requests(ctx);
}
ModelEvent::AfterBlockCompleted(after_block_completed_event) => {
if FeatureFlag::PromptSuggestionsViaMAA.is_enabled() {
self.abort_pending_requests(ctx);
return;
}
let BlockType::User(block_completed) = &after_block_completed_event.block_type
else {
return;
};
self.handle_user_block_completed(block_completed, ctx);
}
_ => {}
}
}
fn handle_controller_event(
&mut self,
event: &BlocklistAIControllerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
BlocklistAIControllerEvent::FinishedReceivingOutput { stream_id, .. } => {
if self
.pending_unit_test_stream_id
.as_ref()
.is_some_and(|pending| pending == stream_id)
{
self.pending_unit_test_stream_id = None;
}
if self
.pending_code_diff_stream_id
.as_ref()
.is_some_and(|pending| pending == stream_id)
{
self.pending_code_diff_stream_id = None;
if let Some(handle) = self.code_diff_timeout_future_handle.take() {
handle.abort();
}
}
}
BlocklistAIControllerEvent::SentRequest { stream_id, .. } => {
if self.pending_unit_test_stream_id.as_ref() == Some(stream_id)
|| self.pending_code_diff_stream_id.as_ref() == Some(stream_id)
|| self.pending_code_diff_stream_id.as_ref() == Some(stream_id)
{
return;
}
// If a new request is sent that wasn't for passive suggestions, abort any passive suggestions requests.
self.abort_pending_requests(ctx);
}
_ => {}
}
}
fn handle_user_block_completed(
&mut self,
block_completed: &UserBlockCompleted,
ctx: &mut ModelContext<Self>,
) {
if block_completed.was_part_of_agent_interaction {
return;
}
self.abort_pending_requests(ctx);
// Startup commands run while bootstrapping an Oz cloud environment, so we skip
// passive prompt suggestion generation for them to avoid unnecessary requests.
let is_oz_environment_startup_command = FeatureFlag::CloudModeSetupV2.is_enabled()
&& self
.terminal_model
.lock()
.block_list()
.block_at(block_completed.index)
.is_some_and(|block| block.is_oz_environment_startup_command());
if is_oz_environment_startup_command {
return;
}
if should_generate_unit_test_suggestion(block_completed, ctx) {
self.generate_unit_test_suggestion(block_completed.clone(), ctx);
} else if should_generate_prompt_suggestions(block_completed, ctx) {
self.generate_prompt_suggestions(block_completed.clone(), ctx);
}
}
fn generate_prompt_suggestions(
&mut self,
block_completed: UserBlockCompleted,
ctx: &mut ModelContext<Self>,
) {
let block_id = block_completed.serialized_block.id.clone();
let command = block_completed.command.clone();
let start_ts_ms = Utc::now().timestamp_millis();
if let Some(suggestion) = fetch_static_prompt_suggestion(&block_completed) {
ctx.emit(PassiveSuggestionsEvent::PromptSuggestionsGenerated {
prompt_suggestion: suggestion.clone(),
block_id: block_id.clone(),
command,
request_duration_ms: 0,
});
self.maybe_generate_passive_code_diff(suggestion, block_id, ctx);
return;
}
let Some(execution_context) = self
.active_session
.as_ref(ctx)
.ai_execution_environment(ctx)
else {
return;
};
let Some(request) = build_prompt_suggestions_request(
&block_completed,
execution_context,
&self.terminal_model,
) else {
return;
};
let server_api = ServerApiProvider::handle(ctx).as_ref(ctx).get();
let request_future =
async move { server_api.generate_am_query_suggestions(&request).await };
self.prompt_suggestions_future_handle =
Some(ctx.spawn(request_future, move |me, result, ctx| {
me.prompt_suggestions_future_handle = None;
let end_ts_ms = Utc::now().timestamp_millis();
let request_duration_ms = end_ts_ms.saturating_sub(start_ts_ms) as u64;
let prompt_suggestion = match result {
Ok(response) => map_prompt_suggestions_response(response),
Err(err) => {
report_error!(
anyhow::Error::new(err).context("Failed to fetch prompt suggestions")
);
AgentModePromptSuggestion::Error
}
};
ctx.emit(PassiveSuggestionsEvent::PromptSuggestionsGenerated {
prompt_suggestion: prompt_suggestion.clone(),
block_id: block_id.clone(),
command,
request_duration_ms,
});
me.maybe_generate_passive_code_diff(prompt_suggestion, block_id, ctx);
}));
}
fn generate_unit_test_suggestion(
&mut self,
block_completed: UserBlockCompleted,
ctx: &mut ModelContext<Self>,
) {
#[cfg(target_family = "wasm")]
{
let (_, _) = (block_completed, ctx);
}
#[cfg(not(target_family = "wasm"))]
{
let Some(current_dir) = block_completed
.serialized_block
.pwd
.as_ref()
.map(PathBuf::from)
else {
return;
};
self.unit_test_generation_future_handle = Some(ctx.spawn(
async move {
let output = Command::new("git")
.args(["show", "HEAD"])
.current_dir(current_dir)
.stdout(Stdio::piped())
.output()
.await;
if let Ok(output) = output {
return String::from_utf8_lossy(&output.stdout).to_string();
}
String::new()
},
|me, diff_output: String, ctx| {
me.unit_test_generation_future_handle = None;
if diff_output.is_empty() {
return;
}
let diff_json = json!({ "diffs": diff_output });
let request = me.ai_controller.update(ctx, |controller, ctx| {
controller.send_unit_test_suggestions_request(
diff_json.to_string(),
PassiveSuggestionTrigger::CommandRun,
ctx,
)
});
if let Ok((_, stream_id)) = request {
me.pending_unit_test_stream_id = Some(stream_id.clone());
}
},
));
}
}
fn maybe_generate_passive_code_diff(
&mut self,
prompt_suggestion: AgentModePromptSuggestion,
block_id: BlockId,
ctx: &mut ModelContext<Self>,
) {
if !passive_code_diffs_enabled(ctx) {
return;
}
let AgentModePromptSuggestion::Success(query) = prompt_suggestion else {
return;
};
let Some(files) = query.coding_query_context.clone() else {
return;
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let can_read_file = BlocklistAIPermissions::as_ref(ctx)
.can_read_files(
None,
files
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.name,
&shell,
&current_working_directory,
))
})
.collect(),
Some(self.terminal_view_id),
ctx,
)
.is_allowed();
let should_skip_for_remote = self
.active_session
.as_ref(ctx)
.session_type(ctx)
.map(|session_type| matches!(session_type, SessionType::WarpifiedRemote { .. }))
.unwrap_or(true);
if !can_read_file || should_skip_for_remote {
let reason = if !can_read_file {
PromptSuggestionFallbackReason::NoReadFilesPermission
} else {
PromptSuggestionFallbackReason::SSHRemoteSession
};
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed { reason });
return;
}
let prompt_suggestion_id = query.id;
let query_text = query.prompt;
self.code_diff_preflight_future_handle = Some(ctx.spawn(
async move {
let file_future =
read_local_file_context(&files, current_working_directory, shell, None, None);
let Ok(result) = file_future.with_timeout(PASSIVE_CODE_DIFF_FILE_READING_TIMEOUT).await
else {
return Err(anyhow::anyhow!("File reading timed out"));
};
result
},
move |me, content, ctx| {
me.code_diff_preflight_future_handle = None;
let content = match content {
Ok(content) => {
if !content.missing_files.is_empty() {
log::warn!(
"Missing files when retrieving file content for suggested code diffs: {:?}",
content.missing_files
);
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed {
reason: PromptSuggestionFallbackReason::MissingFile,
});
return;
}
content
}
Err(err) => {
log::warn!("Failed to retrieve file content for suggested code diffs: {err}");
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed {
reason: PromptSuggestionFallbackReason::FailedToRetrieveFile,
});
return;
}
};
let mut total_lines = 0;
let mut total_bytes = 0;
let has_large_file = content.file_contexts.iter().any(|file_context| {
let file_content = &file_context.content;
let line_count = file_content.line_count();
let byte_count = file_content.len();
total_lines += line_count;
total_bytes += byte_count;
line_count >= PASSIVE_CODE_DIFF_LONG_FILE_LINE_LIMIT
|| byte_count >= PASSIVE_CODE_DIFF_LONG_FILE_BYTE_LIMIT
});
if has_large_file
|| total_lines >= PASSIVE_CODE_DIFF_TOTAL_LINE_LIMIT
|| total_bytes >= PASSIVE_CODE_DIFF_TOTAL_BYTE_LIMIT
{
let reason =
if has_large_file && total_lines >= PASSIVE_CODE_DIFF_LONG_FILE_LINE_LIMIT {
PromptSuggestionFallbackReason::FileTooManyLines
} else {
PromptSuggestionFallbackReason::FileTooManyBytes
};
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed {
reason,
});
return;
}
let result = me.ai_controller.update(ctx, |controller, ctx| {
controller.send_passive_code_diff_request(
query_text,
&block_id,
content.file_contexts,
ctx,
)
});
match result {
Ok((conversation_id, stream_id)) => {
me.pending_code_diff_stream_id = Some(stream_id.clone());
let code_exchange_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| conversation.root_task_exchanges().next())
.map(|exchange| exchange.id);
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffRequestStarted {
prompt_suggestion_id: prompt_suggestion_id.clone(),
code_exchange_id,
block_id: block_id.clone(),
});
me.start_code_diff_timeout(stream_id, ctx);
}
Err(_) => {
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed {
reason: PromptSuggestionFallbackReason::FailedToSendAIRequest,
});
}
}
},
));
}
fn start_code_diff_timeout(
&mut self,
stream_id: ResponseStreamId,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.code_diff_timeout_future_handle.take() {
handle.abort();
}
self.code_diff_timeout_future_handle = Some(ctx.spawn(
async move {
Timer::after(PASSIVE_CODE_DIFF_AI_QUERY_TIMEOUT).await;
stream_id
},
|me, timed_out_stream_id, ctx| {
me.code_diff_timeout_future_handle = None;
if me
.pending_code_diff_stream_id
.as_ref()
.is_some_and(|pending| pending == &timed_out_stream_id)
{
log::warn!(
"Passive code diff AI request timed out, cancelling stream {timed_out_stream_id:?}"
);
me.ai_controller.update(ctx, |controller, ctx| {
controller.try_cancel_pending_response_stream(
&timed_out_stream_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
me.pending_code_diff_stream_id = None;
ctx.emit(PassiveSuggestionsEvent::PassiveCodeDiffFailed {
reason: PromptSuggestionFallbackReason::AIQueryTimeout,
});
}
},
));
}
}
impl Entity for PassiveSuggestionsModel {
type Event = PassiveSuggestionsEvent;
}
fn should_generate_prompt_suggestions(
block_completed: &UserBlockCompleted,
ctx: &ModelContext<PassiveSuggestionsModel>,
) -> bool {
if block_completed.command.trim().is_empty() {
return false;
}
if !NetworkStatus::as_ref(ctx).is_online() {
return false;
}
AISettings::as_ref(ctx).is_prompt_suggestions_enabled(ctx)
&& UserWorkspaces::as_ref(ctx).is_prompt_suggestions_toggleable()
}
fn should_generate_unit_test_suggestion(
block_completed: &UserBlockCompleted,
ctx: &ModelContext<PassiveSuggestionsModel>,
) -> bool {
let enabled = AISettings::as_ref(ctx).is_code_suggestions_enabled(ctx)
&& UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable();
enabled
&& block_completed.command.starts_with("git")
&& block_completed.command.contains("commit")
&& block_completed.serialized_block.exit_code.was_successful()
}
fn passive_code_diffs_enabled(ctx: &ModelContext<PassiveSuggestionsModel>) -> bool {
let ai_settings = AISettings::as_ref(ctx);
let is_prompt_suggestions_enabled = ai_settings.is_prompt_suggestions_enabled(ctx);
let is_code_suggestions_enabled = ai_settings.is_code_suggestions_enabled(ctx);
let is_toggleable = UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable();
is_prompt_suggestions_enabled && is_code_suggestions_enabled && is_toggleable
}
fn fetch_static_prompt_suggestion(block: &UserBlockCompleted) -> Option<AgentModePromptSuggestion> {
if !block.serialized_block.exit_code.was_successful() {
return None;
}
static_suggested_query(&block.command).map(AgentModePromptSuggestion::Success)
}
fn build_prompt_suggestions_request(
block: &UserBlockCompleted,
execution_context: WarpAiExecutionContext,
terminal_model: &Arc<FairMutex<TerminalModel>>,
) -> Option<GenerateAMQuerySuggestionsRequest> {
let exit_code = block.serialized_block.exit_code;
let working_dir = block.serialized_block.pwd.as_ref();
let (processed_input, processed_output) = {
let model = terminal_model.lock();
let terminal_width = model.block_list().size().columns();
let Some(current_block) = model.block_list().block_with_id(&block.serialized_block.id)
else {
log::error!(
"Failed to fetch prompt suggestions, could not find block with ID: {:?}",
block.serialized_block.id
);
return None;
};
current_block.get_block_content_summary(
terminal_width,
NUM_TOP_BLOCK_LINES,
NUM_BOTTOM_BLOCK_LINES,
)
};
let json_message = json!({
"command": processed_input,
"output": processed_output,
"exit_code": exit_code,
"pwd": working_dir,
});
Some(GenerateAMQuerySuggestionsRequest {
context_messages: vec![json_message.to_string()],
system_context: execution_context.to_json_string(),
exit_code: exit_code.value(),
})
}
fn map_prompt_suggestions_response(
response: GenerateAMQuerySuggestionsResponse,
) -> AgentModePromptSuggestion {
let is_valid_code_delegation = response.is_valid_code_delegation();
let Some(suggestion) = response.suggestion else {
return AgentModePromptSuggestion::None;
};
match suggestion {
Suggestion::Coding(coding_query) if is_valid_code_delegation => {
AgentModePromptSuggestion::Success(PromptSuggestion {
id: response.id,
label: None,
prompt: coding_query.query,
coding_query_context: Some(
coding_query
.files
.into_iter()
.map(Into::into)
.collect::<Vec<_>>(),
),
static_prompt_suggestion_name: None,
should_start_new_conversation: true,
})
}
Suggestion::Simple(simple_query) => AgentModePromptSuggestion::Success(PromptSuggestion {
id: response.id,
label: None,
prompt: simple_query.query,
coding_query_context: None,
static_prompt_suggestion_name: None,
should_start_new_conversation: true,
}),
_ => AgentModePromptSuggestion::None,
}
}
@@ -0,0 +1,901 @@
use std::sync::Arc;
use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent};
use crate::ai::agent::api::generate_multi_agent_output;
use crate::ai::agent::AIIdentifiers;
use crate::ai::agent::FileContext;
use crate::ai::agent::PassiveCodeDiffEntry;
use crate::ai::agent::PassiveSuggestionTrigger;
use crate::ai::agent::{conversation::AIConversationId, ShellCommandCompletedTrigger};
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::inline_action::code_diff_view::FileDiff;
use crate::ai::blocklist::{
apply_edits, BlocklistAIHistoryModel, FileReadResult, RequestFileEditsFormatKind,
SessionContext,
};
use crate::ai::paths::host_native_absolute_path;
use crate::auth::auth_state::AuthStateProvider;
use crate::server::server_api::ServerApiProvider;
use crate::settings::AISettings;
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::terminal_model::TerminalModel;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::workspaces::user_workspaces::UserWorkspaces;
use ai::agent::action::{AIAgentActionType, FileEdit};
use ai::diff_validation::ParsedDiff;
use chrono::{DateTime, Utc};
use parking_lot::FairMutex;
use warp_core::features::FeatureFlag;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use std::{path::PathBuf, time::Duration};
use crate::ai::blocklist::{read_local_file_context, BlocklistAIPermissions};
use warp_terminal::shell::ShellLaunchData;
use crate::util::link_detection::{detect_file_paths, DetectedLinkType};
use crate::util::openable_file_type::is_binary_file;
use ai::agent::FileLocations;
use warpui::AppContext;
use warpui::r#async::FutureExt as AsyncFutureExt;
use itertools::Itertools;
}
}
pub enum PassiveSuggestionsEvent {
NewPromptSuggestion {
prompt: String,
label: Option<String>,
request_duration_ms: u64,
/// The trigger for the suggestion. `None` when the server indicated the
/// trigger is not relevant to the suggestion.
trigger: Option<PassiveSuggestionTrigger>,
conversation_id: Option<AIConversationId>,
/// The server-assigned request token from the passive suggestion
/// request. Used to join client-side telemetry with server-side logs.
server_request_token: Option<String>,
},
NewCodeDiffSuggestion {
diffs: Vec<FileDiff>,
edit_format_kind: RequestFileEditsFormatKind,
title: Option<String>,
/// The original search/replace edits from the LLM response.
original_edits: Vec<PassiveCodeDiffEntry>,
/// The conversation ID to continue in when the user clicks "iterate
/// with agent" or "accept and continue". `None` for ephemeral triggers
/// (shell command with no prior conversation).
conversation_id: Option<AIConversationId>,
request_duration_ms: u64,
trigger: PassiveSuggestionTrigger,
/// The server-assigned request token from the passive suggestion
/// request. Used to join client-side telemetry with server-side logs.
/// `None` on the legacy code path.
server_request_token: Option<String>,
},
}
/// Tracks an out-of-band passive suggestions request.
struct Request {
/// Conversation ID for the passive suggestion request.
conversation_id: AIConversationId,
trigger: PassiveSuggestionTrigger,
start_ts: DateTime<Utc>,
/// Handle to the spawned future processing the response stream.
_stream_handle: SpawnedFutureHandle,
_cancellation_tx: futures::channel::oneshot::Sender<()>,
}
pub struct PassiveSuggestionsModel {
ai_controller: ModelHandle<BlocklistAIController>,
latest_request: Option<Request>,
pending_file_read_handle: Option<SpawnedFutureHandle>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
terminal_view_id: EntityId,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
active_session: ModelHandle<ActiveSession>,
}
impl PassiveSuggestionsModel {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ai_controller: ModelHandle<BlocklistAIController>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
me.handle_model_event(event, ctx);
});
ctx.subscribe_to_model(&ai_controller, |me, event, ctx| {
me.handle_controller_event(event, ctx);
});
Self {
active_session,
ai_controller,
latest_request: None,
pending_file_read_handle: None,
terminal_model,
ambient_agent_view_model,
terminal_view_id,
}
}
pub fn abort_pending_requests(&mut self, _ctx: &mut ModelContext<Self>) {
if let Some(handle) = self.pending_file_read_handle.take() {
handle.abort();
}
// Dropping the [`Request`] aborts the spawned stream handle.
self.latest_request.take();
}
/// Sends a MAA request to generate passive suggestions.
///
/// As much as possible, this method avoids mutating the conversation data model;
/// the response stream is consumed inline to extract relevant tool calls
/// without touching conversation history. This is intentional to avoid
/// polluting existing conversations and the history model in general
/// with passive conversations.
fn send_request(
&mut self,
followup_conversation_id: Option<AIConversationId>,
trigger: PassiveSuggestionTrigger,
supported_tools: Vec<warp_multi_agent_api::ToolType>,
ctx: &mut ModelContext<Self>,
) {
// Capture before the call — `Some` means there's a real conversation
// the user can continue in; `None` means ephemeral.
let continuable_conversation_id = followup_conversation_id;
let Ok((conversation_id, request_params)) =
self.ai_controller.update(ctx, |controller, ctx| {
controller.build_passive_suggestions_request_params(
followup_conversation_id,
trigger.clone(),
supported_tools,
ctx,
)
})
else {
return;
};
let server_api = ServerApiProvider::as_ref(ctx).get();
let (cancellation_tx, cancellation_rx) = futures::channel::oneshot::channel();
let stream_handle = ctx.spawn(
async move {
let stream_result =
generate_multi_agent_output(server_api, request_params, cancellation_rx).await;
extract_suggestion_from_stream(stream_result).await
},
move |me, result, ctx| {
let Some(latest_request) = &me.latest_request else {
return;
};
if latest_request.conversation_id != conversation_id {
return;
}
if !me.is_suggestion_still_valid(ctx) {
return;
}
let Some(extracted) = result else {
return;
};
let request_duration_ms = Utc::now()
.signed_duration_since(latest_request.start_ts)
.num_milliseconds()
.max(0) as u64;
let trigger = latest_request.trigger.clone();
let StreamExtractionResult {
suggestion: extracted,
server_request_token,
} = extracted;
match extracted {
ExtractedSuggestion::Prompt {
prompt,
label,
is_trigger_irrelevant,
} => {
if prompt.is_empty() {
return;
}
let trigger = if is_trigger_irrelevant {
log::debug!("[passive-suggestions] trigger marked irrelevant, omitting from prompt suggestion event");
None
} else {
Some(trigger)
};
ctx.emit(PassiveSuggestionsEvent::NewPromptSuggestion {
prompt,
label,
request_duration_ms,
trigger,
conversation_id: continuable_conversation_id,
server_request_token,
});
}
ExtractedSuggestion::CodeDiff { apply_file_diffs } => {
let AIAgentActionType::RequestFileEdits { file_edits, title } =
AIAgentActionType::from(apply_file_diffs)
else {
unreachable!()
};
let edit_format_kind = classify_edit_format(&file_edits);
let original_edits = file_edits_to_passive_diffs(&file_edits);
let session_context =
SessionContext::from_session(me.active_session.as_ref(ctx), ctx);
let identifiers = AIIdentifiers::default();
let background_executor = ctx.background_executor();
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
ctx.spawn(
async move {
apply_edits(
file_edits,
&session_context,
&identifiers,
background_executor,
auth_state,
true,
|path| async move {
FileReadResult::from(std::fs::read_to_string(path))
},
)
.await
},
move |me: &mut Self, applied_diffs: Result<Vec<ai::diff_validation::AIRequestedCodeDiff>, _>, ctx: &mut ModelContext<Self>| {
let Ok(applied_diffs) = applied_diffs else {
log::warn!("[passive-code-diff] apply_edits failed");
return;
};
if applied_diffs.is_empty() {
log::warn!("[passive-code-diff] no diffs generated");
return;
}
let cwd = me
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = me.active_session.as_ref(ctx).shell_launch_data(ctx);
let diffs: Vec<FileDiff> = applied_diffs
.into_iter()
.map(|diff: ai::diff_validation::AIRequestedCodeDiff| {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell,
&cwd,
);
FileDiff::new(diff.original_content, path, diff.diff_type)
})
.collect();
ctx.emit(PassiveSuggestionsEvent::NewCodeDiffSuggestion {
diffs,
edit_format_kind,
title,
original_edits: original_edits.clone(),
conversation_id: continuable_conversation_id,
request_duration_ms,
trigger,
server_request_token: server_request_token.clone(),
});
},
);
}
}
},
);
self.latest_request = Some(Request {
_stream_handle: stream_handle,
_cancellation_tx: cancellation_tx,
conversation_id,
trigger,
start_ts: Utc::now(),
});
}
/// Returns true if the current suggestion context is still valid.
fn is_suggestion_still_valid(&self, ctx: &ModelContext<Self>) -> bool {
let Some(latest_request) = &self.latest_request else {
return false;
};
match &latest_request.trigger {
PassiveSuggestionTrigger::AgentResponseCompleted { exchange_id } => {
// If there's been a non-passive exchange since the one that triggered
// this prompt suggestion, it isn't valid anymore.
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&latest_request.conversation_id)
.and_then(|c| c.last_non_passive_exchange())
.map(|e| &e.id)
== Some(exchange_id)
}
PassiveSuggestionTrigger::ShellCommandCompleted(trigger) => {
// If the latest block has changed, this isn't a valid suggestion anymore.
self.terminal_model
.lock()
.block_list()
.last_non_hidden_block()
.map(|b| b.id())
== Some(&trigger.executed_shell_command.id)
}
PassiveSuggestionTrigger::CommandRun | PassiveSuggestionTrigger::FilesChanged => false,
}
}
fn handle_model_event(&mut self, event: &ModelEvent, ctx: &mut ModelContext<Self>) {
match event {
ModelEvent::AfterBlockStarted { .. } => {
self.abort_pending_requests(ctx);
}
ModelEvent::AfterBlockCompleted(after_block_completed_event) => {
if !FeatureFlag::PromptSuggestionsViaMAA.is_enabled() {
self.abort_pending_requests(ctx);
return;
}
if let BlockType::User(block_completed) = &after_block_completed_event.block_type {
if !block_completed.was_part_of_agent_interaction {
self.handle_user_block_completed(block_completed, ctx);
}
}
}
_ => {}
}
}
fn handle_controller_event(
&mut self,
event: &BlocklistAIControllerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
BlocklistAIControllerEvent::SentRequest { .. } => {
// Once a non-passive request is sent, cancel any pending passive requests.
self.abort_pending_requests(ctx);
}
BlocklistAIControllerEvent::FinishedReceivingOutput {
conversation_id, ..
} => {
if !FeatureFlag::PromptSuggestionsViaMAA.is_enabled() {
self.abort_pending_requests(ctx);
return;
}
self.handle_finished_stream(*conversation_id, ctx);
}
_ => {}
}
}
fn handle_finished_stream(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.abort_pending_requests(ctx);
if !is_prompt_suggestions_enabled(ctx) {
return;
}
// Suppress passive suggestions in cloud mode sessions.
if self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() {
return;
}
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(&conversation_id) else {
return;
};
let Some(latest_exchange) = conversation.latest_exchange() else {
return;
};
let latest_exchange_id = latest_exchange.id;
let status = conversation.status();
if status.is_done() {
self.send_request(
Some(conversation_id),
PassiveSuggestionTrigger::AgentResponseCompleted {
exchange_id: latest_exchange_id,
},
vec![warp_multi_agent_api::ToolType::SuggestPrompt],
ctx,
);
}
}
fn send_shell_command_completed_request(
&mut self,
conversation_id: Option<AIConversationId>,
block_context: Box<BlockContext>,
relevant_files: Vec<FileContext>,
supported_tools: Vec<warp_multi_agent_api::ToolType>,
ctx: &mut ModelContext<Self>,
) {
let trigger =
PassiveSuggestionTrigger::ShellCommandCompleted(ShellCommandCompletedTrigger {
executed_shell_command: block_context,
relevant_files,
});
self.send_request(conversation_id, trigger, supported_tools, ctx);
}
fn handle_user_block_completed(
&mut self,
block_completed: &UserBlockCompleted,
ctx: &mut ModelContext<Self>,
) {
self.abort_pending_requests(ctx);
// Suppress passive suggestions in cloud mode sessions.
if self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent() {
return;
}
// Startup commands run while bootstrapping an Oz cloud environment, so we skip
// passive prompt suggestion generation for them to avoid unnecessary requests.
let is_oz_environment_startup_command = FeatureFlag::CloudModeSetupV2.is_enabled()
&& self
.terminal_model
.lock()
.block_list()
.block_at(block_completed.index)
.is_some_and(|block| block.is_oz_environment_startup_command());
if is_oz_environment_startup_command {
return;
}
let is_prompt_suggestions_enabled = is_prompt_suggestions_enabled(ctx);
let is_passive_code_diffs_enabled = is_passive_code_diffs_enabled(ctx);
if !is_prompt_suggestions_enabled && !is_passive_code_diffs_enabled {
return;
}
let mut supported_tools = Vec::new();
if is_prompt_suggestions_enabled {
supported_tools.push(warp_multi_agent_api::ToolType::SuggestPrompt);
}
let block_context = BlockContext::from_completed_block(block_completed);
let (conversation_id, block_context) = {
let model = self.terminal_model.lock();
let Some(block) = model.block_list().block_at(block_completed.index) else {
return;
};
let conversation_id = block.agent_view_visibility().agent_view_conversation_id();
(conversation_id, block_context)
};
// If passive code diffs are enabled, check for any files that were read.
#[cfg(feature = "local_fs")]
if is_passive_code_diffs_enabled {
if let Some(current_working_directory) = block_completed.serialized_block.pwd.clone() {
let block_contents =
format!("{}\n{}", &block_context.command, &block_context.output);
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_for_detection = shell.clone();
let current_working_directory_for_detection = current_working_directory.clone();
let terminal_view_id = self.terminal_view_id;
self.pending_file_read_handle = Some(ctx.spawn(
async move {
match tokio::task::spawn_blocking(move || {
detect_relevant_file_paths_for_block(
&block_contents,
&current_working_directory_for_detection,
shell_for_detection.as_ref(),
)
})
.await
{
Ok(paths) => paths,
Err(err) => {
log::warn!(
"[passive-suggestions] failed to detect relevant file paths: {err}"
);
vec![]
}
}
},
move |me, candidate_paths, ctx| {
let Some(file_locations) = get_allowed_file_locations_for_paths(
candidate_paths,
conversation_id.as_ref(),
terminal_view_id,
ctx,
) else {
me.pending_file_read_handle = None;
me.send_shell_command_completed_request(
conversation_id,
block_context,
vec![],
supported_tools,
ctx,
);
return;
};
me.pending_file_read_handle =
Some(ctx.spawn(read_files(file_locations, current_working_directory, shell), move |me, relevant_files, ctx| {
me.pending_file_read_handle = None;
supported_tools.push(warp_multi_agent_api::ToolType::ApplyFileDiffs);
me.send_shell_command_completed_request(
conversation_id,
block_context,
relevant_files,
supported_tools,
ctx,
);
}));
},
));
return;
}
}
if !supported_tools.is_empty() {
self.send_shell_command_completed_request(
conversation_id,
block_context,
vec![],
supported_tools,
ctx,
);
}
}
}
impl Entity for PassiveSuggestionsModel {
type Event = PassiveSuggestionsEvent;
}
/// Result of extracting a suggestion from an out-of-band response stream.
struct StreamExtractionResult {
suggestion: ExtractedSuggestion,
/// The server-assigned request token from the `StreamInit` event,
/// used to correlate client telemetry with server-side logs.
server_request_token: Option<String>,
}
enum ExtractedSuggestion {
Prompt {
prompt: String,
label: Option<String>,
is_trigger_irrelevant: bool,
},
CodeDiff {
apply_file_diffs: warp_multi_agent_api::message::tool_call::ApplyFileDiffs,
},
}
/// Consumes the entire response stream, coalescing incremental message
/// updates (via field masks) into final messages, then inspects them
/// for `SuggestPrompt` or `ApplyFileDiffs` tool calls.
async fn extract_suggestion_from_stream(
stream_result: Result<
crate::ai::agent::api::ResponseStream,
ai::agent::convert::ConvertToAPITypeError,
>,
) -> Option<StreamExtractionResult> {
use crate::ai::agent::task::helper::MessageExt;
use futures_util::StreamExt;
use warp_multi_agent_api as api;
let Ok(mut stream) = stream_result else {
return None;
};
// Drain the stream, collecting all client actions and the server token.
let mut client_actions: Vec<api::ClientAction> = Vec::new();
let mut server_request_token: Option<String> = None;
while let Some(event) = stream.next().await {
let Ok(response_event) = event else {
continue;
};
match response_event.r#type {
Some(api::response_event::Type::Init(init)) => {
if !init.request_id.is_empty() {
server_request_token = Some(init.request_id);
}
}
Some(api::response_event::Type::ClientActions(actions)) => {
client_actions.extend(actions.actions);
}
_ => {}
}
}
// Coalesce incremental message updates into final messages.
let messages = coalesce_messages_from_client_actions(&client_actions);
// Scan final messages for suggestion tool calls.
for message in &messages {
let Some(tool_call) = message.tool_call() else {
continue;
};
let Some(tool) = tool_call.tool.as_ref() else {
continue;
};
match tool {
api::message::tool_call::Tool::SuggestPrompt(suggest_prompt) => {
let Some(display_mode) = suggest_prompt.display_mode.as_ref() else {
continue;
};
if let api::message::tool_call::suggest_prompt::DisplayMode::PromptChip(chip) =
display_mode
{
let label = if chip.label.is_empty() {
None
} else {
Some(chip.label.clone())
};
return Some(StreamExtractionResult {
suggestion: ExtractedSuggestion::Prompt {
prompt: chip.prompt.clone(),
label,
is_trigger_irrelevant: suggest_prompt.is_trigger_irrelevant,
},
server_request_token,
});
}
}
api::message::tool_call::Tool::ApplyFileDiffs(apply_file_diffs) => {
return Some(StreamExtractionResult {
suggestion: ExtractedSuggestion::CodeDiff {
apply_file_diffs: apply_file_diffs.clone(),
},
server_request_token,
});
}
_ => {}
}
}
None
}
/// Coalesces a sequence of client actions into final message state by applying
/// field-mask updates and appends incrementally.
fn coalesce_messages_from_client_actions(
client_actions: &[warp_multi_agent_api::ClientAction],
) -> Vec<warp_multi_agent_api::Message> {
use field_mask::FieldMaskOperation;
use std::collections::HashMap;
use warp_multi_agent_api as api;
use warp_multi_agent_api::client_action::Action;
let mut messages_by_id: HashMap<String, api::Message> = HashMap::new();
let mut message_order: Vec<String> = Vec::new();
for action in client_actions {
match &action.action {
Some(Action::CreateTask(create_task)) => {
if let Some(task) = &create_task.task {
for message in &task.messages {
let id = message.id.clone();
if !messages_by_id.contains_key(&id) {
message_order.push(id.clone());
}
messages_by_id.insert(id, message.clone());
}
}
}
Some(Action::AddMessagesToTask(add)) => {
for message in &add.messages {
let id = message.id.clone();
if !messages_by_id.contains_key(&id) {
message_order.push(id.clone());
}
messages_by_id.insert(id, message.clone());
}
}
Some(Action::UpdateTaskMessage(update)) => {
if let Some(new_message) = &update.message {
let id = new_message.id.clone();
let mask = update.mask.clone().unwrap_or_default();
if let Some(existing) = messages_by_id.get_mut(&id) {
if let Ok(merged) = FieldMaskOperation::update(
&api::MESSAGE_DESCRIPTOR,
existing,
new_message,
mask,
)
.apply()
{
*existing = merged;
}
} else {
message_order.push(id.clone());
messages_by_id.insert(id, new_message.clone());
}
}
}
Some(Action::AppendToMessageContent(append)) => {
if let Some(new_message) = &append.message {
let id = new_message.id.clone();
let mask = append.mask.clone().unwrap_or_default();
if let Some(existing) = messages_by_id.get_mut(&id) {
if let Ok(merged) = FieldMaskOperation::append(
&api::MESSAGE_DESCRIPTOR,
existing,
new_message,
mask,
)
.apply()
{
*existing = merged;
}
} else {
message_order.push(id.clone());
messages_by_id.insert(id, new_message.clone());
}
}
}
_ => {}
}
}
message_order
.into_iter()
.filter_map(|id| messages_by_id.remove(&id))
.collect()
}
/// Converts a slice of [`FileEdit`]s into [`PassiveCodeDiffEntry`] values,
/// preserving the original search/replace content from the LLM response.
fn file_edits_to_passive_diffs(file_edits: &[FileEdit]) -> Vec<PassiveCodeDiffEntry> {
let mut entries = Vec::new();
for edit in file_edits {
let file_path = edit.file().unwrap_or_default().to_string();
match edit {
FileEdit::Edit(ParsedDiff::StrReplaceEdit {
search, replace, ..
}) => {
entries.push(PassiveCodeDiffEntry {
file_path,
search: search.clone().unwrap_or_default(),
replace: replace.clone().unwrap_or_default(),
});
}
FileEdit::Edit(ParsedDiff::V4AEdit { hunks, .. }) => {
for hunk in hunks {
entries.push(PassiveCodeDiffEntry {
file_path: file_path.clone(),
search: hunk.old.clone(),
replace: hunk.new.clone(),
});
}
}
FileEdit::Create { content, .. } => {
entries.push(PassiveCodeDiffEntry {
file_path,
search: String::new(),
replace: content.clone().unwrap_or_default(),
});
}
FileEdit::Delete { .. } => {
entries.push(PassiveCodeDiffEntry {
file_path,
search: String::new(),
replace: String::new(),
});
}
}
}
entries
}
fn classify_edit_format(file_edits: &[FileEdit]) -> RequestFileEditsFormatKind {
let has_str_replace = file_edits
.iter()
.any(|e| matches!(e, FileEdit::Edit(ParsedDiff::StrReplaceEdit { .. })));
let has_v4a = file_edits
.iter()
.any(|e| matches!(e, FileEdit::Edit(ParsedDiff::V4AEdit { .. })));
match (has_str_replace, has_v4a) {
(true, false) => RequestFileEditsFormatKind::StrReplace,
(false, true) => RequestFileEditsFormatKind::V4A,
(true, true) => RequestFileEditsFormatKind::Mixed,
(false, false) => RequestFileEditsFormatKind::Unknown,
}
}
fn is_passive_code_diffs_enabled(ctx: &ModelContext<PassiveSuggestionsModel>) -> bool {
AISettings::as_ref(ctx).is_code_suggestions_enabled(ctx)
&& UserWorkspaces::as_ref(ctx).is_code_suggestions_toggleable()
}
fn is_prompt_suggestions_enabled(ctx: &ModelContext<PassiveSuggestionsModel>) -> bool {
AISettings::as_ref(ctx).is_prompt_suggestions_enabled(ctx)
&& UserWorkspaces::as_ref(ctx).is_prompt_suggestions_toggleable()
}
#[cfg(feature = "local_fs")]
fn detect_relevant_file_paths_for_block(
block_contents: &str,
current_working_directory: &str,
shell: Option<&ShellLaunchData>,
) -> Vec<PathBuf> {
// TODO (suraj): use line num hint to limit the line range to read.
detect_file_paths(current_working_directory, block_contents, shell)
.into_values()
.filter_map(|link| match link {
DetectedLinkType::FilePath { absolute_path, .. } => Some(absolute_path),
DetectedLinkType::Url(_) => None,
})
.filter(|path| path.is_file())
.filter(|path| !is_binary_file(path))
.unique()
.collect()
}
#[cfg(feature = "local_fs")]
fn get_allowed_file_locations_for_paths(
paths: Vec<PathBuf>,
conversation_id: Option<&AIConversationId>,
terminal_view_id: EntityId,
ctx: &AppContext,
) -> Option<Vec<FileLocations>> {
if paths.is_empty() {
return None;
}
if !BlocklistAIPermissions::as_ref(ctx)
.can_read_files(conversation_id, paths.clone(), Some(terminal_view_id), ctx)
.is_allowed()
{
return None;
}
Some(
paths
.into_iter()
.map(|path| FileLocations {
name: path.to_string_lossy().to_string(),
lines: vec![],
})
.collect_vec(),
)
}
#[cfg(feature = "local_fs")]
async fn read_files(
file_locations: Vec<FileLocations>,
current_working_directory: String,
shell: Option<ShellLaunchData>,
) -> Vec<FileContext> {
let file_future = read_local_file_context(
&file_locations,
Some(current_working_directory),
shell,
None,
// TODO (suraj): do something smarter than a single, fixed limit.
Some(500000),
);
let Ok(result) = file_future.with_timeout(Duration::from_secs(2)).await else {
return vec![];
};
match result {
Ok(result) => result.file_contexts,
Err(err) => {
log::warn!("Failed to retrieve file content for suggest prompt relevant files: {err}");
vec![]
}
}
}
@@ -0,0 +1,20 @@
mod legacy;
mod maa;
mod static_prompt_suggestions;
use warpui::ModelHandle;
pub use legacy::{
PassiveSuggestionsEvent as LegacyPassiveSuggestionsEvent,
PassiveSuggestionsModel as LegacyPassiveSuggestionsModel,
};
pub use maa::{
PassiveSuggestionsEvent as MaaPassiveSuggestionsEvent,
PassiveSuggestionsModel as MaaPassiveSuggestionsModel,
};
#[derive(Clone)]
pub struct PassiveSuggestionsModels {
pub legacy: ModelHandle<LegacyPassiveSuggestionsModel>,
pub maa: ModelHandle<MaaPassiveSuggestionsModel>,
}
@@ -0,0 +1,319 @@
use regex::Regex;
use std::sync::LazyLock;
use uuid::Uuid;
use crate::terminal::view::PromptSuggestion;
pub struct StaticPromptSuggestion {
pub name: &'static str,
pub pattern: &'static str,
pub label_template: Option<&'static str>,
pub query_template: &'static str,
}
/// Attempts to match a terminal command against predefined static prompt suggestions.
///
/// If the command matches a static rule, this returns a [`SuggestedQuery`] with details from the
/// command substituted into the rule's query template.
pub fn static_suggested_query(command: &str) -> Option<PromptSuggestion> {
// Try each rule in turn and apply the first match.
for pattern in &*RULE_PATTERNS {
if let Some(captures) = pattern.regex.captures(command) {
// If there's a match, apply placeholders to the query.
let label = pattern
.rule
.label_template
.map(|template| apply_captures(template, &captures));
let query = apply_captures(pattern.rule.query_template, &captures);
return Some(PromptSuggestion {
id: Uuid::new_v4().to_string(),
label,
prompt: query,
coding_query_context: None,
static_prompt_suggestion_name: Some(pattern.rule.name.to_string()),
should_start_new_conversation: false,
});
}
}
None
}
/// A static prompt suggestion with its pattern precompiled to a [`Regex`].
struct StaticPromptRule {
rule: &'static StaticPromptSuggestion,
regex: Regex,
}
static RULE_PATTERNS: LazyLock<Vec<StaticPromptRule>> = LazyLock::new(|| {
STATIC_RULES
.iter()
.map(|rule| match Regex::new(rule.pattern) {
Ok(regex) => StaticPromptRule { rule, regex },
Err(e) => {
panic!(
"Invalid pattern for static prompt rule `{}`: {}",
rule.name, e
);
}
})
.collect()
});
static STATIC_RULES: &[StaticPromptSuggestion] = &[
// git checkout -b <branch>: Checks out a new branch named <branch>.
StaticPromptSuggestion {
name: "GIT_CHECKOUT_NEW_BRANCH",
pattern: r"^git\s+checkout\s+-b\s+(\S+)\s*$",
label_template: Some("Code a feature or fix a bug in {1}"),
query_template:
"Implement a feature or fix a bug in {1}. Ask me for all the details you need.",
},
// git clone <repo>: Clones a repository named <repo>.
StaticPromptSuggestion {
name: "GIT_CLONE",
pattern: r"^git\s+clone\s+(\S+)\s*$",
label_template: Some("Help me code a feature or fix a bug in {1}"),
query_template:
"Implement a feature or fix a bug in {1}. Ask me for all the details you need.",
},
// git switch -c <branch>: Creates and switches to a new branch named <branch>.
StaticPromptSuggestion {
name: "GIT_SWITCH_NEW_BRANCH",
pattern: r"^git\s+switch\s+-c\s+(\S+)\s*$",
label_template: Some("Code a feature or fix a bug in {1}"),
query_template:
"Implement a feature or fix a bug in {1}. Ask me for all the details you need.",
},
// git push: Pushes changes to a remote repository.
StaticPromptSuggestion {
name: "GIT_PUSH",
pattern: r"^git\s+push\s*$",
label_template: None,
query_template: "Help me create a pull request.",
},
// git init: Initializes a new, empty Git repository.
StaticPromptSuggestion {
name: "GIT_INIT",
pattern: r"^git\s+init\s*$",
label_template: Some("Help me start a new project"),
query_template: "Help me start a new project. Ask me for all the details you need.",
},
// npm init / yarn init / pnpm init: Initializes a Node.js project.
StaticPromptSuggestion {
name: "NODE_PACKAGE_INIT",
pattern: r"^(npm|yarn|pnpm)\s+init\s*$",
label_template: Some("Help me start a Node.js project"),
query_template: "Help me start a Node.js project. Ask me for all the details you need.",
},
// npx create-react-app <project>: Creates a new React app called <project>.
StaticPromptSuggestion {
name: "NPX_CREATE_REACT_APP",
pattern: r"^npx\s+create-react-app\s+(\S+)\s*$",
label_template: Some("Help me create a new React app"),
query_template:
"Help me create a new React app called {1}. Ask me for all the details you need.",
},
// npx create-next-app <project>: Creates a new Next.js app called <project>.
StaticPromptSuggestion {
name: "NPX_CREATE_NEXT_APP",
pattern: r"^npx\s+create-next-app\s+(\S+)\s*$",
label_template: Some("Help me create a new Next.js app"),
query_template:
"Help me create a new Next.js app called {1}. Ask me for all the details you need.",
},
// cargo new <project>: Creates a new Rust package named <project>.
StaticPromptSuggestion {
name: "CARGO_NEW_PROJECT",
pattern: r"^cargo\s+new\s+(\S+)\s*$",
label_template: Some("Help me start a Rust project for {1}"),
query_template:
"Help me start a Rust project for {1}. Ask me for all the details you need.",
},
// poetry new <project>: Creates a new Poetry-based Python project named <project>.
StaticPromptSuggestion {
name: "POETRY_NEW_PROJECT",
pattern: r"^poetry\s+new\s+(\S+)\s*$",
label_template: Some("Help me start a Poetry project for {1}"),
query_template:
"Help me start a Poetry project for {1}. Ask me for all the details you need.",
},
// django-admin startproject <project>: Creates a new Django project named <project>.
StaticPromptSuggestion {
name: "DJANGO_START_PROJECT",
pattern: r"^django-admin\s+startproject\s+(\S+)\s*$",
label_template: Some("Help me start a Django project for {1}"),
query_template:
"Help me start a Django project for {1}. Ask me for all the details you need.",
},
// rails new <app>: Creates a new Rails app named <app>.
StaticPromptSuggestion {
name: "RAILS_NEW_APP",
pattern: r"^rails\s+new\s+(\S+)\s*$",
label_template: Some("Help me start a Rails app for {1}"),
query_template: "Help me start a Rails app for {1}. Ask me for all the details you need.",
},
// gradle init / mvn archetype:generate: Initializes a Gradle or Maven project.
StaticPromptSuggestion {
name: "JAVA_PROJECT_INIT",
pattern: r"^(gradle\s+init|mvn\s+archetype:generate)\s*$",
label_template: Some("Help me start a Gradle/Maven project"),
query_template:
"Help me start a Gradle/Maven project. Ask me for all the details you need.",
},
// go mod init <module>: Initializes a new Go module named <module>.
StaticPromptSuggestion {
name: "GO_MOD_INIT",
pattern: r"^go\s+mod\s+init\s+(\S+)\s*$",
label_template: Some("Help me start a Go project for {1}"),
query_template: "Help me start a Go project for {1}. Ask me for all the details you need.",
},
// swift package init: Initializes a new Swift package.
StaticPromptSuggestion {
name: "SWIFT_PACKAGE_INIT",
pattern: r"^swift\s+package\s+init\s*$",
label_template: Some("Help me start a Swift project"),
query_template: "Help me start a Swift project. Ask me for all the details you need.",
},
// terraform init: Initializes Terraform in the current directory.
StaticPromptSuggestion {
name: "TERRAFORM_INIT",
pattern: r"^terraform\s+init\s*$",
label_template: Some("Help me start a Terraform configuration"),
query_template:
"Help me start a Terraform configuration. Ask me for all the details you need.",
},
// prisma init: Initializes Prisma in the current project.
StaticPromptSuggestion {
name: "PRISMA_INIT",
pattern: r"^prisma\s+init\s*$",
label_template: Some("Help me set up Prisma in this project"),
query_template: "Help me set up Prisma in this project.",
},
// python -m venv <env_name>: Creates a new Python virtual environment named <env_name>.
StaticPromptSuggestion {
name: "PYTHON_CREATE_VENV",
pattern: r"^python\s+-m\s+venv\s+(\S+)\s*$",
label_template: None,
query_template: "Help me install dependencies for {1}.",
},
// bundle init: Creates a new Gemfile (Ruby Bundler).
StaticPromptSuggestion {
name: "BUNDLE_INIT",
pattern: r"^bundle\s+init\s*$",
label_template: Some("Help me set up a new Ruby project"),
query_template: "Help me set up a new Ruby project. Ask me for all the details you need.",
},
// ollama pull <model>: Pulls an Ollama model named <model>.
StaticPromptSuggestion {
name: "OLLAMA_PULL_MODEL",
pattern: r"^ollama\s+pull\s+(\S+)\s*$",
label_template: None,
query_template: "Help me set up a Modelfile for {1}.",
},
// kubectl top nodes: Shows node resource usage in Kubernetes.
StaticPromptSuggestion {
name: "KUBECTL_TOP_NODES",
pattern: r"^kubectl\s+top\s+(nodes|node|no)\s*$",
label_template: None,
query_template: "Help me understand resource utilization in my cluster.",
},
// kubectl top pods: Shows pod resource usage in Kubernetes.
StaticPromptSuggestion {
name: "KUBECTL_TOP_PODS",
pattern: r"^kubectl\s+top\s+(pods|po|pod)\s*$",
label_template: None,
query_template: "Help me understand resource utilization in my cluster.",
},
// kubectl get...: Gets Kubernetes resources (any).
StaticPromptSuggestion {
name: "KUBECTL_GET_RESOURCES",
pattern: r"^kubectl\s+get.*$",
label_template: None,
query_template: "Help me inspect Kubernetes resources.",
},
// docker ps: Lists Docker containers.
StaticPromptSuggestion {
name: "DOCKER_LIST_CONTAINERS",
pattern: r"^docker\s+ps\s*$",
label_template: None,
query_template: "Help me manage running containers.",
},
// docker image ls: Lists Docker images.
StaticPromptSuggestion {
name: "DOCKER_LIST_IMAGES",
pattern: r"^docker\s+image\s+ls\s*$",
label_template: None,
query_template: "Help me manage Docker images.",
},
// docker-compose up -d <service>: Spins up a service <service> in Docker Compose.
StaticPromptSuggestion {
name: "DOCKER_COMPOSE_UP_SERVICE",
pattern: r"^docker-compose\s+up\s+-d\s+(\S+)\s*$",
label_template: Some("Help me manage or troubleshoot {1} with Docker Compose"),
query_template: "Help me manage or troubleshoot {1} with Docker Compose.",
},
// docker network create <network>: Creates a Docker network named <network>.
StaticPromptSuggestion {
name: "DOCKER_NETWORK_CREATE",
pattern: r"^docker\s+network\s+create\s+(\S+)\s*$",
label_template: None,
query_template: "Help me configure containers to use {1}.",
},
// vagrant init <box>: Initializes a Vagrant box named <box>.
StaticPromptSuggestion {
name: "VAGRANT_INIT_BOX",
pattern: r"^vagrant\s+init\s+(\S+)\s*$",
label_template: None,
query_template: "Help me set up or customize a Vagrant box {1}.",
},
// vagrant up: Brings up a Vagrant environment.
StaticPromptSuggestion {
name: "VAGRANT_UP",
pattern: r"^vagrant\s+up\s*$",
label_template: None,
query_template: "Help me provision my environment or troubleshoot Vagrant startup.",
},
// grep -r <pattern>: Searches recursively for <pattern> in files.
StaticPromptSuggestion {
// Capture everything after `grep -r ` into capture group 1.
name: "GREP_RECURSIVE_SEARCH",
pattern: r"^grep\s+-r\s+(.*)$",
label_template: None,
query_template: "Help me search code across files for {1}.",
},
// find <args>: Searches for files/directories using `find`.
StaticPromptSuggestion {
// Capture everything after `find ` into capture group 1.
// E.g. `find . -name "*.rs"`.
name: "FIND_FILES",
pattern: r"^find\s+(.*)$",
label_template: None,
query_template: "Help me search code across files with {1}.",
},
// ssh-keygen (no args): Generates an SSH key with default options.
StaticPromptSuggestion {
// This pattern matches "ssh-keygen" by itself or anything after it (e.g. "-t rsa -b 4096").
name: "SSH_KEYGEN",
pattern: r"^ssh-keygen(?:\s+(.*))?$",
// We’ll keep the label/query generic so it applies whether or not the user passed extra flags.
// Not using the capture group here, but it's there if we need it for the future.
label_template: None,
query_template: "Walk me through generating an SSH key.",
},
];
pub fn apply_captures(template: &str, captures: &regex::Captures) -> String {
// We'll look for placeholders of the form `{1}`, `{2}`, etc. and replace them with the
// corresponding capture group.
let mut result = String::from(template);
for i in 1..captures.len() {
let placeholder = format!("{{{i}}}");
if let Some(m) = captures.get(i) {
result = result.replace(&placeholder, m.as_str());
}
}
result
}