Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,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(),
}
}
}
+9
View File
@@ -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;
+832
View File
@@ -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);
});
}
+6
View File
@@ -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
})
}