Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//! This module contains all auxiliary code for hitting the GenerateAIInputSuggestions endpoint in
|
||||
//! warp-server that serves Agent Predict.
|
||||
|
||||
mod api;
|
||||
|
||||
pub use api::*;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
|
||||
use crate::terminal::model::block::BlockState;
|
||||
use crate::terminal::TerminalModel;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandContext {
|
||||
pub pwd: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub exit_code: i64,
|
||||
}
|
||||
/// Used for AI-powered input suggestions (next action prediction). We
|
||||
/// pass relevant context per block to AI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextMessageInput {
|
||||
pub input: String,
|
||||
pub output: String,
|
||||
pub context: CommandContext,
|
||||
}
|
||||
|
||||
/// A section of command history that can be used as context for intelligent autosuggestions.
|
||||
/// The context includes a consecutive sequence of commands in a session (previous_commands) and the next_command,
|
||||
/// previous_commands can be similar to the user's recently run commands, and next_command can be used
|
||||
/// to predict the next command.
|
||||
#[derive(Clone)]
|
||||
pub struct HistoryContext {
|
||||
pub previous_commands: Vec<crate::persistence::model::Command>,
|
||||
pub next_command: crate::persistence::model::Command,
|
||||
}
|
||||
|
||||
/// Context needed for a Next Command suggestion that remains constant regardless of what
|
||||
/// the user has typed in the input. This is reused as the user edits the input and generates new suggestions.
|
||||
#[derive(Clone)]
|
||||
pub struct NextCommandContext {
|
||||
pub history_contexts: Vec<HistoryContext>,
|
||||
pub ai_execution_context: WarpAiExecutionContext,
|
||||
pub context_messages: Vec<ContextMessageInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CommandWithContext {
|
||||
pub command: String,
|
||||
pub pwd: Option<String>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub git_branch: Option<String>,
|
||||
pub start_ts: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl From<&crate::persistence::model::Command> for CommandWithContext {
|
||||
fn from(command: &crate::persistence::model::Command) -> Self {
|
||||
CommandWithContext {
|
||||
command: command.command.clone(),
|
||||
pwd: command.pwd.clone(),
|
||||
exit_code: command.exit_code,
|
||||
git_branch: command.git_branch.clone(),
|
||||
start_ts: command.start_ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryContext {
|
||||
pub fn to_context_string(&self) -> String {
|
||||
self.previous_commands
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.next_command))
|
||||
.map(CommandWithContext::from)
|
||||
.filter_map(|command_with_context| serde_json::to_string(&command_with_context).ok())
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Same as to_context_string, but skips any commands up to and including the specified ID.
|
||||
// Returns a tuple of the resulting string and a boolean indicating whether any commands were skipped.
|
||||
pub fn to_context_string_skip_before_command_id(&self, id: i32) -> (String, bool) {
|
||||
let mut command_strings = Vec::with_capacity(self.previous_commands.len());
|
||||
let mut skipped = false;
|
||||
for command in self
|
||||
.previous_commands
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.next_command))
|
||||
{
|
||||
// Skip all commands up to the specified ID
|
||||
if command.id == id {
|
||||
command_strings.clear();
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
let command_with_context = CommandWithContext {
|
||||
command: command.command.clone(),
|
||||
pwd: command.pwd.clone(),
|
||||
exit_code: command.exit_code,
|
||||
git_branch: command.git_branch.clone(),
|
||||
start_ts: command.start_ts,
|
||||
};
|
||||
if let Ok(command_string) = serde_json::to_string(&command_with_context) {
|
||||
command_strings.push(command_string);
|
||||
}
|
||||
}
|
||||
(command_strings.join("\n"), skipped)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the last X user-run command blocks to use as context for the AI model. Truncates blocks
|
||||
/// where relevant to top/bottom lines.
|
||||
pub fn get_context_messages(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
number_of_blocks: usize,
|
||||
number_of_top_lines_per_grid: usize,
|
||||
number_of_bottom_lines_per_grid: usize,
|
||||
) -> Vec<ContextMessageInput> {
|
||||
let model = terminal_model.lock();
|
||||
let blocks = model.block_list().blocks();
|
||||
let terminal_width = model.block_list().size().columns();
|
||||
|
||||
let filtered_blocks: Vec<_> = blocks
|
||||
.iter()
|
||||
.filter(|block| {
|
||||
block.state() == BlockState::DoneWithExecution && !block.is_in_band_command_block()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let last_blocks = if filtered_blocks.len() > number_of_blocks {
|
||||
&filtered_blocks[filtered_blocks.len() - number_of_blocks..]
|
||||
} else {
|
||||
&filtered_blocks[..]
|
||||
};
|
||||
|
||||
last_blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
let (processed_input, processed_output) = block.get_block_content_summary(
|
||||
terminal_width,
|
||||
number_of_top_lines_per_grid,
|
||||
number_of_bottom_lines_per_grid,
|
||||
);
|
||||
|
||||
ContextMessageInput {
|
||||
input: processed_input,
|
||||
output: processed_output,
|
||||
context: CommandContext {
|
||||
exit_code: block.exit_code().value() as i64,
|
||||
pwd: block.pwd().cloned(),
|
||||
git_branch: block.git_branch().cloned(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ContextMessageInput>>()
|
||||
}
|
||||
|
||||
pub fn convert_context_messages_to_strings(
|
||||
context_messages: Vec<ContextMessageInput>,
|
||||
) -> Vec<String> {
|
||||
context_messages
|
||||
.iter()
|
||||
.filter_map(|context_message| serde_json::to_string(context_message).ok())
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// Merges history contexts that might be overlapping into a string of commands.
|
||||
/// History contexts should be sorted from oldest to newest.
|
||||
/// See generate_ai_input_suggestions_test.rs for an example.
|
||||
fn merge_history_contexts_to_string(history_contexts: Vec<HistoryContext>) -> String {
|
||||
if history_contexts.is_empty() {
|
||||
return "".to_string();
|
||||
}
|
||||
let mut last_command_id = history_contexts[0].next_command.id;
|
||||
let mut final_string_lines = Vec::with_capacity(history_contexts.len());
|
||||
final_string_lines.push(history_contexts[0].to_context_string());
|
||||
for history_context in history_contexts.iter().skip(1) {
|
||||
let (lines, is_overlapping) =
|
||||
history_context.to_context_string_skip_before_command_id(last_command_id);
|
||||
if !is_overlapping {
|
||||
final_string_lines.push("...".to_owned());
|
||||
}
|
||||
final_string_lines.push(lines);
|
||||
last_command_id = history_context.next_command.id;
|
||||
}
|
||||
final_string_lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn create_generate_ai_input_suggestions_request(
|
||||
next_command_context: NextCommandContext,
|
||||
prefix: Option<String>,
|
||||
block_context: Option<Box<crate::ai::block_context::BlockContext>>,
|
||||
previous_result: Option<crate::terminal::input::IntelligentAutosuggestionResult>,
|
||||
) -> GenerateAIInputSuggestionsRequest {
|
||||
GenerateAIInputSuggestionsRequest {
|
||||
context_messages: convert_context_messages_to_strings(
|
||||
next_command_context.context_messages,
|
||||
),
|
||||
system_context: next_command_context.ai_execution_context.to_json_string(),
|
||||
history_context: merge_history_contexts_to_string(next_command_context.history_contexts),
|
||||
rejected_suggestions: vec![],
|
||||
prefix,
|
||||
block_context,
|
||||
previous_result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "generate_ai_input_suggestions_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,13 @@
|
||||
//! This module contains Rust types for the GenerateAIInputSuggestions endpoint in warp-server that
|
||||
//! serves Agent Mode.
|
||||
//!
|
||||
//! These types are manually transposed from the API schema defined in go
|
||||
//! (warp-server/model/types/generate_ai_input_suggestions/(request.go|response.go|common.go)).
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Rust version of `GenerateAIInputSuggestionsRequest` and its fields.
|
||||
//!
|
||||
//! These types correspond to the warp-go types defined in
|
||||
//! warp-server/model/types/generate_ai_input_suggestions/request.go.
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ai::block_context::BlockContext;
|
||||
use crate::terminal::input::IntelligentAutosuggestionResult;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
/// Top-level request type for the `GenerateAIInputSuggestions` API endpoint.
|
||||
pub struct GenerateAIInputSuggestionsRequest {
|
||||
/// The previous blocks that were run in the session. Each item in the array is expected to correspond to 1 block.
|
||||
/// TODO(advait): we've purposely switched over to a free-form string for faster iteration here. We should
|
||||
/// switch back to strongly typed fields, once we've figured out the right schema/context/API for this.
|
||||
pub context_messages: Vec<String>,
|
||||
|
||||
/// Relevant command history we've found that can inform the next command.
|
||||
/// TODO(roland): we've purposely switched over to a free-form string for faster iteration here. We should
|
||||
/// switch back to strongly typed fields, once we've figured out the right schema/context/API for this.
|
||||
pub history_context: String,
|
||||
|
||||
/// System/platform relevant context for system prompt.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_context: Option<String>,
|
||||
|
||||
/// Earlier suggestions that the user did not accept.
|
||||
/// If this is populated, it means the user wanted to generate new suggestions,
|
||||
/// and we should not show them the same suggestions again.
|
||||
pub rejected_suggestions: Vec<String>,
|
||||
|
||||
/// The prefix that the user has already typed in the terminal input.
|
||||
pub prefix: Option<String>,
|
||||
|
||||
/// Context about the just-completed block.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub block_context: Option<Box<BlockContext>>,
|
||||
|
||||
/// The autosuggestion result from the previous next-command prediction, if any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_result: Option<IntelligentAutosuggestionResult>,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Rust version of `GenerateAIInputSuggestionsResponse` and its fields.
|
||||
//!
|
||||
//! These types correspond to the warp-go types defined in
|
||||
//! warp-server/model/types/generate_ai_input_suggestions/response.go.
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentModeSuggestionV2 {
|
||||
pub query: String,
|
||||
pub context_block_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Top-level response type for the `GenerateAIInputSuggestions` API endpoint.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GenerateAIInputSuggestionsResponseV2 {
|
||||
pub commands: Vec<String>,
|
||||
pub ai_queries: Vec<AgentModeSuggestionV2>,
|
||||
pub most_likely_action: String,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::*;
|
||||
use crate::persistence::model::Command;
|
||||
use crate::terminal::model::terminal_model::BlockIndex;
|
||||
use crate::terminal::model::test_utils::create_test_block_with_grids;
|
||||
use crate::test_util::mock_blockgrid;
|
||||
|
||||
#[test]
|
||||
fn test_merge_history_contexts_to_string_basic() {
|
||||
let mut all_commands = vec![];
|
||||
for i in 0..15 {
|
||||
all_commands.push(Command {
|
||||
id: i,
|
||||
command: format!("command {i}"),
|
||||
pwd: Some("/home/user".to_string()),
|
||||
exit_code: Some(0),
|
||||
git_branch: Some("main".to_string()),
|
||||
start_ts: None,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Create some overlapping history contexts
|
||||
let mut all_history_contexts = vec![HistoryContext {
|
||||
previous_commands: all_commands[0..3].to_vec(),
|
||||
next_command: all_commands[3].clone(),
|
||||
}];
|
||||
all_history_contexts.push(HistoryContext {
|
||||
previous_commands: all_commands[1..4].to_vec(),
|
||||
next_command: all_commands[4].clone(),
|
||||
});
|
||||
all_history_contexts.push(HistoryContext {
|
||||
previous_commands: all_commands[4..6].to_vec(),
|
||||
next_command: all_commands[6].clone(),
|
||||
});
|
||||
|
||||
// Gap here (...)
|
||||
|
||||
// These should be merged together
|
||||
all_history_contexts.push(HistoryContext {
|
||||
previous_commands: all_commands[7..8].to_vec(),
|
||||
next_command: all_commands[8].clone(),
|
||||
});
|
||||
all_history_contexts.push(HistoryContext {
|
||||
previous_commands: all_commands[8..9].to_vec(),
|
||||
next_command: all_commands[9].clone(),
|
||||
});
|
||||
|
||||
// Gap here (...)
|
||||
|
||||
all_history_contexts.push(HistoryContext {
|
||||
previous_commands: all_commands[12..14].to_vec(),
|
||||
next_command: all_commands[14].clone(),
|
||||
});
|
||||
|
||||
let result = merge_history_contexts_to_string(all_history_contexts);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
r#"{"command":"command 0","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 1","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 2","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 3","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 4","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 5","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 6","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
...
|
||||
{"command":"command 7","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 8","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 9","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
...
|
||||
{"command":"command 12","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 13","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}
|
||||
{"command":"command 14","pwd":"/home/user","exit_code":0,"git_branch":"main","start_ts":null}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_block_content_summary_extreme_narrow_vs_wide() {
|
||||
// Create blocks with many lines to see the difference in truncation
|
||||
let mut massive_input = String::new();
|
||||
for i in 0..1000 {
|
||||
massive_input.push_str(&format!("Input line {i} with some content\r\n"));
|
||||
}
|
||||
let prompt_grid = mock_blockgrid(&massive_input);
|
||||
let rprompt_grid = mock_blockgrid("");
|
||||
|
||||
let mut massive_output = String::new();
|
||||
for i in 0..1000 {
|
||||
massive_output.push_str(&format!("Output line {i} with some content\r\n"));
|
||||
}
|
||||
let output_grid = mock_blockgrid(&massive_output);
|
||||
|
||||
// Note terminal model isn't being used here (i.e. we're not creating a test model
|
||||
// and using the # of columns from it), instead we simulate this test.
|
||||
let block = create_test_block_with_grids(
|
||||
BlockIndex::zero(),
|
||||
prompt_grid,
|
||||
rprompt_grid,
|
||||
output_grid,
|
||||
false, // honor_ps1
|
||||
);
|
||||
|
||||
// Test with extremely narrow terminal
|
||||
let (input_narrow, output_narrow) = block.get_block_content_summary(
|
||||
20, // Very narrow terminal
|
||||
75, // Top lines
|
||||
25, // Bottom lines
|
||||
);
|
||||
|
||||
// Test with wide terminal
|
||||
let (input_wide, output_wide) = block.get_block_content_summary(
|
||||
200, // Wide terminal
|
||||
75, // Top lines
|
||||
25, // Bottom lines
|
||||
);
|
||||
|
||||
// Narrow terminal - top should have 562 lines and bottom should have 188 lines.
|
||||
|
||||
assert!(input_narrow.contains("Input line 561"));
|
||||
assert!(!input_narrow.contains("Input line 562"));
|
||||
assert!(!input_narrow.contains("Input line 812"));
|
||||
assert!(input_narrow.contains("Input line 813"));
|
||||
|
||||
assert!(output_narrow.contains("Output line 561"));
|
||||
assert!(!output_narrow.contains("Output line 562"));
|
||||
assert!(!output_narrow.contains("Output line 812"));
|
||||
assert!(output_narrow.contains("Output line 813"));
|
||||
|
||||
// Wide terminal - top should have 75 lines and bottom should have 25 lines.
|
||||
assert!(input_wide.contains("Input line 74"));
|
||||
assert!(!input_wide.contains("Input line 75"));
|
||||
assert!(!input_wide.contains("Input line 974"));
|
||||
assert!(input_wide.contains("Input line 975"));
|
||||
|
||||
assert!(output_wide.contains("Output line 74"));
|
||||
assert!(!output_wide.contains("Output line 75"));
|
||||
assert!(!output_wide.contains("Output line 974"));
|
||||
assert!(output_wide.contains("Output line 975"));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! This module contains all auxiliary code for hitting the GenerateAMQuerySuggestions endpoint in
|
||||
//! warp-server that serves Agent Predict.
|
||||
|
||||
mod api;
|
||||
|
||||
pub use api::*;
|
||||
@@ -0,0 +1,13 @@
|
||||
//! This module contains Rust types for the GenerateAMQuerySuggestions endpoint in warp-server that
|
||||
//! serves Agent Mode.
|
||||
//!
|
||||
//! These types are manually transposed from the API schema defined in go
|
||||
//! (warp-server/model/types/generate_am_query_suggestions/(request.go|response.go|common.go)).
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Rust version of `GenerateAMQuerySuggestions` and its fields.
|
||||
//!
|
||||
//! These types correspond to the warp-go types defined in
|
||||
//! warp-server/model/types/generate_am_query_suggestions/request.go.
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
/// Top-level request type for the `GenerateAMQuerySuggestion` API endpoint.
|
||||
pub struct GenerateAMQuerySuggestionsRequest {
|
||||
/// The previous blocks that were run in the session. Each item in the array is expected to correspond to 1 block.
|
||||
/// TODO(advait): we've purposely switched over to a free-form string for faster iteration here. We should
|
||||
/// switch back to strongly typed fields, once we've figured out the right schema/context/API for this.
|
||||
pub context_messages: Vec<String>,
|
||||
|
||||
/// System/platform relevant context for system prompt.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_context: Option<String>,
|
||||
|
||||
/// Exit code for the command run.
|
||||
pub exit_code: i32,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Rust version of `GenerateAMQuerySuggestionsResponse` and its fields.
|
||||
//!
|
||||
//! These types correspond to the warp-go types defined in
|
||||
//! warp-server/model/types/generate_am_query_suggestions/response.go.
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ai::agent::FileLocations;
|
||||
|
||||
/// Top-level response type for the `GenerateAMQuerySuggestions` API endpoint.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GenerateAMQuerySuggestionsResponse {
|
||||
pub id: String,
|
||||
pub suggestion: Option<Suggestion>,
|
||||
}
|
||||
|
||||
/// Represents a particular type of suggestion.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Suggestion {
|
||||
Simple(SimpleQuery),
|
||||
Coding(CodingQuery),
|
||||
}
|
||||
|
||||
/// Simple suggestion structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SimpleQuery {
|
||||
pub query: String,
|
||||
/// If the query is a complex task, we should use Dispatch to create a plan first.
|
||||
pub should_plan_task: bool,
|
||||
}
|
||||
|
||||
impl GenerateAMQuerySuggestionsResponse {
|
||||
/// Check whether the response contains a valid code delegation, which should include:
|
||||
/// 1) the coding_query field is non-empty.
|
||||
/// 2) the attached file locations to the coding_query is non-empty.
|
||||
pub fn is_valid_code_delegation(&self) -> bool {
|
||||
matches!(&self.suggestion, Some(Suggestion::Coding(coding_query)) if !coding_query.files.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CodingQuery {
|
||||
pub files: Vec<GeneratedFileLocations>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GeneratedFileLocations {
|
||||
pub file_name: String,
|
||||
pub line_numbers: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl From<GeneratedFileLocations> for FileLocations {
|
||||
fn from(value: GeneratedFileLocations) -> Self {
|
||||
Self {
|
||||
name: value.file_name,
|
||||
// We are explicitly disgarding the line_numbers right now.
|
||||
// TODO(kevin): Convert them into Range<usize>.
|
||||
lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! This module contains all code relevant to Agent Predict within Warp.
|
||||
//!
|
||||
//! Agent Predict attempts to predict the next action the user will take in Warp.
|
||||
|
||||
pub(crate) mod generate_ai_input_suggestions;
|
||||
pub(crate) mod generate_am_query_suggestions;
|
||||
pub mod next_command_model;
|
||||
pub(crate) mod predict_am_queries;
|
||||
pub mod prompt_suggestions;
|
||||
@@ -0,0 +1,832 @@
|
||||
use crate::ai::block_context::BlockContext;
|
||||
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
|
||||
use crate::completer::SessionContext;
|
||||
use crate::report_error;
|
||||
use crate::server::server_api::{AIApiError, ServerApi};
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::event::UserBlockCompleted;
|
||||
use crate::terminal::input::{CompleterData, IntelligentAutosuggestionResult};
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::{History, HistoryEntry, TerminalModel};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use chrono::Utc;
|
||||
use futures::stream::AbortHandle;
|
||||
use itertools::Itertools;
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_imports))]
|
||||
use parking_lot::{FairMutex, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::time::Duration;
|
||||
use warp_completer::completer::{
|
||||
self, expand_command_aliases, AliasExpansionResult, CompleterOptions,
|
||||
CompletionsFallbackStrategy, MatchStrategy,
|
||||
};
|
||||
use warp_completer::meta::Spanned;
|
||||
use warp_completer::parsers::hir::{Command, Expression, FlagType};
|
||||
use warp_completer::parsers::ParsedExpression;
|
||||
use warp_core::features::FeatureFlag;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use warpui::r#async::FutureExt;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::generate_ai_input_suggestions::{
|
||||
create_generate_ai_input_suggestions_request, get_context_messages,
|
||||
GenerateAIInputSuggestionsRequest, GenerateAIInputSuggestionsResponseV2, NextCommandContext,
|
||||
};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use diesel::SqliteConnection;
|
||||
use std::path::PathBuf;
|
||||
use warp_completer::parsers::hir::ArgType;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
const MAX_NUM_SIMILAR_HISTORY_CONTEXT: usize = 25;
|
||||
|
||||
/// The number of additional preceding commands for each HistoryContext
|
||||
/// included in the LLM request.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
const NUM_ADDITIONAL_PREV_COMMAND_CONTEXT_LLM: usize = 2;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
const ARG_GENERATOR_VALIDATION_TIMEOUT: Duration = Duration::from_millis(150);
|
||||
|
||||
pub fn is_next_command_enabled(app: &warpui::AppContext) -> bool {
|
||||
AISettings::as_ref(app).is_intelligent_autosuggestions_enabled(app)
|
||||
&& UserWorkspaces::as_ref(app).is_next_command_enabled()
|
||||
}
|
||||
|
||||
/// Information about an autosuggestion that would have been made if purely based off history.
|
||||
/// If there was no history, history_command_prediction would be an empty string.
|
||||
#[derive(Clone, Default, PartialEq, Debug)]
|
||||
pub struct HistoryBasedAutosuggestionState {
|
||||
pub history_command_prediction: String,
|
||||
pub history_command_prediction_likelihood: f64,
|
||||
pub total_history_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub enum NextCommandSuggestionState {
|
||||
#[default]
|
||||
None,
|
||||
Cycling,
|
||||
Ready {
|
||||
request: Box<GenerateAIInputSuggestionsRequest>,
|
||||
response: GenerateAIInputSuggestionsResponseV2,
|
||||
/// How long the request took to complete, in milliseconds.
|
||||
request_duration_ms: i64,
|
||||
/// If true, we made a call to an LLM to generate this.
|
||||
/// Otherwise it came from history.
|
||||
is_from_ai: bool,
|
||||
/// If true, this suggestion came from the user explicitly cycling (is not the initial suggestion).
|
||||
is_from_cycle: bool,
|
||||
history_based_autosuggestion_state: HistoryBasedAutosuggestionState,
|
||||
},
|
||||
}
|
||||
|
||||
impl NextCommandSuggestionState {
|
||||
pub fn is_ready(&self) -> bool {
|
||||
matches!(self, NextCommandSuggestionState::Ready { .. })
|
||||
}
|
||||
|
||||
pub fn is_cycling(&self) -> bool {
|
||||
matches!(self, NextCommandSuggestionState::Cycling)
|
||||
}
|
||||
|
||||
pub fn command_suggestion(&self) -> Option<&str> {
|
||||
match &self {
|
||||
// The server only returns one command suggestion as the most likely action.
|
||||
NextCommandSuggestionState::Ready { response, .. } => {
|
||||
let command = &response.most_likely_action;
|
||||
// If AI accidentally returned JSON instead of a plain string for the most likely action, don't use it.
|
||||
if command.starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
Some(command)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Struct storing the result of the zero-state next command suggestion,
|
||||
/// used for telemetry purposes.
|
||||
#[derive(Clone)]
|
||||
pub struct ZeroStateSuggestionInfo {
|
||||
pub request: Box<GenerateAIInputSuggestionsRequest>,
|
||||
pub response: GenerateAIInputSuggestionsResponseV2,
|
||||
/// How long the request took to complete, in milliseconds.
|
||||
pub request_duration_ms: i64,
|
||||
/// If true, we made a call to an LLM to generate this.
|
||||
/// Otherwise it came from history.
|
||||
pub is_from_ai: bool,
|
||||
pub history_based_autosuggestion_state: HistoryBasedAutosuggestionState,
|
||||
}
|
||||
|
||||
pub struct NextCommandModel {
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
server_api: Arc<ServerApi>,
|
||||
#[cfg(feature = "local_fs")]
|
||||
conn: Option<Arc<Mutex<SqliteConnection>>>,
|
||||
|
||||
next_command_state: NextCommandSuggestionState,
|
||||
/// Context used to generate the zero-state suggestion.
|
||||
/// We reuse this but apply filtering on history_contexts as the user edits the input.
|
||||
cached_zerostate_next_command_context: Option<NextCommandContext>,
|
||||
zerostate_suggestion_info: Option<ZeroStateSuggestionInfo>,
|
||||
next_command_abort_handle: Option<AbortHandle>,
|
||||
}
|
||||
|
||||
impl Entity for NextCommandModel {
|
||||
type Event = NextCommandModelEvent;
|
||||
}
|
||||
|
||||
pub enum NextCommandModelEvent {
|
||||
NextCommandSuggestionReady,
|
||||
}
|
||||
|
||||
impl NextCommandModel {
|
||||
pub fn new(
|
||||
sessions: ModelHandle<Sessions>,
|
||||
model: Arc<FairMutex<TerminalModel>>,
|
||||
server_api: Arc<ServerApi>,
|
||||
) -> Self {
|
||||
#[cfg(feature = "local_fs")]
|
||||
let conn = crate::persistence::database_file_path()
|
||||
.to_str()
|
||||
.and_then(|db_url| {
|
||||
crate::persistence::establish_ro_connection(db_url)
|
||||
.ok()
|
||||
.map(|conn| Arc::new(Mutex::new(conn)))
|
||||
});
|
||||
Self {
|
||||
sessions,
|
||||
model,
|
||||
server_api,
|
||||
#[cfg(feature = "local_fs")]
|
||||
conn,
|
||||
next_command_state: NextCommandSuggestionState::None,
|
||||
cached_zerostate_next_command_context: None,
|
||||
zerostate_suggestion_info: None,
|
||||
next_command_abort_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns snippets of command history (HistoryContext) that are similar to the completed_block.
|
||||
/// Each HistoryContext contains some sequential commands run in the same session,
|
||||
/// where the last element of HistoryContext.previous_commands is the same as completed_block.
|
||||
/// Returns None if there was a connection issue, and Some(empty vec)
|
||||
/// if there is no similar historical context.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn get_similar_history_context(
|
||||
conn: &mut SqliteConnection,
|
||||
completed_block: &UserBlockCompleted,
|
||||
num_additional_preceding_commands: usize,
|
||||
) -> Vec<crate::ai::predict::generate_ai_input_suggestions::HistoryContext> {
|
||||
// The number of commands from history affects how quickly we "learn" new patterns, the lower the faster.
|
||||
let Ok(same_commands_from_history) =
|
||||
crate::persistence::commands::get_same_commands_from_history(
|
||||
conn,
|
||||
completed_block,
|
||||
MAX_NUM_SIMILAR_HISTORY_CONTEXT,
|
||||
)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
// Iterate from oldest to newest
|
||||
same_commands_from_history
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|command| {
|
||||
let next_command =
|
||||
crate::persistence::commands::get_next_command(conn, &command).ok()?;
|
||||
if num_additional_preceding_commands == 0 {
|
||||
return Some(
|
||||
crate::ai::predict::generate_ai_input_suggestions::HistoryContext {
|
||||
previous_commands: vec![command],
|
||||
next_command,
|
||||
},
|
||||
);
|
||||
}
|
||||
// We know next_command comes after command.
|
||||
// Get some more commands that came before command so there's additional context before next_command.
|
||||
let mut previous_commands = crate::persistence::commands::get_previous_commands(
|
||||
conn,
|
||||
&command,
|
||||
num_additional_preceding_commands,
|
||||
)
|
||||
.ok()?;
|
||||
previous_commands.push(command);
|
||||
Some(
|
||||
crate::ai::predict::generate_ai_input_suggestions::HistoryContext {
|
||||
previous_commands,
|
||||
next_command,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> &NextCommandSuggestionState {
|
||||
&self.next_command_state
|
||||
}
|
||||
|
||||
pub fn get_zero_state_suggestion_info(&self) -> Option<&ZeroStateSuggestionInfo> {
|
||||
self.zerostate_suggestion_info.as_ref()
|
||||
}
|
||||
|
||||
pub fn clear_state(&mut self) {
|
||||
self.next_command_state = NextCommandSuggestionState::None;
|
||||
self.cached_zerostate_next_command_context = None;
|
||||
self.zerostate_suggestion_info = None;
|
||||
self.abort_inflight_request();
|
||||
}
|
||||
|
||||
pub fn abort_inflight_request(&mut self) {
|
||||
if let Some(abort_handle) = self.next_command_abort_handle.take() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_next_command_suggestion(&mut self, _ctx: &mut ModelContext<Self>) {
|
||||
// TODO(roland): reconsider down arrow UX for next command or remove completely
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
fn get_next_command_context(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
#[cfg(feature = "local_fs")] conn: Option<Arc<Mutex<SqliteConnection>>>,
|
||||
ai_execution_context: WarpAiExecutionContext,
|
||||
block_completed: &UserBlockCompleted,
|
||||
) -> NextCommandContext {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_mut))]
|
||||
let mut history_contexts = vec![];
|
||||
let context_messages = get_context_messages(terminal_model.clone(), 5, 100, 200);
|
||||
#[cfg(feature = "local_fs")]
|
||||
if let Some(conn) = conn {
|
||||
let mut conn = conn.lock();
|
||||
history_contexts = Self::get_similar_history_context(
|
||||
&mut conn,
|
||||
block_completed,
|
||||
NUM_ADDITIONAL_PREV_COMMAND_CONTEXT_LLM,
|
||||
);
|
||||
}
|
||||
NextCommandContext {
|
||||
history_contexts,
|
||||
ai_execution_context,
|
||||
context_messages,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a zero-state next command suggestion immediately after a block completes.
|
||||
pub fn generate_next_command_suggestion(
|
||||
&mut self,
|
||||
block_completed: UserBlockCompleted,
|
||||
context: WarpAiExecutionContext,
|
||||
completer_data: CompleterData,
|
||||
block_context: Option<Box<BlockContext>>,
|
||||
previous_result: Option<IntelligentAutosuggestionResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Clear the cached next command context so we don't use stale data.
|
||||
self.cached_zerostate_next_command_context = None;
|
||||
self.zerostate_suggestion_info = None;
|
||||
self.generate_next_command_suggestion_with_prefix(
|
||||
None,
|
||||
block_completed,
|
||||
context,
|
||||
completer_data,
|
||||
block_context,
|
||||
previous_result,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the most recent command with a matching prefix run in the user's current working directory.
|
||||
/// If no such command exists, returns the most recent command anywhere with a matching prefix.
|
||||
pub fn get_reverse_chronological_potential_autosuggestions(
|
||||
prefix: &str,
|
||||
completer_data: &CompleterData,
|
||||
app: &AppContext,
|
||||
) -> Option<Vec<HistoryEntry>> {
|
||||
let session_id = completer_data.active_block_session_id()?;
|
||||
let history_entries = History::as_ref(app).commands(session_id)?;
|
||||
let working_dir = completer_data
|
||||
.active_block_metadata
|
||||
.as_ref()
|
||||
.and_then(|block_metadata| block_metadata.current_working_directory());
|
||||
Some(find_potential_autosuggestions_from_history(
|
||||
history_entries.into_iter(),
|
||||
prefix,
|
||||
working_dir,
|
||||
))
|
||||
}
|
||||
|
||||
/// Generates a next command suggestion with a prefix in the input.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub fn generate_next_command_suggestion_with_prefix(
|
||||
&mut self,
|
||||
prefix: Option<String>,
|
||||
block_completed: UserBlockCompleted,
|
||||
context: WarpAiExecutionContext,
|
||||
completer_data: CompleterData,
|
||||
block_context: Option<Box<BlockContext>>,
|
||||
previous_result: Option<IntelligentAutosuggestionResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = self.server_api.clone();
|
||||
let terminal_model = self.model.clone();
|
||||
let cached_next_command_context = self.cached_zerostate_next_command_context.clone();
|
||||
|
||||
let completion_context = completer_data.completion_session_context(ctx);
|
||||
// This is only needed if we have a prefix.
|
||||
let reverse_chronological_potential_autosuggestions = if let Some(prefix) = &prefix {
|
||||
Self::get_reverse_chronological_potential_autosuggestions(prefix, &completer_data, ctx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
let conn = self.conn.clone();
|
||||
self.next_command_state = NextCommandSuggestionState::None;
|
||||
self.abort_inflight_request();
|
||||
let session_env_vars = completer_data
|
||||
.active_block_session_id()
|
||||
.and_then(|session_id| {
|
||||
self.sessions.read(ctx, |sessions, _| {
|
||||
sessions.get_env_vars_for_session(session_id)
|
||||
})
|
||||
});
|
||||
self.next_command_abort_handle = Some(
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let mut history_based_autosuggestion_state =
|
||||
HistoryBasedAutosuggestionState::default();
|
||||
let start_ts_ms = Utc::now().timestamp_millis();
|
||||
|
||||
let mut next_command_context =
|
||||
if let Some(cached_next_command_context) = cached_next_command_context {
|
||||
cached_next_command_context
|
||||
} else {
|
||||
Self::get_next_command_context(
|
||||
terminal_model,
|
||||
#[cfg(feature = "local_fs")]
|
||||
conn,
|
||||
context,
|
||||
&block_completed,
|
||||
)
|
||||
};
|
||||
// Filter history contexts for only cases where the next command matches our prefix.
|
||||
if let Some(prefix) = &prefix {
|
||||
next_command_context.history_contexts = next_command_context
|
||||
.history_contexts
|
||||
.into_iter()
|
||||
.filter(|context| context.next_command.command.starts_with(prefix))
|
||||
.collect_vec();
|
||||
}
|
||||
// First, use rich history to find commands with a matching prefix that were run
|
||||
// in a similar context, taking into account the most recent block run.
|
||||
if !next_command_context.history_contexts.is_empty() {
|
||||
let mut history_next_command_counts = counter::Counter::new();
|
||||
for history_context in &next_command_context.history_contexts {
|
||||
history_next_command_counts[&history_context.next_command.command] += 1;
|
||||
}
|
||||
|
||||
let mut total_history_count = history_next_command_counts.total::<usize>();
|
||||
let most_likely_next_commands =
|
||||
history_next_command_counts.k_most_common_ordered(5);
|
||||
for (most_likely_next_command, count) in &most_likely_next_commands {
|
||||
if !is_command_valid(most_likely_next_command, completion_context.as_ref(), session_env_vars.as_ref()).await {
|
||||
log::debug!("Discarding most likely next command from rich history that failed validation: `{most_likely_next_command}`");
|
||||
total_history_count -= *count;
|
||||
continue;
|
||||
}
|
||||
let history_command_prediction_likelihood =
|
||||
*count as f64 / total_history_count as f64;
|
||||
history_based_autosuggestion_state = HistoryBasedAutosuggestionState {
|
||||
history_command_prediction: most_likely_next_command.to_owned(),
|
||||
history_command_prediction_likelihood,
|
||||
total_history_count,
|
||||
};
|
||||
|
||||
// If one command is very likely based on history, skip the LLM call and return it directly.
|
||||
// Use history-based autosuggestion if there are a min number of similar cases in history
|
||||
// AND the same command was run at least skip_llm_confidence_threshold of the time.
|
||||
// Partial autosuggestions with a prefix have more lenient requirements
|
||||
// because latency matters more as the user is typing.
|
||||
let (min_history_count, skip_llm_confidence_threshold) =
|
||||
if prefix.is_some() {
|
||||
(1, 0.1)
|
||||
} else {
|
||||
(2, 0.25)
|
||||
};
|
||||
if total_history_count >= min_history_count
|
||||
&& history_command_prediction_likelihood
|
||||
>= skip_llm_confidence_threshold
|
||||
{
|
||||
// We construct the request even though we're not sending it to the server because
|
||||
// it might be used later for cycling next command suggestions.
|
||||
let request = create_generate_ai_input_suggestions_request(
|
||||
next_command_context.clone(),
|
||||
prefix,
|
||||
block_context,
|
||||
previous_result,
|
||||
);
|
||||
return (
|
||||
Ok(GenerateAIInputSuggestionsResponseV2 {
|
||||
commands: vec![most_likely_next_command.to_owned()],
|
||||
ai_queries: vec![],
|
||||
most_likely_action: most_likely_next_command.to_owned(),
|
||||
}),
|
||||
request,
|
||||
false,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
false,
|
||||
next_command_context,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = create_generate_ai_input_suggestions_request(
|
||||
next_command_context.clone(),
|
||||
prefix.clone(),
|
||||
block_context,
|
||||
previous_result,
|
||||
);
|
||||
|
||||
// For zero-state next command suggestions, return the result immediately.
|
||||
let Some(prefix) = prefix else {
|
||||
return (
|
||||
server_api.generate_ai_input_suggestions(&request).await,
|
||||
request,
|
||||
true,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
false,
|
||||
next_command_context,
|
||||
);
|
||||
};
|
||||
|
||||
// At this point we know we're generating a partial suggestion with a prefix.
|
||||
// First, return the most recent command with a matching prefix run in the same pwd
|
||||
// (if exists, otherwise just most recent command anywhere with matching prefix).
|
||||
for reverse_chronological_command in reverse_chronological_potential_autosuggestions.unwrap_or_default() {
|
||||
if is_command_valid(&reverse_chronological_command.command, completion_context.as_ref(), session_env_vars.as_ref()).await {
|
||||
return (
|
||||
Ok(GenerateAIInputSuggestionsResponseV2 {
|
||||
commands: vec![reverse_chronological_command.command.clone()],
|
||||
ai_queries: vec![],
|
||||
most_likely_action: reverse_chronological_command.command,
|
||||
}),
|
||||
request,
|
||||
false,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
false,
|
||||
next_command_context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no command anywhere in history with a matching prefix, fallback to the first completer result.
|
||||
if let Some(completion_context) = completion_context {
|
||||
let completion_result = completer::suggestions(
|
||||
&prefix,
|
||||
prefix.len(),
|
||||
session_env_vars.as_ref(),
|
||||
CompleterOptions {
|
||||
match_strategy: MatchStrategy::CaseSensitive,
|
||||
fallback_strategy: CompletionsFallbackStrategy::None,
|
||||
suggest_file_path_completions_only: false,
|
||||
parse_quotes_as_literals: false,
|
||||
},
|
||||
&completion_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
let autosuggestion = completion_result.and_then(|result| {
|
||||
let replacement_span = result.replacement_span;
|
||||
result.suggestions.into_iter().next().map(|s| {
|
||||
// Reproduce the final buffer text with the autosuggestion since the
|
||||
// completer only gives the replacement span of the suggestion.
|
||||
let result = format!(
|
||||
"{}{}",
|
||||
&prefix[0..replacement_span.start()],
|
||||
s.replacement()
|
||||
);
|
||||
result
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(autosuggestion) = autosuggestion {
|
||||
if is_command_valid(&autosuggestion, Some(&completion_context), session_env_vars.as_ref()).await {
|
||||
return (
|
||||
Ok(GenerateAIInputSuggestionsResponseV2 {
|
||||
commands: vec![autosuggestion.clone()],
|
||||
ai_queries: vec![],
|
||||
most_likely_action: autosuggestion,
|
||||
}),
|
||||
request,
|
||||
false,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
false,
|
||||
next_command_context,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only if we have no commands from history and no completions, use the LLM to generate a partial suggestion.
|
||||
let response = server_api.generate_ai_input_suggestions(&request).await;
|
||||
(
|
||||
response,
|
||||
request,
|
||||
true,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
false,
|
||||
next_command_context,
|
||||
)
|
||||
},
|
||||
Self::on_next_command_suggestion_result,
|
||||
)
|
||||
.abort_handle(),
|
||||
);
|
||||
}
|
||||
|
||||
fn on_next_command_suggestion_result(
|
||||
&mut self,
|
||||
result: (
|
||||
Result<GenerateAIInputSuggestionsResponseV2, AIApiError>,
|
||||
GenerateAIInputSuggestionsRequest,
|
||||
bool,
|
||||
i64,
|
||||
HistoryBasedAutosuggestionState,
|
||||
bool,
|
||||
NextCommandContext,
|
||||
),
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.next_command_abort_handle = None;
|
||||
let (
|
||||
result,
|
||||
request,
|
||||
is_from_ai,
|
||||
start_ts_ms,
|
||||
history_based_autosuggestion_state,
|
||||
is_from_cycle,
|
||||
next_command_context,
|
||||
) = result;
|
||||
let end_ts_ms = Utc::now().timestamp_millis();
|
||||
let request_duration_ms = end_ts_ms - start_ts_ms;
|
||||
if request.prefix.is_none() {
|
||||
self.cached_zerostate_next_command_context = Some(next_command_context);
|
||||
}
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if let Some(prefix) = &request.prefix {
|
||||
if !response.most_likely_action.starts_with(prefix) {
|
||||
// This is not expected to happen because the server applies its own filtering,
|
||||
// but check just in case.
|
||||
log::warn!(
|
||||
"Next command suggestion `{}` does not start with prefix `{}`.",
|
||||
response.most_likely_action,
|
||||
prefix
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
self.zerostate_suggestion_info = Some(ZeroStateSuggestionInfo {
|
||||
request: Box::new(request.clone()),
|
||||
response: response.clone(),
|
||||
request_duration_ms,
|
||||
is_from_ai,
|
||||
history_based_autosuggestion_state: history_based_autosuggestion_state
|
||||
.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.next_command_state = NextCommandSuggestionState::Ready {
|
||||
request: Box::new(request),
|
||||
response,
|
||||
request_duration_ms,
|
||||
is_from_ai,
|
||||
is_from_cycle,
|
||||
history_based_autosuggestion_state,
|
||||
};
|
||||
ctx.emit(NextCommandModelEvent::NextCommandSuggestionReady);
|
||||
}
|
||||
Err(err) => {
|
||||
report_error!(
|
||||
anyhow::anyhow!(err).context("Failed to generate Next Command suggestion")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SingletonEntity for NextCommandModel {}
|
||||
|
||||
/// Validates that the arg is valid given its type (e.g. filepath exists if it's a filepath arg).
|
||||
/// This uses a file system call, so this function should be called only in background threads.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
async fn is_arg_valid(
|
||||
full_command: &str,
|
||||
arg: &Spanned<ParsedExpression>,
|
||||
ctx: &SessionContext,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
) -> bool {
|
||||
let Expression::ValidatableArgument(arg_types_to_validate) = arg.expression() else {
|
||||
return true;
|
||||
};
|
||||
// The expression shouldn't be parsed as a `ValidatableArgument` if the arg types are empty,
|
||||
// but we check this just in case.
|
||||
if arg_types_to_validate.is_empty() {
|
||||
return true;
|
||||
}
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
// If we have arg types to validate, the arg must pass validation for at least one of them.
|
||||
// If the argument has one or more generators, validate these last because they're more expensive
|
||||
// and we can check all generators together using completions suggestions.
|
||||
let mut has_generator_arg_type = false;
|
||||
for arg_type in arg_types_to_validate {
|
||||
match arg_type {
|
||||
ArgType::File => {
|
||||
let mut path_arg = PathBuf::from(arg.value().as_str());
|
||||
if path_arg.is_relative() {
|
||||
if let Ok(working_dir) = PathBuf::try_from(ctx.current_working_directory.clone()) {
|
||||
path_arg = working_dir.join(path_arg);
|
||||
}
|
||||
}
|
||||
if path_arg.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ArgType::Folder => {
|
||||
let mut path_arg = PathBuf::from(arg.value().as_str());
|
||||
if path_arg.is_relative() {
|
||||
if let Ok(working_dir) = PathBuf::try_from(ctx.current_working_directory.clone()) {
|
||||
path_arg = working_dir.join(path_arg);
|
||||
}
|
||||
}
|
||||
if path_arg.is_dir() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ArgType::Generator(_) => {
|
||||
has_generator_arg_type = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
if has_generator_arg_type {
|
||||
// We don't have completions implemented for feature flags like --features=with_local_server.
|
||||
// If arg is the span of `with_local_server`, attempting to complete on --features= to validate it will return no results.
|
||||
// We should only use completions to validate the arg if the previous character is whitespace, until completions handles this case.
|
||||
let prev_char = full_command.get(..arg.span.start()).and_then(|s| s.chars().next_back());
|
||||
if prev_char.is_some_and(|c| !c.is_whitespace()) {
|
||||
return true;
|
||||
}
|
||||
// Running completions runs all generators, so we only need to do this once.
|
||||
// TODO(roland): this also generates completions from sources other than generators, which are unnecessary.
|
||||
// If performance becomes a concern, consider validating against generators sequentially and returning early if valid.
|
||||
// We use completions suggestions because it's simpler to implement and read.
|
||||
let completions_future = completer::suggestions(
|
||||
full_command,
|
||||
arg.span.start(),
|
||||
session_env_vars,
|
||||
CompleterOptions {
|
||||
match_strategy: MatchStrategy::CaseSensitive,
|
||||
fallback_strategy: CompletionsFallbackStrategy::None,
|
||||
suggest_file_path_completions_only: false,
|
||||
parse_quotes_as_literals: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
// If the completions call times out, assume the arg is valid.
|
||||
// This is necessary because some generators can hang (e.g. kubectl commands if the cluster isn't running).
|
||||
let Ok(completion_result) = completions_future.with_timeout(ARG_GENERATOR_VALIDATION_TIMEOUT).await else {
|
||||
log::debug!("Generator validation for arg `{}` in command `{}` timed out - assuming it's valid", arg.value().as_str(), full_command);
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(completion_result) = completion_result else {
|
||||
return true;
|
||||
};
|
||||
for suggestion in completion_result.suggestions {
|
||||
if suggestion.display() == arg.value().as_str() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we didn't pass validation for any of the possible arg types, this arg is invalid.
|
||||
log::debug!("arg `{}` in command `{}` failed validation", arg.value().as_str(), full_command);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the command is valid.
|
||||
/// Currently uses completions specs to check if parsing is successful, and validates
|
||||
/// that any filepaths args actually exist on disk.
|
||||
/// This uses a file system call, so this function should be called only in background threads.
|
||||
pub async fn is_command_valid(
|
||||
command: &str,
|
||||
ctx: Option<&SessionContext>,
|
||||
session_env_vars: Option<&HashMap<String, String>>,
|
||||
) -> bool {
|
||||
if !FeatureFlag::ValidateAutosuggestions.is_enabled() {
|
||||
return true;
|
||||
}
|
||||
let Some(ctx) = ctx else {
|
||||
return true;
|
||||
};
|
||||
let AliasExpansionResult {
|
||||
expanded_command_line,
|
||||
classified_command,
|
||||
..
|
||||
} = expand_command_aliases(command, false, ctx).await;
|
||||
|
||||
let Some(classified_command) = classified_command else {
|
||||
return true;
|
||||
};
|
||||
|
||||
// We assume the command is valid on parse error because
|
||||
// 1. Our completion specs are not always comprehensive (unknown args/options cause parse error)
|
||||
// 2. Our parsing logic has some bugs that need to be investigated (INT-816)
|
||||
if classified_command.error.is_some() {
|
||||
log::debug!(
|
||||
"Assuming command `{}` is valid because it failed to parse: {:?}",
|
||||
expanded_command_line,
|
||||
classified_command.error.unwrap()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// If we can't classify the command, it means we don't have completion specs for it.
|
||||
// Assume it's valid.
|
||||
let Command::Classified(shell_command) = classified_command.command else {
|
||||
return true;
|
||||
};
|
||||
if let Some(positionals) = &shell_command.args.positionals {
|
||||
for positional in positionals {
|
||||
if !is_arg_valid(&expanded_command_line, positional, ctx, session_env_vars).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(flags) = shell_command.args.flags {
|
||||
for flag in flags.iter() {
|
||||
if let FlagType::Argument { value } = &flag.flag_type {
|
||||
if !is_arg_valid(&expanded_command_line, value, ctx, session_env_vars).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Scans the given history entries in reverse order for commands that start
|
||||
/// with the buffer text to return as a potential autosuggestion. Prioritizes commands
|
||||
/// in history that were executed in the user's current working directory,
|
||||
/// with any command executed in other directories at the end.
|
||||
fn find_potential_autosuggestions_from_history<'a>(
|
||||
history_entries: impl DoubleEndedIterator<Item = &'a HistoryEntry>,
|
||||
buffer_text: &str,
|
||||
working_dir: Option<&str>,
|
||||
) -> Vec<HistoryEntry> {
|
||||
let mut commands_in_same_dir = vec![];
|
||||
let mut commands_in_other_dirs = vec![];
|
||||
for entry in history_entries.rev() {
|
||||
if !entry.command.starts_with(buffer_text) {
|
||||
continue;
|
||||
}
|
||||
let same_dir = entry
|
||||
.pwd
|
||||
.as_ref()
|
||||
.zip(working_dir)
|
||||
.is_some_and(|(pwd, working_dir)| pwd == working_dir);
|
||||
|
||||
if same_dir {
|
||||
commands_in_same_dir.push(entry.clone());
|
||||
} else {
|
||||
commands_in_other_dirs.push(entry.clone());
|
||||
}
|
||||
}
|
||||
commands_in_same_dir.extend(commands_in_other_dirs);
|
||||
commands_in_same_dir
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "next_command_model_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,238 @@
|
||||
use crate::completer::SessionContext;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::model::session::{
|
||||
command_executor::testing::TestCommandExecutor, SessionInfo,
|
||||
};
|
||||
use typed_path::TypedPathBuf;
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_completer::{meta::SpannedItem, parsers::ParsedToken};
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_same_directory() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Documents", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
// We should return the most recent command from history that starts with the
|
||||
// buffer text and was executed in the current working directory.
|
||||
let autosuggestions = find_potential_autosuggestions_from_history(
|
||||
history_entries.iter(),
|
||||
"cd D",
|
||||
Some("/Users/tadej"),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec![
|
||||
"cd Documents".to_owned(),
|
||||
"cd Dotfiles".to_owned(),
|
||||
"cd Downloads".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_error_exit_code() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Documents", "/Users/tadej", 1),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
// We want to return failed commands in case the user wants to run it again.
|
||||
let autosuggestions = find_potential_autosuggestions_from_history(
|
||||
history_entries.iter(),
|
||||
"cd D",
|
||||
Some("/Users/tadej"),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec![
|
||||
"cd Documents".to_owned(),
|
||||
"cd Dotfiles".to_owned(),
|
||||
"cd Downloads".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_no_working_dir() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
// No working directory, so return the first successful command.
|
||||
let autosuggestions =
|
||||
find_potential_autosuggestions_from_history(history_entries.iter(), "cd D", None)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec!["cd Downloads".to_owned(), "cd Dotfiles".to_owned(),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_different_directory() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Documents", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
// There are no commands in history that were executed in the current directory,
|
||||
// So we return the most recent command that starts with the buffer text.
|
||||
let autosuggestions = find_potential_autosuggestions_from_history(
|
||||
history_entries.iter(),
|
||||
"cd D",
|
||||
Some("/Users/jonas"),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec![
|
||||
"cd Downloads".to_owned(),
|
||||
"cd Documents".to_owned(),
|
||||
"cd Dotfiles".to_owned(),
|
||||
]
|
||||
);
|
||||
|
||||
// There isn't a current working directory, so return the most recent command that
|
||||
// starts with the buffer text.
|
||||
let autosuggestions =
|
||||
find_potential_autosuggestions_from_history(history_entries.iter(), "cd D", None)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec![
|
||||
"cd Downloads".to_owned(),
|
||||
"cd Documents".to_owned(),
|
||||
"cd Dotfiles".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_no_matching_commands() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Documents", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
let autosuggestions = find_potential_autosuggestions_from_history(
|
||||
history_entries.iter(),
|
||||
"cd Z",
|
||||
Some("/Users/jonas"),
|
||||
);
|
||||
|
||||
assert_eq!(autosuggestions, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_matches_command_with_no_pwd() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Documents", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Pictures"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej/dev", 0),
|
||||
];
|
||||
|
||||
let autosuggestions = find_potential_autosuggestions_from_history(
|
||||
history_entries.iter(),
|
||||
"cd P",
|
||||
Some("/Users/tadej"),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(autosuggestions, vec!["cd Pictures".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_autosuggestion_from_history_with_no_pwd_and_no_working_directory() {
|
||||
let history_entries = [
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Dotfiles", "/Users/tadej", 0),
|
||||
HistoryEntry::command_only("cd Documents"),
|
||||
HistoryEntry::with_pwd_and_exit_code("cd Downloads", "/Users/tadej", 0),
|
||||
];
|
||||
|
||||
// When no working directory is passed, it shouldn't consider a command with
|
||||
// no pwd to be executed in the "same" directory and prioritize it.
|
||||
let autosuggestions =
|
||||
find_potential_autosuggestions_from_history(history_entries.iter(), "cd D", None)
|
||||
.into_iter()
|
||||
.map(|history_entry| history_entry.command)
|
||||
.collect_vec();
|
||||
|
||||
assert_eq!(
|
||||
autosuggestions,
|
||||
vec![
|
||||
"cd Downloads".to_owned(),
|
||||
"cd Documents".to_owned(),
|
||||
"cd Dotfiles".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_session_context(cwd: TypedPathBuf, app: &App) -> SessionContext {
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test(),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
app.read(|ctx| SessionContext::new(session, CommandRegistry::default().into(), cwd, ctx))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feature_flag_arg_is_valid_with_no_whitespace_before_arg() {
|
||||
App::test((), |app| async move {
|
||||
let ctx = test_session_context(TypedPathBuf::from("/test/home/"), &app);
|
||||
|
||||
let full_command = "cargo run --features=with_local_server,fast_dev";
|
||||
let with_local_server_arg = ParsedExpression::new(
|
||||
Expression::ValidatableArgument(vec![ArgType::Generator("feature_flags".into())]),
|
||||
ParsedToken::new("with_local_server".to_string()),
|
||||
)
|
||||
.spanned((21, 38));
|
||||
let fast_dev_arg = ParsedExpression::new(
|
||||
Expression::ValidatableArgument(vec![ArgType::Generator("feature_flags".into())]),
|
||||
ParsedToken::new("fast_dev".to_string()),
|
||||
)
|
||||
.spanned((39, 47));
|
||||
let is_valid = is_arg_valid(full_command, &with_local_server_arg, &ctx, None).await;
|
||||
assert!(is_valid);
|
||||
|
||||
let is_valid = is_arg_valid(full_command, &fast_dev_arg, &ctx, None).await;
|
||||
assert!(is_valid);
|
||||
|
||||
let is_valid = is_command_valid(full_command, Some(&ctx), None).await;
|
||||
assert!(is_valid);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! This module contains all auxiliary code for hitting the PredictAMQueries endpoint in
|
||||
//! warp-server that serves Agent Predict.
|
||||
|
||||
mod api;
|
||||
|
||||
pub use api::*;
|
||||
@@ -0,0 +1,13 @@
|
||||
//! This module contains Rust types for the PredictAMQueries endpoint in warp-server that
|
||||
//! serves Agent Mode.
|
||||
//!
|
||||
//! These types are manually transposed from the API schema defined in go
|
||||
//! (warp-server/model/types/predict_am_queries/(request.go|response.go|common.go)).
|
||||
//!
|
||||
//! Documentation on the types here is directly borrowed from the documentation on the go schema;
|
||||
//! see the go schema for the source-of-truth.
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
@@ -0,0 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PredictAMQueriesRequest {
|
||||
pub context_messages: Vec<String>,
|
||||
pub partial_query: String,
|
||||
pub system_context: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PredictAMQueriesResponse {
|
||||
pub suggestion: String,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use warpui::{keymap::Keystroke, AppContext};
|
||||
|
||||
use crate::{terminal::TerminalModel, util::bindings::keybinding_name_to_keystroke};
|
||||
|
||||
pub const ACCEPT_PROMPT_SUGGESTION_KEYBINDING: &str = "terminal:accept_prompt_suggestions";
|
||||
|
||||
pub static REJECT_PROMPT_SUGGESTION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| Keystroke {
|
||||
ctrl: true,
|
||||
key: "c".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
pub fn is_accept_prompt_suggestion_bound_to_cmd_enter(app: &AppContext) -> bool {
|
||||
static CMD_ENTER_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| Keystroke {
|
||||
cmd: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
keybinding_name_to_keystroke(ACCEPT_PROMPT_SUGGESTION_KEYBINDING, app)
|
||||
.is_some_and(|keystroke| keystroke == *CMD_ENTER_KEYSTROKE)
|
||||
}
|
||||
|
||||
pub fn is_accept_prompt_suggestion_bound_to_ctrl_enter(app: &AppContext) -> bool {
|
||||
static CTRL_ENTER_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| Keystroke {
|
||||
ctrl: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
keybinding_name_to_keystroke(ACCEPT_PROMPT_SUGGESTION_KEYBINDING, app)
|
||||
.is_some_and(|keystroke| keystroke == *CTRL_ENTER_KEYSTROKE)
|
||||
}
|
||||
|
||||
/// Returns `true` if the last AI block in the blocklist has a pending suggested diff or unit test
|
||||
/// suggestion.
|
||||
pub fn has_pending_code_or_unit_test_prompt_suggestion(
|
||||
terminal_model: &TerminalModel,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
terminal_model
|
||||
.block_list()
|
||||
.last_non_hidden_ai_block_handle(app)
|
||||
.is_some_and(|ai_block| {
|
||||
let block = ai_block.as_ref(app);
|
||||
if !block.is_passive_conversation(app) || block.is_hidden(app) {
|
||||
return false;
|
||||
}
|
||||
// Check for pending passive code diff
|
||||
let has_code_diff = block.find_undismissed_code_diff(app).is_some();
|
||||
// Check for pending MAA suggested prompt (e.g., unit test suggestion)
|
||||
let has_suggested_prompt = block.pending_unit_test_suggestion(app).is_some();
|
||||
has_code_diff || has_suggested_prompt
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user