Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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: ®ex::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
|
||||
}
|
||||
Reference in New Issue
Block a user