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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
use crate::ai::blocklist::BlocklistAIPermissions;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub enum AskUserQuestionDecision {
Completed(Vec<AskUserQuestionAnswerItem>),
Cancelled,
}
pub struct AskUserQuestionExecutor {
result_rx: (
async_channel::Sender<AskUserQuestionDecision>,
async_channel::Receiver<AskUserQuestionDecision>,
),
terminal_view_id: EntityId,
}
impl AskUserQuestionExecutor {
pub fn new(terminal_view_id: EntityId) -> Self {
Self {
result_rx: async_channel::unbounded(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
!BlocklistAIPermissions::as_ref(ctx).can_ask_user_question(
&input.conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let questions = match &input.action.action {
AIAgentActionType::AskUserQuestion { questions } => questions,
_ => {
return ActionExecution::InvalidAction;
}
};
if self.should_autoexecute(input, ctx) {
let question_ids = questions
.iter()
.map(|question| question.question_id.clone())
.collect();
return ActionExecution::Sync(AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::SkippedByAutoApprove { question_ids },
));
}
let receiver = self.result_rx.1.clone();
ActionExecution::new_async(
async move { receiver.recv().await },
|result, _ctx| match result {
Ok(AskUserQuestionDecision::Completed(answers)) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success {
answers,
})
}
Ok(AskUserQuestionDecision::Cancelled) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
Err(_) => {
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete(&self, answers: Vec<AskUserQuestionAnswerItem>) {
let _ = self
.result_rx
.0
.try_send(AskUserQuestionDecision::Completed(answers));
}
pub fn cancel(&self) {
let _ = self
.result_rx
.0
.try_send(AskUserQuestionDecision::Cancelled);
}
}
#[cfg(test)]
impl Default for AskUserQuestionExecutor {
fn default() -> Self {
Self::new(EntityId::new())
}
}
impl Entity for AskUserQuestionExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "ask_user_question_tests.rs"]
mod tests;
@@ -0,0 +1,285 @@
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionResultType};
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::execution_profiles::{
profiles::AIExecutionProfilesModel, AskUserQuestionPermission,
};
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
use crate::LaunchMode;
use ai::agent::action::AskUserQuestionItem;
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
use warpui::{App, EntityId, ModelHandle};
fn build_action(action_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(action_id.to_string()),
action: AIAgentActionType::AskUserQuestion {
questions: vec![AskUserQuestionItem {
question_id: "q1".to_string(),
question: "What should we use?".to_string(),
question_type: ai::agent::action::AskUserQuestionType::MultipleChoice {
is_multiselect: false,
options: vec![],
supports_other: true,
},
}],
},
task_id: TaskId::new(format!("task-{action_id}")),
requires_result: false,
}
}
#[test]
fn should_autoexecute_returns_false_when_autoapprove_is_enabled_and_profile_always_blocks() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, profiles) = initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
});
profiles.update(&mut app, |profiles, ctx| {
let profile_id = *profiles.active_profile(Some(terminal_view_id), ctx).id();
profiles.set_ask_user_question(profile_id, AskUserQuestionPermission::AlwaysAsk, ctx);
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(!result);
});
}
fn initialize_ask_user_question_test(
app: &mut App,
terminal_view_id: EntityId,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<AIExecutionProfilesModel>,
) {
initialize_settings_for_tests(app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
app.add_singleton_model(BlocklistAIPermissions::new);
// Ensure asking questions is allowed by default regardless of compile-time profile
// defaults (e.g. agent_mode_evals overrides ask_user_question to Never).
profiles.update(app, |profiles, ctx| {
if let Some(profile_id) = profiles.create_profile(ctx) {
profiles.set_ask_user_question(
profile_id,
AskUserQuestionPermission::AskExceptInAutoApprove,
ctx,
);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
}
});
(history, profiles)
}
#[test]
fn should_autoexecute_returns_false_when_questions_are_allowed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor.should_autoexecute(input, ctx)
});
assert!(!result);
});
}
#[test]
fn should_autoexecute_returns_true_when_autoapprove_is_enabled_and_profile_allows_override() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, _) = initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(result);
});
}
#[test]
fn execute_returns_sync_skipped_question_ids_when_autoapprove_is_enabled() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, _) = initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, true, false, ctx)
});
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::SkippedByAutoApprove { question_ids }
)) if question_ids == vec!["q1".to_string()]
));
});
}
#[test]
fn execute_returns_async_and_resolves_on_complete() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("action-a");
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
executor.update(&mut app, |executor, _| {
executor.complete(vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}]);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success { answers })
if answers
== vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}]
));
});
}
#[test]
fn cancel_resolves_as_cancelled() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
executor.update(&mut app, |executor, _| {
executor.cancel();
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
));
});
}
#[test]
fn should_autoexecute_uses_active_terminal_profile_permission() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, profiles) = initialize_ask_user_question_test(&mut app, terminal_view_id);
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
let action = build_action("ask-user-question");
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
});
profiles.update(&mut app, |profiles, ctx| {
let profile_id = profiles
.create_profile(ctx)
.expect("test profile should be created");
profiles.set_ask_user_question(profile_id, AskUserQuestionPermission::Never, ctx);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
});
let result = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id,
};
executor.should_autoexecute(input, ctx)
});
assert!(result);
});
}
@@ -0,0 +1,281 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::terminal::model::session::active_session::ActiveSession;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use super::get_server_output_id;
#[cfg(not(target_family = "wasm"))]
use crate::{
ai::{
agent::{AIAgentAction, AIAgentActionResultType, CallMCPToolResult},
blocklist::{action_model::AIAgentActionType, BlocklistAIPermissions},
mcp::TemplatableMCPServerManager,
},
send_telemetry_from_app_ctx, TelemetryEvent,
};
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
pub struct CallMCPToolExecutor {
_active_session: ModelHandle<ActiveSession>,
#[allow(dead_code)]
terminal_view_id: EntityId,
}
impl CallMCPToolExecutor {
pub fn new(_active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
_active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::CallMCPTool {
server_id, name, ..
},
..
},
conversation_id,
} = input
else {
return false;
};
BlocklistAIPermissions::as_ref(ctx).can_call_mcp_tool(
server_id.as_ref(),
name.as_str(),
&conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction
}
#[cfg(not(target_family = "wasm"))]
{
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
AIAgentActionType::CallMCPTool {
server_id,
name,
input,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
let serde_json::Value::Object(mut arguments) = input.clone() else {
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
};
// Prefer the templatable server over the legacy server if both exist.
// It is possible for both to exist in some tricky race conditions, but in those cases
// we shouldn't care about the legacy servers.
let templatable_mcp_manager = TemplatableMCPServerManager::as_ref(ctx);
// Coerce whole-number f64 args to i64 for fields declared as `"type": "integer"`
// in the tool's input schema. MCP tool args round-trip through
// `google.protobuf.Struct` on the wire, which erases the integer/float distinction
// by storing everything as f64. Without coercion, the ryu formatter serializes
// whole-number f64 as "5.0", which strict MCP servers (e.g. GoLand) reject for
// integer-typed fields.
if let Some(schema) =
templatable_mcp_manager.tool_input_schema(*server_id, name.as_str())
{
coerce_integer_args(&mut arguments, &schema);
}
let templatable_peer = if let Some(installation_id) = server_id {
templatable_mcp_manager
.server_with_installation_id_and_tool_name(*installation_id, name.to_owned())
} else {
templatable_mcp_manager.server_with_tool_name(name.to_owned())
};
let Some(reconnecting_peer) = templatable_peer else {
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
let name_owned_inner = name_owned.clone();
ActionExecution::new_async(
async move {
reconnecting_peer
.call_tool(rmcp::model::CallToolRequestParam {
name: name_owned_inner.into(),
arguments: Some(arguments),
})
.await
},
move |res, ctx| handle_call_tool_result(res, server_output_id, name_clone, ctx),
)
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for CallMCPToolExecutor {
type Event = ();
}
/// Coerces whole-number floats in `args` to integers for fields declared as
/// [`"type": "integer"`](https://json-schema.org/understanding-json-schema/reference/type)
/// in the tool's JSON Schema `input_schema`.
///
/// MCP tool args round-trip through `google.protobuf.Struct` on the wire, whose
/// `NumberValue` stores everything as `f64`. Without this fix, serde_json emits
/// whole-number floats as `"5.0"`, which strict MCP servers reject for integer fields.
pub(crate) fn coerce_integer_args(
args: &mut serde_json::Map<String, serde_json::Value>,
input_schema: &serde_json::Map<String, serde_json::Value>,
) {
let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) else {
return;
};
for (key, prop_def) in properties {
let is_integer = prop_def.get("type").and_then(|t| t.as_str()) == Some("integer");
if !is_integer {
continue;
}
let Some(serde_json::Value::Number(n)) = args.get_mut(key) else {
continue;
};
let Some(f) = n.as_f64() else { continue };
if f.fract() != 0.0 {
continue;
}
if let Ok(i) = i64::try_from(f as i128) {
*n = serde_json::Number::from(i);
}
}
}
#[cfg(test)]
#[path = "call_mcp_tool_tests.rs"]
mod tests;
/// Handles the result of a call_tool request, converting it to an AIAgentActionResultType.
#[cfg(not(target_family = "wasm"))]
fn handle_call_tool_result(
res: Result<rmcp::model::CallToolResult, rmcp::ServiceError>,
server_output_id: Option<crate::ai::blocklist::action_model::execute::ServerOutputId>,
tool_name: String,
ctx: &warpui::AppContext,
) -> AIAgentActionResultType {
let action_result = match res {
Ok(result) => {
// Even if the call was successful, the response could still be an error so we need to check.
if matches!(result.is_error, Some(true)) {
let error_message = result
.structured_content
.map(|content| content.to_string())
.unwrap_or_else(|| {
let content_str = result
.content
.into_iter()
.filter_map(|content| {
use rmcp::model::RawContent::*;
if let Text(raw_text_content) = content.raw {
Some(raw_text_content.text)
} else {
log::warn!("Error content found unsupported content type");
None
}
})
.collect_vec()
.join("\n");
if content_str.is_empty() {
"MCP tool call returned an error.".to_string()
} else {
content_str
}
});
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: Some(
crate::server::telemetry::MCPServerTelemetryError::ResponseError(
error_message.clone()
)
),
},
ctx
);
CallMCPToolResult::Error(error_message)
} else {
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: None,
},
ctx
);
CallMCPToolResult::Success { result }
}
}
Err(e) => {
let error_message = e.to_string();
log::warn!("Executing MCP tool resulted in error: {e:?}");
send_telemetry_from_app_ctx!(
TelemetryEvent::MCPToolCallAccepted {
server_output_id,
tool_call: tool_name,
error: Some(rmcp::RmcpError::Service(e).into()),
},
ctx
);
CallMCPToolResult::Error(error_message)
}
};
AIAgentActionResultType::CallMCPTool(action_result)
}
@@ -0,0 +1,48 @@
//! Unit tests for the `coerce_integer_args` helper.
use super::*;
use serde_json::json;
fn obj(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
match value {
serde_json::Value::Object(m) => m,
_ => panic!("expected a JSON object"),
}
}
#[test]
fn whole_float_is_coerced_when_schema_declares_integer() {
let mut args = obj(json!({ "line": 5.0 }));
let schema = obj(json!({
"properties": { "line": { "type": "integer" } }
}));
coerce_integer_args(&mut args, &schema);
// Serialized as "5", not "5.0", and round-trips as i64.
assert_eq!(serde_json::to_string(&args["line"]).unwrap(), "5");
assert_eq!(args["line"].as_i64(), Some(5));
}
#[test]
fn no_coercion_when_not_typed_as_integer() {
// Three scenarios that should all preserve the original float value:
// * schema declares `"type": "number"` (explicit float)
// * schema has no `properties` at all
// * schema property lacks a `"type"` key
let cases = [
json!({ "properties": { "x": { "type": "number" } } }),
json!({}),
json!({ "properties": { "x": { "description": "no type" } } }),
];
for schema_value in cases {
let mut args = obj(json!({ "x": 1.0 }));
let schema = obj(schema_value);
coerce_integer_args(&mut args, &schema);
assert_eq!(args["x"].as_f64(), Some(1.0));
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "1.0");
}
}
@@ -0,0 +1,164 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType,
CreateDocumentsRequest, CreateDocumentsResult, DocumentContext,
},
artifacts::Artifact,
blocklist::BlocklistAIHistoryModel,
document::ai_document_model::{AIDocumentModel, AIDocumentVersion},
execution_profiles::profiles::AIExecutionProfilesModel,
},
notebooks::editor::model::FileLinkResolutionContext,
terminal::model::session::active_session::ActiveSession,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct CreateDocumentsExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: warpui::EntityId,
}
impl CreateDocumentsExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: warpui::EntityId,
) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
id: action_id,
action: AIAgentActionType::CreateDocuments(CreateDocumentsRequest { documents }),
..
} = action
else {
return ActionExecution::<CreateDocumentsResult>::InvalidAction;
};
// Access the model synchronously before the async block
let model = AIDocumentModel::handle(ctx);
let created_documents: Vec<DocumentContext> = documents
.iter()
.enumerate()
.map(|(index, document)| {
// If we have streamed updates for this plan, just apply the last streamed update.
// A full reset would cause syntax highlighting in code blocks to flicker.
let existing_id = model
.as_ref(ctx)
.streaming_document_id_for_create_documents(&conversation_id, action_id, index);
let id = if let Some(existing_id) = existing_id {
model.update(ctx, |model, model_ctx| {
model.apply_streamed_agent_update(
&existing_id,
&document.title,
&document.content,
model_ctx,
);
});
existing_id
} else {
// If we weren't streaming updates for this document before, create the whole document now.
let session = self.active_session.as_ref(ctx);
let working_directory = session.current_working_directory().cloned();
let shell_launch_data = session.shell_launch_data(ctx);
let file_link_resolution_context =
working_directory.map(|working_directory| FileLinkResolutionContext {
working_directory,
shell_launch_data,
});
model.update(ctx, |model, model_ctx| {
model.create_document(
&document.title,
document.content.clone(),
conversation_id,
file_link_resolution_context.clone(),
model_ctx,
)
})
};
let profile = AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(self.terminal_view_id), ctx);
let should_autosync = profile.data().autosync_plans_to_warp_drive;
if should_autosync {
model.update(ctx, |model, model_ctx| {
model.sync_to_warp_drive(id, model_ctx);
});
}
// Add plan artifact to the conversation.
let artifact = Artifact::Plan {
document_uid: id.to_string(),
notebook_uid: None, // Will be updated when synced to Warp Drive
title: Some(document.title.clone()),
};
let terminal_view_id = self.terminal_view_id;
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
if let Some(conversation) = history.conversation_mut(&conversation_id) {
conversation.add_artifact(artifact, terminal_view_id, ctx);
}
});
// Read the actual content from the created document.
// The AIDocumentModel does some processing to remove additional newlines, since our rich text editor
// renders every newline as a linebreak.
let actual_content = model
.as_ref(ctx)
.get_document_content(&id, ctx)
.unwrap_or_else(|| document.content.clone());
DocumentContext {
document_id: id,
document_version: AIDocumentVersion::default(),
content: actual_content,
line_ranges: vec![],
}
})
.collect();
// Clear any streaming mappings for this action now that we've finalized results.
model.update(ctx, |model, ctx| {
model.clear_streaming_documents_for_action(&conversation_id, action_id, ctx);
});
ActionExecution::Sync(CreateDocumentsResult::Success { created_documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for CreateDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,154 @@
use ai::diff_validation::DiffDelta;
use futures::{future::BoxFuture, FutureExt};
use std::collections::HashMap;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest,
EditDocumentsResult,
},
document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentUpdateSource},
};
use crate::notebooks::post_process_notebook;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct EditDocumentsExecutor;
impl EditDocumentsExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action: AIAgentActionType::EditDocuments(EditDocumentsRequest { diffs }),
..
} = action
else {
return ActionExecution::<EditDocumentsResult>::InvalidAction;
};
let model = AIDocumentModel::handle(ctx);
let mut updated_documents = Vec::new();
let mut error_messages = Vec::new();
let mut document_deltas: HashMap<AIDocumentId, Vec<DiffDelta>> = HashMap::new();
// First pass: validate all diffs and accumulate deltas
for diff in diffs.iter() {
// Get current document content
let current_content = match model
.as_ref(ctx)
.get_document_content(&diff.document_id, ctx)
{
Some(content) => content,
None => {
error_messages.push(format!("Document {} does not exist.", diff.document_id));
continue;
}
};
// Apply the diff using fuzzy matching logic
let search_replace = ai::diff_validation::SearchAndReplace {
search: post_process_notebook(&diff.search),
replace: post_process_notebook(&diff.replace),
};
let content_name = format!("document_{}", diff.document_id);
let fuzzy_result = ai::diff_validation::fuzzy_match_diffs(
&content_name,
&[search_replace],
current_content,
);
// Check if diff application failed
if fuzzy_result.warrants_failure() {
let error_msg = if let Some(failures) = &fuzzy_result.failures {
if failures.fuzzy_match_failures > 0 {
format!(
"Could not apply diff to document {}: content mismatch",
diff.document_id
)
} else if failures.noop_deltas > 0 {
format!("Changes to document {} were already made", diff.document_id)
} else {
format!("Failed to apply diff to document {}", diff.document_id)
}
} else {
format!("Unknown diff failure for document {}", diff.document_id)
};
error_messages.push(error_msg);
continue;
}
// Accumulate deltas for this document
if let ai::diff_validation::DiffType::Update { deltas, .. } = fuzzy_result.diff_type {
document_deltas
.entry(diff.document_id)
.or_default()
.extend(deltas);
}
}
// If any diffs failed, don't apply any deltas.
// This result will be sent to the LLM as an error, so we don't want partial applications since they agent won't know about them.
if !error_messages.is_empty() {
let combined_errors = error_messages.join("\n");
return ActionExecution::Sync(EditDocumentsResult::Error(combined_errors).into());
}
// For every document, apply all deltas at once and collect updated documents for response
model.update(ctx, |model, model_ctx| {
for (document_id, deltas) in document_deltas {
if let Some(version) = model.create_new_version_and_apply_diffs(
&document_id,
deltas,
AIDocumentUpdateSource::Agent,
model_ctx,
) {
let new_content = model
.get_document_content(&document_id, model_ctx)
.unwrap_or_default();
updated_documents.push(DocumentContext {
document_id,
document_version: version,
content: new_content,
line_ranges: vec![],
});
}
}
});
// All diffs succeeded, return success
ActionExecution::Sync(EditDocumentsResult::Success { updated_documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for EditDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,110 @@
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::conversation_yaml;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::blocklist::history_model::CloudConversationData;
use ai::agent::action_result::FetchConversationResult;
use futures::future::BoxFuture;
use futures::FutureExt;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::AIAgentActionType;
use crate::BlocklistAIHistoryModel;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct FetchConversationExecutor;
impl FetchConversationExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::FetchConversation { conversation_id } = &action.action else {
return ActionExecution::<Option<CloudConversationData>>::InvalidAction;
};
let conversation_id = conversation_id.clone();
let server_token = ServerConversationToken::new(conversation_id.clone());
let history = BlocklistAIHistoryModel::as_ref(ctx);
let load_future = history.load_conversation_by_server_token(&server_token, ctx);
ActionExecution::new_async(load_future, move |cloud_conversation, _ctx| {
// TODO(REMOTE-1203): FetchConversation can't materialize non-Oz conversation transcripts yet.
let conversation = cloud_conversation.and_then(|cc| match cc {
CloudConversationData::Oz(c) => Some(c),
CloudConversationData::CLIAgent(_) => {
log::warn!("FetchConversation does not support CLI agent conversations");
None
}
});
materialize_conversation(conversation.map(|c| *c), &conversation_id)
})
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
/// Materializes a loaded conversation's tasks into YAML files on disk.
fn materialize_conversation(
conversation: Option<AIConversation>,
server_conversation_id: &str,
) -> AIAgentActionResultType {
let Some(conversation) = conversation else {
log::warn!("FetchConversation: failed to load conversation {server_conversation_id}");
return AIAgentActionResultType::FetchConversation(FetchConversationResult::Error(
format!("Failed to load conversation {server_conversation_id}"),
));
};
let tasks: Vec<warp_multi_agent_api::Task> = conversation
.all_tasks()
.filter_map(|task| task.source().cloned())
.collect();
log::info!(
"FetchConversation: materializing {} tasks for conversation {server_conversation_id}",
tasks.len(),
);
match conversation_yaml::materialize_tasks_to_yaml(&tasks) {
Ok(directory_path) => {
log::info!(
"FetchConversation: wrote YAML to {directory_path} \
for conversation {server_conversation_id}"
);
AIAgentActionResultType::FetchConversation(FetchConversationResult::Success {
directory_path,
})
}
Err(e) => {
log::error!("FetchConversation: failed to materialize YAML: {e}");
AIAgentActionResultType::FetchConversation(FetchConversationResult::Error(format!(
"Failed to materialize conversation: {e}"
)))
}
}
}
impl Entity for FetchConversationExecutor {
type Event = ();
}
@@ -0,0 +1,371 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use futures::FutureExt;
use itertools::Itertools;
use warpui::r#async::FutureExt as AsyncFutureExt;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType, FileGlobResult,
FileGlobV2Match, FileGlobV2Result,
};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::paths::{host_native_absolute_path, join_paths, shell_native_absolute_path};
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::{
ai::agent::AIAgentActionResultType,
send_telemetry_from_app_ctx,
terminal::{
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
ShellLaunchData,
},
TelemetryEvent,
};
use warp_core::features::FeatureFlag;
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
use super::{
get_server_output_id, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
};
pub struct FileGlobExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
fn log_file_glob_error(conversation_id: AIConversationId, ctx: &mut AppContext) {
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_app_ctx!(TelemetryEvent::FileGlobToolFailed { server_output_id }, ctx);
}
impl FileGlobExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::FileGlob { path, .. }
| AIAgentActionType::FileGlobV2 {
search_dir: path, ..
},
..
},
conversation_id,
} = input
else {
return false;
};
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let absolute_path =
host_native_absolute_path(path.as_str(), &shell, &current_working_directory);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![PathBuf::from(absolute_path)],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action:
AIAgentActionType::FileGlob { patterns, path }
| AIAgentActionType::FileGlobV2 {
patterns,
search_dir: path,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let absolute_path = shell_native_absolute_path(
path.as_str(),
shell_launch_data.as_ref(),
current_working_directory.as_ref(),
);
let session = self.active_session.as_ref(ctx).session(ctx);
let patterns_clone = patterns.clone();
let conversation_id_clone = input.conversation_id;
let is_file_glob_v2 = is_file_glob_v2(&input);
ActionExecution::new_async(
async move {
match run_file_glob(patterns_clone, absolute_path, session, shell_launch_data)
.with_timeout(FILE_GLOB_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("File glob operation timed out")),
}
},
move |result, ctx| match result {
Ok(file_glob_result) => {
match file_glob_result {
FileGlobV2Result::Error(ref e) => {
log::warn!("Executing file_glob resulted in error: {e:?}");
log_file_glob_error(conversation_id_clone, ctx);
}
FileGlobV2Result::Success { .. } => {
send_telemetry_from_app_ctx!(
TelemetryEvent::FileGlobToolSucceeded,
ctx
);
}
_ => {}
}
// Convert FileGlobV2Result to FileGlobResult if the request was not V2.
if is_file_glob_v2 {
AIAgentActionResultType::FileGlobV2(file_glob_result)
} else {
AIAgentActionResultType::FileGlob(file_glob_result.into())
}
}
Err(e) => {
log::warn!("Failed to execute file_glob: {e:?}");
log_file_glob_error(conversation_id_clone, ctx);
if is_file_glob_v2 {
AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(e.to_string()))
} else {
AIAgentActionResultType::FileGlob(FileGlobResult::Error(e.to_string()))
}
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool {
self.active_session
.as_ref(ctx)
.session(ctx)
.is_some_and(|session| session.supports_parallel_command_execution())
}
}
fn is_file_glob_v2(input: &ExecuteActionInput) -> bool {
matches!(input.action.action, AIAgentActionType::FileGlobV2 { .. })
}
async fn run_file_glob(
patterns: Vec<String>,
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
) -> anyhow::Result<FileGlobV2Result> {
if patterns.is_empty() {
return Err(anyhow::anyhow!("No patterns provided to file_glob"));
}
let Some(session) = session else {
return Err(anyhow::anyhow!("No session provided to file_glob"));
};
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
if is_in_git_repo {
run_git_ls_files_command(
&patterns,
&absolute_path,
session.as_ref(),
shell_launch_data,
)
.await
} else if session.shell().shell_type() == ShellType::PowerShell {
run_powershell_get_childitem_command(&patterns, &absolute_path, session.as_ref()).await
} else {
run_find_command(&patterns, &absolute_path, session.as_ref()).await
}
}
/// Uses git ls-files to list all files in a git repository and filters them by pattern.
async fn run_git_ls_files_command(
patterns: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
) -> anyhow::Result<FileGlobV2Result> {
let pattern_args = patterns
.iter()
.flat_map(|pattern| {
[
// Matches on files in the target path.
join_paths(&[target_path, pattern], shell_launch_data.as_ref()),
// Matches on files in any subdirectory of the target path.
join_paths(&[target_path, "*", pattern], shell_launch_data.as_ref()),
]
})
.map(|pattern| format!("'{pattern}'"))
.join(" ");
let command = format!("git ls-files -c -o --exclude-standard -- {pattern_args}");
let command_output = session
.execute_command(
command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let output = String::from_utf8_lossy(command_output.output()).to_string();
if command_output.success() {
// git ls-files outputs paths relative to the current directory. For consistency with the
// `find` and PowerShell implementations, convert to absolute paths.
let absolute_paths = non_empty_lines(&output)
.map(|relative_path| {
join_paths(&[target_path, relative_path], shell_launch_data.as_ref())
})
.map(|path| FileGlobV2Match { file_path: path });
Ok(FileGlobV2Result::Success {
matched_files: absolute_paths.collect(),
warnings: None,
})
} else {
Err(anyhow::anyhow!(output))
}
}
/// Uses the find command for Unix-like environments to find files matching patterns.
async fn run_find_command(
patterns: &[String],
target_path: &str,
session: &Session,
) -> anyhow::Result<FileGlobV2Result> {
// Build a find command with -name for each pattern
let pattern_args = patterns
.iter()
.map(|pattern| format!(" -name '{pattern}'"))
.join(" -o");
let find_command = format!("find \"{target_path}\" -type f {pattern_args}");
let command_output = session
.execute_command(
find_command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let stdout = String::from_utf8_lossy(&command_output.stdout).to_string();
let stderr = String::from_utf8_lossy(&command_output.stderr).to_string();
let has_results = FeatureFlag::FileGlobV2Warnings.is_enabled() && !stdout.trim().is_empty();
if command_output.success() || has_results {
let files = non_empty_lines(&stdout).map(|line| FileGlobV2Match {
file_path: line.to_string(),
});
let warnings = if FeatureFlag::FileGlobV2Warnings.is_enabled() && !stderr.trim().is_empty()
{
Some(stderr)
} else {
None
};
Ok(FileGlobV2Result::Success {
matched_files: files.collect(),
warnings,
})
} else {
Err(anyhow::anyhow!(stderr))
}
}
/// Uses PowerShell's Get-ChildItem to find files matching patterns.
async fn run_powershell_get_childitem_command(
patterns: &[String],
target_path: &str,
session: &Session,
) -> anyhow::Result<FileGlobV2Result> {
let pattern_args = patterns
.iter()
.map(|pattern| format!("'{pattern}'"))
.join(",");
let command = format!(
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path \"{target_path}\" | ForEach-Object {{ $_.FullName }}"
);
let command_output = session
.execute_command(
command.as_str(),
Some(target_path),
None,
ExecuteCommandOptions::default(),
)
.await?;
let output = String::from_utf8_lossy(command_output.output()).to_string();
if command_output.success() {
let files = non_empty_lines(&output).map(|line| FileGlobV2Match {
file_path: line.to_string(),
});
Ok(FileGlobV2Result::Success {
matched_files: files.collect(),
warnings: None,
})
} else {
Err(anyhow::anyhow!(output))
}
}
fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
str.lines().filter(|line| !line.is_empty())
}
impl Entity for FileGlobExecutor {
type Event = ();
}
@@ -0,0 +1,334 @@
use std::path::{Path, PathBuf};
use futures::{future::BoxFuture, FutureExt};
use itertools::Itertools;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionResultType,
AIAgentActionType, FileLocations, GetFilesRequestType, GetFilesResult,
},
blocklist::BlocklistAIPermissions,
get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesStatus,
},
paths::host_native_absolute_path,
},
terminal::model::session::active_session::ActiveSession,
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
pub struct GetFilesExecutor {
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
}
impl GetFilesExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
) -> Self {
Self {
active_session,
get_relevant_files_controller,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::GetFiles(get_files_request),
..
},
conversation_id,
} = input
else {
return false;
};
// TODO: figure out how to avoid constructing the full paths in `should_execute`
// and then again in `execute`, and then again on every render.
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
match get_files_request {
GetFilesRequestType::RelevantFileQuery { .. } => {
match self.get_relevant_files_controller.as_ref(ctx).status(id) {
Some(relevant_files_status) => match relevant_files_status {
GetRelevantFilesStatus::Pending { root_repo_path } => {
// If we have access to read the repo, we can auto-execute the search.
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![root_repo_path.to_owned()],
self.terminal_view_id,
ctx,
)
.is_allowed()
}
// We can autoexecute if the request has not yet been sent or has failed,
// in which case we report the failure back to the LLM.
GetRelevantFilesStatus::InFlight { .. }
| GetRelevantFilesStatus::Failed { .. } => true,
GetRelevantFilesStatus::Success { file_paths, .. } => {
// If we've retrieved the relevant file paths, auto-execution (to read
// the file contents) depends on the user's permission.
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
file_paths
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.as_os_str().to_string_lossy(),
&shell,
&current_working_directory,
))
})
.collect(),
self.terminal_view_id,
ctx,
)
.is_allowed()
}
},
// Shouldn't be possible.
None => false,
}
}
GetFilesRequestType::FileLocations(file_locations) => {
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
file_locations
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.name,
&shell,
&current_working_directory,
))
})
.collect(),
self.terminal_view_id,
ctx,
)
.is_allowed()
}
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
id,
action: AIAgentActionType::GetFiles(get_files_request),
..
} = action
else {
return ActionExecution::InvalidAction;
};
let Some(current_working_directory) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.map(PathBuf::from)
else {
// This should really never happen; it implies that we don't know what the
// current working directory is, which is never the case.
return ActionExecution::Sync(AIAgentActionResultType::GetFiles(
GetFilesResult::Error(
"The search failed. Try another way to locate the relevant files.".to_string(),
),
));
};
match get_files_request {
GetFilesRequestType::RelevantFileQuery {
query,
partial_paths,
} => {
match self
.get_relevant_files_controller
.as_ref(ctx)
.status(id)
.cloned()
{
Some(GetRelevantFilesStatus::Pending { root_repo_path }) => {
// Add the repo root as a temporary permission; if the user gave us permission to
// search the repo, we can certainly search files within it for the rest of the convo.
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
vec![root_repo_path.to_owned()],
);
});
// Start the actual search.
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
&current_working_directory,
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::NotReady,
Err(e) => {
log::warn!(
"Failed to send get_relevant_files request for directory: {:?}",
e
);
let error_message = match e {
GetRelevantFilesError::Pending => {
"The current git repository is still being indexed, so search is unavailable right now. You can try again later".to_owned()
}
GetRelevantFilesError::CreateFailed => {
"Relevant file search in the current directory is not available".to_owned()
}
GetRelevantFilesError::Missing => {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
};
ActionExecution::Sync(AIAgentActionResultType::GetFiles(
GetFilesResult::Error(error_message),
))
}
}
}
Some(GetRelevantFilesStatus::InFlight { .. }) => ActionExecution::NotReady,
// The search succeeded so now we can look up the specific files.
Some(GetRelevantFilesStatus::Success { file_paths, .. }) => self
.execute_get_file_by_location_action(
file_paths
.iter()
.map(|path| FileLocations {
name: path.to_string_lossy().to_string(),
lines: vec![],
})
.collect_vec(),
conversation_id,
ctx,
),
Some(GetRelevantFilesStatus::Failed { .. }) => ActionExecution::Sync(
AIAgentActionResultType::GetFiles(GetFilesResult::Error(
"The search failed. Try another way to locate the relevant files."
.to_owned(),
)),
),
None => {
log::warn!(
"Tried to execute a GetFiles action without a corresponding status"
);
ActionExecution::InvalidAction
}
}
}
GetFilesRequestType::FileLocations(file_locations) => self
.execute_get_file_by_location_action(file_locations.clone(), conversation_id, ctx),
}
}
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let Some(pwd) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned()
else {
log::warn!("Failed to preprocess GetRelevantFiles action because no pwd");
return futures::future::ready(()).boxed();
};
self.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.queue_request(input.action.id.clone(), Path::new(&pwd), ctx);
});
futures::future::ready(()).boxed()
}
fn execute_get_file_by_location_action(
&self,
files: Vec<FileLocations>,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) -> ActionExecution<anyhow::Result<GetFilesResult>> {
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
files.iter().map(|file| Path::new(&file.name)),
);
});
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
ActionExecution::Async {
execute_future: Box::pin(async move {
let result =
read_local_file_context(&files, current_working_directory, shell, None, None).await?;
if result.missing_files.is_empty() {
Ok(GetFilesResult::Success {
files: result.file_contexts,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(GetFilesResult::Error(format!(
"These files do not exist: {}",
missing_files
)))
}
}),
on_complete: Box::new(|res, _ctx| {
let action_result = res.unwrap_or_else(|e| GetFilesResult::Error(e.to_string()));
AIAgentActionResultType::GetFiles(action_result)
}),
}
}
}
impl Entity for GetFilesExecutor {
type Event = ();
}
@@ -0,0 +1,695 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use warp_util::standardized_path::StandardizedPath;
use futures::future::BoxFuture;
use futures::FutureExt;
use warpui::r#async::FutureExt as AsyncFutureExt;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::redaction::redact_secrets;
use crate::ai::agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionType, GrepResult, ServerOutputId,
};
use crate::ai::blocklist::{
telemetry_banner::should_collect_ai_ugc_telemetry, BlocklistAIPermissions,
};
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::PrivacySettings;
use crate::{
ai::agent::{AIAgentActionResultType, GrepFileMatch, GrepLineMatch},
send_telemetry_from_app_ctx,
terminal::{
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
ShellLaunchData,
},
TelemetryEvent,
};
use super::{
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
ExecuteActionInput, PreprocessActionInput,
};
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
fn escape_double_quotes(s: &str) -> String {
s.replace('"', "\\\"")
}
fn powershell_escape_double_quotes(s: &str) -> String {
s.replace('"', "`\"")
}
/// Information about the Grep call that resulted in an error, used to send
/// telemetry about the error.
struct GrepError {
command: Option<String>,
output: Option<String>,
/// The error message from the Grep call. This should NOT contain UGC.
error: GrepErrorType,
}
enum GrepErrorType {
NonZeroExitCode,
Other(String),
}
impl GrepError {
/// Create a new GrepError with the given error message. This should NOT
/// contain UGC.
pub fn new(error_message: String) -> Self {
Self {
command: None,
output: None,
error: GrepErrorType::Other(error_message),
}
}
pub fn new_for_non_zero_exit_code() -> Self {
Self {
command: None,
output: None,
error: GrepErrorType::NonZeroExitCode,
}
}
pub fn with_command(mut self, command: String) -> Self {
self.command = Some(command);
self
}
pub fn with_output(mut self, output: String) -> Self {
self.output = Some(output);
self
}
/// Returns an error message for logging. This should not contain UGC.
pub fn error_message(&self) -> &str {
match &self.error {
GrepErrorType::NonZeroExitCode => NON_ZERO_EXIT_CODE_ERROR,
GrepErrorType::Other(error) => error,
}
}
/// Returns an error message to be returned as input to the AI conversation.
/// This may contain UGC.
pub fn error_for_conversation(&self) -> String {
match &self {
GrepError {
error: GrepErrorType::NonZeroExitCode,
output: Some(output),
..
} => format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}"),
GrepError {
error: GrepErrorType::NonZeroExitCode,
output: None,
..
} => NON_ZERO_EXIT_CODE_ERROR.to_string(),
GrepError {
error: GrepErrorType::Other(error),
..
} => error.clone(),
}
}
}
#[allow(clippy::too_many_arguments)]
fn create_redacted_grep_error_event(
should_collect_ugc: bool,
server_output_id: Option<ServerOutputId>,
mut queries: Vec<String>,
mut path: String,
shell_type: Option<ShellType>,
mut working_directory: Option<String>,
mut absolute_path: String,
mut error: GrepError,
) -> TelemetryEvent {
for query in queries.iter_mut() {
redact_secrets(query);
}
redact_secrets(&mut path);
if let Some(working_directory) = working_directory.as_mut() {
redact_secrets(working_directory);
}
redact_secrets(&mut absolute_path);
if let Some(command) = error.command.as_mut() {
redact_secrets(command);
}
if let Some(output) = error.output.as_mut() {
redact_secrets(output);
}
TelemetryEvent::GrepToolFailed {
queries: should_collect_ugc.then_some(queries),
path: should_collect_ugc.then_some(path),
shell_type,
working_directory: should_collect_ugc.then_some(working_directory).flatten(),
absolute_path: should_collect_ugc.then_some(absolute_path),
error: error.error_message().to_string(),
command: should_collect_ugc.then_some(error.command).flatten(),
output: should_collect_ugc.then_some(error.output).flatten(),
server_output_id,
}
}
#[allow(clippy::too_many_arguments)]
fn log_grep_error(
conversation_id: AIConversationId,
queries: Vec<String>,
path: String,
shell_type: Option<ShellType>,
working_directory: Option<String>,
absolute_path: String,
error: GrepError,
ctx: &mut AppContext,
) {
let should_collect_ugc = should_collect_ai_ugc_telemetry(
ctx,
PrivacySettings::handle(ctx)
.as_ref(ctx)
.is_telemetry_enabled,
);
let server_output_id = get_server_output_id(conversation_id, ctx);
let event = create_redacted_grep_error_event(
should_collect_ugc,
server_output_id,
queries,
path,
shell_type,
working_directory,
absolute_path,
error,
);
send_telemetry_from_app_ctx!(event, ctx);
}
pub struct GrepExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
impl GrepExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::Grep { path, .. },
..
},
conversation_id,
} = input
else {
return false;
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let absolute_path = host_native_absolute_path(path, &shell, &current_working_directory);
BlocklistAIPermissions::handle(ctx)
.as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![PathBuf::from(absolute_path)],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::Grep { queries, path },
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_type = self.active_session.as_ref(ctx).shell_type(ctx);
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let absolute_path = shell_native_absolute_path(
path,
shell_launch_data.as_ref(),
current_working_directory.as_ref(),
);
let session = self.active_session.as_ref(ctx).session(ctx);
let path_clone = path.clone();
let queries_clone = queries.clone();
let other_queries_clone = queries.clone();
let absolute_path_clone = absolute_path.clone();
let working_directory_clone = current_working_directory.clone();
let conversation_id_clone = input.conversation_id;
ActionExecution::new_async(
async move {
match run_grep(queries_clone, absolute_path, session, shell_launch_data)
.with_timeout(GREP_TIMEOUT)
.await
{
Ok(result) => result,
Err(_) => Err(GrepError::new("Grep operation timed out".to_string())),
}
},
move |result, ctx| match result {
Ok(grep_result) => {
match grep_result {
GrepResult::Error(ref e) => {
log::warn!("Executing grep resulted in error: {e:?}");
log_grep_error(
conversation_id_clone,
other_queries_clone,
path_clone,
shell_type,
working_directory_clone,
absolute_path_clone,
GrepError::new(e.to_string()),
ctx,
);
}
GrepResult::Success { .. } => {
send_telemetry_from_app_ctx!(TelemetryEvent::GrepToolSucceeded, ctx);
}
_ => {}
}
AIAgentActionResultType::Grep(grep_result)
}
Err(e) => {
log::warn!("Failed to execute grep: {:?}", e.error_message());
let error_for_conversation = e.error_for_conversation();
log_grep_error(
conversation_id_clone,
other_queries_clone,
path_clone,
shell_type,
working_directory_clone,
absolute_path_clone,
e,
ctx,
);
AIAgentActionResultType::Grep(GrepResult::Error(error_for_conversation))
}
},
)
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub(super) fn can_execute_in_parallel(&self, ctx: &AppContext) -> bool {
self.active_session
.as_ref(ctx)
.session(ctx)
.is_some_and(|session| session.supports_parallel_command_execution())
}
}
/// Runs a grep-like search to find the files and line numbers that match the queries.
///
/// Depending on the environment, this uses the most optimized tool to perform the search:
/// - if the search is in a git repo, we run `git grep` in the session.
/// `git grep` is the most optimized tool for searching in a git repo since it's already indexed.
/// - otherwise, if the search is against the local file system, we run `ripgrep` via the library.
/// `ripgrep` is a more optimized version of `grep`.
/// - otherwise, we run vanilla `grep` in the session
async fn run_grep(
queries: Vec<String>,
absolute_path: String,
session: Option<Arc<Session>>,
shell_launch_data: Option<ShellLaunchData>,
) -> Result<GrepResult, GrepError> {
if queries.is_empty() {
return Err(GrepError::new("No queries provided to grep".to_string()));
}
let Some(session) = session else {
return Err(GrepError::new("No session provided to grep".to_string()));
};
let is_file = is_file_path(&absolute_path, &session).await;
let execute_directory = if is_file {
// If path is a file, use its parent directory as the execution directory.
// Use StandardizedPath instead of std::path::Path to avoid encoding a
// remote path with the local platform's path separators.
let Ok(standardized) = StandardizedPath::try_new(&absolute_path) else {
return Err(GrepError::new(
"Could not determine parent directory of file when running grep".to_string(),
));
};
let Some(parent) = standardized.parent() else {
return Err(GrepError::new(
"Could not determine parent directory of file when running grep".to_string(),
));
};
Cow::Owned(parent.as_str().to_owned())
} else {
Cow::Borrowed(absolute_path.as_str())
};
// TODO(CODE-239): Cache the result of this check.
let is_grep_in_git_repo = is_git_repository(&execute_directory, &session)
.await
.unwrap_or_else(|e| {
log::error!("Failed to run command to check if in git repository: {e:?}");
false
});
let shell_type = session.shell().shell_type();
// The most optimized tool to perform the search is `git grep`;
// whether the session is local or remote, we can run `git grep` in the session.
// The next best way to search is ripgrep, but we can only run that if the session is local;
// ripgrep is run using the core lib, not as a command (not everyone will have it installed).
// And in the worst case, we run vanilla `grep` in the session. Although not optimal, this should always work.
if is_grep_in_git_repo {
run_git_grep_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
shell_type,
&execute_directory,
)
.await
} else {
#[cfg(not(target_family = "wasm"))]
if session.is_local() {
return run_ripgrep(&queries, absolute_path).await;
}
if shell_type == ShellType::PowerShell {
run_select_string_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
&execute_directory,
)
.await
} else {
run_grep_command(
&queries,
&absolute_path,
&session,
shell_launch_data,
&execute_directory,
)
.await
}
}
}
#[cfg(not(target_family = "wasm"))]
async fn run_ripgrep(queries: &[String], absolute_path: String) -> Result<GrepResult, GrepError> {
let path = PathBuf::from(absolute_path);
let result = warp_ripgrep::search::search(queries, &[path], false, false).await;
match result {
Ok(matches) => {
let mut files_map: HashMap<PathBuf, Vec<GrepLineMatch>> = HashMap::new();
for m in matches {
files_map
.entry(m.file_path)
.or_default()
.push(GrepLineMatch {
line_number: m.line_number as usize,
});
}
let matched_files: Vec<GrepFileMatch> = files_map
.into_iter()
.map(|(file_path, matched_lines)| GrepFileMatch {
file_path: file_path.to_string_lossy().to_string(),
matched_lines,
})
.collect();
Ok(GrepResult::Success { matched_files })
}
Err(e) => Err(GrepError::new(format!("Ripgrep search failed: {e}"))),
}
}
/// Assumes that git is installed in the user's session.
async fn run_git_grep_command(
queries: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
shell_type: ShellType,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// This command works on all the shells we support (even PowerShell).
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
for query in queries {
let escaped_query = format!(
"\"{}\"",
if shell_type == ShellType::PowerShell {
powershell_escape_double_quotes(query)
} else {
escape_double_quotes(query)
}
);
grep_command.push_str(format!(" -e {escaped_query}").as_str());
}
grep_command.push_str(format!(" \"{target_path}\"").as_str());
let command_output = session
.execute_command(
grep_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(grep_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
.with_output(output.into())
})
} else if command_output
.exit_code()
.is_some_and(|exit_code| exit_code.value() == 1)
{
// If the exit code is 1, then grep completed successfully but found no
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(grep_command)
.with_output(output.into()))
}
}
async fn run_grep_command(
queries: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// Summary of the options we use:
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
// * "-n" includes line numbers
// * "-r" performs a recursive search
// * "-I" ignores binary files
// * "-H" prints file name headers
// * "-E" uses extended regex expressions
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
for query in queries {
grep_command.push_str(format!(" -e \"{}\"", escape_double_quotes(query)).as_str());
}
grep_command.push_str(format!(" \"{target_path}\"").as_str());
let command_output = session
.execute_command(
grep_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(grep_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(grep_command)
.with_output(output.into())
})
} else if command_output
.exit_code()
.is_some_and(|exit_code| exit_code.value() == 1)
{
// If the exit code is 1, then grep completed successfully but found no
// matches.
Ok(GrepResult::Success {
matched_files: vec![],
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(grep_command)
.with_output(output.into()))
}
}
/// Runs a PowerShell `Select-String` command.
async fn run_select_string_command(
queries: &[String],
target_path: &str,
session: &Session,
shell_launch_data: Option<ShellLaunchData>,
execute_directory: &str,
) -> Result<GrepResult, GrepError> {
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
// TODO(CODE-239): Make this command more efficient when searching a file.
let select_string_command = format!(
"Get-ChildItem -Path \"{}\" -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
target_path,
queries
.iter()
.map(|q| format!("\"{}\"", powershell_escape_double_quotes(q)))
.collect::<Vec<_>>()
.join(",")
);
let command_output = session
.execute_command(
select_string_command.as_str(),
Some(execute_directory),
None,
ExecuteCommandOptions::default(),
)
.await
.map_err(|e| GrepError::new(e.to_string()).with_command(select_string_command.clone()))?;
let output = String::from_utf8_lossy(command_output.output());
if command_output.success() {
parse_grep_output(
output.as_ref(),
shell_launch_data,
Some(execute_directory.to_string()),
)
.map(|matched_files| GrepResult::Success { matched_files })
.map_err(|e| {
GrepError::new(e.to_string())
.with_command(select_string_command)
.with_output(output.into())
})
} else {
Err(GrepError::new_for_non_zero_exit_code()
.with_command(select_string_command)
.with_output(output.into()))
}
}
/// Parses the output of grep or a grep-like command into the format that we pass
/// back to the agent.
///
/// Assumes the output is in the format:
/// `{relative_file_path}:{line_number}:{line_contents}`.
fn parse_grep_output(
output: &str,
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
) -> anyhow::Result<Vec<GrepFileMatch>> {
let mut matched_files = HashMap::new();
for line in output.trim().split("\n") {
let mut parts = line.split(":");
let file = parts.next();
let line_number = parts.next();
let (Some(file), Some(line_number)) = (file, line_number) else {
return Err(anyhow::anyhow!(
"Failed to parse Grep output, unexpected format"
));
};
let line_number = match line_number.parse::<usize>() {
Ok(line_number) => line_number,
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to parse line number in Grep output: {:?}",
e
));
}
};
matched_files
.entry(file)
.or_insert_with(Vec::new)
.push(GrepLineMatch { line_number });
}
Ok(matched_files
.into_iter()
.map(|(file, matched_lines)| GrepFileMatch {
file_path: host_native_absolute_path(
file,
&shell_launch_data,
&current_working_directory,
),
matched_lines,
})
.collect())
}
impl Entity for GrepExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "grep_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use super::*;
use crate::terminal::{model::secrets::regexes::FIREBASE_AUTH_DOMAIN, shell::ShellType};
#[test]
fn test_create_redacted_grep_error_event() {
crate::terminal::model::set_user_and_enterprise_secret_regexes(
[&regex::Regex::new(FIREBASE_AUTH_DOMAIN).expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Create input with a known secret pattern (Firebase domain)
let queries = vec![
"normal query".to_string(),
"query with warp-server-staging.firebaseapp.com secret".to_string(),
];
let path = "path/to/file/with/warp-server-staging.firebaseapp.com/secret".to_string();
let shell_type = Some(ShellType::Bash);
let working_directory = Some("/users/test/warp-server-staging.firebaseapp.com".to_string());
let absolute_path =
"/absolute/path/with/warp-server-staging.firebaseapp.com/secret".to_string();
let error = GrepError::new("Error message".to_string())
.with_command("grep warp-server-staging.firebaseapp.com".to_string())
.with_output("Output with warp-server-staging.firebaseapp.com".to_string());
// Call the function with the test inputs
let event = create_redacted_grep_error_event(
true,
None,
queries.clone(),
path.clone(),
shell_type,
working_directory.clone(),
absolute_path.clone(),
error,
);
// Verify the telemetry event has redacted secrets
if let TelemetryEvent::GrepToolFailed {
queries: Some(redacted_queries),
path: Some(redacted_path),
shell_type: _,
working_directory: Some(redacted_working_directory),
absolute_path: Some(redacted_absolute_path),
command: Some(redacted_command),
output: Some(redacted_output),
error: _,
server_output_id: _,
} = event
{
// Verify secrets are redacted from all relevant fields
assert_eq!(redacted_queries.len(), 2);
assert_eq!(redacted_queries[0], "normal query");
assert!(!redacted_queries[1].contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_queries[1].contains("*****"));
assert!(!redacted_path.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_path.contains("*****"));
assert!(!redacted_working_directory.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_working_directory.contains("*****"));
assert!(!redacted_absolute_path.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_absolute_path.contains("*****"));
assert!(!redacted_command.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_command.contains("*****"));
assert!(!redacted_output.contains("warp-server-staging.firebaseapp.com"));
assert!(redacted_output.contains("*****"));
} else {
panic!("Expected GrepToolFailed event");
}
}
@@ -0,0 +1,75 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::{
agent::{
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest,
ReadDocumentsResult,
},
document::ai_document_model::AIDocumentModel,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct ReadDocumentsExecutor;
impl ReadDocumentsExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// Document operations are always auto-executed
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest { document_ids }),
..
} = action
else {
return ActionExecution::<ReadDocumentsResult>::InvalidAction;
};
// Access the model synchronously before the async block
let model = AIDocumentModel::handle(ctx);
let documents: Vec<DocumentContext> = document_ids
.iter()
.filter_map(|id| {
let model = model.as_ref(ctx);
let content = model.get_document_content(id, ctx)?;
let version = model.get_current_document(id)?.version;
Some(DocumentContext {
document_id: *id,
content,
line_ranges: vec![],
document_version: version,
})
})
.collect();
ActionExecution::Sync(ReadDocumentsResult::Success { documents }.into())
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadDocumentsExecutor {
type Event = ();
}
@@ -0,0 +1,275 @@
use std::path::{Path, PathBuf};
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest,
ReadFilesResult,
},
blocklist::BlocklistAIPermissions,
paths::host_native_absolute_path,
},
terminal::model::session::{active_session::ActiveSession, SessionType},
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
pub struct ReadFilesExecutor {
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
}
impl ReadFilesExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }),
..
},
conversation_id,
} = input
else {
return false;
};
// TODO: figure out how to avoid constructing the full paths in `should_execute`
// and then again in `execute`, and then again on every render.
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
locations
.iter()
.map(|file| {
PathBuf::from(host_native_absolute_path(
&file.name,
&shell,
&current_working_directory,
))
})
.collect(),
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
action: AIAgentActionType::ReadFiles(ReadFilesRequest { locations }),
..
} = action
else {
return ActionExecution::InvalidAction;
};
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
locations.iter().map(|file| Path::new(&file.name)),
);
});
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let locations = locations.clone();
// Check if this is a remote session with a connected host.
let session_type = self.active_session.as_ref(ctx).session_type(ctx);
let remote_client = match &session_type {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned(),
_ => None,
};
// Remote session without a usable remote server client. File reading
// requires either local access or a connected remote server, neither
// of which is available.
if matches!(session_type, Some(SessionType::WarpifiedRemote { .. }))
&& remote_client.is_none()
{
return ActionExecution::Sync(AIAgentActionResultType::ReadFiles(
ReadFilesResult::Error(
"The file read/edit tool is not available on this remote session. \
Try using a different tool."
.to_string(),
),
));
}
if let Some(client) = remote_client {
return ActionExecution::Async {
execute_future: Box::pin(async move {
let request = remote_server::proto::ReadFileContextRequest {
files: locations
.iter()
.map(|loc| {
let absolute_path = host_native_absolute_path(
&loc.name,
&shell,
&current_working_directory,
);
remote_server::proto::ReadFileContextFile {
path: absolute_path,
line_ranges: loc
.lines
.iter()
.map(|r| remote_server::proto::LineRange {
start: r.start as u32,
end: r.end as u32,
})
.collect(),
}
})
.collect(),
max_file_bytes: None,
max_batch_bytes: None,
};
let response = client
.read_file_context(request)
.await
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
if !response.failed_files.is_empty() && response.file_contexts.is_empty() {
let failed = response
.failed_files
.iter()
.map(|f| {
let reason = f
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown error");
format!("{}: {reason}", f.path)
})
.collect::<Vec<_>>()
.join(", ");
return Ok(ReadFilesResult::Error(format!(
"Failed to read files: {failed}"
)));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(
text,
) => crate::ai::agent::AnyFileContent::StringContent(text),
remote_server::proto::file_context_proto::Content::BinaryContent(
bytes,
) => crate::ai::agent::AnyFileContent::BinaryContent(bytes),
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc.last_modified_epoch_millis.map(|ms| {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms)
});
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
Ok(ReadFilesResult::Success {
files: file_contexts,
})
}),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result =
res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
AIAgentActionResultType::ReadFiles(action_result)
}),
};
}
// Local path.
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await?;
if result.missing_files.is_empty() {
Ok(ReadFilesResult::Success {
files: result.file_contexts,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(ReadFilesResult::Error(format!(
"These files do not exist: {missing_files}"
)))
}
}),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
AIAgentActionResultType::ReadFiles(action_result)
}),
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadFilesExecutor {
type Event = ();
}
@@ -0,0 +1,164 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
#[cfg(not(target_family = "wasm"))]
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::terminal::model::session::active_session::ActiveSession;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
#[cfg(not(target_family = "wasm"))]
use crate::ai::{
agent::{AIAgentActionResultType, ReadMCPResourceResult},
blocklist::{
action_model::{AIAgentAction, AIAgentActionType},
BlocklistAIPermissions,
},
};
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
pub struct ReadMCPResourceExecutor {
_active_session: ModelHandle<ActiveSession>,
#[cfg_attr(target_family = "wasm", expect(unused))]
terminal_view_id: EntityId,
}
impl ReadMCPResourceExecutor {
pub fn new(_active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
_active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action:
AIAgentActionType::ReadMCPResource {
server_id,
name,
uri,
..
},
..
},
conversation_id,
} = input
else {
return false;
};
BlocklistAIPermissions::as_ref(ctx).can_read_mcp_resource(
server_id.as_ref(),
name.as_str(),
uri.as_deref(),
&conversation_id,
Some(self.terminal_view_id),
ctx,
)
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput { action, .. } = input;
let AIAgentAction {
action:
AIAgentActionType::ReadMCPResource {
server_id: _,
name,
uri,
},
..
} = action
else {
return ActionExecution::InvalidAction;
};
let templatable_mcp_client = TemplatableMCPServerManager::as_ref(ctx);
let resource = match uri {
Some(uri) => templatable_mcp_client
.resources()
.find(|resource| &resource.uri == uri),
None => templatable_mcp_client
.resources()
.find(|resource| &resource.name == name),
};
let Some(resource) = resource else {
return ActionExecution::Sync(AIAgentActionResultType::ReadMCPResource(
ReadMCPResourceResult::Error("MCP server resource not found".to_owned()),
));
};
let uri = resource.uri.clone();
let Some(reconnecting_peer) = templatable_mcp_client.server_with_resource(resource)
else {
return ActionExecution::Sync(AIAgentActionResultType::ReadMCPResource(
ReadMCPResourceResult::Error("MCP server for resource not found".to_owned()),
));
};
ActionExecution::new_async(
async move {
reconnecting_peer
.read_resource(rmcp::model::ReadResourceRequestParam { uri })
.await
},
|res, _ctx| handle_read_resource_result(res),
)
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadMCPResourceExecutor {
type Event = ();
}
/// Handles the result of a read_resource request, converting it to an AIAgentActionResultType.
#[cfg(not(target_family = "wasm"))]
fn handle_read_resource_result(
res: Result<rmcp::model::ReadResourceResult, rmcp::ServiceError>,
) -> AIAgentActionResultType {
let action_result = match res {
Ok(response) => ReadMCPResourceResult::Success {
resource_contents: response.contents,
},
Err(e) => ReadMCPResourceResult::Error(e.to_string()),
};
AIAgentActionResultType::ReadMCPResource(action_result)
}
@@ -0,0 +1,94 @@
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use crate::ai::skills::{SkillManager, SkillTelemetryEvent};
use crate::send_telemetry_from_ctx;
use ai::agent::action_result::AnyFileContent;
use warpui::{ModelContext, SingletonEntity};
use crate::ai::agent::AIAgentActionType;
use crate::ai::agent::ReadSkillRequest;
use crate::ai::agent::ReadSkillResult;
use ai::agent::action_result::FileContext;
use futures::future::{BoxFuture, FutureExt};
use warpui::Entity;
pub struct ReadSkillExecutor;
impl ReadSkillExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// User-created skills are readable on demand.
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::ReadSkill(ReadSkillRequest { skill: skill_ref }) = &action.action
else {
return ActionExecution::<ReadSkillResult>::InvalidAction;
};
match SkillManager::as_ref(ctx).skill_by_reference(skill_ref) {
Some(skill) => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
name: Some(skill.name.clone()),
scope: Some(skill.scope),
provider: Some(skill.provider),
error: false,
},
ctx
);
let content = FileContext::new(
skill.path.to_string_lossy().into_owned(),
AnyFileContent::StringContent(skill.content.clone()),
skill.line_range.clone(),
None,
);
ActionExecution::Sync(ReadSkillResult::Success { content }.into())
}
None => {
send_telemetry_from_ctx!(
SkillTelemetryEvent::Read {
reference: skill_ref.clone(),
name: None,
scope: None,
provider: None,
error: true,
},
ctx
);
ActionExecution::Sync(
ReadSkillResult::Error(format!("Skill not found: {:?}", skill_ref)).into(),
)
}
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for ReadSkillExecutor {
type Event = ();
}
#[cfg(test)]
#[path = "read_skill_tests.rs"]
mod tests;
@@ -0,0 +1,142 @@
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::agent::ReadSkillRequest;
use crate::ai::agent::ReadSkillResult;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
use crate::ai::blocklist::action_model::AIConversationId;
use crate::ai::skills::SkillManager;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
use ai::skills::{parse_skill, SkillReference};
use repo_metadata::{
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
};
use std::fs;
use std::io::Write;
use tempfile::TempDir;
use warpui::App;
use watcher::HomeDirectoryWatcher;
fn initialize_app(app: &mut App) {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(SkillManager::new);
}
fn create_test_skill_file(dir: &TempDir, name: &str, description: &str) -> std::path::PathBuf {
let skill_content = format!(
r#"---
name: {}
description: {}
---
# {}
## Instructions
Test instructions for this skill.
## Examples
Example usage of the skill.
"#,
name, description, name
);
let skill_dir = dir.path().join(format!(".claude/skills/{}", name));
fs::create_dir_all(&skill_dir).unwrap();
let skill_path = skill_dir.join("SKILL.md");
let mut file = fs::File::create(&skill_path).unwrap();
file.write_all(skill_content.as_bytes()).unwrap();
file.flush().unwrap();
skill_path
}
#[test]
fn test_read_skill_executor_success() {
let temp_dir = TempDir::new().unwrap();
let skill_path = create_test_skill_file(&temp_dir, "test-skill", "A test skill");
App::test((), |mut app| async move {
initialize_app(&mut app);
// Populate SkillManager cache with the test skill
let parsed_skill = parse_skill(&skill_path).expect("Failed to parse test skill");
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
manager.add_skill_for_testing(parsed_skill);
});
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path.clone()),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
match result {
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Success { content },
)) => {
assert_eq!(content.file_name, skill_path.to_string_lossy().to_string());
}
_ => panic!("Successfully read skill file; should return ReadSkillResult::Success"),
}
});
});
}
#[test]
fn test_read_skill_executor_file_not_found() {
let temp_dir = TempDir::new().unwrap();
// Don't create the SKILL.md file
let skill_path = temp_dir.path().join("SKILL.md");
App::test((), |mut app| async move {
initialize_app(&mut app);
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
let action = AIAgentAction {
id: AIAgentActionId::from("test-action-id".to_string()),
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
skill: SkillReference::Path(skill_path),
}),
task_id: TaskId::new("test-task-id".to_string()),
requires_result: false,
};
let input = ExecuteActionInput {
action: &action,
conversation_id: AIConversationId::new(),
};
executor_handle.update(&mut app, |executor, ctx| {
let result: AnyActionExecution = executor.execute(input, ctx).into();
match result {
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
ReadSkillResult::Error(error_msg),
)) => {
// Should contain an error about file not found or I/O error
assert!(!error_msg.is_empty());
}
_ => panic!(
"Nonexistent SKILL.md file at given path; should return ReadSkillResult::Error"
),
}
});
});
}
@@ -0,0 +1,137 @@
use std::collections::HashSet;
use ai::agent::action_result::{AIAgentActionResultType, RequestComputerUseResult};
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
use crate::ai::agent::{AIAgentActionId, AIAgentActionType};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::TelemetryEvent;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct RequestComputerUseExecutor {
terminal_view_id: EntityId,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
/// Actions that were determined to be auto-executed in should_autoexecute().
/// Used to determine is_autoexecuted when emitting telemetry in execute().
autoexecuted_actions: HashSet<AIAgentActionId>,
}
impl RequestComputerUseExecutor {
pub fn new(terminal_view_id: EntityId) -> Self {
Self {
terminal_view_id,
ambient_agent_task_id: None,
autoexecuted_actions: HashSet::new(),
}
}
pub fn set_ambient_agent_task_id(&mut self, id: Option<AmbientAgentTaskId>) {
self.ambient_agent_task_id = id;
}
pub(super) fn should_autoexecute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::RequestComputerUse(_) = &action.action else {
return false;
};
// Check profile permission
let permission = crate::ai::blocklist::BlocklistAIPermissions::as_ref(ctx)
.get_computer_use_setting(ctx, Some(self.terminal_view_id));
if permission.is_always_allow() {
// Track that this action was auto-executed for telemetry in execute()
self.autoexecuted_actions.insert(action.id.clone());
return true;
}
// Otherwise require user confirmation for computer use.
false
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
} = input;
let AIAgentActionType::RequestComputerUse(request) = &action.action else {
return ActionExecution::InvalidAction;
};
// If we're executing, that implies that computer use has been approved.
let is_autoexecuted = self.autoexecuted_actions.remove(&action.id);
send_telemetry_from_ctx!(
TelemetryEvent::ComputerUseApproved {
conversation_id,
is_autoexecuted,
ambient_agent_task_id: self.ambient_agent_task_id,
},
ctx
);
let screenshot_params = request.screenshot_params;
let mut actor = computer_use::create_actor();
let platform = actor.platform();
ActionExecution::Async {
execute_future: Box::pin(async move {
let result = actor
.perform_actions(&[], computer_use::Options { screenshot_params })
.await;
(result, platform)
}),
on_complete: Box::new(|action_result, _ctx| match action_result {
(
Ok(computer_use::ActionResult {
screenshot: Some(screenshot),
..
}),
Some(platform),
) => AIAgentActionResultType::RequestComputerUse(
RequestComputerUseResult::Approved {
screenshot,
platform,
},
),
(
Ok(computer_use::ActionResult {
screenshot: Some(_),
..
}),
None,
) => AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Error(
"Unknown platform".to_string(),
)),
(Ok(_), _) => {
AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Error(
"Failed to capture initial screenshot".to_string(),
))
}
(Err(err), _) => AIAgentActionResultType::RequestComputerUse(
RequestComputerUseResult::Error(err),
),
}),
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for RequestComputerUseExecutor {
type Event = ();
}
@@ -0,0 +1,445 @@
mod apply_diff_model;
mod diff_application;
mod telemetry;
use warp_util::file::FileSaveError;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::diff_validation::AIRequestedCodeDiff;
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use itertools::Itertools;
use vec1::{vec1, Vec1};
use warp_core::send_telemetry_from_ctx;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
use apply_diff_model::ApplyDiffModel;
pub(crate) use diff_application::apply_edits;
use diff_application::DiffApplicationError;
pub(crate) use diff_application::FileReadResult;
pub(crate) use telemetry::MalformedFinalLineProxyEvent;
#[allow(unused_imports)]
pub use telemetry::{EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent};
pub use telemetry::{
EditReceivedEvent, EditResolvedEvent, EditStats, RequestFileEditsFormatKind,
RequestFileEditsTelemetryEvent,
};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId,
AIAgentActionResultType, AIAgentActionType, AIAgentOutputMessage,
AIAgentOutputMessageType, AIIdentifiers, RequestFileEditsResult, UpdatedFileContext,
},
blocklist::{
inline_action::code_diff_view::{
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
},
BlocklistAIPermissions, RequestedEditResolution,
},
paths::host_native_absolute_path,
},
safe_warn,
terminal::model::session::{active_session::ActiveSession, SessionType},
BlocklistAIHistoryModel,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>,
apply_diff_model: ModelHandle<ApplyDiffModel>,
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
/// Set of action IDs where diff application failed.
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
terminal_view_id: EntityId,
}
impl RequestFileEditsExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
let apply_diff_model = ctx.add_model(|_| ApplyDiffModel::new(active_session.clone()));
Self {
active_session,
apply_diff_model,
diff_views: HashMap::new(),
diff_application_failures: HashMap::new(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::RequestFileEdits { file_edits, .. },
..
},
conversation_id,
} = input
else {
return false;
};
let paths: Vec<PathBuf> = file_edits
.iter()
.filter_map(|edit| edit.file().map(PathBuf::from))
.collect();
// Don't allow autoexecution if the diff was generated passively.
let Some(latest_exchange) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.latest_exchange())
else {
return false;
};
if latest_exchange.has_passive_request() {
return false;
}
// Allow "autoexecution" if the diff application failed so that we can continue execution.
// This is a terrible hack--but allows us to continue execution and let the LLM potentially recover
// from the LLM.
// If we don't do this, a failed diff application will block execution of the entire AI conversation
// without any possibility of recovery.
if self
.diff_application_failures
.contains_key(&input.action.id)
{
return true;
}
BlocklistAIPermissions::as_ref(ctx)
.can_write_files(&conversation_id, &paths, Some(self.terminal_view_id), ctx)
.is_allowed()
}
/// Registers a diff view to handle a RequestFileEdits action.
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in
/// order for the necessary state to be set to handle the action.
pub fn register_requested_edits(
&mut self,
action_id: &AIAgentActionId,
view: &ViewHandle<CodeDiffView>,
) {
self.diff_views.insert(action_id.clone(), view.clone());
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::RequestFileEdits { .. },
..
},
..
} = input
else {
return ActionExecution::InvalidAction;
};
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!("Tried to execute a RequestFileEdits action without a diff view");
return ActionExecution::NotReady;
};
// If diff application failed, early exit.
if let Some(errors) = self.diff_application_failures.remove(id) {
return ActionExecution::Sync(AIAgentActionResultType::RequestFileEdits(
RequestFileEditsResult::DiffApplicationFailed {
error: DiffApplicationError::error_for_conversation(&errors),
},
));
}
let identifiers = self
.generate_ai_identifiers(&input.conversation_id, id, ctx)
.unwrap_or_else(|| AIIdentifiers {
client_conversation_id: Some(input.conversation_id),
..Default::default()
});
let (result_tx, result_rx) = oneshot::channel();
let mut result_tx = Some(result_tx);
ctx.subscribe_to_view(diff_view, move |_me, event, ctx| match event {
CodeDiffViewEvent::Rejected => {
let Some(result_tx) = result_tx.take() else {
return;
};
let _ = result_tx.send(RequestFileEditsResult::Cancelled);
}
CodeDiffViewEvent::SavedAcceptedDiffs {
diff,
updated_files,
file_contents,
deleted_files,
save_errors,
} => {
let Some(result_tx) = result_tx.take() else {
return;
};
// If saving any file failed, report it as an error to the LLM. Other files may
// have saved successfully, but we're ignoring this edge case for now.
if !save_errors.is_empty() {
let error = save_errors
.iter()
.filter_map(|err| match err.as_ref() {
FileSaveError::IOError { error, path } => {
Some(format!("Failed to save file {path:?}: {error}"))
}
_ => None,
})
.join("\n");
let _ = result_tx.send(RequestFileEditsResult::DiffApplicationFailed { error });
return;
}
let passive_diff = BlocklistAIHistoryModel::as_ref(ctx)
.is_entirely_passive_conversation(&input.conversation_id);
send_telemetry_from_ctx!(
RequestFileEditsTelemetryEvent::EditResolved(EditResolvedEvent {
identifiers: identifiers.clone(),
response: RequestedEditResolution::Accept,
stats: EditStats {
files_edited: updated_files.len(),
lines_added: diff.lines_added,
lines_removed: diff.lines_removed,
},
passive_diff,
},),
ctx
);
// Build a map of file path → content from the editor buffers.
// This avoids re-reading files from disk or the remote server.
let content_map: HashMap<String, String> = file_contents.iter().cloned().collect();
let mut file_edited_map = HashMap::new();
for (file_location, was_edited) in updated_files.iter() {
file_edited_map.insert(file_location.name.clone(), *was_edited);
}
let _ = result_tx.send(RequestFileEditsResult::Success {
diff: diff.unified_diff.clone(),
updated_files: updated_files
.iter()
.map(|(file_location, was_edited)| {
let content = content_map
.get(&file_location.name)
.cloned()
.unwrap_or_default();
let line_count = content.lines().count();
UpdatedFileContext {
was_edited_by_user: *was_edited,
file_context: crate::ai::agent::FileContext {
file_name: file_location.name.clone(),
content: crate::ai::agent::AnyFileContent::StringContent(
content,
),
line_range: None,
last_modified: None,
line_count,
},
}
})
.collect(),
deleted_files: deleted_files.clone(),
lines_added: diff.lines_added,
lines_removed: diff.lines_removed,
});
}
_ => (),
});
diff_view.update(ctx, |diff_view, ctx| {
diff_view.accept_and_save(ctx);
});
ActionExecution::new_async(result_rx, |result, _ctx| match result {
Ok(result) => AIAgentActionResultType::RequestFileEdits(result),
Err(oneshot::Canceled) => {
AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::Cancelled)
}
})
}
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let AIAgentAction {
id,
action: AIAgentActionType::RequestFileEdits { file_edits, .. },
..
} = input.action
else {
return futures::future::ready(()).boxed();
};
let ai_identifiers = self
.generate_ai_identifiers(&input.conversation_id, id, ctx)
.unwrap_or_else(|| AIIdentifiers {
client_conversation_id: Some(input.conversation_id),
..Default::default()
});
let passive_diff = BlocklistAIHistoryModel::as_ref(ctx)
.is_entirely_passive_conversation(&input.conversation_id);
send_telemetry_from_ctx!(
RequestFileEditsTelemetryEvent::EditReceived(EditReceivedEvent {
identifiers: ai_identifiers.clone(),
unique_files: file_edits.iter().map(|file| file.file()).unique().count(),
diffs: file_edits.len(),
passive_diff,
}),
ctx
);
let (tx, rx) = oneshot::channel();
let files = file_edits.clone();
let id = id.clone();
let apply_future = self.apply_diff_model.update(ctx, |model, ctx| {
model.apply_diffs(files, &ai_identifiers, passive_diff, ctx)
});
ctx.spawn(
async move {
let applied_diffs = apply_future.await;
(applied_diffs, id, tx)
},
|me, (diffs, id, tx), ctx| {
me.on_diffs_applied(diffs, id, tx, ctx);
},
);
async {
rx.await.ok();
}
.boxed()
}
fn on_diffs_applied(
&mut self,
applied_diffs: Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>,
id: AIAgentActionId,
tx: oneshot::Sender<()>,
ctx: &mut ModelContext<Self>,
) {
tx.send(()).ok();
let Some(diff_view) = self.diff_views.get(&id) else {
log::warn!(
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view"
);
return;
};
let applied_diffs = match applied_diffs {
Ok(diffs) if !diffs.is_empty() => diffs,
Ok(_) => {
// We didn't generate any diffs--consider this a failure.
log::warn!("No diffs generated");
self.diff_application_failures
.insert(id, vec1![DiffApplicationError::EmptyDiff]);
return;
}
Err(err) => {
safe_warn!(
safe: ("Failed to generate diffs"),
full: ("Failed to generate diffs {err:?}")
);
self.diff_application_failures.insert(id, err);
return;
}
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let mut diffs = Vec::with_capacity(applied_diffs.len());
for diff in applied_diffs {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
diffs.push(file_diff);
}
// Set the session type on the diff view so save/delete/create routes
// through the correct FileModel backend.
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
diff_view.update(ctx, |diff_view, ctx| {
diff_view.set_diff_session_type(diff_session_type);
diff_view.set_candidate_diffs(diffs, ctx);
});
}
fn generate_ai_identifiers(
&self,
conversation_id: &AIConversationId,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> Option<AIIdentifiers> {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let conversation = history_model.conversation(conversation_id)?;
// Find the `AIAgentExchange` and its corresponding `AIAgentOutput` for this given action.
let (exchange, output) = conversation.all_exchanges().into_iter().find_map(|exchange| {
let output = exchange.output_status.output()?;
let contains_action = output.get().messages.iter().any(|step| {
matches!(step, AIAgentOutputMessage{ message: AIAgentOutputMessageType::Action(AIAgentAction { id, .. }), .. } if id == action_id)
});
contains_action.then_some((exchange, output))
})?;
let server_output_id = output.get().server_output_id.clone();
let model_id = output.get().model_info.as_ref().map(|m| m.model_id.clone());
Some(AIIdentifiers {
client_conversation_id: Some(*conversation_id),
client_exchange_id: Some(exchange.id),
server_output_id,
server_conversation_id: conversation
.server_conversation_token()
.cloned()
.map(Into::into),
model_id,
})
}
}
impl Entity for RequestFileEditsExecutor {
type Event = ();
}
@@ -0,0 +1,162 @@
//! Entity submodel that encapsulates all filesystem access for diff application.
//!
//! The executor holds a [`ModelHandle<ApplyDiffModel>`] and calls
//! [`ApplyDiffModel::apply_diffs`] without knowing whether the session is local
//! or remote. Internally the method resolves the session context and remote
//! client from the model context, then dispatches:
//!
//! - **Local**: calls [`apply_edits`] with a `std::fs`-backed closure.
//! - **Remote**: calls [`apply_edits`] with a [`RemoteServerClient`]-backed closure.
use ai::diff_validation::AIRequestedCodeDiff;
use futures::FutureExt;
use vec1::Vec1;
use warpui::r#async::BoxFuture;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
use crate::ai::agent::{AIIdentifiers, FileEdit};
use crate::ai::blocklist::SessionContext;
use crate::auth::AuthStateProvider;
use crate::terminal::model::session::active_session::ActiveSession;
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
/// Entity submodel that encapsulates filesystem access for diff application.
///
/// Held as a [`ModelHandle`] by the [`super::RequestFileEditsExecutor`].
pub(crate) struct ApplyDiffModel {
active_session: ModelHandle<ActiveSession>,
}
impl Entity for ApplyDiffModel {
type Event = ();
}
impl ApplyDiffModel {
pub fn new(active_session: ModelHandle<ActiveSession>) -> Self {
Self { active_session }
}
/// Resolves session context and remote client from the model context, then
/// returns a future that applies the edits locally or remotely.
pub fn apply_diffs(
&self,
edits: Vec<FileEdit>,
ai_identifiers: &AIIdentifiers,
passive_diff: bool,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>> {
let session_context = SessionContext::from_session(self.active_session.as_ref(ctx), ctx);
let background_executor = ctx.background_executor();
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
let ai_identifiers = ai_identifiers.clone();
let remote_client = session_context.host_id().and_then(|host_id| {
remote_server::manager::RemoteServerManager::as_ref(ctx)
.client_for_host(host_id)
.cloned()
});
let is_remote = session_context.is_remote();
let fut = async move {
if is_remote {
match remote_client {
Some(client) => {
apply_edits(
edits,
&session_context,
&ai_identifiers,
background_executor,
auth_state,
passive_diff,
|path| {
let client = client.clone();
async move { read_remote_file(&client, &path).await }
},
)
.await
}
None => Err(vec1::vec1![
DiffApplicationError::RemoteFileOperationsUnsupported
]),
}
} else {
apply_edits(
edits,
&session_context,
&ai_identifiers,
background_executor,
auth_state,
passive_diff,
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
)
.await
}
};
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
fut.boxed_local()
} else {
fut.boxed()
}
}
}
}
// ── Remote file reading ──────────────────────────────────────────────────────────
/// Per-file byte limit for remote diff application (10 MB).
const MAX_DIFF_READ_BYTES: u32 = 10_000_000;
async fn read_remote_file(
client: &remote_server::client::RemoteServerClient,
path: &str,
) -> FileReadResult {
let request = remote_server::proto::ReadFileContextRequest {
files: vec![remote_server::proto::ReadFileContextFile {
path: path.to_string(),
line_ranges: vec![],
}],
max_file_bytes: Some(MAX_DIFF_READ_BYTES),
max_batch_bytes: None,
};
match client.read_file_context(request).await {
Ok(response) => {
if let Some(fc) = response.file_contexts.into_iter().next() {
// A whole-file read that was truncated by the byte limit will
// have line_range_start/end set even though no ranges were
// requested. Detect this and fail explicitly rather than
// applying the diff to partial content.
if fc.line_range_start.is_some() || fc.line_range_end.is_some() {
return FileReadResult::ReadError(format!(
"File exceeds the {MAX_DIFF_READ_BYTES}-byte limit for remote diff \
application and was truncated. The diff cannot be applied safely."
));
}
match fc.content {
Some(remote_server::proto::file_context_proto::Content::TextContent(
content,
)) => FileReadResult::Found(content),
Some(remote_server::proto::file_context_proto::Content::BinaryContent(_)) => {
// apply-diff only works with text files
FileReadResult::ReadError("File is binary".to_string())
}
None => FileReadResult::Found(String::new()),
}
} else if let Some(failed) = response.failed_files.into_iter().next() {
let message = failed
.error
.map(|e| e.message)
.unwrap_or_else(|| "Unknown error".to_string());
if message.contains("not found") || message.contains("Not found") {
FileReadResult::NotFound
} else {
FileReadResult::ReadError(message)
}
} else {
FileReadResult::NotFound
}
}
Err(err) => FileReadResult::ReadError(format!("{err}")),
}
}
@@ -0,0 +1,765 @@
//! Module containing helper code to apply suggested diffs from an LLM
//! to a set of files on the user's filesystem.
use std::{
collections::{hash_map::Entry, HashMap, HashSet},
future::Future,
sync::Arc,
};
use ai::diff_validation::{
fuzzy_match_diffs, fuzzy_match_v4a_diffs, AIRequestedCodeDiff, DiffDelta, DiffMatchFailures,
DiffType, ParsedDiff, SearchAndReplace, V4AHunk,
};
use itertools::Itertools;
use vec1::Vec1;
use warpui::r#async::executor::Background;
use crate::{
ai::{
agent::{AIIdentifiers, FileEdit},
blocklist::SessionContext,
paths::host_native_absolute_path,
},
auth::auth_state::AuthState,
safe_debug, safe_warn, send_telemetry_on_executor,
};
use super::telemetry::{
DiffInvalidFileEvent, DiffMatchFailedEvent, MissingLineNumbersEvent,
RequestFileEditsTelemetryEvent,
};
/// Result of reading a file from disk or a remote server.
///
/// This is the common currency between the local (`std::fs`) and remote
/// (`RemoteServerClient`) file-reading paths so that all diff application
/// logic can be shared.
pub(crate) enum FileReadResult {
/// The file was found and its full content is available.
Found(String),
/// The file does not exist.
NotFound,
/// The file could not be read for a reason other than "not found".
ReadError(String),
}
impl From<std::io::Result<String>> for FileReadResult {
fn from(result: std::io::Result<String>) -> Self {
match result {
Ok(content) => FileReadResult::Found(content),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => FileReadResult::NotFound,
Err(err) => FileReadResult::ReadError(format!("{err:#}")),
}
}
}
/// Errors that can occur while applying a diff.
#[derive(Debug)]
pub(crate) enum DiffApplicationError {
/// Some diffs could not be matched against the file content.
UnmatchedDiffs {
file: String,
match_failures: DiffMatchFailures,
},
/// The file that a diff should be applied to did not exist.
MissingFile {
file: String,
},
/// The file could not be read (I/O error, permissions, remote connectivity, etc.).
ReadFailed {
file: String,
// TODO(CODE-353): Display I/O errors to the user, since they may be able to fix them.
#[expect(dead_code)]
message: String,
},
/// A file that was supposed to be new already exists.
AlreadyExists {
file: String,
},
/// The diff contained multiple attempts to create the same file.
MultipleFileCreation {
file: String,
},
/// No diffs could be applied.
EmptyDiff,
MutatedDeletedFile {
file: String,
},
MultipleFileRenames {
file: String,
},
/// File read/write operations are not available on this remote session.
/// This covers both connection-dropped and unsupported SSH session types.
RemoteFileOperationsUnsupported,
}
impl DiffApplicationError {
/// Format this error for inclusion in the agent conversation. The error should help the LLM
/// retry and generate a valid diff.
fn to_conversation_message(&self) -> String {
match self {
DiffApplicationError::UnmatchedDiffs {
file,
match_failures,
} => {
use std::fmt::Write;
let mut message = String::new();
if match_failures.fuzzy_match_failures > 0 {
let _ = write!(message, "Could not apply all diffs to {file}.");
}
if match_failures.noop_deltas > 0 {
if !message.is_empty() {
message.push(' ');
}
let _ = write!(message, "The changes to {file} were already made.");
}
message
}
DiffApplicationError::MissingFile { file } => {
format!("{file} does not exist. Is the path correct?")
}
DiffApplicationError::AlreadyExists { file } => {
format!("Could not create {file} because it already exists.")
}
DiffApplicationError::ReadFailed { file, .. } => {
format!("Could not read {file}")
}
DiffApplicationError::MultipleFileCreation { file } => {
format!("There can only be one attempt to create {file}.")
}
DiffApplicationError::MultipleFileRenames { file } => {
format!("There can only be one attempt to rename {file}.")
}
DiffApplicationError::MutatedDeletedFile { file } => {
format!("Could not mutate a deleted file {file}.")
}
DiffApplicationError::EmptyDiff => "No diffs could be applied.".to_string(),
DiffApplicationError::RemoteFileOperationsUnsupported => {
"The file read/edit tool is not available on this remote session. Try using a different tool.".to_string()
}
}
}
/// Format a list of errors for inclusion in the agent conversation.
pub fn error_for_conversation(errors: &Vec1<DiffApplicationError>) -> String {
if errors.len() == 1 {
errors.first().to_conversation_message()
} else {
errors
.iter()
.format_with("\n", |err, f| {
f(&format_args!("* {}", err.to_conversation_message()))
})
.to_string()
}
}
}
/// Given a list of suggested edits from the server API, parse it into applicable diffs to be shown
/// to the user as a series of code diffs.
///
/// * Search-and-replace diffs are matched to existing files on disk
/// * Created files are expected to not already exist
///
/// Errors are reported as telemetry, and also returned for display.
pub(crate) async fn apply_edits<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
ai_identifiers: &AIIdentifiers,
background_executor: Arc<Background>,
auth_state: Arc<AuthState>,
passive_diff: bool,
read_file: F,
) -> Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let result = apply_edits_internal(edits, session_context, &read_file).await;
// Send telemetry for all diff application errors.
// Count of attempts to edit a file that doesn't exist or create a file that already exists.
let mut invalid_file_count = 0;
for error in result.errors.iter() {
match error {
DiffApplicationError::UnmatchedDiffs { match_failures, .. } => {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::DiffMatchFailed(DiffMatchFailedEvent {
identifiers: ai_identifiers.clone(),
failures: *match_failures,
passive_diff,
}),
background_executor
);
}
DiffApplicationError::MissingFile { .. }
| DiffApplicationError::ReadFailed { .. }
| DiffApplicationError::AlreadyExists { .. }
| DiffApplicationError::MultipleFileCreation { .. }
| DiffApplicationError::MutatedDeletedFile { .. }
| DiffApplicationError::MultipleFileRenames { .. }
| DiffApplicationError::RemoteFileOperationsUnsupported => {
invalid_file_count += 1;
}
DiffApplicationError::EmptyDiff => {}
}
}
if invalid_file_count > 0 {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::DiffInvalidFile(DiffInvalidFileEvent {
count: invalid_file_count,
identifiers: ai_identifiers.clone(),
passive_diff,
}),
background_executor
);
}
// Send telemetry for any warnings, which don't necessarily prevent diff application.
let total_missing_line_numbers: u8 = result
.warnings
.iter()
.map(|warning| match warning {
DiffWarning::MissingLineNumbers { count, .. } => *count,
})
.sum();
if total_missing_line_numbers > 0 {
send_telemetry_on_executor!(
auth_state,
RequestFileEditsTelemetryEvent::MissingLineNumbers(MissingLineNumbersEvent {
identifiers: ai_identifiers.clone(),
count: total_missing_line_numbers,
passive_diff,
}),
background_executor
);
}
match Vec1::try_from_vec(result.errors) {
Ok(errors) => Err(errors),
Err(vec1::Size0Error) => Ok(result.diffs),
}
}
/// Warnings are issues that don't necessarily prevent diff application, but indicate an unexpected
/// response from the LLM.
///
/// For example, we expect the search string in a diff to include line numbers, but can rely on
/// fuzzy matching if they're missing.
#[derive(Debug, Clone)]
pub enum DiffWarning {
/// Search blocks that are missing line numbers.
MissingLineNumbers { count: u8 },
}
#[derive(Default)]
struct DiffResult {
/// All successfully-applied diffs, grouped by file.
diffs: Vec<AIRequestedCodeDiff>,
/// All errors that occurred while applying diffs.
errors: Vec<DiffApplicationError>,
/// All warnings that occurred while applying diffs.
warnings: Vec<DiffWarning>,
}
/// You generally want to use `apply_edits`, however, if you don't want to report telemetry or be as
/// strict, this is available. For example, we use this when debug importing conversations.
async fn apply_edits_internal<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
read_file: &F,
) -> DiffResult
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let mut search_replace_deltas: HashMap<String, Vec<SearchAndReplace>> = HashMap::new();
let mut v4a_deltas: HashMap<String, Vec<V4AHunk>> = HashMap::new();
let mut new_files: HashMap<String, String> = HashMap::new();
let mut deleted_files: HashSet<String> = HashSet::new();
let mut file_renames: HashMap<String, String> = HashMap::new();
let mut result = DiffResult::default();
for edit in edits {
match edit {
FileEdit::Edit(diff) => {
let Some(file_path) = diff.file().cloned() else {
continue;
};
match diff {
ParsedDiff::StrReplaceEdit { .. } => {
let deltas = search_replace_deltas.entry(file_path).or_default();
if let Ok(d) = diff.try_into() {
deltas.push(d);
}
}
ParsedDiff::V4AEdit { hunks, move_to, .. } => {
v4a_deltas
.entry(file_path.clone())
.or_default()
.extend(hunks);
if let Some(move_to) = move_to {
if file_renames.contains_key(&file_path) {
result
.errors
.push(DiffApplicationError::MultipleFileRenames {
file: file_path.clone(),
});
continue;
}
file_renames.insert(file_path, move_to);
}
}
};
}
FileEdit::Create { file, content } => {
let Some(file_path) = file else { continue };
match new_files.entry(file_path) {
Entry::Occupied(entry) => {
result
.errors
.push(DiffApplicationError::MultipleFileCreation {
file: entry.key().clone(),
});
continue;
}
Entry::Vacant(entry) => {
let Some(content) = content else {
continue;
};
entry.insert(content);
}
}
}
FileEdit::Delete { file } => {
// Deleting the same file multiple times is valid, I guess...
let Some(file_path) = file else { continue };
deleted_files.insert(file_path);
}
}
}
let search_replace_files: HashSet<String> = search_replace_deltas.keys().cloned().collect();
let v4a_files: HashSet<String> = v4a_deltas.keys().cloned().collect();
let new_file_paths: HashSet<String> = new_files.keys().cloned().collect();
let deleted_file_paths: HashSet<String> = deleted_files.iter().cloned().collect();
for (file_path, deltas) in search_replace_deltas {
// If a file is also being explicitly created/deleted, skip applying edits to avoid
// producing redundant errors (e.g. MissingFile alongside MultipleFileCreation).
if new_file_paths.contains(&file_path) || deleted_file_paths.contains(&file_path) {
continue;
}
apply_search_replace(file_path, deltas, session_context, read_file, &mut result).await;
}
for (file_path, deltas) in v4a_deltas {
if new_file_paths.contains(&file_path) || deleted_file_paths.contains(&file_path) {
continue;
}
let rename_to = file_renames.get(&file_path).cloned();
apply_v4a_update(
file_path,
deltas,
rename_to,
session_context,
read_file,
&mut result,
)
.await;
}
for (file, content) in new_files {
if search_replace_files.contains(&file)
|| v4a_files.contains(&file)
|| file_renames.contains_key(&file)
{
result
.errors
.push(DiffApplicationError::MultipleFileCreation { file });
} else {
apply_create_file(file, content, session_context, read_file, &mut result).await;
}
}
for file in deleted_files {
if new_file_paths.contains(&file)
|| search_replace_files.contains(&file)
|| v4a_files.contains(&file)
|| file_renames.contains_key(&file)
{
result
.errors
.push(DiffApplicationError::MutatedDeletedFile { file });
} else {
apply_delete_file(file, session_context, read_file, &mut result).await;
}
}
result
}
/// Converts a file-creation request into a diff.
async fn apply_create_file<F, Fut>(
file_path: String,
content: String,
session_context: &SessionContext,
read_file: &F,
result: &mut DiffResult,
) where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let absolute_path = host_native_absolute_path(
&file_path,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(absolute_path.clone()).await {
FileReadResult::Found(_) => {
safe_warn!(
safe: ("Agent Code tried to create a file that already exists"),
full: ("Agent Code tried to create a file that already exists: {absolute_path:?}")
);
result
.errors
.push(DiffApplicationError::AlreadyExists { file: file_path });
}
FileReadResult::NotFound => {
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::creation(content),
failures: None,
original_content: String::new(),
});
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to check if file exists for Agent Code: {err}"),
full: ("Unable to check if file exists for Agent Code: {absolute_path:?} {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
}
}
}
async fn apply_delete_file<F, Fut>(
file_path: String,
session_context: &SessionContext,
read_file: &F,
result: &mut DiffResult,
) where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let absolute_path = host_native_absolute_path(
&file_path,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(absolute_path.clone()).await {
FileReadResult::Found(file_content) => {
let num_lines = file_content.lines().count();
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::deletion(num_lines),
failures: None,
original_content: file_content,
})
}
FileReadResult::NotFound => {
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read file for Agent Code: {err}"),
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
}
}
}
/// Applies a set of search-and-replace diffs to a file.
async fn apply_search_replace<F, Fut>(
file_path: String,
deltas: Vec<SearchAndReplace>,
session_context: &SessionContext,
read_file: &F,
result: &mut DiffResult,
) where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let absolute_path = host_native_absolute_path(
&file_path,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(absolute_path.clone()).await {
FileReadResult::NotFound => {
match deltas.into_iter().exactly_one() {
Ok(SearchAndReplace { search, replace }) => {
if search.is_empty() {
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path,
diff_type: DiffType::creation(replace),
failures: None,
original_content: String::new(),
})
} else {
safe_warn!(
safe: ("Suggested non-empty diff on non-existent file"),
full: ("Suggested non-empty diff on non-existent file: {absolute_path:?}")
);
// A non-empty search block on a non-existent file indicates that the
// LLM likely got the path wrong, and is not trying to create a new file.
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
}
}
Err(err) => {
safe_warn!(
safe: ("Suggested {} diffs on non-existent file", err.len()),
full: ("Suggested {} diffs on non-existent file: {absolute_path:?}", err.len())
);
// Multiple diffs on a non-existent file indicate that the LLM likely got
// the path wrong, and is not trying to create a new file.
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
}
}
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read file for Agent Code: {err}"),
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
}
FileReadResult::Found(file_content) => {
safe_debug!(
safe: ("Matching diffs"),
full: ("Matching diffs for: {file_path:?}")
);
let fuzzy_match_diffs = fuzzy_match_diffs(&file_path, &deltas, file_content);
// Add warnings from the failure info - the `DiffMatchFailures` type includes both
// fatal and non-fatal errors.
if let Some(failures) = fuzzy_match_diffs.failures.as_ref() {
if failures.missing_line_numbers > 0 {
result.warnings.push(DiffWarning::MissingLineNumbers {
count: failures.missing_line_numbers,
});
}
}
if fuzzy_match_diffs.warrants_failure() {
if let Some(failures) = fuzzy_match_diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying diff: {failures:?}"),
full: ("Failure(s) applying diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
}
result.diffs.push(fuzzy_match_diffs);
}
}
}
async fn apply_v4a_update<F, Fut>(
file_path: String,
deltas: Vec<V4AHunk>,
rename_to: Option<String>,
session_context: &SessionContext,
read_file: &F,
result: &mut DiffResult,
) where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
{
let absolute_path = host_native_absolute_path(
&file_path,
session_context.shell(),
session_context.current_working_directory(),
);
let file_content = match read_file(absolute_path.clone()).await {
FileReadResult::NotFound => {
safe_warn!(
safe: ("V4A edits requested on non-existent file"),
full: ("V4A edits requested on non-existent file: {absolute_path:?}")
);
result
.errors
.push(DiffApplicationError::MissingFile { file: file_path });
return;
}
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read file for Agent Code: {err}"),
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: file_path,
message: err,
});
return;
}
FileReadResult::Found(content) => content,
};
safe_debug!(
safe: ("Matching V4A diffs"),
full: ("Matching V4A diffs for: {file_path:?}")
);
// Check if we're renaming to an existing file.
let rename_target_content = if let Some(target) = &rename_to {
let target_absolute = host_native_absolute_path(
target,
session_context.shell(),
session_context.current_working_directory(),
);
match read_file(target_absolute.clone()).await {
FileReadResult::Found(content) => Some(content),
FileReadResult::NotFound => None,
FileReadResult::ReadError(err) => {
safe_warn!(
safe: ("Unable to read rename target file: {err}"),
full: ("Unable to read rename target file {target_absolute:?}: {err}")
);
result.errors.push(DiffApplicationError::ReadFailed {
file: target.clone(),
message: err,
});
return;
}
}
} else {
None
};
if let Some(target_content) = rename_target_content {
// Renaming A to B where B already exists:
// 1. Create deletion for A.
// 2. Replace all of B with A.
// 3. Create update for B that applies the original diff to A.
let rename_target = rename_to.unwrap();
// First, match the V4A diffs against the source file (without rename)
let source_diffs = fuzzy_match_v4a_diffs(&file_path, &deltas, None, file_content.clone());
if source_diffs.warrants_failure() {
if let Some(failures) = source_diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying V4A diff: {failures:?}"),
full: ("Failure(s) applying V4A diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
return;
}
let target_num_lines = target_content.lines().count();
let source_num_lines = file_content.lines().count();
// Reuse the copy that fuzzy_match_v4a_diffs already
// made for its original_content field, so we only
// allocate once instead of cloning file_content again.
let deletion_original_content = source_diffs.original_content;
// Replace all of B's content with A's content.
// Moves file_content — no clone needed.
let mut new_deltas = Vec::new();
let replacement_range = if target_num_lines == 0 {
0..0
} else {
1..(target_num_lines + 1)
};
new_deltas.push(DiffDelta {
replacement_line_range: replacement_range,
insertion: file_content,
});
// Apply the original diff to A.
if let DiffType::Update {
deltas: source_deltas,
..
} = source_diffs.diff_type
{
new_deltas.extend(source_deltas);
}
// Create deletion diff for source file A
result.diffs.push(AIRequestedCodeDiff {
file_name: file_path.clone(),
diff_type: DiffType::deletion(source_num_lines),
failures: None,
original_content: deletion_original_content,
});
result.diffs.push(AIRequestedCodeDiff {
file_name: rename_target,
diff_type: DiffType::update(new_deltas, None),
failures: None,
original_content: target_content,
});
} else {
// Normal case: no rename or rename to non-existent file
let diffs = fuzzy_match_v4a_diffs(&file_path, &deltas, rename_to, file_content);
if diffs.warrants_failure() {
if let Some(failures) = diffs.failures.as_ref() {
safe_warn!(
safe: ("Failure(s) applying V4A diff: {failures:?}"),
full: ("Failure(s) applying V4A diff for {absolute_path:?}: {failures:?}")
);
result.errors.push(DiffApplicationError::UnmatchedDiffs {
file: file_path.clone(),
match_failures: *failures,
});
}
}
result.diffs.push(diffs);
}
}
#[cfg(test)]
#[path = "diff_application_tests.rs"]
mod tests;
@@ -0,0 +1,248 @@
/// Coarse format classification for the edit payload that produced a code diff.
///
/// This distinguishes the legacy search/replace edit format from the structured
/// V4A patch format used by `apply_patch`.
use ai::diff_validation::DiffMatchFailures;
use serde::Serialize;
use serde_json::json;
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use crate::ai::{agent::AIIdentifiers, blocklist::RequestedEditResolution};
/// Telemetry events associated with the `RequestFileEdits` AI agent action.
#[derive(Serialize, Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum RequestFileEditsTelemetryEvent {
EditResolved(EditResolvedEvent),
EditAcceptClicked(EditAcceptClickedEvent),
EditAcceptAndContinueClicked(EditAcceptAndContinueClickedEvent),
DiffMatchFailed(DiffMatchFailedEvent),
DiffInvalidFile(DiffInvalidFileEvent),
EditReceived(EditReceivedEvent),
MissingLineNumbers(MissingLineNumbersEvent),
MalformedFinalLineProxy(MalformedFinalLineProxyEvent),
}
/// Emitted when a user Accepts or Rejects a code diff suggestsion from Agent Mode.
#[derive(Serialize, Debug)]
pub struct EditResolvedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
pub response: RequestedEditResolution,
/// Information about the resolved edit, only set if it is accepted.
pub stats: EditStats,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when a user selects Accept for a code diff suggestion.
#[derive(Serialize, Debug)]
pub struct EditAcceptClickedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when a user selects Accept and start conversation for a code diff suggestion.
#[derive(Serialize, Debug)]
pub struct EditAcceptAndContinueClickedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
}
#[derive(Serialize, Debug)]
pub struct EditStats {
/// Number of files that were edited.
pub files_edited: usize,
/// Number of lines that were added.
pub lines_added: usize,
/// Number of lines that were removed.
pub lines_removed: usize,
}
#[derive(Serialize, Debug)]
pub struct DiffMatchFailedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
#[serde(flatten)]
pub failures: DiffMatchFailures,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Could not find the file(s) given in a code diff.
#[derive(Serialize, Debug)]
pub struct DiffInvalidFileEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
pub count: usize,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when code edits are displayed to the user.
#[derive(Serialize, Debug)]
pub struct EditReceivedEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of unique files in the code diff.
pub unique_files: usize,
/// Total number of diffs in the event.
pub diffs: usize,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
/// Emitted when search blocks are missing line numbers (non-fatal warning).
#[derive(Serialize, Debug)]
pub struct MissingLineNumbersEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of search blocks missing line numbers.
pub count: u8,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
#[derive(Serialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum RequestFileEditsFormatKind {
/// Legacy search/replace diff format (`edit_files` style).
StrReplace,
/// Structured V4A patch format (`apply_patch` style with Begin/End Patch hunks).
V4A,
/// Both formats were present in the same requested edit payload.
Mixed,
/// The format could not be determined from the payload.
Unknown,
}
/// Emitted when accepted diffs indicate a likely malformed trailing-line condition.
///
/// This signal is emitted when final changed lines intersect the model-proposed terminal changed
/// range and the proposed terminal line matches a malformed-line heuristic.
#[derive(Serialize, Debug)]
pub struct MalformedFinalLineProxyEvent {
#[serde(flatten)]
pub identifiers: AIIdentifiers,
/// Number of files included in the accepted edit.
pub file_count: usize,
/// Number of files that were edited by the user prior to accepting.
pub edited_file_count: usize,
/// Number of files where:
/// - final changed lines intersect the model-proposed terminal changed range, and
/// - the proposed terminal line matched the malformed-line heuristic.
pub correction_count: usize,
/// Number of `correction_count` detections where `was_edited` was true.
pub edited_correction_count: usize,
/// Number of `correction_count` detections where `was_edited` was false.
pub unedited_correction_count: usize,
/// Coarse source format for the requested edit payload.
pub format_kind: RequestFileEditsFormatKind,
/// Whether this is a passive diff.
pub passive_diff: bool,
}
impl TelemetryEvent for RequestFileEditsTelemetryEvent {
fn name(&self) -> &'static str {
RequestFileEditsTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<serde_json::Value> {
match self {
RequestFileEditsTelemetryEvent::EditResolved(resolved_edit_event) => {
Some(json!(resolved_edit_event))
}
RequestFileEditsTelemetryEvent::EditAcceptClicked(edit_accept_clicked_event) => {
Some(json!(edit_accept_clicked_event))
}
RequestFileEditsTelemetryEvent::EditAcceptAndContinueClicked(
edit_accept_and_continue_clicked_event,
) => Some(json!(edit_accept_and_continue_clicked_event)),
RequestFileEditsTelemetryEvent::DiffMatchFailed(diff_application_error_event) => {
Some(json!(diff_application_error_event))
}
RequestFileEditsTelemetryEvent::DiffInvalidFile(diff_invalid_file_event) => {
Some(json!(diff_invalid_file_event))
}
RequestFileEditsTelemetryEvent::EditReceived(edit_received_event) => {
Some(json!(edit_received_event))
}
RequestFileEditsTelemetryEvent::MissingLineNumbers(missing_line_numbers_event) => {
Some(json!(missing_line_numbers_event))
}
RequestFileEditsTelemetryEvent::MalformedFinalLineProxy(
malformed_final_line_proxy_event,
) => Some(json!(malformed_final_line_proxy_event)),
}
}
fn description(&self) -> &'static str {
RequestFileEditsTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
RequestFileEditsTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
RequestFileEditsTelemetryEventDiscriminants::from(self).contains_ugc()
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl RequestFileEditsTelemetryEventDiscriminants {
pub fn contains_ugc(&self) -> bool {
false
}
}
impl TelemetryEventDesc for RequestFileEditsTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::EditResolved => "AgentMode.Code.SuggestedEditResolved",
Self::EditAcceptClicked => "AgentMode.Code.SuggestedEditAcceptClicked",
Self::EditAcceptAndContinueClicked => {
"AgentMode.Code.SuggestedEditAcceptAndContinueClicked"
}
Self::DiffMatchFailed => "AgentMode.Code.DiffMatchFailed",
Self::DiffInvalidFile => "AgentMode.Code.InvalidFile",
Self::EditReceived => "AgentMode.Code.SuggestedEditReceived",
Self::MissingLineNumbers => "AgentMode.Code.MissingLineNumbers",
Self::MalformedFinalLineProxy => "AgentMode.Code.MalformedFinalLineProxy",
}
}
fn description(&self) -> &'static str {
match self {
Self::EditResolved => "Agent Mode pending code edit suggestion resolved",
Self::EditAcceptClicked => {
"User selected Accept for a code diff suggestion in Agent Mode"
}
Self::EditAcceptAndContinueClicked => {
"User selected Accept and start conversation for a code diff suggestion in Agent Mode"
}
Self::DiffMatchFailed => "Failed to match code diff",
Self::DiffInvalidFile => "File(s) in code diff could not be found",
Self::EditReceived => "Agent Mode suggested a code edit",
Self::MissingLineNumbers => "Code diff was missing line numbers",
Self::MalformedFinalLineProxy => {
"Suggested code diff likely required malformed trailing line correction (heuristic)"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
}
warp_core::register_telemetry_event!(RequestFileEditsTelemetryEvent);
@@ -0,0 +1,390 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use itertools::Itertools;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
},
blocklist::{action_model::execute::get_server_output_id, BlocklistAIPermissions},
get_relevant_files::controller::{
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesError,
},
},
features::FeatureFlag,
send_telemetry_from_ctx,
terminal::model::session::active_session::ActiveSession,
TelemetryEvent,
};
use super::{
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
pub struct SearchCodebaseExecutor {
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
/// Per-action response channels for searches that are still waiting on
/// `GetRelevantFilesController`.
active_searches: HashMap<AIAgentActionId, oneshot::Sender<SearchCodebaseResult>>,
/// Cached repo roots derived during preprocessing so permission checks and execution can agree
/// on which repository the action actually targets.
root_repo_paths: HashMap<AIAgentActionId, PathBuf>,
terminal_view_id: EntityId,
}
impl SearchCodebaseExecutor {
pub fn new(
active_session: ModelHandle<ActiveSession>,
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&get_relevant_files_controller, |me, event, ctx| {
if !me.active_searches.contains_key(event.action_id()) {
return;
}
match event {
GetRelevantFilesControllerEvent::Success { fragments, .. } => {
let action_id = event.action_id().clone();
let locations = fragments
.iter()
.map(|location| location.into())
.collect_vec();
let current_working_directory = me
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = me.active_session.as_ref(ctx).shell_launch_data(ctx);
ctx.spawn(
async move {
match read_local_file_context(
&locations,
current_working_directory,
shell,
None,
None,
)
.await
{
Ok(result) => {
if !result.missing_files.is_empty() {
let missing_files = result.missing_files.join(", ");
SearchCodebaseResult::Failed {
message: format!(
"These files do not exist: {missing_files}"
),
reason: SearchCodebaseFailureReason::InvalidFilePaths,
}
} else {
SearchCodebaseResult::Success {
files: result.file_contexts,
}
}
}
Err(e) => SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::ClientError,
message: e.to_string(),
},
}
},
move |me, result, _| {
let Some(result_tx) = me.active_searches.remove(&action_id) else {
return;
};
if let Err(e) = result_tx.send(result) {
log::warn!(
"Failed to send search codebase results to receiver {e:?}."
);
}
},
);
}
GetRelevantFilesControllerEvent::Error { action_id } => {
let Some(result_tx) = me.active_searches.remove(action_id) else {
return;
};
if let Err(e) = result_tx.send(SearchCodebaseResult::Failed {
message: "The search failed. Try another way to locate the relevant files."
.to_owned(),
reason: SearchCodebaseFailureReason::GetRelevantFilesError,
}) {
log::warn!("Failed to send search codebase results to receiver {e:?}.");
}
}
}
});
Self {
active_session,
get_relevant_files_controller,
active_searches: HashMap::new(),
root_repo_paths: HashMap::new(),
terminal_view_id,
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput {
action:
AIAgentAction {
id,
action: AIAgentActionType::SearchCodebase(..),
..
},
conversation_id,
} = input
else {
return false;
};
self.root_repo_paths.get(id).is_none_or(|root_repo_path| {
// If we have access to read the repo, we can auto-execute the search.
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![root_repo_path.to_owned()],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
})
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
id,
action:
AIAgentActionType::SearchCodebase(SearchCodebaseRequest {
query,
partial_paths,
codebase_path,
}),
..
} = action
else {
return ActionExecution::InvalidAction;
};
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
let Some(current_working_directory) = self
.active_session
.as_ref(ctx)
.current_working_directory()
.map(PathBuf::from)
else {
// This should really never happen; it implies that we don't know what the
// current working directory is, which is never the case.
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
message: "The search failed. Try another way to locate the relevant files."
.to_string(),
},
));
};
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
let server_output_id = get_server_output_id(input.conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
action_id: id.clone(),
server_output_id,
is_cross_repo,
},
ctx
);
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
let action_id = id.clone();
// Check if directory exists on background thread since its a sys call; no need to block
// main thread since its just for telemetry.
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
let error = if exists {
"The codebase isn't indexed".to_string()
} else {
"The codebase doesn't exist".to_string()
};
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
ctx
);
});
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
}));
};
// Add the repo root as a temporary permission; if the user gave us permission to
// search the repo, we can certainly search files within it for the rest of the convo.
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
conversation_id,
vec![root_dir_for_search.to_owned()],
);
});
let (result_tx, result_rx) = oneshot::channel();
self.active_searches.insert(id.clone(), result_tx);
// Start the actual search.
match self
.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.send_request(
root_dir_for_search,
query.clone(),
partial_paths.as_ref(),
id.clone(),
ctx,
)
}) {
Ok(_) => ActionExecution::Async {
execute_future: Box::pin(result_rx),
on_complete: Box::new(
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
let action_result = res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
message: e.to_string(),
reason: SearchCodebaseFailureReason::ClientError,
});
AIAgentActionResultType::SearchCodebase(action_result)
},
),
},
Err(e) => {
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
let error_message = match e {
GetRelevantFilesError::Pending => {
"The current git repository is still being indexed, so search is unavailable right now. You can try again later".to_owned()
}
GetRelevantFilesError::CreateFailed => {
"Relevant file search in the current directory is not available".to_owned()
}
GetRelevantFilesError::Missing => {
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
}
};
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
SearchCodebaseResult::Failed {
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
message: error_message,
},
))
}
}
}
pub fn root_repo_for_action(&self, id: &AIAgentActionId) -> Option<&Path> {
self.root_repo_paths.get(id).map(|path| path.as_path())
}
pub(super) fn cancel_execution(
&mut self,
action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) {
// Drop the waiting sender first so any late completion from the controller becomes a no-op.
self.active_searches.remove(action_id);
self.get_relevant_files_controller
.update(ctx, |controller, ctx| {
controller.cancel_request_for_action(action_id, ctx)
});
}
fn get_root_repo_path_for_request(
&self,
request: &SearchCodebaseRequest,
app: &AppContext,
) -> Option<PathBuf> {
let SearchCodebaseRequest { codebase_path, .. } = request;
let codebase_path = codebase_path.as_deref().map(PathBuf::from);
let Some(pwd) = self
.active_session
.as_ref(app)
.current_working_directory()
.map(PathBuf::from)
else {
// This should never really happen, since we should always have a pwd.
log::warn!("No pwd found for search codebase request");
return None;
};
let search_dir = if FeatureFlag::CrossRepoContext.is_enabled() {
match codebase_path {
Some(codebase_path) if codebase_path == Path::new(".") => pwd,
Some(codebase_path) => codebase_path,
None => pwd,
}
} else {
pwd
};
self.get_relevant_files_controller
.as_ref(app)
.root_directory_for_search(&search_dir, app)
}
/// In the preprocessing step, we determine the root of the repo path for the codebase to be
/// searched, and cache it. This is used downstream to render UI thats derived from the root
/// repo path, which isn't really trivially computable from the `action` itself.
pub(super) fn preprocess_action(
&mut self,
input: PreprocessActionInput,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
let AIAgentAction {
id,
action: AIAgentActionType::SearchCodebase(request),
..
} = input.action
else {
log::error!("Expected a SearchCodebase action when preprocessing action");
return futures::future::ready(()).boxed();
};
if let Some(root_repo_path) = self.get_root_repo_path_for_request(request, ctx) {
self.root_repo_paths.insert(id.clone(), root_repo_path);
}
futures::future::ready(()).boxed()
}
}
impl Entity for SearchCodebaseExecutor {
type Event = ();
}
@@ -0,0 +1,147 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendMessageResult};
use crate::ai::blocklist::telemetry::{
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
TeamAgentCommunicationTransport, TeamAgentOrchestrationVersion,
};
use crate::server::server_api::ai::SendAgentMessageRequest;
use crate::server::server_api::ServerApiProvider;
use warp_core::features::FeatureFlag;
use warp_core::send_telemetry_from_ctx;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct SendMessageToAgentExecutor;
impl SendMessageToAgentExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> AnyActionExecution {
let AIAgentAction {
action:
AIAgentActionType::SendMessageToAgent {
addresses,
subject,
message,
},
..
} = input.action
else {
return ActionExecution::<()>::InvalidAction.into();
};
let conversation_id = input.conversation_id;
let addresses = addresses.clone();
let subject = subject.clone();
let message_body = message.clone();
if FeatureFlag::OrchestrationV2.is_enabled() {
let sender_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.run_id())
.map(|s| s.to_string())
.unwrap_or_default();
let log_addresses = addresses.clone();
let log_subject = subject.clone();
let log_sender_run_id = sender_run_id.clone();
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let request = SendAgentMessageRequest {
to: addresses,
subject,
body: message_body,
sender_run_id,
};
return ActionExecution::new_async(
async move { ai_client.send_agent_message(request).await },
move |result, ctx| match result {
Ok(response) => {
let message_id =
response.message_ids.into_iter().next().unwrap_or_default();
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Success { message_id },
)
}
Err(err) => {
let error_message = err.to_string();
send_telemetry_from_ctx!(
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
TeamAgentCommunicationFailedEvent {
communication_kind: TeamAgentCommunicationKind::Message,
transport: TeamAgentCommunicationTransport::ServerApi,
orchestration_version: TeamAgentOrchestrationVersion::V2,
failure_reason:
TeamAgentCommunicationFailureReason::RequestFailed,
source_conversation_id: conversation_id,
source_run_id: (!log_sender_run_id.is_empty())
.then(|| log_sender_run_id.clone()),
target_count: Some(log_addresses.len()),
lifecycle_event_type: None,
error_message: Some(error_message.clone()),
}
),
ctx
);
log::warn!(
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} sender_run_id={log_sender_run_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} error={err:#}"
);
AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(error_message),
)
}
},
)
.into();
}
let result = OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.send_message(conversation_id, &addresses, subject, message_body, ctx)
});
let result = match result {
SendMessageResult::MessageSent { message_id } => {
SendMessageToAgentResult::Success { message_id }
}
SendMessageResult::Error(error) => SendMessageToAgentResult::Error(error),
};
ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(result)).into()
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Default for SendMessageToAgentExecutor {
fn default() -> Self {
Self::new()
}
}
impl Entity for SendMessageToAgentExecutor {
type Event = ();
}
@@ -0,0 +1,895 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::{select, FutureExt};
use futures_lite::pin;
use itertools::Itertools;
use parking_lot::FairMutex;
use warp_core::command::ExitCode;
use warp_core::execution_mode::AppExecutionMode;
use warp_util::path::ShellFamily;
use warpui::r#async::{Spawnable, Timer};
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionType, AIAgentPtyWriteMode, ReadShellCommandOutputResult,
RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::permissions::CommandExecutionPermission;
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::WriteToPtyPermission;
use crate::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::{
formatted_terminal_contents_for_input, Block, BlockId, CURSOR_MARKER,
};
use crate::terminal::shell::ShellType;
use crate::{
ai::agent::AIAgentActionResultType,
terminal::{
model::session::active_session::ActiveSession,
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
};
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
/// Senders used by the `Check now` affordance to force a long-running shell command's
/// pending poll future to resolve immediately with a fresh snapshot, bypassing the
/// agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
control_handback_sender: Option<oneshot::Sender<()>>,
}
impl ShellCommandExecutor {
pub const MAX_WAIT_DURATION: Duration = Duration::from_secs(2);
/// Maximum delay we will honor for any agent-requested wait. Applies both
/// to finite `ShellCommandDelay::Duration` requests and to
/// `ShellCommandDelay::OnCompletion`, which would otherwise wait indefinitely.
pub const MAX_AGENT_DELAY_DURATION: Duration = Duration::from_secs(120);
pub fn new(
active_session: ModelHandle<ActiveSession>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, Self::handle_terminal_model_event);
Self {
active_session,
terminal_model,
block_finished_senders: HashMap::new(),
force_refresh_senders: HashMap::new(),
terminal_view_id,
control_handback_sender: None,
}
}
fn handle_terminal_model_event(&mut self, event: &ModelEvent, _ctx: &mut ModelContext<Self>) {
// We wait for precmd for the block _after_ the requested command's block so that
// downstream checks for current working directory are fresh. The precmd hook is when
// the shell relays current working directory to warp.
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
if let Some(block) = block_selector.get_block(&model) {
if block.is_command_finished() {
if let Err(e) = block_finished_tx.send(()) {
log::warn!(
"Failed to notify block completion for running requested command: {e:?}"
)
}
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
}
}
}
}
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
let blocklist_permissions = BlocklistAIPermissions::as_ref(ctx);
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
is_read_only,
is_risky,
..
} => {
let Some(escape_char) = self
.active_session
.as_ref(ctx)
.shell_type(ctx)
.map(|s| ShellFamily::from(s).escape_char())
else {
return false;
};
let autoexecution_permission = blocklist_permissions.can_autoexecute_command(
&input.conversation_id,
command,
escape_char,
is_read_only.unwrap_or(false),
*is_risky,
Some(self.terminal_view_id),
ctx,
);
if let CommandExecutionPermission::Allowed(reason) = autoexecution_permission {
send_telemetry_from_ctx!(
TelemetryEvent::AutoexecutedAgentModeRequestedCommand { reason },
ctx
);
} else if let CommandExecutionPermission::Denied(reason) = autoexecution_permission
{
if AppExecutionMode::as_ref(ctx).is_autonomous() {
log::warn!(
"Command denied during autonomous execution, reason: {reason:?}"
);
}
}
autoexecution_permission.is_allowed()
}
AIAgentActionType::WriteToLongRunningShellCommand { block_id, .. } => {
let terminal_model = self.terminal_model.lock();
let block = terminal_model.block_list().block_with_id(block_id);
if block.is_none_or(|block| block.finished()) {
// If the block is already finished, allow auto-execution - the finished output
// will be returned.
true
} else {
let should_autoexecute = match blocklist_permissions.can_write_to_pty(
&input.conversation_id,
Some(self.terminal_view_id),
ctx,
) {
WriteToPtyPermission::AlwaysAllow => true,
WriteToPtyPermission::AskOnFirstWrite => terminal_model
.block_list()
.active_block()
.has_agent_written_to_block(),
_ => false,
};
if should_autoexecute {
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentActionExecuted {
conversation_id: input.conversation_id,
block_id: block_id.clone(),
is_autoexecuted: true,
},
ctx
);
}
should_autoexecute
}
}
AIAgentActionType::ReadShellCommandOutput { .. } => true,
AIAgentActionType::TransferShellCommandControlToUser { .. } => false,
_ => false,
}
}
/// Decorate the command so that we can turn off pager.
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
match self.active_session.as_ref(ctx).shell_type(ctx) {
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
// avoid cases with aliases.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
// gets evaluated first.
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
// For powershell, we use Out-Host to send paged output to the
// console. Add a backslash to avoid executing an alias.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
// If we can't determine a shell type, run command as it is.
None => command.clone(),
}
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let model = self.terminal_model.lock();
// Determine the action we want to take based on the input.
let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle();
match &input.action.action {
AIAgentActionType::RequestCommandOutput {
command,
uses_pager,
wait_until_completion,
..
} => {
if model
.block_list()
.active_block()
.is_active_and_long_running()
{
// If there is an active block, we can't execute another command.
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution,
));
}
// If the command might use pager and can't be interacted with,
// we pipe its output to cat so we can prevent activating the altscreen.
// The parentheses here ensures the command always gets evaluated first.
let decorated_command =
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion {
self.turn_off_pager_for_command(command, ctx)
} else {
command.clone()
};
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(),
command: decorated_command,
});
let block_selector = BlockSelector::RequestedCommandId(action_id.clone());
let command = command.clone();
drop(model);
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), None),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_requested_command(command, result)
},
)
}
AIAgentActionType::WriteToLongRunningShellCommand {
block_id,
input,
mode,
} => {
let Some(block) = model.block_list().block_with_id(block_id) else {
return ActionExecution::Sync(
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Error(
ShellCommandError::BlockNotFound,
),
),
);
};
if block.finished() {
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
return ActionExecution::Sync(
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id: block.id().clone(),
output,
exit_code,
},
),
);
}
// Drop immutable borrow.
drop(model);
let mut model = self.terminal_model.lock();
if let Some(block) = model.block_list_mut().mut_block_from_id(block_id) {
block.mark_agent_written_to_block();
}
drop(model);
ctx.emit(ShellCommandExecutorEvent::WriteToPty {
input: input.clone(),
mode: *mode,
});
let block_selector = BlockSelector::Id(block_id.clone());
ActionExecution::new_async(
self.action_result_future(
block_selector.clone(),
Some(ShellCommandDelay::Duration(Duration::from_millis(200))),
),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_write_to_long_running_shell_command(result)
},
)
}
AIAgentActionType::ReadShellCommandOutput { block_id, delay } => {
let Some(block) = model.block_list().block_with_id(block_id) else {
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
));
};
if block.finished() {
let command = block.command_with_secrets_unobfuscated(false);
let output: String = block.output_with_secrets_unobfuscated();
let exit_code = block.exit_code();
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id: block_id.clone(),
output,
exit_code,
},
));
}
drop(model);
let block_selector = BlockSelector::Id(block_id.clone());
ActionExecution::new_async(
self.action_result_future(block_selector.clone(), delay.clone()),
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.force_refresh_senders.remove(&block_selector);
});
}
action_result_for_read_shell_command_output(command.clone(), result)
},
)
}
AIAgentActionType::TransferShellCommandControlToUser { reason } => {
let active_block = model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return ActionExecution::Sync(
AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Error(
ShellCommandError::BlockNotFound,
),
),
);
}
let block_id = active_block.id().clone();
drop(model);
// Emit event to transfer control to user.
ctx.emit(ShellCommandExecutorEvent::TransferControlToUser {
action_id: action_id.clone(),
reason: reason.clone(),
});
// Create a channel to wait for control handback.
let (handback_tx, handback_rx) = oneshot::channel();
self.control_handback_sender = Some(handback_tx);
let block_selector = BlockSelector::Id(block_id.clone());
// Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx);
// Build the future that captures terminal model and block data.
let transfer_future = {
let terminal_model = self.terminal_model.clone();
let block_id = block_id.clone();
async move {
pin!(handback_rx);
pin!(block_finished_rx);
// Wait for either control handback or block completion.
let transfer_result = select! {
val = handback_rx => match val {
Ok(_) => TransferControlResult::ControlHandedBack,
Err(_) => TransferControlResult::Cancelled,
},
val = block_finished_rx => match val {
Ok(_) => TransferControlResult::BlockFinished,
Err(_) => TransferControlResult::Cancelled,
},
};
// Convert to ActionResult
let model = terminal_model.lock();
match transfer_result {
TransferControlResult::ControlHandedBack
| TransferControlResult::BlockFinished => {
match model.block_list().block_with_id(&block_id) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_preempted: false,
}
}
}
None => ActionResult::BlockNotFound,
}
}
TransferControlResult::Cancelled => ActionResult::Cancelled,
}
}
};
ActionExecution::new_async(transfer_future, move |result, ctx| {
// Clean up.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector);
me.control_handback_sender = None;
});
}
action_result_for_transfer_shell_command_control_to_user(result)
})
}
_ => ActionExecution::InvalidAction,
}
}
/// Called when user hands control back to agent after TransferShellCommandControlToUser.
pub fn notify_control_handed_back(&mut self) {
if let Some(sender) = self.control_handback_sender.take() {
let _ = sender.send(());
}
}
/// Produces a future which resolves when the action is complete and
/// we have a result to send to the agent.
fn action_result_future(
&mut self,
block_selector: BlockSelector,
delay: Option<ShellCommandDelay>,
) -> impl Spawnable<Output = ActionResult> {
// Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx);
// Create a channel so the `Check now` affordance can short-circuit the timeout
// and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx);
// Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone();
#[derive(Debug, Clone, Copy)]
enum WakeReason {
BlockFinished,
Timeout,
/// User clicked `Check now` in the warping indicator, short-circuiting
/// the agent-set poll timer. Treated as a preemption so the server does
/// not interpret the early snapshot as a completion.
ForceRefresh,
}
async move {
// If we support long-running commands, set up a timeout after which we'll
// treat the command as long-running and give the agent a snapshot of the
// current state. Otherwise, we'll wait indefinitely for the command to
// finish executing.
let mut timeout = match delay {
Some(ShellCommandDelay::Duration(duration)) => {
// Enforce a maximum allowed delay that the agent may request, never waiting longer than MAX_AGENT_DELAY_DURATION.
// If the requested duration exceeds this cap, we'll still behave as if the agent may expect a running command,
// so there's no need to signal preemption (the agent already anticipates an incomplete command state).
Timer::after(duration.min(Self::MAX_AGENT_DELAY_DURATION))
}
Some(ShellCommandDelay::OnCompletion) => {
Timer::after(Self::MAX_AGENT_DELAY_DURATION)
}
None => Timer::after(Self::MAX_WAIT_DURATION),
}
.fuse();
pin!(block_metadata_received_rx);
pin!(force_refresh_rx);
let wake_reason = select! {
val = block_metadata_received_rx => match val {
Ok(_) => WakeReason::BlockFinished,
Err(_) => return ActionResult::Cancelled,
},
val = force_refresh_rx => match val {
// User asked the agent to check now; fall through to the snapshot
// code path below. Treated as a preemption (snapshot arrives before
// the agent's own timer would have fired).
Ok(_) => WakeReason::ForceRefresh,
// Sender was dropped (e.g. because the executor is being torn down).
Err(_) => return ActionResult::Cancelled,
},
_ = timeout => WakeReason::Timeout,
};
// Mark the snapshot as preempted if woken early, allowing the server to distinguish
// true completion from a forced client poll (`ForceRefresh`) or a timeout during `on_completion`.
let is_preempted = matches!(wake_reason, WakeReason::ForceRefresh)
|| matches!(
(&wake_reason, &delay),
(WakeReason::Timeout, Some(ShellCommandDelay::OnCompletion))
);
// At this point, we've either received block metadata or we've timed out.
// Check the current state of the block and produce a result accordingly.
let model = terminal_model.lock();
let result = match block_selector.get_block(&model) {
Some(block) => {
if block.finished() {
ActionResult::CommandFinished {
block_id: block.id().clone(),
output: block.output_with_secrets_unobfuscated(),
exit_code: block.exit_code(),
}
} else {
let grid_contents = if model.is_alt_screen_active() {
formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(),
None,
CURSOR_MARKER,
)
} else {
formatted_terminal_contents_for_input(
block.output_grid().grid_handler(),
// TODO(vorporeal): This is probably too large.
Some(1000),
CURSOR_MARKER,
)
};
ActionResult::LongRunningCommandSnapshot {
block_id: block.id().clone(),
grid_contents,
cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(),
is_preempted,
}
}
}
None => ActionResult::BlockNotFound,
};
result
}
}
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return;
}
let selector = if active_block
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
} else {
BlockSelector::Id(active_block.id().clone())
};
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
/// Force any in-flight poll for the given long-running command block to resolve
/// immediately with a fresh snapshot, bypassing the agent-set timeout.
///
/// Called by the `Check now` affordance in the warping indicator. No-ops if there
/// is no matching in-flight poll (e.g. because the block already finished or the
/// agent has transferred control to the user).
pub fn force_refresh_block(&mut self, block_id: &BlockId) {
let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at
// most one: a given block can have at most one in-flight `action_result_future`
// at a time.
let matching_selector = self
.force_refresh_senders
.keys()
.find(|selector| {
selector
.get_block(&terminal_model)
.is_some_and(|block| block.id() == block_id)
})
.cloned();
drop(terminal_model);
if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
let _ = sender.send(());
}
}
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector {
Id(BlockId),
RequestedCommandId(AIAgentActionId),
}
impl BlockSelector {
fn get_block<'a>(&self, model: &'a TerminalModel) -> Option<&'a Block> {
match self {
BlockSelector::Id(block_id) => model.block_list().block_with_id(block_id),
BlockSelector::RequestedCommandId(requested_command_id) => model
.block_list()
.block_for_ai_action_id(requested_command_id),
}
}
}
/// Returns the result from executing a requested command.
fn action_result_for_requested_command(
command: String,
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
command,
block_id,
output,
exit_code,
}),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
..
} => AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot {
command,
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
},
),
ActionResult::BlockNotFound | ActionResult::Cancelled => {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution,
)
}
}
}
/// Returns the result from writing to a long-running shell command.
fn action_result_for_write_to_long_running_shell_command(
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished {
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot {
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Cancelled,
),
ActionResult::BlockNotFound => AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound),
),
}
}
/// Returns the result from reading shell command output.
fn action_result_for_read_shell_command_output(
command: String,
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
output,
exit_code,
block_id,
} => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished {
command,
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
command,
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => {
AIAgentActionResultType::ReadShellCommandOutput(ReadShellCommandOutputResult::Cancelled)
}
ActionResult::BlockNotFound => AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::Error(ShellCommandError::BlockNotFound),
),
}
}
/// Returns the result from transferring shell command control to user.
fn action_result_for_transfer_shell_command_control_to_user(
result: ActionResult,
) -> AIAgentActionResultType {
match result {
ActionResult::CommandFinished {
block_id,
output,
exit_code,
} => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::CommandFinished {
block_id,
output,
exit_code,
},
),
ActionResult::LongRunningCommandSnapshot {
block_id,
grid_contents,
cursor,
is_alt_screen_active,
is_preempted,
} => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot {
block_id,
grid_contents,
cursor: cursor.to_owned(),
is_alt_screen_active,
is_preempted,
},
),
ActionResult::Cancelled => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Cancelled,
),
ActionResult::BlockNotFound => AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Error(ShellCommandError::BlockNotFound),
),
}
}
#[derive(Debug, Clone)]
pub enum ShellCommandExecutorEvent {
ExecuteCommand {
action_id: AIAgentActionId,
command: String,
},
WriteToPty {
input: Bytes,
mode: AIAgentPtyWriteMode,
},
CancelExecution,
/// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser {
action_id: AIAgentActionId,
reason: String,
},
}
impl Entity for ShellCommandExecutor {
type Event = ShellCommandExecutorEvent;
}
/// Result from waiting for control transfer.
#[derive(Debug, Clone)]
enum TransferControlResult {
ControlHandedBack,
BlockFinished,
Cancelled,
}
/// The possible results of taking an action.
#[derive(Debug, Clone)]
enum ActionResult {
CommandFinished {
block_id: BlockId,
output: String,
exit_code: ExitCode,
},
LongRunningCommandSnapshot {
block_id: BlockId,
grid_contents: String,
cursor: &'static str,
is_alt_screen_active: bool,
is_preempted: bool,
},
Cancelled,
BlockNotFound,
}
@@ -0,0 +1,438 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::orchestration_event_poller::OrchestrationEventPoller;
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use warp_cli::agent::Harness;
use warp_core::features::FeatureFlag;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
/// The result sent back to the executor after observing the child agent's lifecycle.
enum StartAgentDecision {
/// The child conversation was created successfully.
Started { agent_id: String },
/// An error occurred while starting the agent.
Error(String),
}
fn invalid_local_child_harness_error(harness_type: &str) -> String {
let harness_name = harness_type.trim();
if harness_name.is_empty() {
"Local child harness type is missing.".to_string()
} else {
format!("Unsupported local child harness '{harness_name}'.")
}
}
/// Groups the data for a single StartAgent invocation as it flows from the
/// executor through the terminal view and pane group into the controller.
#[derive(Clone)]
pub struct StartAgentRequest {
pub name: String,
pub prompt: String,
pub execution_mode: StartAgentExecutionMode,
pub lifecycle_subscription: Option<Vec<LifecycleEventType>>,
pub parent_conversation_id: AIConversationId,
pub parent_run_id: Option<String>,
}
/// Tracks a single in-flight StartAgent action. At most one can be pending at
/// a time because StartAgent actions execute serially (RunningActionPhase::Serial).
struct PendingStartAgent {
parent_conversation_id: AIConversationId,
/// Set when `StartedNewConversation` fires for a conversation whose
/// `parent_conversation_id` matches.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentDecision>,
}
pub struct StartAgentExecutor {
/// The currently pending StartAgent action, if any.
pending: Option<PendingStartAgent>,
}
impl StartAgentExecutor {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self { pending: None }
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
BlocklistAIHistoryEvent::StartedNewConversation {
new_conversation_id,
..
} => {
let Some(pending) = self.pending.as_mut() else {
return;
};
if pending.child_conversation_id.is_some() {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(new_conversation_id) else {
return;
};
if conversation.parent_conversation_id() == Some(pending.parent_conversation_id) {
pending.child_conversation_id = Some(*new_conversation_id);
}
}
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
return;
}
let pending = self.pending.take().unwrap();
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.orchestration_agent_id());
match agent_id {
Some(id) => {
let _ = pending.sender.try_send(StartAgentDecision::Started {
agent_id: id.clone(),
});
if FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventPoller::handle(ctx).update(ctx, |poller, ctx| {
poller.register_watched_run_id(
pending.parent_conversation_id,
id,
ctx,
);
});
} else {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_started(*conversation_id, ctx);
});
}
}
None => {
log::error!(
"ConversationServerTokenAssigned fired but no agent identifier for \
{conversation_id:?}"
);
let _ = pending.sender.try_send(StartAgentDecision::Error(
"Server did not assign an agent identifier".to_string(),
));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"missing_agent_id".to_string(),
"Server did not assign an agent identifier".to_string(),
ctx,
);
});
}
}
}
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(conversation_id) else {
return;
};
let error_msg = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message(),
);
if let Some(error_msg) = error_msg {
let pending = self.pending.take().unwrap();
let _ = pending
.sender
.try_send(StartAgentDecision::Error(error_msg.clone()));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"conversation_status".to_string(),
error_msg,
ctx,
);
});
}
}
}
BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::ReassignedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } => {}
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
// TODO(QUALITY-342): this should be a setting
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action:
AIAgentActionType::StartAgent {
version,
name,
prompt,
execution_mode,
lifecycle_subscription,
},
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let prompt = prompt.clone();
let version = *version;
let parent_conversation_id = input.conversation_id;
let (execution_mode, parent_run_id) = match execution_mode.clone() {
StartAgentExecutionMode::Local { harness_type: None } => {
// Legacy local Oz child agents do not use
// StartAgentRequest.parent_run_id. Instead, the child
// conversation is linked back to its parent on the first
// request via Request.metadata.parent_agent_id, sourced
// from the conversation's versioned orchestration_agent_id()
// (run_id in v2, server conversation token in v1). Remote
// child agents and local third-party harness children need
// parent_run_id here because their run is spawned before that
// first child request exists.
(StartAgentExecutionMode::Local { harness_type: None }, None)
}
StartAgentExecutionMode::Local {
harness_type: Some(harness_type),
} => {
let Some(harness) = Harness::parse_local_child_harness(&harness_type) else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: invalid_local_child_harness_error(&harness_type),
version,
},
));
};
if !FeatureFlag::OrchestrationV2.is_enabled() {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Local harness child agents require orchestration v2."
.to_string(),
version,
},
));
}
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.run_id());
let Some(parent_run_id) = parent_run_id else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error:
"Local harness child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Local {
harness_type: Some(harness.to_string()),
},
Some(parent_run_id),
)
}
StartAgentExecutionMode::Remote {
environment_id,
skill_references,
model_id,
computer_use_enabled,
worker_host,
harness_type,
title,
} => {
if !FeatureFlag::OrchestrationV2.is_enabled() {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Remote child agents require orchestration v2.".to_string(),
version,
},
));
}
let harness_type = Harness::parse_orchestration_harness(&harness_type)
.map(|harness| harness.to_string())
.unwrap_or(harness_type);
if Harness::parse_orchestration_harness(&harness_type) == Some(Harness::OpenCode) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Remote child agents do not support the opencode harness yet."
.to_string(),
version,
},
));
}
// An empty environment_id is allowed and means the child will be spawned with an
// empty environment (no preconfigured repositories, secrets, or integrations).
// Callers are discouraged from relying on this, but we intentionally do not reject
// it here so that agent authors can opt into running without an environment.
if environment_id.trim().is_empty() {
log::warn!(
"Starting remote child agent with empty environment_id; the child will run \
with an empty environment."
);
}
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.run_id());
let Some(parent_run_id) = parent_run_id else {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error {
error: "Remote child agents require the parent run_id to be available."
.to_string(),
version,
},
));
};
(
StartAgentExecutionMode::Remote {
environment_id,
skill_references,
model_id,
computer_use_enabled,
worker_host,
harness_type,
title,
},
Some(parent_run_id),
)
}
};
let (sender, receiver) = async_channel::bounded(1);
self.pending = Some(PendingStartAgent {
parent_conversation_id,
child_conversation_id: None,
sender,
});
ctx.emit(StartAgentExecutorEvent::CreateAgent(StartAgentRequest {
name: name.clone(),
prompt,
execution_mode,
lifecycle_subscription: lifecycle_subscription.clone(),
parent_conversation_id,
parent_run_id,
}));
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
match result {
Ok(StartAgentDecision::Started { agent_id }) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id,
version,
})
}
Ok(StartAgentDecision::Error(error)) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
}
Err(_) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Cancelled { version })
}
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
fn start_agent_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
match status {
ConversationStatus::Error => Some(
error_message
.filter(|message| !message.trim().is_empty())
.unwrap_or("Child agent failed to initialize")
.to_string(),
),
ConversationStatus::Cancelled => {
Some("Child agent was cancelled before initialization".to_string())
}
ConversationStatus::Blocked { blocked_action } => {
let blocked_action = blocked_action.trim();
Some(if blocked_action.is_empty() {
"Child agent startup was blocked before initialization".to_string()
} else {
blocked_action.to_string()
})
}
ConversationStatus::InProgress | ConversationStatus::Success => None,
}
}
impl Entity for StartAgentExecutor {
type Event = StartAgentExecutorEvent;
}
pub enum StartAgentExecutorEvent {
CreateAgent(StartAgentRequest),
}
#[cfg(test)]
#[path = "start_agent_tests.rs"]
mod tests;
@@ -0,0 +1,326 @@
use super::*;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use ai::agent::action_result::StartAgentVersion;
use warp_core::features::FeatureFlag;
use warpui::{App, EntityId};
fn build_start_agent_action(
version: StartAgentVersion,
execution_mode: StartAgentExecutionMode,
) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("start-agent-action".to_string()),
action: AIAgentActionType::StartAgent {
version,
name: "Agent 1".to_string(),
prompt: "Investigate the failure".to_string(),
execution_mode,
lifecycle_subscription: None,
},
task_id: TaskId::new("start-agent-task".to_string()),
requires_result: false,
}
}
#[test]
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
ctx,
)
});
executor.read(&app, |executor, _| {
assert_eq!(
executor
.pending
.as_ref()
.and_then(|pending| pending.child_conversation_id),
Some(child_conversation_id)
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Blocked {
blocked_action:
"GitHub authentication required before starting the child agent."
.to_string(),
},
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error
== "GitHub authentication required before starting the child agent."
&& version == StartAgentVersion::V1
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_none());
});
});
}
#[test]
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async execution");
};
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"Agent 1".to_string(),
parent_conversation_id,
ctx,
)
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error_message(
terminal_view_id,
child_conversation_id,
ConversationStatus::Error,
Some("Failed to resolve child agent skills: review-comments".to_string()),
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Failed to resolve child agent skills: review-comments"
&& version == StartAgentVersion::V1
));
});
}
#[test]
fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("claude".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Local harness child agents require orchestration v2."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("codex".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Unsupported local child harness 'codex'."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::local_harness("claude".to_string()),
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error
== "Local harness child agents require the parent run_id to be available."
&& version == StartAgentVersion::V2
));
});
}
#[test]
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
App::test((), |mut app| async move {
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V2,
StartAgentExecutionMode::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![],
model_id: String::new(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "opencode".to_string(),
title: String::new(),
},
);
let execution = executor.update(&mut app, |executor, ctx| {
let input = ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
};
let result: AnyActionExecution = executor.execute(input, ctx).into();
result
});
let AnyActionExecution::Sync(result) = execution else {
panic!("expected sync execution");
};
assert!(matches!(
result,
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
if error == "Remote child agents do not support the opencode harness yet."
&& version == StartAgentVersion::V2
));
});
}
@@ -0,0 +1,95 @@
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SuggestNewConversationResult,
};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
/// Whether the client accepted or rejected the new conversation. We make this a separate type from
/// `SuggestNewConversationResult` for more ergonomic threading of the message_id through
/// the various layers of action handling.
pub enum NewConversationDecision {
Accept,
Reject,
}
pub struct SuggestNewConversationExecutor {
suggest_new_conversation_result_rx: (
async_channel::Sender<NewConversationDecision>,
async_channel::Receiver<NewConversationDecision>,
),
}
impl SuggestNewConversationExecutor {
pub fn new() -> Self {
Self {
suggest_new_conversation_result_rx: async_channel::unbounded(),
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
false
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::SuggestNewConversation { message_id },
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
let message_id = message_id.clone();
let receiver = self.suggest_new_conversation_result_rx.clone().1;
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
match result {
Ok(NewConversationDecision::Accept) => {
AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Accepted { message_id },
)
}
Ok(NewConversationDecision::Reject) => {
AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Rejected,
)
}
Err(_) => AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
),
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete_suggest_new_conversation_action(&self, decision: NewConversationDecision) {
let _ = self.suggest_new_conversation_result_rx.0.try_send(decision);
}
}
impl Default for SuggestNewConversationExecutor {
fn default() -> Self {
Self::new()
}
}
impl Entity for SuggestNewConversationExecutor {
type Event = ();
}
@@ -0,0 +1,107 @@
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
use warp_core::features::FeatureFlag;
use warpui::{Entity, ModelContext};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionType,
SuggestPromptRequest, SuggestPromptResult,
},
blocklist::action_model::execute::{
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
},
},
AIAgentActionResultType,
};
pub struct PromptSuggestionExecutor {
suggest_prompt_result_tx: Option<oneshot::Sender<SuggestPromptResult>>,
}
impl Default for PromptSuggestionExecutor {
fn default() -> Self {
Self::new()
}
}
impl PromptSuggestionExecutor {
pub fn new() -> Self {
Self {
suggest_prompt_result_tx: None,
}
}
pub(super) fn should_autoexecute(
&self,
_input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let AIAgentAction {
action: AIAgentActionType::SuggestPrompt(request),
..
} = input.action
else {
return ActionExecution::InvalidAction;
};
if FeatureFlag::PromptSuggestionsViaMAA.is_enabled() {
if let SuggestPromptRequest::PromptSuggestion { prompt, label } = request {
ctx.emit(PromptSuggestionExecutorEvent::NewPromptSuggestion {
prompt: prompt.clone(),
label: label.clone(),
conversation_id: input.conversation_id,
action_id: input.action.id.clone(),
});
}
}
let (result_tx, result_rx) = oneshot::channel();
self.suggest_prompt_result_tx = Some(result_tx);
ActionExecution::new_async(result_rx, |result, _ctx| match result {
Ok(SuggestPromptResult::Accepted { query }) => {
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Accepted { query })
}
Ok(SuggestPromptResult::Cancelled) | Err(_) => {
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Cancelled)
}
})
}
pub(super) fn preprocess_action(
&mut self,
_action: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
pub fn complete_suggest_prompt_action(&mut self, result: SuggestPromptResult) {
if let Some(sender) = self.suggest_prompt_result_tx.take() {
let _ = sender.send(result);
}
}
}
impl Entity for PromptSuggestionExecutor {
type Event = PromptSuggestionExecutorEvent;
}
#[derive(Debug)]
pub enum PromptSuggestionExecutorEvent {
NewPromptSuggestion {
prompt: String,
label: Option<String>,
conversation_id: AIConversationId,
action_id: AIAgentActionId,
},
}
@@ -0,0 +1,186 @@
#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
#[cfg(test)]
#[path = "upload_artifact_tests.rs"]
mod tests;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
use crate::terminal::model::session::active_session::ActiveSession;
#[cfg(not(target_family = "wasm"))]
use crate::{
ai::{
agent::{AIAgentAction, AIAgentActionResultType, AIAgentActionType, UploadArtifactResult},
agent_sdk::artifact_upload::{FileArtifactUploadRequest, FileArtifactUploader},
blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions},
paths::host_native_absolute_path,
},
server::server_api::ServerApiProvider,
};
#[cfg(not(target_family = "wasm"))]
use warpui::SingletonEntity;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct UploadArtifactExecutor {
#[cfg_attr(target_family = "wasm", allow(dead_code))]
active_session: ModelHandle<ActiveSession>,
#[cfg_attr(target_family = "wasm", allow(dead_code))]
terminal_view_id: EntityId,
}
impl UploadArtifactExecutor {
pub fn new(active_session: ModelHandle<ActiveSession>, terminal_view_id: EntityId) -> Self {
Self {
active_session,
terminal_view_id,
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> bool {
#[cfg(target_family = "wasm")]
{
false
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action:
AIAgentAction {
action: AIAgentActionType::UploadArtifact(request),
..
},
conversation_id,
} = input
else {
return false;
};
let resolved_path = self.resolve_path(&request.file_path, ctx);
BlocklistAIPermissions::as_ref(ctx)
.can_read_files_with_conversation(
&conversation_id,
vec![resolved_path],
Some(self.terminal_view_id),
ctx,
)
.is_allowed()
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables), allow(dead_code))]
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> AnyActionExecution {
#[cfg(target_family = "wasm")]
{
ActionExecution::<()>::InvalidAction.into()
}
#[cfg(not(target_family = "wasm"))]
{
let ExecuteActionInput {
action,
conversation_id,
..
} = input;
let AIAgentAction {
action: AIAgentActionType::UploadArtifact(request),
..
} = action
else {
return ActionExecution::<()>::InvalidAction.into();
};
let resolved_path = self.resolve_path(&request.file_path, ctx);
let server_conversation_token = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| conversation.server_conversation_token())
.cloned();
let Some(server_conversation_token) = server_conversation_token else {
return ActionExecution::<()>::Sync(AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(
"Current conversation has not been synced to the server yet".to_string(),
),
))
.into();
};
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(conversation_id, [resolved_path.clone()]);
});
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_api = ServerApiProvider::as_ref(ctx).get();
let description = request.description.clone();
ActionExecution::new_async(
async move {
let uploader = FileArtifactUploader::new(ai_client, server_api);
let request = FileArtifactUploadRequest {
path: resolved_path,
run_id: None,
conversation_id: Some(server_conversation_token),
description,
};
let association = uploader.resolve_upload_association(&request).await?;
uploader.upload_with_association(request, association).await
},
|result, _ctx| match result {
Ok(upload) => {
AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Success {
artifact_uid: upload.artifact.artifact_uid,
filepath: Some(upload.artifact.filepath),
mime_type: upload.artifact.mime_type,
description: upload.artifact.description,
size_bytes: upload.size_bytes,
})
}
Err(err) => AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(err.to_string()),
),
},
)
.into()
}
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
#[cfg(not(target_family = "wasm"))]
fn resolve_path(&self, file_path: &str, ctx: &ModelContext<Self>) -> PathBuf {
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx);
PathBuf::from(host_native_absolute_path(
file_path,
&shell,
&current_working_directory,
))
}
}
impl Entity for UploadArtifactExecutor {
type Event = ();
}
@@ -0,0 +1,234 @@
use std::fs;
use std::path::{Path, PathBuf};
use async_channel::unbounded;
use warpui::{App, EntityId, ModelHandle};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
UploadArtifactRequest, UploadArtifactResult,
};
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::execution_profiles::{profiles::AIExecutionProfilesModel, ActionPermission};
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
use crate::terminal::event::BlockMetadataReceivedEvent;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::{SessionId, SessionInfo, Sessions};
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::terminal::ShellLaunchData;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
use crate::LaunchMode;
use super::*;
fn build_upload_artifact_action(file_path: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from("upload-artifact-action".to_string()),
action: AIAgentActionType::UploadArtifact(UploadArtifactRequest {
file_path: file_path.to_string(),
description: Some("Upload the generated report".to_string()),
}),
task_id: TaskId::new("upload-artifact-task".to_string()),
requires_result: false,
}
}
fn initialize_upload_artifact_test(
app: &mut App,
terminal_view_id: EntityId,
current_working_directory: &Path,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ModelHandle<ActiveSession>,
) {
initialize_settings_for_tests(app);
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
app.add_singleton_model(BlocklistAIPermissions::new);
profiles.update(app, |profiles, ctx| {
if let Some(profile_id) = profiles.create_profile(ctx) {
profiles.set_read_files(profile_id, &ActionPermission::AlwaysAsk, ctx);
profiles.set_active_profile(terminal_view_id, profile_id, ctx);
}
});
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app
.add_model(|ctx| ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx));
let session_id = SessionId::from(7);
sessions.update(app, |sessions, _ctx| {
let mut session_info = SessionInfo::new_for_test().with_id(session_id);
session_info.launch_data = Some(test_shell_launch_data());
sessions.register_session_for_test(session_info);
});
model_event_dispatcher.update(app, |model_event_dispatcher, ctx| {
model_event_dispatcher.set_active_session_id(session_id);
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(
Some(session_id),
Some(current_working_directory.display().to_string()),
),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
(history, active_session)
}
fn test_shell_launch_data() -> ShellLaunchData {
#[cfg(unix)]
{
ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/bash"),
shell_type: ShellType::Bash,
}
}
#[cfg(windows)]
{
ShellLaunchData::Executable {
executable_path: PathBuf::from(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
),
shell_type: ShellType::PowerShell,
}
}
}
#[test]
fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
let artifact_path = cwd.join("reports/report.txt");
fs::create_dir_all(artifact_path.parent().unwrap()).unwrap();
fs::write(&artifact_path, "artifact contents").unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, active_session) =
initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_upload_artifact_action("reports/report.txt");
let should_autoexecute_before = executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(!should_autoexecute_before);
app.update(|ctx| {
BlocklistAIPermissions::handle(ctx).update(ctx, |permissions, _ctx| {
permissions
.add_temporary_file_read_permissions(conversation_id, [artifact_path.clone()]);
});
});
let should_autoexecute_after = executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(should_autoexecute_after);
});
}
#[test]
fn execute_returns_error_when_conversation_has_not_synced_to_server() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
let artifact_path = cwd.join("report.txt");
fs::write(&artifact_path, "artifact contents").unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (history, active_session) =
initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let conversation_id = history.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, ctx)
});
let action = build_upload_artifact_action(&artifact_path.display().to_string());
let execution = executor.update(&mut app, |executor, ctx| {
executor.execute(
ExecuteActionInput {
action: &action,
conversation_id,
},
ctx,
)
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::UploadArtifact(
UploadArtifactResult::Error(message),
)) if message == "Current conversation has not been synced to the server yet"
));
});
}
#[test]
fn resolve_path_uses_active_session_working_directory_for_relative_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path().join("workspace");
fs::create_dir_all(&cwd).unwrap();
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let (_, active_session) = initialize_upload_artifact_test(&mut app, terminal_view_id, &cwd);
let executor =
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
let resolved_path = executor.update(&mut app, |executor, ctx| {
executor.resolve_path("reports/out.txt", ctx)
});
assert_eq!(resolved_path, cwd.join("reports/out.txt"));
});
}
@@ -0,0 +1,71 @@
use ai::agent::action_result::AIAgentActionResultType;
use futures::{future::BoxFuture, FutureExt};
use warpui::{Entity, ModelContext};
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
pub struct UseComputerExecutor;
impl UseComputerExecutor {
pub fn new() -> Self {
Self
}
pub(super) fn should_autoexecute(
&self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> bool {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::UseComputer(_) = &action.action else {
return false;
};
// We unconditionally return true here because this action is only executed by
// the computer use subagent, which cannot begin without the user approving it via
// a `RequestComputerUse` action, and the approval extends to all computer use
// actions within that computer use subagent.
true
}
pub(super) fn execute(
&mut self,
input: ExecuteActionInput,
_ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
let ExecuteActionInput { action, .. } = input;
let AIAgentActionType::UseComputer(request) = &action.action else {
return ActionExecution::InvalidAction;
};
let actions = request.actions.clone();
let screenshot_params = request.screenshot_params;
ActionExecution::new_async(
async move {
let mut actor = computer_use::create_actor();
match actor
.perform_actions(&actions, computer_use::Options { screenshot_params })
.await
{
Ok(result) => UseComputerResult::Success(result),
Err(error) => UseComputerResult::Error(error),
}
},
|res, _ctx| AIAgentActionResultType::UseComputer(res),
)
}
pub(super) fn preprocess_action(
&mut self,
_input: PreprocessActionInput,
_ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, ()> {
futures::future::ready(()).boxed()
}
}
impl Entity for UseComputerExecutor {
type Event = ();
}
@@ -0,0 +1,99 @@
mod binary_detection {
use std::io::Write as _;
use async_io::block_on;
use tempfile::TempDir;
use super::super::{is_file_content_binary_async, should_read_as_binary};
fn write_file(dir: &TempDir, name: &str, contents: &[u8]) -> std::path::PathBuf {
let path = dir.path().join(name);
let mut file = std::fs::File::create(&path).expect("create temp file");
file.write_all(contents).expect("write temp file");
file.flush().expect("flush temp file");
path
}
#[test]
fn text_file_with_known_extension_is_not_binary() {
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "script.sh", b"#!/usr/bin/env bash\necho hi\n");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn binary_file_with_known_extension_is_binary() {
let dir = TempDir::new().expect("create tempdir");
// Known binary extension — should be classified as binary without
// needing content inspection.
let path = write_file(&dir, "image.png", b"not really a png but extension wins\n");
assert!(block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_shell_script_is_not_binary() {
// Regression test for QUALITY-507: an extensionless shell script (e.g.
// `script/linux/bundle`) was being classified as binary solely because
// its basename isn't in the known extensionless-text allow-list.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(
&dir,
"bundle",
b"#!/usr/bin/env bash\n#\n# Builds a Warp binary and bundles it up for distribution.\n\nset -e\n",
);
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_binary_content_is_binary() {
// An extensionless file whose contents are actually binary should fall
// through the content-based check and be classified as binary.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(
&dir,
"payload",
// NUL byte is a strong binary signal for content_inspector.
&[0u8, 1, 2, 3, b'A', 0, 0, 0, 0xFF, 0xFE, 0xFD],
);
assert!(block_on(should_read_as_binary(&path)));
}
#[test]
fn extensionless_text_allowlisted_is_not_binary() {
// Files whose basenames are in the known text allow-list (e.g. README)
// should take the fast path and skip content inspection.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "README", b"Hello, world!\n");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn empty_extensionless_file_is_not_binary() {
// `content_inspector` treats an empty buffer as text, which is the
// desired behavior for `read_files`: an empty file should be
// surfaced to the agent as an empty string, not as zero binary bytes.
let dir = TempDir::new().expect("create tempdir");
let path = write_file(&dir, "empty", b"");
assert!(!block_on(should_read_as_binary(&path)));
}
#[test]
fn missing_extensionless_file_is_classified_as_binary() {
// When an extensionless file cannot be opened during content
// inspection, `should_read_as_binary` must route to the binary path
// so the binary reader can produce a consistent `Missing` result.
let dir = TempDir::new().expect("create tempdir");
let missing = dir.path().join("does-not-exist");
assert!(block_on(should_read_as_binary(&missing)));
}
#[test]
fn missing_file_helper_is_classified_as_binary() {
// Direct coverage of the low-level helper: opening a non-existent
// path must return `true` so the caller doesn't accidentally try the
// text path on an unreadable file.
let dir = TempDir::new().expect("create tempdir");
let missing = dir.path().join("does-not-exist");
assert!(block_on(is_file_content_binary_async(&missing)));
}
}
@@ -0,0 +1,145 @@
use std::collections::{HashSet, VecDeque};
use uuid::Uuid;
use crate::ai::agent::{AIAgentAction, AIAgentActionId};
/// A unique ID for a batch of preprocessed actions.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct PreprocessId(String);
impl PreprocessId {
fn new() -> Self {
Self(Uuid::new_v4().to_string())
}
}
/// A list of pending preprocessed actions.
/// Each action goes through a preprocessing step where executors
/// can asynchronously do arbitrary work and store any state as needed.
/// Upon completing the preprocessing step for a batch of actions, consumers can
/// call `handle_process_actions_result` to get the actions that are ready to be queued.
#[derive(Default, Debug)]
pub(super) struct PendingPreprocessedActions(VecDeque<PreprocessActionBatch>);
impl PendingPreprocessedActions {
pub fn contains(&self, action_id: &AIAgentActionId) -> bool {
self.0.iter().any(|action| action.contains(action_id))
}
/// Returns the actions that are ready to be queued now that the group of actions identified by [`PreprocessId`] have completed.
/// NOTE this may return actions that have been completed earlier to maintain the invariant that actions are returned in the
/// order they are added.
pub fn handle_preprocess_actions_result(
&mut self,
preprocess_id: PreprocessId,
actions: Vec<AIAgentAction>,
) -> Vec<AIAgentAction> {
let mut actions_to_queue = Vec::with_capacity(actions.len());
// Find the index of the action with the given preprocess_id
let Some(current_index) = self.0.iter().position(|batch| batch.id == preprocess_id) else {
log::warn!("Action not found in list of preprocessed actions");
return vec![];
};
// Check if there are any pending actions before the current one
let has_pending_before = self
.0
.iter()
.take(current_index)
.any(|action| matches!(action.status, PreprocessActionStatus::Pending));
if has_pending_before {
// If there are pending actions before this one, just mark this one as done
// and don't return any actions yet.
self.0[current_index].status = PreprocessActionStatus::Done { actions };
vec![]
} else {
// All actions before this one are done: process them all.
// First, collect actions from all completed batches before this one
for action in self.0.drain(..current_index) {
match action.status {
PreprocessActionStatus::Pending => {
#[cfg(debug_assertions)]
panic!("Preprocess action batch should be completed but was pending")
}
PreprocessActionStatus::Done { actions } => {
actions_to_queue.extend(actions);
}
}
}
// Then add the current batch's actions.
actions_to_queue.extend(actions);
// Remove the current batch.
self.0.pop_front();
// Process any subsequent completed batches.
while let Some(action) = self.0.pop_front() {
match action.status {
PreprocessActionStatus::Pending => {
self.0.push_front(action);
break;
}
PreprocessActionStatus::Done { actions } => {
actions_to_queue.extend(actions);
}
}
}
actions_to_queue
}
}
/// Inserts a batch of actions that need to be preprocessed. Returns a [`PreprocessId`] that
/// uniquely identifies the batch.
pub fn insert_preprocess_action_batch(
&mut self,
action_ids: HashSet<AIAgentActionId>,
) -> PreprocessId {
let preprocess_id = PreprocessId::new();
self.0.push_back(PreprocessActionBatch::new(
preprocess_id.clone(),
action_ids,
));
preprocess_id
}
}
#[derive(Clone, Debug, PartialEq)]
enum PreprocessActionStatus {
Pending,
Done { actions: Vec<AIAgentAction> },
}
/// A batch of actions that need to be preprocessed.
#[derive(Clone, Debug, PartialEq)]
struct PreprocessActionBatch {
/// A unique identifier for this batch.
id: PreprocessId,
/// The current status of this batch.
status: PreprocessActionStatus,
/// Action IDs associated with this batch.
action_ids: HashSet<AIAgentActionId>,
}
impl PreprocessActionBatch {
fn contains(&self, action_id: &AIAgentActionId) -> bool {
self.action_ids.contains(action_id)
}
fn new(preprocess_id: PreprocessId, action_ids: HashSet<AIAgentActionId>) -> Self {
Self {
id: preprocess_id,
status: PreprocessActionStatus::Pending,
action_ids,
}
}
}
#[cfg(test)]
#[path = "preprocess_tests.rs"]
mod tests;
@@ -0,0 +1,99 @@
use super::*;
use crate::ai::agent::{task::TaskId, AIAgentAction, AIAgentActionId, AIAgentActionType};
use std::collections::HashSet;
fn create_test_action(id: AIAgentActionId) -> AIAgentAction {
AIAgentAction {
id,
task_id: TaskId::new("fake-task".to_owned()),
action: AIAgentActionType::RequestCommandOutput {
command: "test".to_string(),
is_read_only: None,
is_risky: None,
rationale: None,
uses_pager: None,
wait_until_completion: true,
citations: vec![],
},
requires_result: false,
}
}
fn generate_new_action_id() -> AIAgentActionId {
AIAgentActionId::from(uuid::Uuid::new_v4().to_string())
}
#[test]
fn test_single_batch_done() {
let mut actions = PendingPreprocessedActions::default();
let action_id = generate_new_action_id();
let mut action_ids = HashSet::new();
action_ids.insert(action_id.clone());
let preprocess_id = actions.insert_preprocess_action_batch(action_ids);
let test_action = create_test_action(action_id.clone());
let result_actions = vec![test_action.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id, result_actions);
// Verify the action is returned
assert_eq!(queued_actions.len(), 1);
assert_eq!(queued_actions[0].id, action_id);
// Verify the batch is removed
assert_eq!(actions.0.len(), 0);
}
#[test]
fn test_multiple_batches() {
let mut actions = PendingPreprocessedActions::default();
// Insert three batches.
let action_id1 = generate_new_action_id();
let mut action_ids1 = HashSet::new();
action_ids1.insert(action_id1.clone());
let preprocess_id1 = actions.insert_preprocess_action_batch(action_ids1);
let action_id2 = generate_new_action_id();
let mut action_ids2 = HashSet::new();
action_ids2.insert(action_id2.clone());
let preprocess_id2 = actions.insert_preprocess_action_batch(action_ids2);
let action_id3 = generate_new_action_id();
let mut action_ids3 = HashSet::new();
action_ids3.insert(action_id3.clone());
let preprocess_id3 = actions.insert_preprocess_action_batch(action_ids3);
// Process the last batch. Should return nothing since the batch is not done.
let test_action3 = create_test_action(action_id3.clone());
let result_actions3 = vec![test_action3.clone()];
let queued_actions3 = actions.handle_preprocess_actions_result(preprocess_id3, result_actions3);
// Nothing should be returned: the first two batches are not done.
assert_eq!(queued_actions3.len(), 0);
assert_eq!(actions.0.len(), 3);
// Process the second-to-last-batch.
let test_action2 = create_test_action(action_id2.clone());
let result_actions2 = vec![test_action2.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id2, result_actions2);
// Nothing should be returned: the first batch is not done.
assert_eq!(queued_actions.len(), 0);
assert_eq!(actions.0.len(), 3);
// Process the first batch.
let test_action1 = create_test_action(action_id1.clone());
let result_actions1 = vec![test_action1.clone()];
let queued_actions = actions.handle_preprocess_actions_result(preprocess_id1, result_actions1);
// All of the batches are done--they should all be returned and the internal queue should be empty.
assert_eq!(queued_actions.len(), 3);
assert_eq!(actions.0.len(), 0);
assert_eq!(queued_actions[0].id, action_id1);
assert_eq!(queued_actions[1].id, action_id2);
assert_eq!(queued_actions[2].id, action_id3);
}
+100
View File
@@ -0,0 +1,100 @@
use std::{collections::HashMap, sync::Arc};
use super::*;
use crate::ai::agent::{task::TaskId, AIAgentActionResultType};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
id: AIAgentActionId::from(id.to_owned()),
task_id: TaskId::new("task".to_owned()),
result: AIAgentActionResultType::InitProject,
})
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
for (phase, can_autoexecute) in phases {
if let Some(current_phase) = current_phase {
if !can_start_action_with_current_phase(current_phase, *phase, *can_autoexecute) {
break;
}
}
count += 1;
current_phase = Some(*phase);
if matches!(*phase, RunningActionPhase::Serial) {
break;
}
}
count
}
#[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase =
RunningActionPhase::Parallel(execute::ParallelExecutionPolicy::ReadOnlyLocalContext);
assert!(can_start_action_with_current_phase(phase, phase, true));
assert!(!can_start_action_with_current_phase(phase, phase, false));
assert!(!can_start_action_with_current_phase(
phase,
RunningActionPhase::Serial,
true
));
assert!(!can_start_action_with_current_phase(
RunningActionPhase::Serial,
phase,
true
));
}
#[test]
fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
let read_only_phase =
RunningActionPhase::Parallel(execute::ParallelExecutionPolicy::ReadOnlyLocalContext);
let actions = vec![
(read_only_phase, true),
(read_only_phase, true),
(RunningActionPhase::Serial, true),
(read_only_phase, true),
(read_only_phase, true),
];
assert_eq!(count_startable_actions_for_pass(&actions), 2);
assert_eq!(count_startable_actions_for_pass(&actions[2..]), 1);
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
}
#[test]
fn finished_results_stay_in_original_action_order() {
let action_order = HashMap::from([
(AIAgentActionId::from("first".to_owned()), 0),
(AIAgentActionId::from("second".to_owned()), 1),
(AIAgentActionId::from("third".to_owned()), 2),
]);
let mut finished_results = [
make_action_result("third"),
make_action_result("first"),
make_action_result("second"),
];
finished_results
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
assert_eq!(
finished_results[0].id,
AIAgentActionId::from("first".to_owned())
);
assert_eq!(
finished_results[1].id,
AIAgentActionId::from("second".to_owned())
);
assert_eq!(
finished_results[2].id,
AIAgentActionId::from("third".to_owned())
);
}
@@ -0,0 +1,197 @@
use crate::context_chips::{
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
git_line_changes_from_chips,
prompt_type::PromptType,
ChipResult,
};
use warpui::{ModelHandle, ViewContext, ViewHandle};
use super::{AgentInputFooter, AgentInputFooterEvent};
impl AgentInputFooter {
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
///
/// This is basically cargo-culted from the equivalent logic in `PromptDisplay`, pared down to
/// only the chip types that we care about in the AgentView input.
fn check_if_chip_values_have_changed(
existing_chips: &[ViewHandle<DisplayChip>],
new_chips: &[ChipResult],
ctx: &mut ViewContext<Self>,
) -> bool {
existing_chips.len() != new_chips.len()
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
let existing_chip = &existing_chips[i];
existing_chip.read(ctx, |chip, _| {
chip.text()
!= chip_result
.value()
.map(|v| v.to_string())
.unwrap_or_default()
|| chip.chip_kind() != chip_result.kind()
// For parity with PromptDisplay: compare the first on-click value only.
|| chip.first_on_click_value() != chip_result.on_click_values().first()
})
})
}
fn create_display_chips(
&self,
new_chips: &[ChipResult],
git_line_changes_info: Option<GitLineChanges>,
ctx: &mut ViewContext<Self>,
) -> Vec<ViewHandle<DisplayChip>> {
let mut display_chips = Vec::with_capacity(new_chips.len());
let mut new_chips = new_chips.iter().peekable();
while let Some(chip_result) = new_chips.next() {
let next_chip_kind = new_chips
.peek()
.map(|chip_result| chip_result.kind().clone());
let view_handle = ctx.add_typed_action_view(|ctx| {
let config = self.display_chip_config.clone();
let mut chip = DisplayChip::new_for_agent_view(
chip_result.clone(),
next_chip_kind,
config,
ctx,
);
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
chip.update_session_context(self.display_chip_config.session_context.clone(), ctx);
chip
});
ctx.subscribe_to_view(&view_handle, move |_, _, event, ctx| match event {
PromptDisplayChipEvent::ToggleMenu { open } => {
ctx.emit(AgentInputFooterEvent::ToggledChipMenu { open: *open });
ctx.notify();
}
PromptDisplayChipEvent::TryExecuteCommand(cmd) => {
ctx.emit(AgentInputFooterEvent::TryExecuteChipCommand(cmd.clone()));
ctx.notify();
}
PromptDisplayChipEvent::OpenCodeReview => {
ctx.emit(AgentInputFooterEvent::OpenCodeReview);
ctx.notify();
}
PromptDisplayChipEvent::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(AgentInputFooterEvent::OpenAIDocument {
document_id: *document_id,
document_version: *document_version,
});
}
_ => {
ctx.notify();
}
});
display_chips.push(view_handle);
}
display_chips
}
fn update_existing_display_chips(
display_chips: &[ViewHandle<DisplayChip>],
git_line_changes_info: Option<GitLineChanges>,
ctx: &mut ViewContext<Self>,
) {
for chip_view in display_chips {
chip_view.update(ctx, |chip, ctx| {
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
ctx.notify();
});
}
}
/// Updates the display chip views based on a change to the underlying metadata that drives the
/// prompt, modeled in `PromptType`.
///
/// This is basically cargo-culted from the equivalent logic in `PromptDisplay`, pared down to
/// only the chip types that we care about in the AgentView input.
///
/// The whole context chip/UDI chip/prompt layer is in need of a big refactor; once the
/// `FeatureFlag::AgentView` is retired we'll have an opportunity to do a refactor with a smaller
/// surface area, presumably because we'll be able to first delete a lot of the affected logic
/// which the UDI and legacy inputs depend on.
pub(super) fn update_display_chips(
&mut self,
model: &ModelHandle<PromptType>,
ctx: &mut ViewContext<Self>,
) {
let new_left_chips = model
.as_ref(ctx)
.agent_view_left_chips(ctx)
.into_iter()
.filter(|chip_result| chip_result.value().is_some())
.collect::<Vec<ChipResult>>();
let new_right_chips = model
.as_ref(ctx)
.agent_view_right_chips(ctx)
.into_iter()
.filter(|chip_result| chip_result.value().is_some())
.collect::<Vec<ChipResult>>();
let new_chips = model
.as_ref(ctx)
.agent_view_chips(ctx)
.into_iter()
.filter(|chip| chip.value().is_some())
.collect::<Vec<ChipResult>>();
let git_line_changes_info = git_line_changes_from_chips(&new_chips);
let should_update_left =
Self::check_if_chip_values_have_changed(&self.left_display_chips, &new_left_chips, ctx);
let should_update_right = Self::check_if_chip_values_have_changed(
&self.right_display_chips,
&new_right_chips,
ctx,
);
if should_update_left {
self.left_display_chips =
self.create_display_chips(&new_left_chips, git_line_changes_info.clone(), ctx);
} else {
Self::update_existing_display_chips(
&self.left_display_chips,
git_line_changes_info.clone(),
ctx,
);
}
if should_update_right {
self.right_display_chips =
self.create_display_chips(&new_right_chips, git_line_changes_info.clone(), ctx);
} else {
Self::update_existing_display_chips(
&self.right_display_chips,
git_line_changes_info.clone(),
ctx,
);
}
// Build display chips for the CLI agent footer separately.
// The CLI selection may include chips not present in the agent view selection.
let new_cli_chips = model
.as_ref(ctx)
.cli_agent_chips(ctx)
.into_iter()
.filter(|chip_result| chip_result.value().is_some())
.collect::<Vec<ChipResult>>();
let should_update_cli =
Self::check_if_chip_values_have_changed(&self.cli_display_chips, &new_cli_chips, ctx);
if should_update_cli {
self.cli_display_chips =
self.create_display_chips(&new_cli_chips, git_line_changes_info.clone(), ctx);
} else {
Self::update_existing_display_chips(
&self.cli_display_chips,
git_line_changes_info,
ctx,
);
}
ctx.notify();
}
}
@@ -0,0 +1,378 @@
//! Modal for customizing the agent input footer chip layout.
//!
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
//! drag/drop chips between left, right, and unused banks.
use warpui::keymap::FixedBinding;
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use crate::chip_configurator::{
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
ChipEditorSectionsConfig,
};
use crate::report_if_error;
use crate::terminal::session_settings::{
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
SessionSettingsChangedEvent, ToolbarChipSelection,
};
use crate::Appearance;
use settings::Setting as _;
use super::toolbar_item::AgentToolbarItemKind;
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
/// Controls which set of items and settings the editor modal operates on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AgentToolbarEditorMode {
#[default]
AgentView,
CLIAgent,
}
pub enum AgentToolbarEditorEvent {
Close,
}
pub struct AgentToolbarEditorModal {
mouse_handles: ChipEditorMouseHandles,
chip_configurator: ChipConfigurator,
mode: AgentToolbarEditorMode,
is_dirty: bool,
}
pub struct AgentToolbarInlineEditor {
mouse_handles: ChipEditorMouseHandles,
chip_configurator: ChipConfigurator,
mode: AgentToolbarEditorMode,
}
#[derive(Clone, Copy, Debug)]
pub enum AgentToolbarEditorAction {
Cancel,
Save,
Chip(ChipConfiguratorAction),
ResetDefault,
/// Dummy action used as on_click for chip bank clicks (no-op).
Activate,
}
#[derive(Clone, Copy, Debug)]
pub enum AgentToolbarInlineEditorAction {
Chip(ChipConfiguratorAction),
ResetDefault,
/// Dummy action used as on_click for chip bank clicks (no-op).
Activate,
}
fn open_toolbar_items_from_settings<V: View>(
chip_configurator: &mut ChipConfigurator,
mode: AgentToolbarEditorMode,
ctx: &mut ViewContext<V>,
) {
let appearance = Appearance::as_ref(ctx);
let session_settings = SessionSettings::as_ref(ctx);
let (current_left, current_right, available) = match mode {
AgentToolbarEditorMode::AgentView => {
let selection = session_settings.agent_footer_chip_selection.clone();
(
selection.left_items(),
selection.right_items(),
AgentToolbarItemKind::all_available(),
)
}
AgentToolbarEditorMode::CLIAgent => {
let selection = session_settings.cli_agent_footer_chip_selection.clone();
(
selection.left_items(),
selection.right_items(),
AgentToolbarItemKind::all_available_for_cli_input(),
)
}
};
chip_configurator.open_left_right_zones_with_items(
current_left,
current_right,
available,
appearance,
);
}
fn open_default_toolbar_items<V: View>(
chip_configurator: &mut ChipConfigurator,
mode: AgentToolbarEditorMode,
ctx: &mut ViewContext<V>,
) {
let appearance = Appearance::as_ref(ctx);
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
}
fn is_toolbar_editor_at_defaults(
mode: AgentToolbarEditorMode,
chip_configurator: &ChipConfigurator,
) -> bool {
let left = chip_configurator.left_item_kinds();
let right = chip_configurator.right_item_kinds();
toolbar_items_match_defaults(mode, &left, &right)
}
fn toolbar_items_match_defaults(
mode: AgentToolbarEditorMode,
left: &[AgentToolbarItemKind],
right: &[AgentToolbarItemKind],
) -> bool {
let (default_left, default_right, _) = AgentToolbarItemKind::defaults_for_mode(mode);
default_left.as_slice() == left && default_right.as_slice() == right
}
impl AgentToolbarInlineEditor {
pub fn new(mode: AgentToolbarEditorMode, ctx: &mut ViewContext<Self>) -> Self {
let mut editor = Self {
mouse_handles: Default::default(),
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
mode,
};
editor.reset_from_settings(ctx);
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
let should_refresh = matches!(
(me.mode, event),
(
AgentToolbarEditorMode::AgentView,
SessionSettingsChangedEvent::AgentToolbarChipSelectionSetting { .. },
) | (
AgentToolbarEditorMode::CLIAgent,
SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. },
)
);
if should_refresh && me.chip_configurator.current_dragging_state.is_none() {
me.reset_from_settings(ctx);
ctx.notify();
}
});
editor
}
fn reset_from_settings(&mut self, ctx: &mut ViewContext<Self>) {
open_toolbar_items_from_settings(&mut self.chip_configurator, self.mode, ctx);
}
fn save_current_selection(&self, ctx: &mut ViewContext<Self>) {
let left = self.chip_configurator.left_item_kinds();
let right = self.chip_configurator.right_item_kinds();
save_toolbar_selection(self.mode, left, right, ctx);
}
fn is_at_defaults(&self) -> bool {
is_toolbar_editor_at_defaults(self.mode, &self.chip_configurator)
}
}
impl Entity for AgentToolbarInlineEditor {
type Event = ();
}
impl TypedActionView for AgentToolbarInlineEditor {
type Action = AgentToolbarInlineEditorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
Self::Action::Chip(chip_action) => {
let should_save = self.chip_configurator.handle_action(chip_action, ctx);
if should_save {
self.save_current_selection(ctx);
}
ctx.notify();
}
Self::Action::ResetDefault => {
open_default_toolbar_items(&mut self.chip_configurator, self.mode, ctx);
self.save_current_selection(ctx);
ctx.notify();
}
Self::Action::Activate => {
// no-op — used as the on_click for chip bank items
}
}
}
}
impl View for AgentToolbarInlineEditor {
fn ui_name() -> &'static str {
"AgentToolbarInlineEditor"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
render_chip_editor_sections(
&self.chip_configurator,
ChipEditorSectionsConfig {
available_section_label: "Available chips",
is_at_defaults: self.is_at_defaults(),
reset_action: AgentToolbarInlineEditorAction::ResetDefault,
activate_action: AgentToolbarInlineEditorAction::Activate,
chip_action_wrapper: AgentToolbarInlineEditorAction::Chip,
mouse_handles: &self.mouse_handles,
},
appearance,
)
}
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
AgentToolbarEditorAction::Cancel,
id!(AgentToolbarEditorModal::ui_name()),
)]);
}
fn save_toolbar_selection<V: View>(
mode: AgentToolbarEditorMode,
left: Vec<AgentToolbarItemKind>,
right: Vec<AgentToolbarItemKind>,
ctx: &mut ViewContext<V>,
) {
let is_default = toolbar_items_match_defaults(mode, &left, &right);
match mode {
AgentToolbarEditorMode::AgentView => {
let selection = if is_default {
AgentToolbarChipSelection::Default
} else {
AgentToolbarChipSelection::Custom { left, right }
};
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.agent_footer_chip_selection
.set_value(selection, ctx));
});
}
AgentToolbarEditorMode::CLIAgent => {
let selection = if is_default {
CLIAgentToolbarChipSelection::Default
} else {
CLIAgentToolbarChipSelection::Custom { left, right }
};
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.cli_agent_footer_chip_selection
.set_value(selection, ctx));
});
}
}
}
impl AgentToolbarEditorModal {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
mouse_handles: Default::default(),
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
mode: AgentToolbarEditorMode::default(),
is_dirty: false,
}
}
pub fn open(&mut self, mode: AgentToolbarEditorMode, ctx: &mut ViewContext<Self>) {
self.reset();
self.mode = mode;
open_toolbar_items_from_settings(&mut self.chip_configurator, mode, ctx);
ctx.notify();
}
fn save_to_settings(&mut self, ctx: &mut ViewContext<Self>) {
if !self.is_dirty {
return;
}
let left = self.chip_configurator.left_item_kinds();
let right = self.chip_configurator.right_item_kinds();
save_toolbar_selection(self.mode, left, right, ctx);
}
fn reset(&mut self) {
self.chip_configurator.reset();
self.is_dirty = false;
}
fn modal_title(&self) -> &'static str {
match self.mode {
AgentToolbarEditorMode::AgentView => AGENT_MODAL_TITLE,
AgentToolbarEditorMode::CLIAgent => CLI_MODAL_TITLE,
}
}
}
impl Entity for AgentToolbarEditorModal {
type Event = AgentToolbarEditorEvent;
}
impl TypedActionView for AgentToolbarEditorModal {
type Action = AgentToolbarEditorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
Self::Action::Cancel => {
self.reset();
ctx.emit(AgentToolbarEditorEvent::Close);
}
Self::Action::Save => {
self.save_to_settings(ctx);
ctx.emit(AgentToolbarEditorEvent::Close);
}
Self::Action::Chip(chip_action) => {
let mutated = self.chip_configurator.handle_action(chip_action, ctx);
if mutated {
self.is_dirty = true;
}
ctx.notify();
}
Self::Action::ResetDefault => {
self.is_dirty = true;
open_default_toolbar_items(&mut self.chip_configurator, self.mode, ctx);
ctx.notify();
}
Self::Action::Activate => {
// no-op — used as the on_click for chip bank items
}
}
}
}
impl AgentToolbarEditorModal {
fn is_at_defaults(&self) -> bool {
is_toolbar_editor_at_defaults(self.mode, &self.chip_configurator)
}
}
impl View for AgentToolbarEditorModal {
fn ui_name() -> &'static str {
"AgentToolbarEditorModal"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
render_chip_editor_modal(
&self.chip_configurator,
ChipEditorModalConfig {
title: self.modal_title(),
available_section_label: "Available chips",
is_at_defaults: self.is_at_defaults(),
is_dirty: self.is_dirty,
cancel_action: AgentToolbarEditorAction::Cancel,
save_action: AgentToolbarEditorAction::Save,
reset_action: AgentToolbarEditorAction::ResetDefault,
activate_action: AgentToolbarEditorAction::Activate,
chip_action_wrapper: AgentToolbarEditorAction::Chip,
mouse_handles: &self.mouse_handles,
},
appearance,
)
}
}
@@ -0,0 +1,447 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::Fill;
use warpui::{
elements::{
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Stack,
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use std::sync::Arc;
use crate::{
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
ai::{
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
},
appearance::Appearance,
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
context_chips::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
},
report_if_error,
server::ids::SyncId,
terminal::input::{MenuPositioning, MenuPositioningProvider},
ui_components::icons::Icon,
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
};
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
/// A selector component for choosing an ambient agent environment.
pub struct EnvironmentSelector {
button: ViewHandle<ActionButton>,
dropdown: ViewHandle<DisplayChipMenu>,
is_menu_open: bool,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
}
pub enum EnvironmentSelectorEvent {
MenuVisibilityChanged { open: bool },
OpenEnvironmentManagementPane,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvironmentSelectorAction {
ToggleMenu,
}
/// Menu item for an environment in the selector.
#[derive(Debug, Clone)]
struct EnvironmentMenuItem {
id: SyncId,
name: String,
is_selected: bool,
}
const ENV_MENU_CHECK_ICON_SIZE: f32 = 16.;
impl GenericMenuItem for EnvironmentMenuItem {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
self.name.clone()
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
None
}
fn action_data(&self) -> String {
self.id.to_string()
}
fn right_side_element(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if !self.is_selected {
return None;
}
let theme = Appearance::as_ref(app).theme();
let color = theme.main_text_color(theme.surface_2()).into_solid();
Some(
ConstrainedBox::new(Icon::Check.to_warpui_icon(Fill::Solid(color)).finish())
.with_width(ENV_MENU_CHECK_ICON_SIZE)
.with_height(ENV_MENU_CHECK_ICON_SIZE)
.finish(),
)
}
}
/// Menu item for the "New Environment" footer option.
#[derive(Debug, Clone)]
struct NewEnvironmentMenuItem;
impl GenericMenuItem for NewEnvironmentMenuItem {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
"New environment".to_string()
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
Some(Icon::Plus)
}
fn action_data(&self) -> String {
"new_environment".to_string()
}
}
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
environments.sort_by(|a, b| {
// Sort by last-used timestamp descending (most recent first), then by display name ascending
b.metadata
.last_task_run_ts
.cmp(&a.metadata.last_task_run_ts)
.then_with(|| {
a.model()
.string_model
.name
.to_lowercase()
.cmp(&b.model().string_model.name.to_lowercase())
})
});
}
impl EnvironmentSelector {
pub fn new(
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("", AgentInputButtonTheme)
.with_icon(Icon::Globe4)
.with_tooltip("Choose an environment")
.with_size(ButtonSize::AgentInputButton)
.with_disabled_theme(DisabledTheme)
.on_click(|ctx| {
ctx.dispatch_typed_action(EnvironmentSelectorAction::ToggleMenu);
})
});
let dropdown = ctx.add_typed_action_view(move |ctx| {
DisplayChipMenu::new(
Vec::<EnvironmentMenuItem>::new(),
Some(FixedFooter::new(Arc::new(NewEnvironmentMenuItem))),
ChipMenuType::Environments,
ctx,
)
});
ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| match event {
PromptDisplayMenuEvent::MenuAction(generic_event) => {
// Check if this is the "New Environment" footer action
if generic_event
.action_item
.as_any()
.downcast_ref::<NewEnvironmentMenuItem>()
.is_some()
{
send_telemetry_from_ctx!(
CloudAgentTelemetryEvent::OpenedEnvironmentManagementPane,
ctx
);
me.set_menu_visibility(false, ctx);
ctx.emit(EnvironmentSelectorEvent::OpenEnvironmentManagementPane);
return;
}
// Otherwise, it's an environment selection.
if let Some(env_item) = generic_event
.action_item
.as_any()
.downcast_ref::<EnvironmentMenuItem>()
{
send_telemetry_from_ctx!(
CloudAgentTelemetryEvent::EnvironmentSelected {
environment_id: env_item.id.into_server(),
},
ctx
);
if me.is_configuring(ctx) {
me.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_item.id), ctx);
});
// Persist the selection to settings for next time.
me.save_selected_environment_to_settings(env_item.id, ctx);
}
me.set_menu_visibility(false, ctx);
}
}
PromptDisplayMenuEvent::CloseMenu => {
me.set_menu_visibility(false, ctx);
}
});
// Subscribe to CloudModel to refresh when environments are added/removed.
ctx.subscribe_to_model(&CloudModel::handle(ctx), |me, _, _, ctx| {
me.ensure_default_selection(ctx);
me.refresh_menu(ctx);
me.refresh_button(ctx);
ctx.notify();
});
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
me.refresh_menu(ctx);
}
me.refresh_button(ctx);
});
let mut me = Self {
button,
dropdown,
is_menu_open: false,
menu_positioning_provider,
ambient_agent_model,
};
me.refresh_menu(ctx);
me.refresh_button(ctx);
me.ensure_default_selection(ctx);
me
}
pub fn is_menu_open(&self) -> bool {
self.is_menu_open
}
fn is_configuring(&self, ctx: &AppContext) -> bool {
self.ambient_agent_model
.as_ref(ctx)
.is_configuring_ambient_agent()
}
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
if self.is_menu_open == is_open {
return;
}
self.is_menu_open = is_open;
if is_open {
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
ctx.focus(&self.dropdown);
}
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
ctx.notify();
}
/// Ensures a default environment is selected if none is currently selected.
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
let current_selection = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id();
if current_selection.is_some() {
return;
}
// First, try to restore the user's last selected environment from settings.
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
// Verify the environment still exists.
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(env_id), ctx);
});
return;
}
}
// Fall back to auto-selecting the most recently used environment.
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
if let Some(first_env) = environments.first() {
self.ambient_agent_model.update(ctx, |model, ctx| {
model.set_environment_id(Some(first_env.id), ctx);
});
}
}
/// Retrieves the last selected environment ID from settings.
fn get_saved_environment_from_settings(&self, ctx: &ViewContext<Self>) -> Option<SyncId> {
*CloudAgentSettings::as_ref(ctx)
.last_selected_environment_id
.value()
}
/// Saves the selected environment ID to settings.
fn save_selected_environment_to_settings(&self, env_id: SyncId, ctx: &mut ViewContext<Self>) {
CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.last_selected_environment_id
.set_value(Some(env_id), ctx));
});
}
fn refresh_menu(&mut self, ctx: &mut ViewContext<Self>) {
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
sort_environments_by_recency(&mut environments);
let selected_id = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.cloned();
let menu_items: Vec<EnvironmentMenuItem> = environments
.iter()
.map(|env| {
let is_selected = selected_id.as_ref() == Some(&env.id);
EnvironmentMenuItem {
id: env.id,
name: env.model().string_model.display_name(),
is_selected,
}
})
.collect();
self.dropdown.update(ctx, |menu, ctx| {
menu.update_menu_items(menu_items, ctx);
});
}
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
let label = self
.ambient_agent_model
.as_ref(ctx)
.selected_environment_id()
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
.map(|env| env.model().string_model.display_name())
.unwrap_or_else(|| "New environment".to_string());
let is_configuring = self.is_configuring(ctx);
self.button.update(ctx, |button, ctx| {
button.set_label(label, ctx);
button.set_tooltip(
if is_configuring {
Some("Choose an environment")
} else {
Some("Agent environment")
},
ctx,
);
button.set_disabled(!is_configuring, ctx);
});
}
fn get_menu_positioning(&self, app: &AppContext) -> OffsetPositioning {
match self.menu_positioning_provider.menu_position(app) {
MenuPositioning::BelowInputBox => OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
MenuPositioning::AboveInputBox => OffsetPositioning::offset_from_parent(
vec2f(0., -4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
}
}
}
impl TypedActionView for EnvironmentSelector {
type Action = EnvironmentSelectorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EnvironmentSelectorAction::ToggleMenu => {
if self.is_configuring(ctx) {
self.set_menu_visibility(!self.is_menu_open, ctx);
}
}
}
}
}
impl View for EnvironmentSelector {
fn ui_name() -> &'static str {
"EnvironmentSelector"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new();
stack.add_child(ChildView::new(&self.button).finish());
if self.is_menu_open {
let menu = ChildView::new(&self.dropdown).finish();
let positioning = self.get_menu_positioning(app);
stack.add_positioned_overlay_child(menu, positioning);
}
stack.finish()
}
}
impl Entity for EnvironmentSelector {
type Event = EnvironmentSelectorEvent;
}
struct DisabledTheme;
impl ActionButtonTheme for DisabledTheme {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
AgentInputButtonTheme.background(hovered, appearance)
}
fn text_color(
&self,
_hovered: bool,
background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
// `background` may be a translucent overlay fill; compute disabled text color against an
// effective solid background to avoid washing out the label.
let base_bg = appearance.theme().surface_1();
let effective_bg = match background {
Some(overlay) => base_bg.blend(&overlay),
None => base_bg,
};
appearance
.theme()
.disabled_text_color(effective_bg)
.into_solid()
}
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
AgentInputButtonTheme.border(appearance)
}
fn should_opt_out_of_contrast_adjustment(&self) -> bool {
AgentInputButtonTheme.should_opt_out_of_contrast_adjustment()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
use serde::{Deserialize, Serialize};
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
use crate::features::FeatureFlag;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::ui_components::icons::Icon;
use super::editor::AgentToolbarEditorMode;
/// Declares which footer(s) a toolbar item is available in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolbarAvailability {
AgentViewOnly,
CLIAgentOnly,
Both,
}
impl ToolbarAvailability {
pub fn is_available_for_agent_view(self) -> bool {
matches!(self, Self::AgentViewOnly | Self::Both)
}
pub fn is_available_for_cli(self) -> bool {
matches!(self, Self::CLIAgentOnly | Self::Both)
}
}
/// A configurable item
///
/// This unifies context-chip data displays with interactive control buttons so
/// they can all be arranged through the same drag-and-drop editor.
#[derive(
Clone,
Debug,
Eq,
PartialEq,
Hash,
Serialize,
Deserialize,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "An item that can appear in the agent toolbar.",
rename_all = "snake_case"
)]
pub enum AgentToolbarItemKind {
#[schemars(description = "A prompt context chip.")]
ContextChip(ContextChipKind),
// Agent view only
ModelSelector,
NLDToggle,
ContextWindowUsage,
// CLI agent only
FileExplorer,
RichInput,
// Both
VoiceInput,
// Renamed from ImageAttach; alias preserves existing user toolbar configs.
#[serde(alias = "ImageAttach")]
FileAttach,
ShareSession,
// CLI agent only – opens settings to the Coding Agents section.
Settings,
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
FastForwardToggle,
}
impl AgentToolbarItemKind {
pub fn available_in(&self) -> ToolbarAvailability {
match self {
Self::ContextChip(_) | Self::VoiceInput | Self::FileAttach | Self::ShareSession => {
ToolbarAvailability::Both
}
Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
Self::FileExplorer | Self::RichInput | Self::Settings => {
ToolbarAvailability::CLIAgentOnly
}
}
}
/// Whether this item should be visible to session viewers.
/// Items that control host settings or initiate actions on the host's
/// behalf are hidden from viewers.
pub fn available_to_session_viewer(
&self,
status: &SharedSessionStatus,
is_cloud_mode: bool,
) -> bool {
match self {
Self::Settings | Self::ShareSession | Self::FileExplorer => !status.is_viewer(),
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
Self::ContextChip(_)
| Self::ModelSelector
| Self::NLDToggle
| Self::ContextWindowUsage
| Self::RichInput
| Self::VoiceInput => true,
}
}
pub fn display_label(&self) -> &'static str {
match self {
Self::ContextChip(_) => "Context Chip",
Self::ModelSelector => "Model Selector",
Self::NLDToggle => "Autodetection",
Self::VoiceInput => "Voice Input",
Self::FileAttach => "Attach File",
Self::ContextWindowUsage => "Context Usage",
Self::FileExplorer => "File Explorer",
Self::RichInput => "Rich Input",
Self::ShareSession => "/remote-control",
Self::Settings => "Settings",
Self::FastForwardToggle => "Fast Forward",
}
}
pub fn icon(&self) -> Option<Icon> {
match self {
Self::ContextChip(kind) => kind.udi_icon(),
Self::ModelSelector => Some(Icon::Oz),
Self::NLDToggle => Some(Icon::NLD),
Self::VoiceInput => Some(Icon::Microphone),
Self::FileAttach => Some(Icon::Plus),
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
Self::FileExplorer => Some(Icon::FileCopy),
Self::RichInput => Some(Icon::TextInput),
Self::ShareSession => Some(Icon::Phone01),
Self::Settings => Some(Icon::Settings),
Self::FastForwardToggle => Some(Icon::FastForward),
}
}
pub fn is_context_chip(&self) -> bool {
matches!(self, Self::ContextChip(_))
}
pub fn context_chip_kind(&self) -> Option<&ContextChipKind> {
match self {
Self::ContextChip(kind) => Some(kind),
_ => None,
}
}
/// Default left-side items for the agent view footer.
pub fn default_left() -> Vec<Self> {
let mut items = vec![
Self::ContextChip(ContextChipKind::Ssh),
Self::ContextChip(ContextChipKind::WorkingDirectory),
Self::ContextChip(ContextChipKind::ShellGitBranch),
Self::ContextChip(ContextChipKind::GitDiffStats),
];
if FeatureFlag::GithubPrPromptChip.is_enabled() {
items.push(Self::ContextChip(ContextChipKind::GithubPullRequest));
}
items.push(Self::NLDToggle);
items
}
/// Default right-side items for the agent view footer.
pub fn default_right() -> Vec<Self> {
let mut items = vec![
Self::ContextChip(ContextChipKind::AgentPlanAndTodoList),
Self::ContextWindowUsage,
Self::ModelSelector,
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
items.push(Self::VoiceInput);
items.push(Self::FileAttach);
items
}
/// All items available for the agent view footer configurator.
pub fn all_available() -> Vec<Self> {
let mut items: Vec<Self> = agent_footer_available_chips()
.into_iter()
.map(Self::ContextChip)
.collect();
items.extend([
Self::ModelSelector,
Self::NLDToggle,
Self::VoiceInput,
Self::FileAttach,
Self::ContextWindowUsage,
]);
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle);
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
items
}
/// Default left-side items for the CLI agent footer.
pub fn cli_default_left() -> Vec<Self> {
let mut items = vec![
Self::FileAttach,
Self::VoiceInput,
Self::ContextChip(ContextChipKind::GitDiffStats),
];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
items.push(Self::FileExplorer);
if FeatureFlag::CLIAgentRichInput.is_enabled() {
items.push(Self::RichInput);
}
items
}
/// Default right-side items for the CLI agent footer.
pub fn cli_default_right() -> Vec<Self> {
vec![
Self::ContextChip(ContextChipKind::WorkingDirectory),
Self::ContextChip(ContextChipKind::ShellGitBranch),
Self::Settings,
]
}
/// All items available for the CLI agent footer configurator.
pub fn all_available_for_cli_input() -> Vec<Self> {
let mut items: Vec<Self> = available_chips()
.into_iter()
.map(Self::ContextChip)
.collect();
items.extend([
Self::FileExplorer,
Self::RichInput,
Self::FileAttach,
Self::VoiceInput,
Self::Settings,
]);
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
items
}
/// Returns the appropriate defaults and available items for a given editor mode.
pub fn defaults_for_mode(mode: AgentToolbarEditorMode) -> (Vec<Self>, Vec<Self>, Vec<Self>) {
match mode {
AgentToolbarEditorMode::AgentView => (
Self::default_left(),
Self::default_right(),
Self::all_available(),
),
AgentToolbarEditorMode::CLIAgent => (
Self::cli_default_left(),
Self::cli_default_right(),
Self::all_available_for_cli_input(),
),
}
}
}
impl From<ContextChipKind> for AgentToolbarItemKind {
fn from(kind: ContextChipKind) -> Self {
Self::ContextChip(kind)
}
}
@@ -0,0 +1,920 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use warp_core::features::FeatureFlag;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::assets::asset_cache::AssetSource;
use warpui::elements::{Container, Element, Empty, MouseStateHandle};
use warpui::keymap::Keystroke;
use warpui::platform::OperatingSystem;
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
use crate::ai::agent::conversation::AIConversation;
use crate::ai::agent::{
AIAgentExchangeId, AIAgentOutputStatus, FinishedAIAgentOutput, RenderableAIError,
};
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
BlocklistAIInputEvent, BlocklistAIInputModel,
};
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
use crate::ai::mcp::{
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
TemplatableMCPServerManager,
};
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
use crate::search::slash_command_menu::static_commands::commands;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::message_bar::attached_context::{
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
};
use crate::terminal::input::message_bar::common::{
disableable_message_item_color_overrides, render_standard_message_bar,
};
use crate::terminal::input::message_bar::{
ChipHorizontalAlignment, EmptyMessageProducer, Message, MessageItem, MessageProvider,
};
use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel};
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
use crate::terminal::model::TerminalModel;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::workspace::tab_settings::{TabSettings, TabSettingsChangedEvent};
#[cfg(not(target_family = "wasm"))]
use crate::workspace::WorkspaceAction;
use crate::BlocklistAIHistoryModel;
const FIGMA_ICON_SIZE: f32 = 14.;
#[derive(Clone, Default)]
pub struct AgentMessageBarMouseStates {
pub resume_conversation: MouseStateHandle,
pub fork_from_last_known_good_state: MouseStateHandle,
pub toggle_shortcuts: MouseStateHandle,
pub toggle_slash_commands: MouseStateHandle,
pub toggle_plan: MouseStateHandle,
pub toggle_conversation_menu: MouseStateHandle,
pub toggle_code_review: MouseStateHandle,
pub clear_attached_context: MouseStateHandle,
/// Mouse state handle for the "Get Figma MCP" contextual button.
pub figma_install_button: MouseStateHandle,
/// Mouse state handle for the "Enable Figma MCP" contextual button.
pub figma_enable_button: MouseStateHandle,
}
/// Renders contextual hint text at the bottom of the agent view status bar.
pub struct AgentMessageBar {
agent_view_controller: ModelHandle<AgentViewController>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
input_buffer_model: ModelHandle<InputBufferModel>,
input_model: ModelHandle<BlocklistAIInputModel>,
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
terminal_model: Arc<FairMutex<TerminalModel>>,
mouse_states: AgentMessageBarMouseStates,
/// Whether the word "figma" has been detected in the current input buffer or attached images.
/// Only meaningful when `FeatureFlag::FigmaDetection` is enabled.
figma_detected: bool,
}
impl Entity for AgentMessageBar {
type Event = ();
}
impl AgentMessageBar {
#[allow(clippy::too_many_arguments)]
pub fn new(
agent_view_controller: ModelHandle<AgentViewController>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
input_buffer_model: ModelHandle<InputBufferModel>,
input_model: ModelHandle<BlocklistAIInputModel>,
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| {
if matches!(
event,
AgentViewControllerEvent::EnteredAgentView { .. }
| AgentViewControllerEvent::ExitedAgentView { .. }
) {
ctx.notify();
}
});
ctx.subscribe_to_model(&ephemeral_message_model, |_, _, event, ctx| {
if matches!(event, EphemeralMessageModelEvent::MessageChanged) {
ctx.notify();
}
});
ctx.subscribe_to_model(&input_model, |_, _, event, ctx| {
if matches!(
event,
BlocklistAIInputEvent::InputTypeChanged { .. }
| BlocklistAIInputEvent::LockChanged { .. }
) {
ctx.notify();
}
});
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
let InputBufferUpdateEvent {
old_content: old,
new_content: new,
..
} = event;
// If the user inputs into the buffer, dismiss any explicit message if we have one.
me.ephemeral_message_model
.update(ctx, |m, ctx| m.try_dismiss_explicit_message(ctx));
let empty_state_changed = old.is_empty() != new.is_empty();
let in_shell_mode = !me.input_model.as_ref(ctx).is_ai_input_enabled();
if empty_state_changed || in_shell_mode {
ctx.notify();
}
me.update_figma_detected(ctx);
});
ctx.subscribe_to_model(&shortcut_view_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&input_suggestions_model, |me, _, event, ctx| match event {
InputSuggestionsModeEvent::ModeChanged {
buffer_to_restore: _,
input_config_to_restore: _,
} => {
if me.input_suggestions_model.as_ref(ctx).is_inline_menu_open() {
// When opening an inline menu, dismiss any explicit message if we have one.
me.ephemeral_message_model
.update(ctx, |m, ctx| m.try_dismiss_explicit_message(ctx));
}
ctx.notify();
}
});
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
ctx.notify();
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
if matches!(
event,
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
) {
ctx.notify();
}
});
ctx.subscribe_to_model(&AIDocumentModel::handle(ctx), |_, _, event, ctx| {
if matches!(event, AIDocumentModelEvent::DocumentVisibilityChanged(_)) {
ctx.notify();
}
});
ctx.subscribe_to_model(&context_model, |me, _, event, ctx| {
if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event {
me.update_figma_detected(ctx);
ctx.notify();
}
});
ctx.subscribe_to_model(&TabSettings::handle(ctx), |_, _, event, ctx| {
if matches!(event, TabSettingsChangedEvent::ShowCodeReviewButton { .. }) {
ctx.notify();
}
});
if FeatureFlag::FigmaDetection.is_enabled() {
// When the state of the Figma MCP changes, re-render to update the Figma CTA button.
ctx.subscribe_to_model(
&TemplatableMCPServerManager::handle(ctx),
|_, model, event, ctx| {
if let TemplatableMCPServerManagerEvent::StateChanged { uuid, .. } = event {
if let Some(figma_mcp_uuid) =
model.as_ref(ctx).get_figma_installation_uuid()
{
if uuid == &figma_mcp_uuid {
ctx.notify();
}
}
}
},
);
}
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
ctx.notify();
}
});
Self {
agent_view_controller,
ephemeral_message_model,
shortcut_view_model,
input_buffer_model,
input_model,
input_suggestions_model,
slash_command_model,
context_model,
terminal_model,
mouse_states: AgentMessageBarMouseStates::default(),
figma_detected: false,
}
}
}
impl AgentMessageBar {
/// Sets `figma_detected` by checking both the current input text and attached images.
/// `figma_detected` is `true` when either the text contains "figma" (case-insensitive)
/// or any attached image was exported from Figma.
fn update_figma_detected(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::FigmaDetection.is_enabled() {
return;
}
let text_has_figma = self
.input_buffer_model
.as_ref(ctx)
.current_value()
.to_lowercase()
.contains("figma");
let image_has_figma = self
.context_model
.as_ref(ctx)
.pending_images()
.iter()
.any(|image| image.is_figma);
let detected = text_has_figma || image_has_figma;
if self.figma_detected != detected {
self.figma_detected = detected;
ctx.notify();
}
}
/// Returns the Figma MCP status if the contextual button area should be rendered
/// (i.e. when `FeatureFlag::FigmaDetection` is enabled and "figma" is detected in the input).
fn figma_button_status(&self, app: &AppContext) -> Option<FigmaMcpStatus> {
if FeatureFlag::FigmaDetection.is_enabled() && self.figma_detected {
Some(TemplatableMCPServerManager::as_ref(app).get_figma_mcp_status())
} else {
None
}
}
}
impl View for AgentMessageBar {
fn ui_name() -> &'static str {
"AgentMessageBar"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
// If an inline menu is open, a 'message line' is rendered by the inline menu itself,
// so defer to that.
let input_suggestions_model = self.input_suggestions_model.as_ref(app);
if input_suggestions_model.is_inline_menu_open() {
return Empty::new().finish();
}
let shortcut_view_model = self.shortcut_view_model.as_ref(app);
let input_buffer_model = self.input_buffer_model.as_ref(app);
let input_model = self.input_model.as_ref(app);
let agent_view_controller = self.agent_view_controller.as_ref(app);
let context_model = self.context_model.as_ref(app);
let slash_command_model = self.slash_command_model.as_ref(app);
let terminal_model = self.terminal_model.lock();
let appearance = Appearance::as_ref(app);
let Some(active_conversation) = agent_view_controller
.agent_view_state()
.active_conversation_id()
.and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id))
else {
// This should never be hit, as the agent view requires there be an active conversation.
return Empty::new().finish();
};
let ephemeral_message_model = self.ephemeral_message_model.as_ref(app);
let args = AgentMessageArgs {
active_conversation,
agent_view_controller,
ephemeral_message_model,
shortcut_view_model,
input_buffer_model,
input_model,
slash_command_model,
context_model,
terminal_model: &terminal_model,
appearance,
app,
mouse_states: &self.mouse_states,
};
// Ephemeral messages take highest priority.
let Some(mut message) = ephemeral_message_model
.produce_message(args)
.or_else(|| BootstrappingMessageProducer.produce_message(args))
.or_else(|| ForkSlashCommandMessageProducer.produce_message(args))
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
.or_else(|| ZeroStateMessageProducer.produce_message(args))
.or_else(|| EmptyMessageProducer.produce_message(args))
else {
return Empty::new().finish();
};
// Show credits banner when user has ambient credits remaining.
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
let right_element = if cfg!(target_family = "wasm") {
None
} else if let Some(credits) =
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
{
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
Some(render_ambient_credits_banner(credits, app))
} else {
None
}
} else {
None
};
// Append a Figma MCP chip to the message if applicable.
match self.figma_button_status(app) {
Some(FigmaMcpStatus::NotInstalled) => {
message.items.push(figma_chip(
self.mouse_states.figma_install_button.clone(),
"Get Figma MCP",
Some(InputAction::FigmaAddButtonClicked),
));
}
Some(FigmaMcpStatus::Installed) => {
message.items.push(figma_chip(
self.mouse_states.figma_enable_button.clone(),
"Enable Figma MCP",
Some(InputAction::FigmaEnableButtonClicked),
));
}
Some(FigmaMcpStatus::Enabling) => {
message.items.push(
figma_chip(
self.mouse_states.figma_enable_button.clone(),
"Enabling...",
None,
)
.with_is_disabled(true),
);
}
Some(FigmaMcpStatus::Running) | None => {}
}
let message_bar = render_standard_message_bar(message, right_element, app);
if self.agent_view_controller.as_ref(app).is_inline() {
Container::new(message_bar)
.with_background(agent_view_bg_fill(app))
.finish()
} else {
message_bar
}
}
}
/// Arguments for agent message producers.
#[derive(Copy, Clone)]
pub struct AgentMessageArgs<'a> {
pub active_conversation: &'a AIConversation,
pub agent_view_controller: &'a AgentViewController,
pub ephemeral_message_model: &'a EphemeralMessageModel,
pub shortcut_view_model: &'a AgentShortcutViewModel,
pub input_buffer_model: &'a InputBufferModel,
pub input_model: &'a BlocklistAIInputModel,
pub slash_command_model: &'a SlashCommandModel,
pub context_model: &'a BlocklistAIContextModel,
pub terminal_model: &'a TerminalModel,
pub appearance: &'a Appearance,
pub app: &'a AppContext,
pub mouse_states: &'a AgentMessageBarMouseStates,
}
impl AttachedContextArgs for AgentMessageArgs<'_> {
fn terminal_model(&self) -> &TerminalModel {
self.terminal_model
}
fn input_buffer_model(&self) -> &InputBufferModel {
self.input_buffer_model
}
fn input_model(&self) -> &BlocklistAIInputModel {
self.input_model
}
fn agent_view_controller(&self) -> &AgentViewController {
self.agent_view_controller
}
fn context_model(&self) -> &BlocklistAIContextModel {
self.context_model
}
fn mouse_states(&self) -> &AgentMessageBarMouseStates {
self.mouse_states
}
}
/// Produces a message while the shell is still bootstrapping.
struct BootstrappingMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for BootstrappingMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
if args.terminal_model.block_list().is_bootstrapped()
|| args.terminal_model.is_dummy_cloud_mode_session()
|| args.terminal_model.is_shared_ambient_agent_session()
{
None
} else {
Some(Message::from_text("Starting shell..."))
}
}
}
/// Produces the zero state message
/// When a task is stopped, we also include "Cmd+Shift+R to resume conversation".
/// When a plan exists for the active conversation, we also include "cmd-alt-p to view plan".
struct ZeroStateMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
active_conversation,
agent_view_controller,
input_model,
input_buffer_model,
terminal_model,
app,
mouse_states,
..
} = args;
let is_locked_shell_input =
!input_model.is_ai_input_enabled() && input_model.is_input_type_locked();
if is_locked_shell_input {
return None;
}
let AgentViewState::Active {
original_conversation_length,
..
} = agent_view_controller.agent_view_state()
else {
return None;
};
let mut items = Vec::new();
let show_resume = !active_conversation.is_entirely_passive()
&& (active_conversation.status().is_cancelled()
|| active_conversation.status().is_error());
if show_resume {
let resume_keystroke = if OperatingSystem::get().is_mac() {
Keystroke::parse("cmd-shift-R").expect("keystroke should parse")
} else {
Keystroke::parse("ctrl-alt-r").expect("keystroke should parse")
};
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(resume_keystroke),
MessageItem::text("to resume conversation"),
],
|ctx| {
ctx.dispatch_typed_action(TerminalAction::ResumeConversation);
},
mouse_states.resume_conversation.clone(),
));
}
// Override to disabled text color if the buffer is not empty, because
// these shortcuts require the buffer be empty to take effect.
let is_buffer_empty = input_buffer_model.current_value().is_empty();
let (
color_override_for_shortcuts_and_commands,
bg_color_override_for_shortcuts_and_commands,
) = disableable_message_item_color_overrides(!is_buffer_empty, app);
items.push(
MessageItem::clickable(
vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "?".to_owned(),
..Default::default()
},
color: color_override_for_shortcuts_and_commands,
background_color: bg_color_override_for_shortcuts_and_commands,
},
MessageItem::Text {
content: "for help".into(),
color: color_override_for_shortcuts_and_commands,
},
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ToggleAgentViewShortcuts);
},
mouse_states.toggle_shortcuts.clone(),
)
.with_is_disabled(!is_buffer_empty),
);
items.push(
MessageItem::clickable(
vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "/".to_owned(),
..Default::default()
},
color: color_override_for_shortcuts_and_commands,
background_color: bg_color_override_for_shortcuts_and_commands,
},
MessageItem::Text {
content: "for commands".into(),
color: color_override_for_shortcuts_and_commands,
},
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ToggleSlashCommandsMenu);
},
mouse_states.toggle_slash_commands.clone(),
)
.with_is_disabled(!is_buffer_empty),
);
let is_cloud_agent = matches!(
agent_view_controller.agent_view_state(),
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
);
let plan_count = AIDocumentModel::as_ref(app)
.get_all_documents_for_conversation(active_conversation.id())
.len();
let has_plan = plan_count > 0;
let has_conversation_been_updated_since_agent_view_entry =
*original_conversation_length != active_conversation.exchange_count();
if !is_cloud_agent && !has_conversation_been_updated_since_agent_view_entry {
if let Some(conversations_keystroke) =
keybinding_name_to_keystroke(commands::CONVERSATIONS.name, app)
{
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(conversations_keystroke),
MessageItem::text("open conversation"),
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ToggleConversationsMenu);
},
mouse_states.toggle_conversation_menu.clone(),
));
}
}
// Code review only works locally.
#[cfg(not(target_family = "wasm"))]
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
let code_review_keystroke = if OperatingSystem::get().is_mac() {
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
} else {
Keystroke::parse("ctrl-shift-+").expect("keystroke should parse")
};
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(code_review_keystroke),
MessageItem::text("for code review"),
],
|ctx| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleRightPanel);
},
mouse_states.toggle_code_review.clone(),
));
}
if has_plan {
let is_plan_for_this_conversation_open = agent_view_controller
.pane_group_id()
.is_some_and(|pane_group_id| {
AIDocumentModel::as_ref(app).is_document_visible_by_conversation_in_pane_group(
&active_conversation.id(),
pane_group_id,
)
});
// If changing this text, ensure the logic is consistent with how TerminalAction::ToggleAIDocumentPane is handled.
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(
Keystroke::parse("cmdorctrl-alt-p").expect("keystroke should parse"),
),
MessageItem::text(if is_plan_for_this_conversation_open {
"to hide plan"
} else if plan_count > 1 {
"to view plans"
} else {
"to view plan"
}),
],
|ctx| {
ctx.dispatch_typed_action(TerminalAction::ToggleAIDocumentPane);
},
mouse_states.toggle_plan.clone(),
));
}
if fork_from_last_known_good_state_exchange_id(active_conversation, terminal_model)
.is_some()
{
let fork_keystroke = if OperatingSystem::get().is_mac() {
Keystroke::parse("cmd-alt-y").expect("keystroke should parse")
} else {
Keystroke::parse("ctrl-alt-y").expect("keystroke should parse")
};
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(fork_keystroke),
MessageItem::text("to fork and continue"),
],
|ctx| {
ctx.dispatch_typed_action(
TerminalAction::ForkConversationFromLastKnownGoodState,
);
},
mouse_states.fork_from_last_known_good_state.clone(),
));
}
Some(Message::new(items))
}
}
pub(crate) fn fork_from_last_known_good_state_exchange_id(
active_conversation: &AIConversation,
terminal_model: &TerminalModel,
) -> Option<AIAgentExchangeId> {
if !should_fork_from_last_known_good_state(active_conversation, terminal_model) {
return None;
}
active_conversation
.exchanges_reversed()
.filter(|exchange| exchange.has_user_query())
// Assumes the failed latest exchange is in the root task; exchanges_reversed only
// iterates root-task exchanges.
// Skip the failed latest user query; fork from the nearest prior successful one.
.skip(1)
.find(|exchange| exchange.output_status.is_finished_and_successful())
.map(|exchange| exchange.id)
}
fn should_fork_from_last_known_good_state(
active_conversation: &AIConversation,
terminal_model: &TerminalModel,
) -> bool {
if terminal_model.is_conversation_transcript_viewer()
|| terminal_model.shared_session_status().is_viewer()
|| active_conversation.is_viewing_shared_session()
{
return false;
}
let Some(latest_exchange) = active_conversation.latest_exchange() else {
return false;
};
let error = match &latest_exchange.output_status {
AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { error, .. },
} => error,
_ => return false,
};
match error {
RenderableAIError::QuotaLimit
| RenderableAIError::ServerOverloaded
| RenderableAIError::ContextWindowExceeded(_)
| RenderableAIError::InvalidApiKey { .. }
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
RenderableAIError::InternalWarpError => true,
RenderableAIError::Other {
will_attempt_resume,
..
} => !will_attempt_resume,
}
}
struct ForkSlashCommandMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let SlashCommandEntryState::SlashCommand(detected_command) =
args.slash_command_model.state()
else {
return None;
};
let command_name = detected_command.command.name;
if command_name != commands::FORK.name
&& command_name != commands::FORK_FROM.name
&& command_name != commands::FORK_AND_COMPACT.name
{
return None;
}
let modifier_keystroke = if cfg!(target_os = "macos") {
Keystroke {
key: "enter".to_owned(),
cmd: true,
..Default::default()
}
} else {
Keystroke {
key: "enter".to_owned(),
ctrl: true,
shift: true,
..Default::default()
}
};
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
// Other fork-like commands open in the current pane with Enter and a new pane
// with Cmd/Ctrl+Enter.
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
(" new pane", " new tab")
} else {
(" current pane", " new pane")
};
Some(Message::new(vec![
MessageItem::keystroke(Keystroke {
key: "enter".to_owned(),
..Default::default()
}),
MessageItem::text(primary_label),
MessageItem::keystroke(modifier_keystroke),
MessageItem::text(secondary_label),
]))
}
}
struct HideShortcutsMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for HideShortcutsMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
if !args.shortcut_view_model.is_shortcut_view_open() {
return None;
}
Some(Message::new(vec![MessageItem::clickable(
vec![
MessageItem::keystroke(Keystroke {
key: "?".to_owned(),
..Default::default()
}),
MessageItem::text("to hide help"),
],
|ctx| {
ctx.dispatch_typed_action(InputAction::ToggleAgentViewShortcuts);
},
args.mouse_states.toggle_shortcuts.clone(),
)]))
}
}
struct AutodetectedBashModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
input_model,
appearance,
slash_command_model,
app,
..
} = args;
if input_model.is_ai_input_enabled()
|| input_model.is_input_type_locked()
|| input_buffer_model.current_value().is_empty()
|| slash_command_model.state().is_detected_command()
{
return None;
}
let message = match keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app) {
Some(keystroke) => Message::new(vec![
MessageItem::text("autodetected shell command, "),
MessageItem::keystroke(keystroke),
MessageItem::text(" to override"),
])
.with_text_color(appearance.theme().ansi_fg_blue()),
None => Message::from_text("autodetected shell command"),
};
Some(message)
}
}
struct ExitBashModeMessageProducer;
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
let AgentMessageArgs {
input_buffer_model,
input_model,
appearance,
..
} = args;
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
return None;
}
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
if input_buffer_model.current_value().is_empty() {
(appearance.theme().ansi_fg_blue(), None, None)
} else {
(
Fill::from(appearance.theme().ansi_fg_blue())
.with_opacity(60)
.into_solid(),
Some(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid(),
),
Some(blended_colors::neutral_1(appearance.theme())),
)
};
Some(
Message::new(vec![
MessageItem::Keystroke {
keystroke: Keystroke {
key: "backspace".to_owned(),
..Default::default()
},
color: keystroke_color_override,
background_color: keystroke_bg_color_override,
},
MessageItem::text("to exit shell mode"),
])
.with_text_color(text_color),
)
}
}
/// Creates a `MessageItem::Chip` for a Figma MCP contextual action.
/// When `action` is `Some`, the chip is interactive and dispatches that action on click.
/// When `action` is `None`, the chip is returned without an action (caller should disable it).
fn figma_chip(
mouse_state: MouseStateHandle,
label: &'static str,
action: Option<InputAction>,
) -> MessageItem {
let items = vec![
MessageItem::Image {
source: AssetSource::Bundled {
path: "bundled/svg/figma-colored.svg",
},
width: FIGMA_ICON_SIZE,
height: FIGMA_ICON_SIZE,
},
MessageItem::text(label),
];
if let Some(action) = action {
MessageItem::chip(
items,
move |ctx| ctx.dispatch_typed_action(action.clone()),
mouse_state,
)
.with_horizontal_alignment(ChipHorizontalAlignment::Right)
} else {
MessageItem::Chip {
items,
action: Arc::new(|_| {}),
mouse_state,
disabled: true,
horizontal_alignment: ChipHorizontalAlignment::Right,
}
}
}
@@ -0,0 +1,421 @@
use pathfinder_color::ColorU;
use settings::Setting;
use warp_core::ui::{appearance::Appearance, Icon};
use warpui::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
MouseStateHandle, ParentElement, Shrinkable, Text,
},
fonts::{Properties, Style, Weight::Bold},
platform::Cursor,
prelude::{Border, CornerRadius, Radius},
text_layout::ClipConfig,
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
ai::{
active_agent_views_model::ActiveAgentViewsModel,
agent::conversation::{AIConversationId, ConversationStatus},
blocklist::BlocklistAIHistoryEvent,
},
terminal::BlockListSettings,
ui_components::blended_colors,
view_components::DismissibleToast,
workspace::{ToastStack, WorkspaceAction},
BlocklistAIHistoryModel,
};
use super::{AgentViewController, AgentViewEntryOrigin};
#[derive(Default)]
struct StateHandles {
block: MouseStateHandle,
}
pub struct AgentViewEntryBlockParams {
pub conversation_id: AIConversationId,
pub is_new: bool,
pub is_restored: bool,
pub origin: AgentViewEntryOrigin,
pub agent_view_controller: ModelHandle<AgentViewController>,
}
/// Rich content block rendered in the terminal mode blocklist to represent an Agent View entry for
/// a given conversation.
pub struct AgentViewEntryBlock {
conversation_id: AIConversationId,
agent_view_controller: ModelHandle<AgentViewController>,
is_new: bool,
is_restored: bool,
origin: AgentViewEntryOrigin,
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
cached_title: Option<String>,
state_handles: StateHandles,
}
impl AgentViewEntryBlock {
pub fn new(params: AgentViewEntryBlockParams, ctx: &mut ViewContext<Self>) -> Self {
let AgentViewEntryBlockParams {
conversation_id,
is_new,
is_restored,
origin,
agent_view_controller,
} = params;
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
conversation_id, ..
}
| BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} if *conversation_id == me.conversation_id => {
ctx.notify();
}
BlocklistAIHistoryEvent::DeletedConversation {
conversation_id,
conversation_title,
..
} if *conversation_id == me.conversation_id => {
me.cached_title = conversation_title.clone();
ctx.notify();
}
_ => (),
});
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
Self {
conversation_id,
agent_view_controller,
is_new,
is_restored,
origin,
cached_title: Default::default(),
state_handles: Default::default(),
}
}
}
pub fn render_block_container(
origin: AgentViewEntryOrigin,
content: Box<dyn Element>,
background: ColorU,
appearance: &Appearance,
are_block_dividers_enabled: bool,
) -> Box<dyn Element> {
let border = if are_block_dividers_enabled {
Border::top(1.).with_border_fill(appearance.theme().outline())
} else {
Border::new(1.)
.with_sides(true, false, true, false)
.with_border_fill(appearance.theme().outline())
};
let mut container = Container::new(content).with_background(background);
if matches!(origin, AgentViewEntryOrigin::LongRunningCommand) {
container = container
.with_uniform_padding(12.)
.with_horizontal_margin(16.)
.with_margin_bottom(16.)
.with_margin_top(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
} else {
container = container
.with_horizontal_padding(20.)
.with_vertical_padding(18.)
.with_border(border);
}
container.finish()
}
fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new(
text,
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.with_color(blended_colors::text_disabled(
appearance.theme(),
appearance.theme().background(),
))
.with_style(Properties {
style: Style::Italic,
..Default::default()
})
.finish(),
)
.with_margin_left(8.)
.finish()
}
fn render_deleted_state(
origin: AgentViewEntryOrigin,
cached_title: Option<String>,
appearance: &Appearance,
are_block_dividers_enabled: bool,
) -> Box<dyn Element> {
let disabled_color =
blended_colors::text_disabled(appearance.theme(), appearance.theme().background());
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Text::new(
cached_title.unwrap_or_else(|| "Deleted conversation".to_string()),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(disabled_color)
.with_style(Properties {
weight: Bold,
..Default::default()
})
.finish(),
)
.with_child(render_subtext("Deleted".to_string(), appearance))
.finish();
render_block_container(
origin,
row,
blended_colors::fg_overlay_1(appearance.theme()).into(),
appearance,
are_block_dividers_enabled,
)
}
impl View for AgentViewEntryBlock {
fn ui_name() -> &'static str {
"EnterAgentBlock"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
if self.agent_view_controller.as_ref(app).is_fullscreen() {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
let are_block_dividers_enabled =
*BlockListSettings::as_ref(app).show_block_dividers.value();
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&self.conversation_id) else {
// If the agent_view_block's conversation no longer exists,
// we assume that it has been deleted.
return render_deleted_state(
self.origin,
self.cached_title.clone(),
appearance,
are_block_dividers_enabled,
);
};
if conversation.is_entirely_passive() {
return Empty::new().finish();
}
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
color.a = opacity;
color
}
let status_icon = conversation.status().render_icon(appearance);
let status_icon_bg = match conversation.status() {
ConversationStatus::InProgress => {
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
}
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
ConversationStatus::Cancelled => {
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
}
ConversationStatus::Blocked { .. } => {
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
}
};
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
ConstrainedBox::new(status_icon.finish())
.with_height(16.)
.with_width(16.)
.finish(),
)
.with_uniform_padding(2.)
.with_background_color(status_icon_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_margin_right(8.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
Text::new(
conversation
.title()
.unwrap_or("Untitled conversation".to_string()),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
))
.with_style(Properties {
weight: Bold,
..Default::default()
})
.soft_wrap(false)
.with_clip(ClipConfig::ellipsis())
.finish(),
)
.finish(),
);
let is_active =
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
== Some(self.conversation_id);
let is_open_elsewhere = is_active && !is_active_in_this_pane;
let subtext = if is_open_elsewhere {
Some("Open in different pane")
} else if self.is_restored {
Some("Restored")
} else if !self.is_new
&& !matches!(
self.origin,
AgentViewEntryOrigin::LongRunningCommand
| AgentViewEntryOrigin::AgentRequestedNewConversation
)
{
Some("Continued")
} else {
None
};
if let Some(subtext) = subtext {
row.add_child(render_subtext(subtext.to_string(), appearance));
}
row.add_child(
Container::new(Empty::new().finish())
.with_margin_right(8.)
.finish(),
);
row.add_child(
ConstrainedBox::new(
Icon::ChevronRight
.to_warpui_icon(
blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
)
.into(),
)
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
);
let conversation_id = self.conversation_id;
let origin = self.origin;
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
let background = if hoverable_state.is_hovered() {
blended_colors::fg_overlay_2(appearance.theme())
} else {
blended_colors::fg_overlay_1(appearance.theme())
};
render_block_container(
origin,
row.finish(),
background.into(),
appearance,
are_block_dividers_enabled,
)
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
})
.finish()
}
}
#[derive(Debug, Clone)]
pub enum AgentViewEntryBlockEvent {
EnterAgentView { conversation_id: AIConversationId },
}
impl Entity for AgentViewEntryBlock {
type Event = AgentViewEntryBlockEvent;
}
#[derive(Debug, Clone)]
pub enum EnterAgentBlockAction {
EnterAgentMode { conversation_id: AIConversationId },
}
impl TypedActionView for AgentViewEntryBlock {
type Action = EnterAgentBlockAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
EnterAgentBlockAction::EnterAgentMode { conversation_id } => {
let is_active =
ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(*conversation_id, ctx);
let is_active_in_this_pane = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
== Some(*conversation_id);
if is_active && !is_active_in_this_pane {
let Some(target_terminal_view_id) = ActiveAgentViewsModel::as_ref(ctx)
.terminal_view_id_for_conversation(*conversation_id, ctx)
else {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Couldn't navigate to conversation.".to_string(),
),
window_id,
ctx,
);
});
return;
};
ctx.dispatch_typed_action_deferred(
WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: target_terminal_view_id,
},
);
} else {
ctx.emit(AgentViewEntryBlockEvent::EnterAgentView {
conversation_id: *conversation_id,
});
}
}
}
}
}
@@ -0,0 +1,255 @@
use std::collections::{HashMap, HashSet};
use warpui::elements::{Element, Empty, Flex, MouseStateHandle, ParentElement};
use warpui::platform::Cursor;
use warpui::prelude::Container;
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_navigation_card_with_icon;
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::BlocklistAIHistoryEvent;
use crate::appearance::Appearance;
use crate::terminal::view::TerminalAction;
use crate::ui_components::buttons::close_button;
use crate::BlocklistAIHistoryModel;
#[derive(Debug, Clone)]
pub enum ChildAgentStatusCardAction {
Dismiss(AIConversationId),
}
/// Renders a list of child agent statuses above the agent message bar.
///
/// Each row shows a status icon, agent name, and conversation title.
/// Clicking a row reveals the child agent's hidden pane.
/// Cards can be dismissed via an X button and automatically reappear
/// when the child agent starts or restarts (transitions to InProgress).
pub struct ChildAgentStatusCard {
agent_view_controller: ModelHandle<AgentViewController>,
mouse_states: HashMap<AIConversationId, MouseStateHandle>,
dismiss_mouse_states: HashMap<AIConversationId, MouseStateHandle>,
dismissed: HashSet<AIConversationId>,
previous_statuses: HashMap<AIConversationId, ConversationStatus>,
}
impl Entity for ChildAgentStatusCard {
type Event = ();
}
impl ChildAgentStatusCard {
pub fn new(
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ViewContext<Self>,
) -> Self {
// Subscribe without terminal_view_id filtering so we receive status
// updates for child conversations (which have a different terminal_view_id).
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, |this, _, event, ctx| match event {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
this.on_conversation_status_updated(*conversation_id, ctx);
this.ensure_mouse_states(ctx);
ctx.notify();
}
BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. } => {
this.ensure_mouse_states(ctx);
ctx.notify();
}
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
this.remove_state_for_conversation(*conversation_id);
ctx.notify();
}
_ => {}
});
ctx.subscribe_to_model(&agent_view_controller, |this, _, event, ctx| {
// Reset all per-child state when entering a conversation so stale
// entries from a previous conversation's children don't accumulate.
if matches!(event, AgentViewControllerEvent::EnteredAgentView { .. }) {
this.dismissed.clear();
this.previous_statuses.clear();
this.mouse_states.clear();
this.dismiss_mouse_states.clear();
}
this.ensure_mouse_states(ctx);
ctx.notify();
});
Self {
agent_view_controller,
mouse_states: HashMap::new(),
dismiss_mouse_states: HashMap::new(),
dismissed: HashSet::new(),
previous_statuses: HashMap::new(),
}
}
fn ensure_mouse_states(&mut self, ctx: &AppContext) {
let agent_view_controller = self.agent_view_controller.as_ref(ctx);
let Some(active_conversation_id) = agent_view_controller
.agent_view_state()
.active_conversation_id()
else {
return;
};
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
for child in history_model.child_conversations_of(active_conversation_id) {
let child_id = child.id();
self.mouse_states.entry(child_id).or_default();
self.dismiss_mouse_states.entry(child_id).or_default();
self.previous_statuses
.entry(child_id)
.or_insert_with(|| child.status().clone());
}
}
fn remove_state_for_conversation(&mut self, conversation_id: AIConversationId) {
self.dismissed.remove(&conversation_id);
self.previous_statuses.remove(&conversation_id);
self.mouse_states.remove(&conversation_id);
self.dismiss_mouse_states.remove(&conversation_id);
}
/// Checks whether a child conversation transitioned to `InProgress` from a
/// non-`InProgress` state, mirroring the Started/Restarted lifecycle event
/// logic in `OrchestrationEventService::on_conversation_status_updated`.
/// If so, restores any dismissed card for that conversation.
fn on_conversation_status_updated(
&mut self,
conversation_id: AIConversationId,
ctx: &AppContext,
) {
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
else {
return;
};
if !conversation.is_child_agent_conversation() {
return;
}
let current_status = conversation.status().clone();
if should_restore_dismissed_card(
&current_status,
self.previous_statuses.get(&conversation_id),
) {
self.dismissed.remove(&conversation_id);
}
self.previous_statuses
.insert(conversation_id, current_status);
}
}
impl TypedActionView for ChildAgentStatusCard {
type Action = ChildAgentStatusCardAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ChildAgentStatusCardAction::Dismiss(conversation_id) => {
self.dismissed.insert(*conversation_id);
ctx.notify();
}
}
}
}
impl View for ChildAgentStatusCard {
fn ui_name() -> &'static str {
"ChildAgentStatusCard"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let agent_view_controller = self.agent_view_controller.as_ref(app);
let Some(active_conversation_id) = agent_view_controller
.agent_view_state()
.active_conversation_id()
else {
return Empty::new().finish();
};
let history_model = BlocklistAIHistoryModel::as_ref(app);
let mut children = history_model.child_conversations_of(active_conversation_id);
if children.is_empty() {
return Empty::new().finish();
}
// Sort by creation time so rows have a stable visual order.
children.sort_by_key(|c| c.first_exchange().map(|e| e.start_time));
let appearance = Appearance::as_ref(app);
let mut column = Flex::column();
for child in &children {
let conversation_id = child.id();
if self.dismissed.contains(&conversation_id) {
continue;
}
let agent_name = child.agent_name().unwrap_or("Agent").to_string();
let title = child.title().unwrap_or_else(|| "Untitled".to_string());
let status_icon = child.status().status_icon_and_color(appearance.theme());
let Some(mouse_state) = self.mouse_states.get(&conversation_id).cloned() else {
log::error!(
"Missing mouse state handle for child agent card {:?}",
conversation_id
);
continue;
};
let Some(dismiss_mouse_state) =
self.dismiss_mouse_states.get(&conversation_id).cloned()
else {
log::error!(
"Missing dismiss mouse state handle for child agent card {:?}",
conversation_id
);
continue;
};
let dismiss_button = close_button(appearance, dismiss_mouse_state)
.build()
.on_click(move |ctx: &mut warpui::EventContext<'_>, _, _| {
ctx.dispatch_typed_action(ChildAgentStatusCardAction::Dismiss(conversation_id));
})
.with_cursor(Cursor::PointingHand)
.finish();
let card = conversation_navigation_card_with_icon(
Some(status_icon),
agent_name,
Some(title),
move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
},
mouse_state,
true,
Some(dismiss_button),
app,
);
column.add_child(Container::new(card).with_margin_top(4.).finish());
}
column.finish()
}
}
/// Returns true when a dismissed card should be restored: the conversation
/// transitioned to `InProgress` from a non-`InProgress` state, matching the
/// Started/Restarted lifecycle event semantics.
fn should_restore_dismissed_card(
current_status: &ConversationStatus,
previous_status: Option<&ConversationStatus>,
) -> bool {
let was_in_progress = previous_status.is_some_and(|s| s.is_in_progress());
current_status.is_in_progress() && !was_in_progress
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
use std::borrow::Cow;
use std::time::Duration;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, ModelContext};
use crate::terminal::input::message_bar::{Message, MessageProvider};
use super::agent_message_bar::AgentMessageArgs;
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
pub struct EphemeralMessage {
/// Optional id that may be used to identify the message.
id: Option<Cow<'static, str>>,
/// The message to be displayed.
message: Message,
/// The strategy to be used to determine when to stop showing the message.
dismissal: DismissalStrategy,
}
#[derive(Clone, Copy)]
pub enum DismissalStrategy {
/// Persists until explicitly dismissed by the input.
UntilExplicitlyDismissed,
/// Auto-dismiss after a duration elapses.
Timer(Duration),
}
impl EphemeralMessage {
pub fn new(message: Message, dismissal: DismissalStrategy) -> Self {
Self {
id: None,
message,
dismissal,
}
}
pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_duration(mut self, duration: Duration) -> Self {
self.dismissal = DismissalStrategy::Timer(duration);
self
}
pub fn id(&self) -> Option<&str> {
self.id.as_ref().map(|id| id.as_ref())
}
}
/// Manages messages that are dismissed either explicitly by the input or after a fixed duration.
pub struct EphemeralMessageModel {
current_message: Option<EphemeralMessage>,
clear_timer_handle: Option<SpawnedFutureHandle>,
}
#[derive(Debug, Clone, Copy)]
pub enum EphemeralMessageModelEvent {
MessageChanged,
}
impl EphemeralMessageModel {
pub fn new() -> Self {
Self {
current_message: None,
clear_timer_handle: None,
}
}
pub fn current_message(&self) -> Option<&EphemeralMessage> {
self.current_message.as_ref()
}
/// Shows a message with the given dismissal strategy.
pub fn show_ephemeral_message(
&mut self,
message: EphemeralMessage,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.clear_timer_handle.take() {
handle.abort();
}
let dismissal = message.dismissal;
self.current_message = Some(message);
// If we are dismissing via timer, start the timer.
if let DismissalStrategy::Timer(duration) = dismissal {
let abort_handle = ctx.spawn_abortable(
async move { warpui::r#async::Timer::after(duration).await },
|me, _, ctx| {
me.current_message = None;
me.clear_timer_handle = None;
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
},
|_, _| (),
);
self.clear_timer_handle = Some(abort_handle);
}
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
}
pub fn show_info_ephemeral_message(
&mut self,
message: impl Into<Cow<'static, str>>,
ctx: &mut ModelContext<Self>,
) {
self.show_ephemeral_message(
EphemeralMessage::new(
Message::from_text(message),
DismissalStrategy::Timer(DEFAULT_MESSAGE_DURATION),
),
ctx,
);
}
/// Dismisses the current message if it is not timer-based.
pub fn try_dismiss_explicit_message(&mut self, ctx: &mut ModelContext<Self>) {
let should_dismiss = self
.current_message
.as_ref()
.is_some_and(|m| matches!(m.dismissal, DismissalStrategy::UntilExplicitlyDismissed));
if should_dismiss {
self.current_message = None;
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
}
}
/// Unconditionally clears the current message and cancels any active timer.
pub fn clear_message(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(handle) = self.clear_timer_handle.take() {
handle.abort();
}
if self.current_message.take().is_some() {
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
}
}
}
impl Entity for EphemeralMessageModel {
type Event = EphemeralMessageModelEvent;
}
impl MessageProvider<AgentMessageArgs<'_>> for EphemeralMessageModel {
fn produce_message(&self, _args: AgentMessageArgs<'_>) -> Option<Message> {
self.current_message()
.map(|ephemeral_message| ephemeral_message.message.clone())
}
}
@@ -0,0 +1,177 @@
use std::sync::Arc;
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
use parking_lot::FairMutex;
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{CornerRadius, Radius},
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
};
use crate::{
ai::{
agent::icons,
blocklist::{
block::cli_controller::LongRunningCommandControlState,
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
},
},
terminal::{model::session::Sessions, TerminalModel},
ui_components::{blended_colors, icons::Icon},
};
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
const AGENT_WAITING_FOR_COMMAND_TO_EXIT_MESSAGE: &str = "Agent is waiting for command to exit";
const AGENT_BLOCKED_MESSAGE: &str = "Agent needs your permission to continue";
const AGENT_IN_CONTROL_MESSAGE: &str = "Agent is in control";
const USER_IN_CONTROL_MESSAGE: &str = "User is in control";
/// A header rendered as rich content above the active block when Agent View is in inline mode.
pub struct InlineAgentViewHeader {
terminal_view_id: EntityId,
terminal_model: Arc<FairMutex<TerminalModel>>,
sessions_model: ModelHandle<Sessions>,
action_model: ModelHandle<BlocklistAIActionModel>,
}
impl InlineAgentViewHeader {
pub fn new(
terminal_view_id: EntityId,
terminal_model: Arc<FairMutex<TerminalModel>>,
sessions_model: ModelHandle<Sessions>,
action_model: ModelHandle<BlocklistAIActionModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
if event
.terminal_view_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. } => {
ctx.notify();
}
_ => (),
}
});
ctx.subscribe_to_model(&action_model, |_, _, _, ctx| {
ctx.notify();
});
Self {
terminal_view_id,
terminal_model,
sessions_model,
action_model,
}
}
}
impl Entity for InlineAgentViewHeader {
type Event = ();
}
impl View for InlineAgentViewHeader {
fn ui_name() -> &'static str {
"InlineAgentViewHeader"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let history_model = BlocklistAIHistoryModel::as_ref(app);
let active_conversation = history_model.active_conversation(self.terminal_view_id);
let conversation_status = active_conversation.map(|conv| conv.status().clone());
// Use active conversation's latest_exchange to include subtask exchanges (e.g., CLI subagent)
let is_streaming = active_conversation
.and_then(|conv| conv.latest_exchange())
.map(|exchange| exchange.output_status.is_streaming())
.unwrap_or(false);
let (
is_agent_tagged_in,
is_agent_in_control,
is_user_in_control,
is_action_blocked,
top_level_command,
) = {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
let sessions = self.sessions_model.as_ref(app);
(
active_block.is_agent_tagged_in(),
active_block
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_agent_in_control),
active_block
.long_running_control_state()
.is_some_and(LongRunningCommandControlState::is_user_in_control),
active_block.is_agent_blocked(),
active_block.top_level_command(sessions),
)
};
if is_agent_tagged_in {
let header_background = appearance.theme().surface_2();
let icon = Icon::Oz.to_warpui_icon(
blended_colors::text_main(appearance.theme(), header_background).into(),
);
let message = if let Some(command) = top_level_command.as_deref() {
format!("{AGENT_PROMPT_TO_INTERACT_MESSAGE} `{command}`")
} else {
format!("{AGENT_PROMPT_TO_INTERACT_MESSAGE} the running command")
};
return HeaderConfig::new(message, app)
.with_icon(icon)
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)))
.with_markdown()
.render(app);
}
let action_model = self.action_model.as_ref(app);
let action = action_model.get_async_running_action(app);
let is_waiting_for_command_to_exit = action.as_ref().is_some_and(|action| {
matches!(
action.action,
AIAgentActionType::ReadShellCommandOutput {
delay: Some(ShellCommandDelay::OnCompletion),
..
}
)
});
let is_waiting_on_instructions =
action.is_none() && !is_streaming && is_agent_in_control && !is_action_blocked;
let message = if is_user_in_control {
USER_IN_CONTROL_MESSAGE.to_owned()
} else if is_action_blocked {
AGENT_BLOCKED_MESSAGE.to_owned()
} else if is_waiting_for_command_to_exit {
AGENT_WAITING_FOR_COMMAND_TO_EXIT_MESSAGE.to_owned()
} else if is_waiting_on_instructions {
AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE.to_owned()
} else {
AGENT_IN_CONTROL_MESSAGE.to_owned()
};
let icon = if is_user_in_control || is_waiting_on_instructions {
icons::gray_stop_icon(appearance)
} else if let Some(status) = &conversation_status {
status.render_icon(appearance)
} else {
icons::in_progress_icon(appearance)
};
HeaderConfig::new(message, app)
.with_icon(icon)
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)))
.render(app)
}
}
+139
View File
@@ -0,0 +1,139 @@
pub(crate) mod agent_input_footer;
mod agent_message_bar;
mod agent_view_block;
pub mod child_agent_status_card;
mod controller;
mod ephemeral_message_model;
mod inline_agent_view_header;
// TODO: Move orchestration_conversation_links module import elsewhere.
pub(crate) mod orchestration_conversation_links;
pub mod shortcuts;
mod zero_state_block;
pub use agent_input_footer::*;
pub use agent_message_bar::*;
pub use agent_view_block::*;
pub use controller::*;
pub use ephemeral_message_model::*;
pub use inline_agent_view_header::*;
use warpui::fonts::Properties;
pub use zero_state_block::*;
use std::sync::LazyLock;
use pathfinder_color::ColorU;
use warp_core::ui::theme::Fill;
use warp_core::ui::{appearance::Appearance, color::blend::Blend};
use warpui::keymap::Keystroke;
use warpui::{AppContext, SingletonEntity};
use crate::view_components::action_button::ActionButtonTheme;
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
Keystroke {
cmd: true,
key: "enter".to_owned(),
..Default::default()
}
} else {
Keystroke {
ctrl: true,
shift: true,
key: "enter".to_owned(),
..Default::default()
}
}
}
});
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
LazyLock::new(|| {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
Keystroke {
cmd: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
} else {
Keystroke {
ctrl: true,
alt: true,
key: "enter".to_owned(),
..Default::default()
}
}
}
});
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
let appearance = Appearance::as_ref(app);
appearance.theme().surface_overlay_1()
}
pub fn agent_view_bg_color(app: &AppContext) -> ColorU {
agent_view_bg_fill(app)
.blend(&Appearance::as_ref(app).theme().background())
.into_solid()
}
pub struct AgentViewHeaderTheme;
impl ActionButtonTheme for AgentViewHeaderTheme {
fn background(&self, _: bool, _: &Appearance) -> Option<Fill> {
None
}
fn text_color(
&self,
hovered: bool,
background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
if hovered {
appearance
.theme()
.main_text_color(background.unwrap_or(appearance.theme().background()))
.into_solid()
} else {
appearance
.theme()
.sub_text_color(background.unwrap_or(appearance.theme().background()))
.into_solid()
}
}
fn font_properties(&self) -> Option<Properties> {
Some(Properties::default())
}
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
Some(appearance.theme().surface_overlay_2().into_solid())
}
}
pub struct AgentViewHeaderDisabledTheme;
impl ActionButtonTheme for AgentViewHeaderDisabledTheme {
fn background(&self, _: bool, _: &Appearance) -> Option<Fill> {
None
}
fn text_color(&self, _: bool, background: Option<Fill>, appearance: &Appearance) -> ColorU {
appearance
.theme()
.disabled_text_color(background.unwrap_or(appearance.theme().background()))
.into_solid()
}
fn keyboard_shortcut_background(&self, _: &Appearance) -> Option<ColorU> {
None
}
fn font_properties(&self) -> Option<Properties> {
Some(Properties::default())
}
}
@@ -0,0 +1,237 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
fonts::{Properties, Weight::Bold},
platform::Cursor,
text_layout::ClipConfig,
AppContext, Element, EventContext, SingletonEntity,
};
use crate::{
ai::{
agent::{
api::ServerConversationToken,
conversation::{AIConversation, AIConversationId},
},
agent_conversations_model::AgentConversationsModel,
blocklist::BlocklistAIHistoryModel,
},
ui_components::{blended_colors, icons::Icon},
workspace::{RestoreConversationLayout, WorkspaceAction},
};
pub(crate) fn conversation_id_for_agent_id(
agent_id: &str,
app: &AppContext,
) -> Option<AIConversationId> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
history_model
.conversation_id_for_agent_id(agent_id)
.or_else(|| {
history_model.find_conversation_id_by_server_token(&ServerConversationToken::new(
agent_id.to_string(),
))
})
}
pub(crate) fn parent_conversation_id(
active_conversation: &AIConversation,
app: &AppContext,
) -> Option<AIConversationId> {
active_conversation.parent_conversation_id().or_else(|| {
active_conversation
.parent_agent_id()
.and_then(|id| conversation_id_for_agent_id(id, app))
})
}
pub(crate) fn conversation_navigation_action(
conversation_id: AIConversationId,
app: &AppContext,
) -> WorkspaceAction {
AgentConversationsModel::as_ref(app)
.get_conversation(&conversation_id)
.and_then(|conversation| {
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
})
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
pane_view_locator: None,
window_id: None,
conversation_id,
terminal_view_id: None,
restore_layout: Some(RestoreConversationLayout::SplitPane),
})
}
pub(crate) fn parent_conversation_navigation_card(
active_conversation: &AIConversation,
mouse_state: MouseStateHandle,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let parent_conversation_id = parent_conversation_id(active_conversation, app)?;
let parent_title = BlocklistAIHistoryModel::as_ref(app)
.conversation(&parent_conversation_id)
.and_then(|conversation| conversation.title())
.unwrap_or_else(|| "Parent conversation".to_string());
let action = conversation_navigation_action(parent_conversation_id, app);
Some(conversation_navigation_card(
parent_title,
Some("Back to parent conversation".to_string()),
move |ctx, _, _| {
ctx.dispatch_typed_action(action.clone());
},
mouse_state,
false,
app,
))
}
pub(crate) fn conversation_navigation_card(
title: String,
subtitle: Option<String>,
on_click: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
mouse_state: MouseStateHandle,
expands_to_max_width: bool,
app: &AppContext,
) -> Box<dyn Element> {
conversation_navigation_card_with_icon(
None,
title,
subtitle,
on_click,
mouse_state,
expands_to_max_width,
None,
app,
)
}
/// Renders a clickable card with an optional leading icon, title/subtitle,
/// a trailing chevron, and an optional extra trailing element (e.g. a dismiss
/// button). When `extra_trailing` is provided, the card's Hoverable uses
/// `defer_events_to_children` so the trailing element can handle its own
/// click without also triggering the card's `on_click`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn conversation_navigation_card_with_icon(
icon: Option<(Icon, ColorU)>,
title: String,
subtitle: Option<String>,
on_click: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
mouse_state: MouseStateHandle,
expands_to_max_width: bool,
extra_trailing: Option<Box<dyn Element>>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let has_extra_trailing = extra_trailing.is_some();
let mut hoverable = Hoverable::new(mouse_state, move |hover_state| {
let background = if hover_state.is_hovered() {
blended_colors::fg_overlay_2(theme)
} else {
blended_colors::fg_overlay_1(theme)
};
let mut text_column = Flex::column().with_child(
Text::new(
title.clone(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.soft_wrap(false)
.with_clip(ClipConfig::ellipsis())
.with_style(Properties {
weight: Bold,
..Default::default()
})
.with_color(blended_colors::text_main(
theme,
appearance.theme().background(),
))
.finish(),
);
if let Some(subtitle) = subtitle.as_ref() {
text_column.add_child(
Text::new(
subtitle.clone(),
appearance.ui_font_family(),
(appearance.monospace_font_size() - 2.).max(10.),
)
.soft_wrap(false)
.with_clip(ClipConfig::ellipsis())
.with_color(blended_colors::text_sub(
theme,
appearance.theme().background(),
))
.finish(),
);
}
let text_column = text_column.finish();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some((icon, color)) = icon {
row.add_child(
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(color)).finish())
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_margin_right(6.)
.finish(),
);
}
if expands_to_max_width {
row = row
.with_main_axis_size(MainAxisSize::Max)
.with_child(Shrinkable::new(1., text_column).finish());
} else {
row = row.with_child(text_column);
}
row.add_child(
Container::new(
ConstrainedBox::new(
Icon::ChevronRight
.to_warpui_icon(blended_colors::text_sub(theme, theme.background()).into())
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_margin_left(8.)
.finish(),
);
if let Some(trailing) = extra_trailing {
// Spacer pushes the trailing element to the far right edge.
row.add_child(Expanded::new(1., Empty::new().finish()).finish());
row.add_child(trailing);
}
let row = row.finish();
Container::new(row)
.with_background_color(background.into())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_horizontal_padding(10.)
.with_vertical_padding(8.)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(on_click);
// When an extra trailing element is present (e.g. dismiss button), defer
// click events to children so the trailing element's click handler takes
// precedence over the card's navigation handler.
if has_extra_trailing {
hoverable = hoverable.with_defer_events_to_children();
}
hoverable.finish()
}
@@ -0,0 +1,279 @@
mod model;
pub use model::*;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
use warpui::{
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
keymap::Keystroke,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
};
use crate::ai::blocklist::agent_view::ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE;
use crate::{
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
cmd_or_ctrl_shift,
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
ui_components::blended_colors,
util::bindings::keybinding_name_to_keystroke,
workspace::view::{
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
},
};
#[derive(Copy, Clone, Debug, Default)]
pub struct AgentShortcutsViewContext {
pub is_cloud_agent: bool,
/// True once the user has submitted the first prompt.
pub has_submitted_first_prompt: bool,
}
#[derive(Default)]
pub struct ShortcutProps {
pub keystroke: Keystroke,
pub text: Cow<'static, str>,
pub text_color: Option<ColorU>,
}
pub fn render_shortcut(props: ShortcutProps, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = styles::font_size(appearance);
let font_color = props.text_color.unwrap_or_else(|| {
theme
.sub_text_color(blended_colors::neutral_1(theme).into())
.into()
});
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(render_keystroke(&props.keystroke, app))
.with_margin_right(4.)
.finish(),
)
.with_child(
Expanded::new(
1.,
Text::new(props.text, appearance.ui_font_family(), font_size)
.with_color(font_color)
.finish(),
)
.finish(),
)
.finish()
}
pub fn render_keystroke(keystroke: &Keystroke, app: &AppContext) -> Box<dyn Element> {
render_keystroke_with_color_overrides(keystroke, None, None, app)
}
pub fn render_keystroke_with_color_overrides(
keystroke: &Keystroke,
color: Option<ColorU>,
background_color: Option<ColorU>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = styles::font_size(appearance);
appearance
.ui_builder()
.keyboard_shortcut(keystroke)
.lowercase_modifier()
.with_space_between_keys(2.)
.with_style(UiComponentStyles {
margin: Some(Coords::default()),
padding: Some(Coords::default()),
border_width: Some(1.),
background: Some(
background_color
.unwrap_or_else(|| blended_colors::neutral_3(theme))
.into(),
),
font_color: Some(color.unwrap_or_else(|| theme.foreground().into_solid())),
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(font_size),
width: Some(styles::keystroke_size(appearance)),
height: Some(styles::keystroke_size(appearance)),
..Default::default()
})
.with_line_height_ratio(1.0)
.build()
.finish()
}
pub fn render_agent_shortcuts_view(
context: AgentShortcutsViewContext,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let hide_cloud_zero_state_items = context.is_cloud_agent && !context.has_submitted_first_prompt;
let mut shortcuts = vec![];
if !hide_cloud_zero_state_items {
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke {
key: "!".to_owned(),
..Default::default()
},
text: "input shell command".into(),
..Default::default()
},
app,
));
}
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke {
key: "/".to_owned(),
..Default::default()
},
text: "for slash commands".into(),
..Default::default()
},
app,
));
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke {
key: "@".to_owned(),
..Default::default()
},
text: "for file paths and attaching other context".into(),
..Default::default()
},
app,
));
// Code review is not available for cloud agents.
if !context.is_cloud_agent {
if let Some(keystroke) = keybinding_name_to_keystroke(TOGGLE_RIGHT_PANEL_BINDING_NAME, app)
{
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke,
text: "open code review".into(),
..Default::default()
},
app,
));
}
}
if FeatureFlag::AgentViewConversationListView.is_enabled() {
if let Some(keystroke) =
keybinding_name_to_keystroke(TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, app)
{
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke,
text: "toggle conversation list".into(),
..Default::default()
},
app,
));
}
}
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke::parse(cmd_or_ctrl_shift("y")).expect("is valid keystroke"),
text: "search and continue conversations".into(),
..Default::default()
},
app,
));
// Use cloud keystroke (cmd+opt+enter) for cloud mode, regular keystroke (cmd+enter) otherwise.
let new_conversation_keystroke = if context.is_cloud_agent {
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
} else {
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
};
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: new_conversation_keystroke.clone(),
text: "start a new conversation".into(),
..Default::default()
},
app,
));
if !hide_cloud_zero_state_items {
if let Some(keystroke) =
keybinding_name_to_keystroke(TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, app)
{
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke,
text: "toggle auto-accept".into(),
..Default::default()
},
app,
));
}
}
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke {
key: "c".to_owned(),
ctrl: true,
..Default::default()
},
text: "pause agent".into(),
..Default::default()
},
app,
));
shortcuts.push(render_shortcut(
ShortcutProps {
keystroke: Keystroke {
key: "escape".to_owned(),
..Default::default()
},
text: "go back to terminal".into(),
..Default::default()
},
app,
));
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(8.)
.with_children(shortcuts)
.finish(),
)
.with_vertical_padding(16.)
.with_padding_left(*terminal::view::PADDING_LEFT)
.with_border(
Border::new(1.)
.with_sides(true, false, true, false)
.with_border_color(blended_colors::neutral_2(appearance.theme())),
)
.finish()
}
pub mod styles {
use warp_core::ui::appearance::Appearance;
pub fn keystroke_size(appearance: &Appearance) -> f32 {
font_size(appearance) + 2.
}
pub fn font_size(appearance: &Appearance) -> f32 {
appearance.monospace_font_size() - 2.
}
}
@@ -0,0 +1,73 @@
use warpui::{Entity, ModelContext, ModelHandle};
use warp_core::send_telemetry_from_ctx;
use crate::{
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
server::telemetry::TelemetryEvent,
terminal::input::buffer_model::InputBufferModel,
};
/// Model responsible for managing state required to conditionally render the shortcuts view.
pub struct AgentShortcutViewModel {
is_shortcut_view_open: bool,
}
impl AgentShortcutViewModel {
pub fn new(
input_buffer_model: ModelHandle<InputBufferModel>,
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
if me.is_shortcut_view_open && !event.new_content.is_empty() {
me.hide_shortcut_view(ctx);
}
});
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
me.hide_shortcut_view(ctx);
}
});
Self {
is_shortcut_view_open: false,
}
}
pub fn is_shortcut_view_open(&self) -> bool {
self.is_shortcut_view_open
}
pub fn open_shortcut_view(&mut self, ctx: &mut ModelContext<Self>) {
self.set_shortcut_view_visibility(true, ctx);
}
pub fn hide_shortcut_view(&mut self, ctx: &mut ModelContext<Self>) {
self.set_shortcut_view_visibility(false, ctx);
}
fn set_shortcut_view_visibility(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) {
if is_open == self.is_shortcut_view_open {
return;
}
self.is_shortcut_view_open = is_open;
ctx.emit(AgentShortcutEvent::ToggledViewVisibility {
is_visible: is_open,
});
send_telemetry_from_ctx!(
TelemetryEvent::AgentShortcutsViewToggled {
is_visible: is_open,
},
ctx
);
}
}
impl Entity for AgentShortcutViewModel {
type Event = AgentShortcutEvent;
}
pub enum AgentShortcutEvent {
ToggledViewVisibility { is_visible: bool },
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
use crate::terminal::model::test_utils::block_size;
use crate::terminal::model::{session::Session, TerminalModel};
use crate::terminal::{
color::{self, Colors},
event_listener::ChannelEventListener,
};
use std::{path::PathBuf, sync::Arc};
use warpui::r#async::executor::Background;
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
TerminalModel::new_for_test(
block_size(),
color::List::from(&Colors::default()),
ChannelEventListener::new_for_test(),
Arc::new(Background::default()),
false,
None,
false,
false,
startup_path.map(PathBuf::from),
)
}
fn prebootstrap_terminal_with_startup_path(startup_path: &str) -> TerminalModel {
let mut terminal = terminal_with_startup_path(Some(startup_path));
terminal.block_list_mut().reinit_shell();
terminal
}
#[test]
fn format_session_location_shows_path_only_for_local_sessions() {
let session = Session::test();
let formatted = format_session_location(&session, Some("/Users/alice/repo"));
assert_eq!(formatted, Some("/Users/alice/repo".to_owned()));
}
#[test]
fn format_session_location_shows_user_host_for_remote_sessions() {
let session = Session::test_remote();
let formatted =
format_session_location(&session, Some("/Users/alice/repo")).expect("path exists");
assert!(formatted.starts_with(&format!("{}@{}:", session.user(), session.hostname())));
assert!(formatted.ends_with("/Users/alice/repo"));
}
#[test]
fn format_session_location_preserves_windows_style_paths() {
let session = Session::test_remote();
let formatted =
format_session_location(&session, Some(r"C:\Users\alice\repo")).expect("path exists");
assert!(formatted.starts_with(&format!("{}@{}:", session.user(), session.hostname())));
assert!(formatted.ends_with(r"C:\Users\alice\repo"));
}
#[test]
fn format_session_location_returns_none_when_path_missing() {
let session = Session::test_remote();
let formatted = format_session_location(&session, None);
assert_eq!(formatted, None);
}
#[test]
fn display_working_directory_abbreviates_home_directory() {
let display = display_working_directory(Some("/Users/alice"), Some("/Users/alice"));
assert_eq!(display, Some("~".to_owned()));
}
#[test]
fn display_working_directory_abbreviates_subdirectory_under_home() {
let display = display_working_directory(Some("/Users/alice/repo"), Some("/Users/alice"));
assert_eq!(display, Some("~/repo".to_owned()));
}
#[test]
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.precmd(PrecmdValue {
pwd: Some("/active/path".to_owned()),
session_id: Some(0),
..Default::default()
});
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, Some("/active/path".to_owned()));
}
#[test]
fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_session() {
let terminal = prebootstrap_terminal_with_startup_path("/startup/path");
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, Some("/startup/path".to_owned()));
}
#[test]
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.ssh(SSHValue::default());
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, None);
}
#[test]
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_remote_session() {
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
terminal.init_shell(InitShellValue {
session_id: 123.into(),
shell: "zsh".to_owned(),
hostname: "remote.example.com".to_owned(),
..Default::default()
});
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, None);
}
#[test]
fn cwd_for_recent_conversations_does_not_use_startup_path_after_bootstrap() {
let terminal = terminal_with_startup_path(Some("/startup/path"));
let cwd = current_working_directory_for_zero_state(&terminal);
assert_eq!(cwd, None);
}
#[test]
fn oz_updates_section_renders_when_all_conditions_are_true() {
assert!(should_render_oz_updates_section(true, true, true));
}
#[test]
fn oz_updates_section_does_not_render_when_setting_is_disabled() {
assert!(!should_render_oz_updates_section(true, false, true));
}
#[test]
fn oz_updates_section_does_not_render_without_updates() {
assert!(!should_render_oz_updates_section(true, true, false));
}
#[test]
fn oz_updates_section_does_not_render_when_feature_flag_is_disabled() {
assert!(!should_render_oz_updates_section(false, true, true));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,623 @@
use std::{collections::HashMap, sync::Arc};
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use instant::Instant;
use parking_lot::FairMutex;
use serde::{Deserialize, Serialize};
use warp_core::send_telemetry_from_ctx;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::{
ai::{
agent::{
conversation::AIConversationId, task::TaskId, AIAgentActionId, AIAgentActionResultType,
AIAgentContext, CancellationReason, ReadShellCommandOutputResult,
RequestCommandOutputResult, TransferShellCommandControlToUserResult,
WriteToLongRunningShellCommandResult,
},
blocklist::{
agent_view::{AgentViewController, AgentViewEntryOrigin},
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIHistoryEvent,
},
},
terminal::{
model::block::BlockId,
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
BlocklistAIHistoryModel,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UserTakeOverReason {
Manual,
Stop,
/// The agent explicitly transferred control to the user via the
/// TransferShellCommandControlToUser tool call.
TransferFromAgent {
/// The reason the agent gave for transferring control.
reason: String,
},
}
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
}
impl UserTakeOverReason {
pub fn is_stop(&self) -> bool {
matches!(self, Self::Stop)
}
pub fn is_transfer_from_agent(&self) -> bool {
matches!(self, Self::TransferFromAgent { .. })
}
pub fn transfer_reason(&self) -> Option<&str> {
match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None,
}
}
}
/// Represents which party is in control of the active long running command.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LongRunningCommandControlState {
/// The agent is in control.
///
/// When the agent has control, the user cannot submit input to the command.
Agent {
/// `true` if the agent is blocked on approval from the user for submitting input.
is_blocked: bool,
/// `true` if agent responses should be hidden in the UI.
should_hide_responses: bool,
},
/// The user is in control.
User { reason: UserTakeOverReason },
}
impl LongRunningCommandControlState {
pub fn is_agent_in_control(&self) -> bool {
matches!(self, Self::Agent { .. })
}
pub fn is_agent_blocked(&self) -> bool {
matches!(
self,
Self::Agent {
is_blocked: true,
..
}
)
}
pub fn is_user_in_control(&self) -> bool {
matches!(self, Self::User { .. })
}
pub fn should_hide_responses(&self) -> bool {
matches!(
self,
Self::Agent {
should_hide_responses: true,
..
}
)
}
pub fn user_take_over_reason(&self) -> Option<&UserTakeOverReason> {
match &self {
LongRunningCommandControlState::Agent { .. } => None,
LongRunningCommandControlState::User { reason } => Some(reason),
}
}
}
/// Responsible for managing 'control' (e.g. write permissions) for the active long running
/// agent-requested command.
///
/// Control state is canonically stored on the relevant command `Block` owned by terminal model,
/// but wrapping update APIs in this controller ensures consistent update semantics and makes
/// control state updates subscribable.
pub struct CLISubagentController {
controller: ModelHandle<BlocklistAIController>,
action_model: ModelHandle<BlocklistAIActionModel>,
agent_view_controller: Option<ModelHandle<AgentViewController>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
// Active or recently-active CLI subagent state, keyed by the associated block.
active_subagents_by_block: HashMap<BlockId, ActiveCLISubagentState>,
}
impl CLISubagentController {
pub fn new(
controller: &ModelHandle<BlocklistAIController>,
action_model: &ModelHandle<BlocklistAIActionModel>,
agent_view_controller: Option<ModelHandle<AgentViewController>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
ctx.subscribe_to_model(action_model, |me, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction(..) => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
let snapshot_block_id = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.and_then(|result| snapshot_block_id_for_action_result(&result.result))
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id {
me.active_subagents_by_block
.entry(snapshot_block_id.clone())
.or_default()
.last_snapshot_at = Some(Instant::now());
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
}
_ => (),
});
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock();
let Some(block) = terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
else {
return;
};
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let was_agent_tagged_in = block.interaction_mode().is_agent_tagged_in();
let has_agent_metadata = block.agent_interaction_metadata().is_some();
drop(terminal_model);
let removed_subagent_state = me.active_subagents_by_block.remove(&block_id);
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.last_snapshot_at.is_some())
{
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.task_id.is_some())
{
let is_inline_agent_view =
me.agent_view_controller.as_ref().is_some_and(|controller| {
controller.read(ctx, |controller, _| controller.is_inline())
});
if is_inline_agent_view {
// Mark conversation as successfully completed BEFORE exiting agent view.
// The command finished naturally, so this is a successful completion.
if let Some(conversation_id) = conversation_id {
me.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::OptimisticCLISubagentCompletion,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id,
conversation_id,
initial_requested_command_action_id: requested_command_action_id,
});
}
// Exit inline agent view if agent was tagged in or had metadata (was in control).
if let Some(agent_view_controller) = &me.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
if controller.is_inline() && (was_agent_tagged_in || has_agent_metadata) {
controller.exit_agent_view(ctx);
}
});
}
}
});
Self {
controller: controller.clone(),
action_model: action_model.clone(),
agent_view_controller,
terminal_model,
terminal_view_id,
active_subagents_by_block: HashMap::new(),
}
}
pub fn is_agent_in_control(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.active_block()
.is_agent_in_control()
}
pub(crate) fn is_agent_in_control_or_tagged_in(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.active_block()
.is_agent_in_control_or_tagged_in()
}
pub fn last_snapshot_at(&self, block_id: &BlockId) -> Option<Instant> {
self.active_subagents_by_block
.get(block_id)
.and_then(|state| state.last_snapshot_at)
}
/// Force the currently in-flight poll for the given long-running command block to
/// resolve immediately with a fresh snapshot, bypassing the agent-set timeout.
/// Backs the `Check now` affordance surfaced next to the `Last seen by agent ...`
/// indicator in the warping footer.
pub fn request_force_refresh(&self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let executor_handle = self.action_model.as_ref(ctx).shell_command_executor(ctx);
let block_id = block_id.clone();
executor_handle.update(ctx, move |executor, _| {
executor.force_refresh_block(&block_id);
});
}
pub fn switch_control_to_user(&self, reason: UserTakeOverReason, ctx: &mut ModelContext<Self>) {
let should_cancel_conversation = !reason.is_transfer_from_agent();
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
let block_id = active_block.id().clone();
if let Err(e) = active_block.take_over_control_for_user(reason) {
log::error!("Failed to take control for user: {e:?}");
return;
}
let action_id = active_block.requested_command_action_id().cloned();
let conversation_id = active_block.ai_conversation_id();
let agent_has_control = active_block.is_agent_in_control();
// Conversation cancellation potentially takes a lock on terminal model if the
// cancelled action is a shell command action, so we have to drop the terminal
// model lock before actually cancelling the conversation.
drop(terminal_model);
// Only cancel conversation if user manually took control (not when agent transfers control).
if should_cancel_conversation {
if let Some(conversation_id) = conversation_id {
self.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id,
agent_has_control,
});
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentControlStateChanged {
conversation_id,
block_id,
control_state: CLISubagentControlState::UserInControl,
},
ctx
);
}
pub fn handoff_active_command_control_to_agent(&self, ctx: &mut ModelContext<Self>) {
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
let conversation_id = active_block.ai_conversation_id();
let block_id = active_block.id().clone();
// Check if control was transferred from agent before handoff.
let was_transfer_from_agent = active_block
.long_running_control_state()
.and_then(|state| state.user_take_over_reason())
.is_some_and(|reason| reason.is_transfer_from_agent());
if let Err(e) = active_block.handoff_control_to_agent() {
log::error!("Failed to handoff control to agent: {e:?}");
return;
}
let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control();
drop(terminal_model);
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
if !controller.is_inline() {
if let Err(e) = controller.try_enter_inline_agent_view(
conversation_id,
AgentViewEntryOrigin::LongRunningCommand,
ctx,
) {
log::error!("Failed to enter inline agent view for LRC handoff: {e}");
}
}
});
}
// Trigger an auto-resume of the conversation when handing control to the agent.
if let Some(conversation_id) = conversation_id {
let is_viewing_shared_session = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_viewing_shared_session());
if !is_viewing_shared_session {
let resume_context = {
let terminal_model = self.terminal_model.lock();
block_context_from_terminal_model(&terminal_model, &block_id, false)
.map(Box::new)
.map(AIAgentContext::Block)
.into_iter()
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id,
agent_has_control,
});
// Emit a special event if control was transferred from agent, so the executor can be notified.
if was_transfer_from_agent {
ctx.emit(CLISubagentEvent::ControlHandedBackAfterTransfer);
}
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentControlStateChanged {
conversation_id,
block_id,
control_state: CLISubagentControlState::AgentInControl,
},
ctx
);
}
pub fn toggle_hide_responses(&self, ctx: &mut ModelContext<Self>) {
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if active_block.toggle_subagent_response_visibility() {
let conversation_id = active_block.ai_conversation_id();
let block_id = active_block.id().clone();
let is_hidden = active_block.should_hide_responses();
ctx.emit(CLISubagentEvent::ToggledHideResponses);
if let Some(conversation_id) = conversation_id {
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentResponsesToggled {
conversation_id,
block_id,
is_hidden,
},
ctx
);
}
}
}
fn handle_history_model_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
if event
.terminal_view_id()
.is_some_and(|id| id != self.terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::CreatedSubtask {
task_id,
conversation_id,
..
} => {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(cli_subagent_block_id) = history_model
.as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.get_task(task_id))
.and_then(|task| task.cli_subagent_block_id())
else {
return;
};
let mut terminal_model = self.terminal_model.lock();
let Some(block) = terminal_model
.block_list_mut()
.mut_block_from_id(&cli_subagent_block_id)
else {
return;
};
let block_id = block.id().clone();
if let Err(e) = block.set_agent_interaction_mode_for_agent_monitored_command(
task_id,
*conversation_id,
) {
log::error!("Could not update interaction mode to agent-monitored: {e:?}",);
return;
};
let action_id = block.requested_command_action_id().cloned();
let agent_has_control = block.is_agent_in_control();
drop(terminal_model);
// When the CLI subagent is first created for a long running command,
// the agent now has control. Emit an UpdatedControl event so that
// shared-session state can reflect this initial control state.
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.task_id = Some(task_id.clone());
ctx.emit(CLISubagentEvent::SpawnedSubagent {
task_id: task_id.clone(),
conversation_id: *conversation_id,
block_id: block_id.clone(),
initial_requested_command_action_id: action_id,
});
}
BlocklistAIHistoryEvent::UpgradedTask {
optimistic_id: old_id,
server_id: new_id,
..
} => {
let block_id =
self.active_subagents_by_block
.iter()
.find_map(|(block_id, state)| {
(state.task_id.as_ref() == Some(old_id)).then_some(block_id.clone())
});
if let Some(block_id) = block_id {
let mut terminal_model = self.terminal_model.lock();
if let Some(block) =
terminal_model.block_list_mut().mut_block_from_id(&block_id)
{
match block.upgrade_cli_subagent_task_id(new_id.clone()) {
Ok(()) => {
if let Some(state) =
self.active_subagents_by_block.get_mut(&block_id)
{
state.task_id = Some(new_id.clone());
}
}
Err(e) => {
log::error!(
"Tried to upgrade CLISubagent task ID for non-existent block: {e:?}"
);
}
}
}
}
}
_ => (),
}
}
}
#[derive(Debug, Clone)]
pub enum CLISubagentEvent {
// Emitted when a CLI subagent is spawned for a running command block.
SpawnedSubagent {
task_id: TaskId,
block_id: BlockId,
conversation_id: AIConversationId,
/// The ID of the requested command for which this subagent was spawned, if any.
///
/// None if the subagent was spawned by entering agent mode during a user-executed command,
/// rather than a requested command.
initial_requested_command_action_id: Option<AIAgentActionId>,
},
// Emitted when a CLI subagent's execution ends.
FinishedSubagent {
block_id: BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
},
UpdatedControl {
block_id: BlockId,
requested_command_action_id: Option<AIAgentActionId>,
agent_has_control: bool,
},
UpdatedLastSnapshot,
ToggledHideResponses,
/// Emitted when the user hands control back to the agent after a
/// TransferShellCommandControlToUser action.
ControlHandedBackAfterTransfer,
}
impl Entity for CLISubagentController {
type Event = CLISubagentEvent;
}
fn snapshot_block_id_for_action_result(result: &AIAgentActionResultType) -> Option<&BlockId> {
// Enumerates all possible action result types that read a command output.
match result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot { block_id, .. },
) => Some(block_id),
_ => None,
}
}
@@ -0,0 +1,119 @@
//! Compact free-form text input used by inline AI block actions.
use warpui::{
presenter::ChildView, AppContext, Element, Entity, FocusContext, SingletonEntity, View,
ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey,
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
},
};
/// Wraps an [`EditorView`] for inline prompts that need a lightweight text input.
///
/// Enter submits trimmed non-empty text and clears the buffer. Escape is emitted for the parent
/// view to handle.
pub struct CompactAgentInput {
editor: ViewHandle<EditorView>,
}
/// Events emitted by [`CompactAgentInput`].
pub enum CompactAgentInputEvent {
/// The user pressed Enter with non-empty trimmed contents.
Submit(String),
/// The user pressed Escape while the input was focused.
Escape,
}
impl CompactAgentInput {
/// Creates a compact AI input view backed by an autogrowing, soft-wrapping editor.
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let text_options = TextOptions::ui_text(None, Appearance::as_ref(ctx));
let editor = ctx.add_view(|ctx| {
let options = EditorOptions {
autogrow: true,
soft_wrap: true,
text: text_options,
include_ai_context_menu: true,
propagate_and_no_op_escape_key: PropagateAndNoOpEscapeKey::PropagateFirst,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::AtBoundary,
..Default::default()
};
let mut editor = EditorView::new(options, ctx);
editor.set_is_ai_input(true, ctx);
editor
});
ctx.subscribe_to_view(&editor, Self::handle_editor_event);
Self { editor }
}
/// Sets the placeholder shown while the input buffer is empty.
pub fn set_placeholder_text(&self, text: impl Into<String>, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text(text, ctx);
});
}
/// Returns the underlying editor handle for integrations that need direct editor access.
pub fn editor(&self) -> &ViewHandle<EditorView> {
&self.editor
}
/// Replaces the current buffer contents.
pub fn set_text(&self, text: &str, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(text, ctx);
});
}
fn handle_editor_event(
&mut self,
_handle: ViewHandle<EditorView>,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EditorEvent::Enter => {
let content = self
.editor
.read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned());
if !content.is_empty() {
self.editor
.update(ctx, |editor, ctx| editor.clear_buffer(ctx));
ctx.emit(CompactAgentInputEvent::Submit(content));
}
}
EditorEvent::Escape => {
ctx.emit(CompactAgentInputEvent::Escape);
}
_ => {}
}
}
}
impl View for CompactAgentInput {
fn ui_name() -> &'static str {
"CompactAgentInput"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.editor);
}
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
ChildView::new(&self.editor).finish()
}
}
impl Entity for CompactAgentInput {
type Event = CompactAgentInputEvent;
}
+181
View File
@@ -0,0 +1,181 @@
use std::{collections::HashMap, ops::Range};
use super::{AIBlock, TextLocation};
use crate::ai::agent::{AIAgentTextSection, MessageId};
use crate::terminal::find::{FindOptions, FindableRichContentView, RichContentMatchId};
use itertools::Itertools;
use regex::RegexBuilder;
/// Represents the location of a find match in an AI block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FindMatchLocation {
pub(super) text_location: TextLocation,
pub(super) char_range: Range<usize>,
/// The message ID that contains this match (for Reasoning/Text blocks).
pub(super) message_id: Option<MessageId>,
}
/// Encapsulated find-related state relevant to an AI block.
#[derive(Debug, Default, Clone)]
pub(crate) struct FindState {
/// Matches in this AI block.
matches: HashMap<RichContentMatchId, FindMatchLocation>,
}
impl FindState {
pub(super) fn matches_for_location(
&self,
location: TextLocation,
) -> impl Iterator<Item = &FindMatchLocation> {
self.matches
.values()
.filter(move |find_match_location| find_match_location.text_location == location)
}
pub(super) fn location_for_match(&self, id: RichContentMatchId) -> Option<&FindMatchLocation> {
self.matches.get(&id)
}
}
impl FindableRichContentView for AIBlock {
/// Computes find matches within this AI block and updates the block's `FindState`.
fn run_find(
&mut self,
options: &FindOptions,
ctx: &mut warpui::ViewContext<Self>,
) -> Vec<RichContentMatchId> {
self.clear_matches(ctx);
let mut new_match_ids = vec![];
for (i, input) in self.model.inputs_to_render(ctx).iter().enumerate() {
if let Some(query) = input.user_query() {
for find_match_range in compute_find_matches(&query, options).into_iter() {
let id = RichContentMatchId::default();
new_match_ids.push(id);
self.find_state.matches.insert(
id,
FindMatchLocation {
text_location: TextLocation::Query { input_index: i },
char_range: find_match_range,
message_id: None,
},
);
}
}
}
if let Some(output) = self.model.status(ctx).output_to_render() {
for (section_index, (message_id, text_section)) in output
.get()
.all_text_with_message_id()
.flat_map(|(msg_id, text)| {
text.sections.iter().map(move |section| (msg_id, section))
})
.enumerate()
{
let section_matches = match text_section {
AIAgentTextSection::PlainText { text } => match &text.formatted_lines {
Some(formatted_text) => {
let mut matches: Vec<Vec<Range<usize>>> = vec![];
for line in formatted_text.lines() {
matches.push(compute_find_matches(line.raw_text(), options));
}
matches
}
_ => vec![compute_find_matches(text.text(), options)],
},
AIAgentTextSection::Code { code, .. } => {
vec![compute_find_matches(code.as_str(), options)]
}
AIAgentTextSection::Table { table } => table
.rendered_lines()
.into_iter()
.map(|line| compute_find_matches(&line, options))
.collect(),
AIAgentTextSection::Image { image } => {
vec![compute_find_matches(&image.markdown_source, options)]
}
AIAgentTextSection::MermaidDiagram { diagram } => {
vec![compute_find_matches(&diagram.markdown_source, options)]
}
};
for (line_index, frame_matches) in section_matches.into_iter().enumerate() {
for find_match_range in frame_matches {
let id = RichContentMatchId::default();
new_match_ids.push(id);
self.find_state.matches.insert(
id,
FindMatchLocation {
text_location: TextLocation::Output {
section_index,
line_index,
},
char_range: find_match_range,
message_id: Some(message_id.clone()),
},
);
}
}
}
}
ctx.notify();
new_match_ids
}
fn clear_matches(&mut self, ctx: &mut warpui::ViewContext<Self>) {
self.find_state.matches.clear();
ctx.notify();
}
}
/// Computes find matches (represented as character offsets) within the given `text`.
fn compute_find_matches(text: &str, options: &FindOptions) -> Vec<Range<usize>> {
let Some(query) = options.query.as_ref() else {
return vec![];
};
if options.is_regex_enabled {
let Ok(regex) = RegexBuilder::new(query.as_str())
.case_insensitive(!options.is_case_sensitive)
.build()
else {
log::warn!("Attempted to run find on AI block with invalid regex: {query}");
return vec![];
};
regex
.find_iter(text)
.map(|m| {
// Convert the range from byte offset to char offset.
let char_offset_start = text[..(m.range().start)].chars().count();
let char_offset_end = text[..(m.range().end)].chars().count();
char_offset_start..char_offset_end
})
.collect_vec()
} else if options.is_case_sensitive {
// The length of the query in characters. Note this differs from query.len(), which is
// length in bytes.
let query_len_chars = query.chars().count();
text.match_indices(query.as_ref())
.map(|(start_bytes, _)| {
// The start _char_ index (as opposed to byte index).
let start_chars = text[..start_bytes].chars().count();
start_chars..start_chars + query_len_chars
})
.collect_vec()
} else {
// The length of the query in characters. Note this differs from query.len(), which is
// length in bytes.
let query = query.to_lowercase();
let query_len_chars = query.chars().count();
text.to_lowercase()
.match_indices(&query)
.map(|(start_bytes, _)| {
let start_chars = text[..start_bytes].chars().count();
start_chars..start_chars + query_len_chars
})
.collect_vec()
}
}
@@ -0,0 +1,386 @@
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
},
keymap::FixedBinding,
ui_components::{
button::{Button, ButtonVariant},
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::ui_components::icons::Icon;
use super::numbered_button::render_recommended_badge;
const MARGIN_BETWEEN_BUTTONS: f32 = 4.;
const HAS_OPTIONS: &str = "HasOptions";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([
FixedBinding::new(
"enter",
KeyboardNavigableButtonsAction::Enter,
id!(KeyboardNavigableButtons::ui_name()) & id!(HAS_OPTIONS),
),
FixedBinding::new(
"numpadenter",
KeyboardNavigableButtonsAction::Enter,
id!(KeyboardNavigableButtons::ui_name()) & id!(HAS_OPTIONS),
),
FixedBinding::new(
"up",
KeyboardNavigableButtonsAction::ArrowUp,
id!(KeyboardNavigableButtons::ui_name()) & id!(HAS_OPTIONS),
),
FixedBinding::new(
"down",
KeyboardNavigableButtonsAction::ArrowDown,
id!(KeyboardNavigableButtons::ui_name()) & id!(HAS_OPTIONS),
),
]);
}
#[derive(Debug, Clone)]
pub enum KeyboardNavigableButtonsAction {
HoveredIn(usize),
ButtonClicked(usize),
ArrowUp,
ArrowDown,
Enter,
}
pub enum KeyboardNavigableButtonsEvent {}
pub type ButtonBuilder = Box<dyn Fn(bool, &warpui::AppContext) -> Button>;
pub type OnButtonClickFn = Box<dyn Fn(&mut ViewContext<KeyboardNavigableButtons>)>;
pub struct KeyboardNavigableButtonBuilder {
button_builder: ButtonBuilder,
/// Called when the button is selected through click or enter.
on_click: OnButtonClickFn,
}
impl KeyboardNavigableButtonBuilder {
pub fn new(
button_builder: impl Fn(bool, &warpui::AppContext) -> Button + 'static,
on_selected: impl Fn(&mut ViewContext<KeyboardNavigableButtons>) + 'static,
) -> Self {
Self {
button_builder: Box::new(button_builder),
on_click: Box::new(on_selected),
}
}
}
/// Creates a simple navigation button with standard styling.
/// This is a convenience function for the common case of a text-only button
/// that dispatches an action when clicked.
pub fn simple_navigation_button<A: warpui::Action + Clone + 'static>(
text_label: String,
mouse_state: MouseStateHandle,
action: A,
disabled: bool,
) -> KeyboardNavigableButtonBuilder {
KeyboardNavigableButtonBuilder::new(
move |is_selected, app| {
let appearance = Appearance::as_ref(app);
let mut button = appearance
.ui_builder()
.button(ButtonVariant::Secondary, mouse_state.clone())
.with_style(UiComponentStyles {
font_size: Some(appearance.monospace_font_size()),
..UiComponentStyles::default()
})
.with_hovered_styles(UiComponentStyles {
font_size: Some(appearance.monospace_font_size()),
..UiComponentStyles::default()
});
if disabled {
button = button.disabled();
} else if is_selected {
button = button.with_style(UiComponentStyles {
border_color: Some(appearance.theme().accent().into()),
border_width: Some(1.0),
background: Some(appearance.theme().surface_2().into()),
..UiComponentStyles::default()
});
}
button.with_text_label(text_label.clone())
},
move |ctx: &mut ViewContext<KeyboardNavigableButtons>| {
if !disabled {
ctx.dispatch_typed_action(&action);
}
},
)
}
/// Builds the label for a [`rich_navigation_button`]: a title row (with
/// optional `Recommended` badge) above an optional muted sub-label, plus
/// an enter-key indicator centered vertically within the full label area.
fn build_rich_navigation_label(
text_label: &str,
sub_label: Option<&str>,
recommended: bool,
show_enter_indicator: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
let title = Text::new(
text_label.to_string(),
appearance.ui_font_family(),
font_size,
)
.soft_wrap(true)
.with_color(theme.foreground().into())
.finish();
// Title row: title text + optional recommended badge (enter indicator is
// handled separately so it can be centered within the full button height).
let title_row: Box<dyn Element> = if recommended {
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(
Container::new(render_recommended_badge(appearance))
.with_margin_left(8.)
.finish(),
)
.finish()
} else {
title
};
// Build the text column (title + optional sublabel).
let text_column: Box<dyn Element> = if let Some(sub_label) = sub_label {
let sub_label_element = Text::new(
sub_label.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.soft_wrap(true)
.with_color(internal_colors::neutral_5(theme))
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(title_row)
.with_child(
Container::new(sub_label_element)
.with_margin_top(4.)
.finish(),
)
.finish()
} else {
title_row
};
// Always reserve the same horizontal space for the enter indicator so
// the text column width stays constant and the sub-label doesn't rewrap
// on hover.
const ENTER_KEY_PADDING: f32 = 4.;
let enter_indicator_size = font_size + 2. * ENTER_KEY_PADDING;
let right_element: Box<dyn Element> = if show_enter_indicator {
let enter_icon = ConstrainedBox::new(
Icon::CornerDownLeft
.to_warpui_icon(theme.foreground())
.finish(),
)
.with_width(font_size)
.with_height(font_size)
.finish();
Container::new(enter_icon)
.with_uniform_padding(ENTER_KEY_PADDING)
.with_background(internal_colors::fg_overlay_1(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
} else {
// Invisible spacer matching the enter indicator dimensions.
ConstrainedBox::new(Empty::new().finish())
.with_width(enter_indicator_size)
.with_height(enter_indicator_size)
.finish()
};
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., text_column).finish())
.with_child(Container::new(right_element).with_margin_left(12.).finish())
.finish()
}
/// Creates a keyboard-navigable button with a rich two-line label: a title
/// (with an optional trailing "Recommended" badge) plus an optional muted
/// sub-label underneath.
pub fn rich_navigation_button<A: warpui::Action + Clone + 'static>(
text_label: String,
sub_label: Option<String>,
recommended: bool,
mouse_state: MouseStateHandle,
action: A,
) -> KeyboardNavigableButtonBuilder {
KeyboardNavigableButtonBuilder::new(
move |is_selected, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
let base_style = UiComponentStyles {
font_size: Some(font_size),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
..UiComponentStyles::default()
};
let style = if is_selected {
UiComponentStyles {
border_color: Some(theme.accent().into()),
border_width: Some(1.0),
background: Some(internal_colors::fg_overlay_2(theme).into()),
..base_style
}
} else {
base_style
};
let label = build_rich_navigation_label(
&text_label,
sub_label.as_deref(),
recommended,
is_selected,
appearance,
);
appearance
.ui_builder()
.button(ButtonVariant::Secondary, mouse_state.clone())
.with_style(style)
.with_hovered_styles(base_style)
.with_custom_label(label)
},
move |ctx: &mut ViewContext<KeyboardNavigableButtons>| {
ctx.dispatch_typed_action(&action);
},
)
}
/// A view that wraps buttons to make them keyboard navigable.
/// Mouse hover and keyboard navigation both update the same selection index.
/// When hovering stops, the selection remains on the last selected button.
/// Note that this view must be focused for keyboard shortcuts to work -
/// the parent view likely needs to focus this view manually.
pub struct KeyboardNavigableButtons {
button_builders: Vec<KeyboardNavigableButtonBuilder>,
selected_button_index: usize,
}
impl KeyboardNavigableButtons {
pub fn new(button_builders: Vec<KeyboardNavigableButtonBuilder>) -> Self {
Self {
button_builders,
selected_button_index: 0,
}
}
fn selected_button_index(&self) -> usize {
self.selected_button_index
}
}
impl View for KeyboardNavigableButtons {
fn ui_name() -> &'static str {
"KeyboardNavigableButtons"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, button_builder) in self.button_builders.iter().enumerate() {
let is_selected = index == self.selected_button_index();
let button = (button_builder.button_builder)(is_selected, app);
let mut hoverable = button.build();
hoverable = hoverable
.additional_on_hover(move |is_hovered, ctx, _app, _pos| {
if is_hovered {
ctx.dispatch_typed_action(KeyboardNavigableButtonsAction::HoveredIn(index));
}
})
.on_click(move |ctx, _app, _pos| {
ctx.dispatch_typed_action(KeyboardNavigableButtonsAction::ButtonClicked(index));
});
let margin_bottom = if index == self.button_builders.len() - 1 {
0.
} else {
MARGIN_BETWEEN_BUTTONS
};
content.add_child(
Container::new(hoverable.finish())
.with_margin_bottom(margin_bottom)
.finish(),
);
}
content.finish()
}
fn keymap_context(&self, _app: &AppContext) -> warpui::keymap::Context {
let mut context = Self::default_keymap_context();
if !self.button_builders.is_empty() {
context.set.insert(HAS_OPTIONS);
}
context
}
}
impl TypedActionView for KeyboardNavigableButtons {
type Action = KeyboardNavigableButtonsAction;
fn handle_action(
&mut self,
action: &KeyboardNavigableButtonsAction,
ctx: &mut ViewContext<Self>,
) {
match action {
KeyboardNavigableButtonsAction::HoveredIn(index) => {
self.selected_button_index = *index;
}
KeyboardNavigableButtonsAction::ButtonClicked(index) => {
if let Some(builder) = self.button_builders.get(*index) {
(builder.on_click)(ctx);
}
}
KeyboardNavigableButtonsAction::ArrowUp => {
self.selected_button_index =
(self.selected_button_index + self.button_builders.len() - 1)
% self.button_builders.len();
}
KeyboardNavigableButtonsAction::ArrowDown => {
self.selected_button_index =
(self.selected_button_index + 1) % self.button_builders.len();
}
KeyboardNavigableButtonsAction::Enter => {
if let Some(builder) = self.button_builders.get(self.selected_button_index()) {
(builder.on_click)(ctx);
}
}
};
ctx.notify();
}
}
impl Entity for KeyboardNavigableButtons {
type Event = KeyboardNavigableButtonsEvent;
}
+309
View File
@@ -0,0 +1,309 @@
mod helper;
mod model_impl;
pub use helper::AIBlockModelHelper;
pub use model_impl::*;
use session_sharing_protocol::common::ParticipantId;
use warp_core::features::FeatureFlag;
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
CancellationReason, PassiveSuggestionTrigger, PassiveSuggestionTriggerType,
RenderableAIError, ServerOutputId, Shared,
},
llms::LLMId,
};
use chrono::TimeDelta;
use warpui::{AppContext, ViewContext};
#[derive(Debug, Clone, Copy)]
pub enum PassiveRequestType {
UnitTestSuggestion,
CodeDiff,
PassiveSuggestion(PassiveSuggestionTriggerType),
}
/// The type of request that triggered the AI block.
#[derive(Default, Debug, Clone, Copy)]
pub enum AIRequestType {
#[default]
Active,
Passive(PassiveRequestType),
}
impl AIRequestType {
pub fn from_passive_trigger(trigger: &PassiveSuggestionTrigger) -> Self {
match trigger {
PassiveSuggestionTrigger::CommandRun | PassiveSuggestionTrigger::FilesChanged => {
AIRequestType::Passive(PassiveRequestType::UnitTestSuggestion)
}
_ => AIRequestType::Passive(PassiveRequestType::PassiveSuggestion(trigger.into())),
}
}
pub fn is_active(&self) -> bool {
matches!(self, AIRequestType::Active)
}
pub fn is_passive(&self) -> bool {
matches!(self, AIRequestType::Passive(_))
}
pub fn is_passive_code_diff(&self) -> bool {
matches!(self, AIRequestType::Passive(PassiveRequestType::CodeDiff))
|| (FeatureFlag::PromptSuggestionsViaMAA.is_enabled()
&& matches!(
self,
AIRequestType::Passive(PassiveRequestType::PassiveSuggestion(
PassiveSuggestionTriggerType::ShellCommandCompleted
))
))
}
pub fn is_passive_unit_test_suggestion(&self) -> bool {
matches!(
self,
AIRequestType::Passive(PassiveRequestType::UnitTestSuggestion)
)
}
}
/// UI-layer representation of agent output to be rendered in an [`AIBlock`].
#[derive(Debug, Clone)]
pub enum AIBlockOutputStatus {
Pending,
PartiallyReceived {
output: Shared<AIAgentOutput>,
},
Complete {
output: Shared<AIAgentOutput>,
},
Cancelled {
partial_output: Option<Shared<AIAgentOutput>>,
reason: CancellationReason,
},
Failed {
partial_output: Option<Shared<AIAgentOutput>>,
error: RenderableAIError,
},
}
impl AIBlockOutputStatus {
/// Returns true if the response is still actively being streamed from the server.
pub fn is_streaming(&self) -> bool {
matches!(
self,
AIBlockOutputStatus::Pending | AIBlockOutputStatus::PartiallyReceived { .. }
)
}
/// Returns `true` if the response stream was cancelled.
pub fn is_cancelled(&self) -> bool {
matches!(self, AIBlockOutputStatus::Cancelled { .. })
}
/// Returns the reason for the cancellation, if any.
pub fn cancellation_reason(&self) -> Option<&CancellationReason> {
match self {
AIBlockOutputStatus::Cancelled { reason, .. } => Some(reason),
_ => None,
}
}
pub fn is_complete(&self) -> bool {
matches!(self, AIBlockOutputStatus::Complete { .. })
}
/// Returns the output to be rendered, if any.
pub fn output_to_render(&self) -> Option<Shared<AIAgentOutput>> {
match self {
AIBlockOutputStatus::Pending => None,
AIBlockOutputStatus::PartiallyReceived { output } => Some(output.get_owned()),
AIBlockOutputStatus::Complete { output } => Some(output.get_owned()),
AIBlockOutputStatus::Cancelled { partial_output, .. } => {
partial_output.as_ref().map(Shared::get_owned)
}
AIBlockOutputStatus::Failed { partial_output, .. } => {
partial_output.as_ref().map(Shared::get_owned)
}
}
}
pub fn error(&self) -> Option<&RenderableAIError> {
match self {
AIBlockOutputStatus::Failed { error, .. } => Some(error),
_ => None,
}
}
}
/// Function signature for a callback that may be supplied to
/// [`AIBlockModel::subscribe_to_updates`], to be called whenever a new event is received from the
/// server.
pub type OutputStatusUpdateCallback<V> = Box<dyn FnMut(&mut V, &mut ViewContext<V>)>;
/// Trait to be implemented by data structures that provide the necessary data to back an
/// [`AIBlock`] view.
///
/// You might wonder why this is a trait, as opposed to just a single struct. It's actually quite
/// convenient to have an abstraction layer to completely decouple the model layer for a live agent
/// response stream from data for a restored AI block from history, or an imported AI block for
/// debugging.
pub trait AIBlockModel {
type View;
/// Returns the status of the agent output to be rendered in the AI block.
fn status(&self, app: &AppContext) -> AIBlockOutputStatus;
/// Returns the `server_output_id` associated with this output rendered in this block, if any.
fn server_output_id(&self, app: &AppContext) -> Option<ServerOutputId>;
/// Returns the model ID used to generate the output in this block, which may differ from the
/// requested model ID because of failover, etc.
fn model_id(&self, app: &AppContext) -> Option<LLMId>;
/// Return `true` if the block is a restored-from-history AI block.
fn is_restored(&self) -> bool {
false
}
/// Return `true` if the block was created in the process of forking a conversation.
fn is_forked(&self) -> bool {
false
}
/// Returns `true` if this block renders a user query input that was autodetected as AI.
fn was_autodetected_ai_query(&self, _app: &AppContext) -> bool {
false
}
/// Returns the time elapsed since the request was triggered.
///
/// `None` if there was no request for data in this block (e.g. if it's for a restored AI block).
fn time_since_request_start(&self, _app: &AppContext) -> Option<TimeDelta> {
None
}
/// Returns the [`LLMId`] for the base model used to generate output in this block.
fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId>;
/// Returns the [`AIAgentInput`]s corresponding to the user input to the Agent to be rendered
/// in this block.
fn inputs_to_render<'a>(&'a self, app: &'a AppContext) -> &'a [AIAgentInput];
/// Returns the conversation ID for this block.
fn conversation_id(&self, app: &AppContext) -> Option<AIConversationId>;
/// Returns the exchange ID for this block.
fn exchange_id(&self, _app: &AppContext) -> Option<AIAgentExchangeId> {
None
}
/// Returns the participant ID who initiated this exchange, for shared sessions.
/// Returns None for local (non-shared) sessions.
fn response_initiator(&self, _app: &AppContext) -> Option<ParticipantId> {
None
}
/// Registers the provided `callback` to be called each time an update is received in the agent
/// response stream.
fn on_updated_output(
&self,
callback: OutputStatusUpdateCallback<Self::View>,
ctx: &mut ViewContext<Self::View>,
);
/// Returns the type of request that triggered the AI block.
fn request_type(&self, app: &AppContext) -> AIRequestType;
}
#[cfg(any(test, feature = "integration_tests"))]
pub mod testing {
use warpui::{AppContext, ViewContext};
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentInput, AIAgentOutput, ServerOutputId, Shared,
},
blocklist::{
model::{AIRequestType, PassiveRequestType, PassiveSuggestionTriggerType},
AIBlock,
},
llms::LLMId,
};
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
pub struct FakeAIBlockModel {
input: Vec<AIAgentInput>,
output: Shared<AIAgentOutput>,
model_id: LLMId,
}
impl FakeAIBlockModel {
pub fn new(input: Vec<AIAgentInput>, output: AIAgentOutput) -> Self {
Self {
input,
output: Shared::new(output),
model_id: "fake-llm".to_owned().into(),
}
}
}
impl AIBlockModel for FakeAIBlockModel {
type View = AIBlock;
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
AIBlockOutputStatus::Complete {
output: self.output.clone(),
}
}
fn server_output_id(&self, _app: &AppContext) -> Option<ServerOutputId> {
None
}
fn model_id(&self, _app: &AppContext) -> Option<LLMId> {
None
}
fn base_model<'a>(&'a self, _app: &'a AppContext) -> Option<&'a LLMId> {
Some(&self.model_id)
}
fn inputs_to_render<'a>(&'a self, _app: &'a AppContext) -> &'a [AIAgentInput] {
&self.input
}
fn conversation_id(&self, _app: &AppContext) -> Option<AIConversationId> {
None
}
fn on_updated_output(
&self,
_callback: OutputStatusUpdateCallback<AIBlock>,
_ctx: &mut ViewContext<AIBlock>,
) {
}
fn request_type(&self, app: &AppContext) -> AIRequestType {
let inputs = self.inputs_to_render(app);
if inputs
.iter()
.any(|input| input.is_passive_suggestion_trigger())
{
AIRequestType::Passive(PassiveRequestType::PassiveSuggestion(
PassiveSuggestionTriggerType::ShellCommandCompleted,
))
} else if inputs
.iter()
.any(|input| input.auto_code_diff_query().is_some())
{
AIRequestType::Passive(PassiveRequestType::CodeDiff)
} else {
AIRequestType::Active
}
}
}
}
@@ -0,0 +1,79 @@
use warpui::{AppContext, ViewContext};
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentInput, AIAgentOutput, RenderableAIError,
ServerOutputId, Shared,
},
llms::LLMId,
};
use super::{super::AIBlock, AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
pub struct DebugAIBlockModel {
inputs: Vec<AIAgentInput>,
output: Option<Shared<AIAgentOutput>>,
model: LLMId,
}
impl AIBlockModel for DebugAIBlockModel {
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
match self.output.as_ref() {
Some(output) => AIBlockOutputStatus::Complete {
output: output.clone(),
},
None => AIBlockOutputStatus::Failed {
error: RenderableAIError::Other {
error_message: "No output received.".to_owned(),
will_attempt_resume: false,
waiting_for_network: false,
},
},
}
}
fn server_output_id(&self, _app: &AppContext) -> Option<ServerOutputId> {
self.output
.as_ref()
.and_then(|output| output.get().server_output_id.clone())
}
fn model_id(&self, _app: &AppContext) -> Option<LLMId> {
self.output
.as_ref()
.and_then(|output| output.get().model_id.clone())
}
fn base_model<'a>(&'a self, _app: &'a AppContext) -> &'a LLMId {
&self.model
}
fn inputs_to_render<'a>(&'a self, _app: &'a AppContext) -> &'a Vec<AIAgentInput> {
&self.inputs
}
fn conversation_id(&self, _app: &AppContext) -> Option<AIConversationId> {
None
}
fn on_updated_output(
&self,
_callback: OutputStatusUpdateCallback,
_ctx: &mut ViewContext<AIBlock>,
) {
}
fn request_type(&self, app: &AppContext) -> AIRequestType {
let inputs = self.inputs_to_render(app);
if inputs.iter().any(|input| input.is_suggest_prompt_query()) {
AIRequestType::Passive(PassiveRequestType::SuggestPrompt)
} else if inputs
.iter()
.any(|input| input.auto_code_diff_query().is_some())
{
AIRequestType::Passive(PassiveRequestType::CodeDiff)
} else {
AIRequestType::Active
}
}
}
+163
View File
@@ -0,0 +1,163 @@
use warpui::{AppContext, EntityId, ModelHandle, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversation, AIAgentAction, AIAgentActionId, AIAgentActionType,
AIAgentInput, AIAgentOutputMessageType, SummarizationType,
},
blocklist::BlocklistAIActionModel,
},
BlocklistAIHistoryModel,
};
use super::AIBlockModel;
// Helper methods for accessing data on an impl of `AIBlockModel`.
//
// These are defined within a separate trait rather than default implementations of `AIBlockModel`
// so implementations cannot errantly override them.
pub trait AIBlockModelHelper {
fn is_first_action_in_output(&self, action_id: &AIAgentActionId, app: &AppContext) -> bool;
fn conversation<'a>(&self, app: &'a AppContext) -> Option<&'a AIConversation>;
fn contains_static_prompt_suggestion_input(&self, app: &AppContext) -> bool;
fn contains_create_document_action(&self, app: &AppContext) -> bool;
fn contains_update_document_action(&self, app: &AppContext) -> bool;
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool;
fn is_latest_exchange_in_terminal_pane(
&self,
terminal_view_id: EntityId,
app: &AppContext,
) -> bool;
fn is_conversation_summarization_active(&self, app: &AppContext) -> bool;
fn blocked_action(
&self,
action_model: &ModelHandle<BlocklistAIActionModel>,
app: &AppContext,
) -> Option<AIAgentAction>;
}
impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
fn is_first_action_in_output(&self, action_id: &AIAgentActionId, app: &AppContext) -> bool {
self.status(app).output_to_render().is_some_and(|output| {
output
.get()
.actions()
.next()
.is_some_and(|action| action.id == *action_id)
})
}
fn conversation<'a>(&self, app: &'a AppContext) -> Option<&'a AIConversation> {
self.conversation_id(app)
.and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id))
}
fn contains_static_prompt_suggestion_input(&self, app: &AppContext) -> bool {
self.inputs_to_render(app)
.iter()
.any(|input| matches!(input, AIAgentInput::UserQuery { static_query_type, .. } if static_query_type .is_some()))
}
fn contains_create_document_action(&self, app: &AppContext) -> bool {
if let Some(output) = self.status(app).output_to_render() {
let output = output.get();
output.messages.iter().any(|m| {
matches!(
m.message,
AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::CreateDocuments { .. },
..
})
)
})
} else {
false
}
}
fn contains_update_document_action(&self, app: &AppContext) -> bool {
if let Some(output) = self.status(app).output_to_render() {
let output = output.get();
output.messages.iter().any(|m| {
matches!(
m.message,
AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::EditDocuments { .. },
..
})
)
})
} else {
false
}
}
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool {
self.conversation(app).is_some_and(|conversation| {
match (
conversation.last_non_passive_exchange(),
self.exchange_id(app),
) {
(Some(latest_exchange), Some(id)) => latest_exchange.id == id,
_ => false,
}
})
}
fn is_latest_exchange_in_terminal_pane(
&self,
terminal_view_id: EntityId,
app: &AppContext,
) -> bool {
match (
BlocklistAIHistoryModel::as_ref(app)
.latest_exchange_across_all_conversations(terminal_view_id),
self.exchange_id(app),
) {
(Some(latest_exchange), Some(id)) => latest_exchange.id == id,
_ => false,
}
}
fn is_conversation_summarization_active(&self, app: &AppContext) -> bool {
let Some(output) = self.status(app).output_to_render() else {
return false;
};
let output = output.get();
output.messages.last().is_some_and(|m| {
matches!(
m.message,
crate::ai::agent::AIAgentOutputMessageType::Summarization {
finished_duration: None,
summarization_type: SummarizationType::ConversationSummary,
..
}
)
})
}
fn blocked_action(
&self,
action_model: &ModelHandle<BlocklistAIActionModel>,
app: &AppContext,
) -> Option<AIAgentAction> {
let output = self.status(app).output_to_render()?;
let output = output.get();
output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.message {
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) {
return status.is_blocked().then_some(action.clone());
}
}
None
})
}
}
@@ -0,0 +1,240 @@
use std::marker::PhantomData;
use anyhow::{anyhow, Result};
use chrono::{Local, TimeDelta};
use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use session_sharing_protocol::common::ParticipantId;
use warpui::{AppContext, SingletonEntity, View, ViewContext};
use crate::ai::{
agent::{
conversation::AIConversationId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
AIAgentOutputStatus, FinishedAIAgentOutput, ServerOutputId, Shared,
},
blocklist::{
history_model,
model::{AIRequestType, PassiveRequestType},
},
llms::LLMId,
};
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
/// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`.
pub struct AIBlockModelImpl<V> {
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
is_restored: bool,
is_forked: bool,
_view: PhantomData<V>,
}
impl<V> AIBlockModelImpl<V>
where
V: View,
{
pub fn new(
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
is_restored: bool,
is_forked: bool,
app: &AppContext,
) -> Result<Self> {
BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation_id)
.ok_or_else(|| {
anyhow!(
"Failed to find agent conversation data for conversation_id: {:?}",
conversation_id
)
})
.and_then(|conversation| {
conversation.exchange_with_id(exchange_id).ok_or_else(|| {
anyhow!(
"Failed to find agent exchange data for exchange_id: {:?}",
exchange_id
)
})
})
.map(|_| Self {
exchange_id,
conversation_id,
is_restored,
is_forked,
_view: PhantomData,
})
}
fn exchange<'a>(&self, app: &'a AppContext) -> Result<&'a AIAgentExchange> {
let res = BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.conversation_id)
.and_then(|conversation| conversation.exchange_with_id(self.exchange_id));
// There is no reason this should ever happen in the normal course of a session.
if let Some(exchange) = res {
Ok(exchange)
} else {
Err(anyhow!(
"No exchange found for conversation_id: {:?}, exchange_id: {:?}",
self.conversation_id,
self.exchange_id
))
}
}
}
impl<V> AIBlockModel for AIBlockModelImpl<V>
where
V: View,
{
type View = V;
fn is_restored(&self) -> bool {
self.is_restored
}
fn is_forked(&self) -> bool {
self.is_forked
}
fn status(&self, app: &AppContext) -> AIBlockOutputStatus {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&self.conversation_id) else {
return AIBlockOutputStatus::Pending;
};
let Some(exchange) = conversation.exchange_with_id(self.exchange_id) else {
return AIBlockOutputStatus::Pending;
};
match &exchange.output_status {
AIAgentOutputStatus::Streaming { output: None, .. } => AIBlockOutputStatus::Pending,
AIAgentOutputStatus::Streaming {
output: Some(output),
..
} => {
if output.get().messages.is_empty() {
AIBlockOutputStatus::Pending
} else {
AIBlockOutputStatus::PartiallyReceived {
output: output.get_owned(),
}
}
}
AIAgentOutputStatus::Finished {
finished_output, ..
} => match finished_output {
FinishedAIAgentOutput::Success { output } => AIBlockOutputStatus::Complete {
output: output.get_owned(),
},
FinishedAIAgentOutput::Cancelled { output, reason } => {
AIBlockOutputStatus::Cancelled {
partial_output: output.as_ref().map(Shared::get_owned),
reason: *reason,
}
}
FinishedAIAgentOutput::Error { error, output } => AIBlockOutputStatus::Failed {
partial_output: output.as_ref().map(Shared::get_owned),
error: error.clone(),
},
},
}
}
fn time_since_request_start(&self, app: &AppContext) -> Option<TimeDelta> {
let exchange = self.exchange(app);
match exchange {
Ok(exchange) => Some(Local::now().signed_duration_since(exchange.start_time)),
Err(err) => {
log::error!("Failed to get time since request start. {err}");
None
}
}
}
fn base_model<'a>(&'a self, app: &'a AppContext) -> Option<&'a LLMId> {
let exchange = self.exchange(app);
match exchange {
Ok(exchange) => Some(&exchange.model_id),
Err(err) => {
log::error!("Failed to get base model. {err}");
None
}
}
}
fn inputs_to_render<'a>(&'a self, app: &'a AppContext) -> &'a [AIAgentInput] {
self.exchange(app)
.map(|ex| ex.input.as_slice())
.unwrap_or(&[])
}
fn conversation_id(&self, _app: &AppContext) -> Option<AIConversationId> {
Some(self.conversation_id)
}
fn exchange_id(&self, _app: &AppContext) -> Option<AIAgentExchangeId> {
Some(self.exchange_id)
}
fn response_initiator(&self, app: &AppContext) -> Option<ParticipantId> {
self.exchange(app)
.ok()
.and_then(|ex| ex.response_initiator.clone())
}
fn server_output_id(&self, app: &AppContext) -> Option<ServerOutputId> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(&self.conversation_id)?;
let exchange = conversation.exchange_with_id(self.exchange_id)?;
exchange.output_status.server_output_id()
}
fn model_id(&self, app: &AppContext) -> Option<LLMId> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(&self.conversation_id)?;
let exchange = conversation.exchange_with_id(self.exchange_id)?;
exchange.output_status.model_id()
}
fn on_updated_output(
&self,
mut callback: OutputStatusUpdateCallback<V>,
ctx: &mut ViewContext<V>,
) {
let exchange_id = self.exchange_id;
let conversation_id = self.conversation_id;
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
let BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id: event_exchange_id,
conversation_id: event_conversation_id,
..
} = event
else {
return;
};
if *event_exchange_id == exchange_id {
callback(me, ctx);
} else if *event_conversation_id == conversation_id {
ctx.notify();
}
});
}
fn request_type(&self, app: &AppContext) -> AIRequestType {
if self
.exchange(app)
.map(|exchange| exchange.has_passive_code_diff())
.unwrap_or(false)
{
AIRequestType::Passive(PassiveRequestType::CodeDiff)
} else if let Some(trigger) = self
.exchange(app)
.ok()
.and_then(|exchange| exchange.passive_suggestion_trigger())
{
AIRequestType::from_passive_trigger(trigger)
} else {
AIRequestType::Active
}
}
}
@@ -0,0 +1,430 @@
use warpui::{
elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, EventHandler,
Flex, Hoverable, MouseInBehavior, MouseStateHandle, ParentElement, SavePosition,
ScrollTarget, ScrollToPositionMode,
},
keymap::FixedBinding,
ui_components::{button::Button, components::UiComponent},
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
};
use super::numbered_button::{
build_inline_input_content, build_numbered_button, build_text_button_content,
};
const MARGIN_BETWEEN_BUTTONS: f32 = 4.;
const NUMBER_SELECT_ENABLED: &str = "NumberSelectEnabled";
const KEYBOARD_NAVIGATION_ENABLED: &str = "KeyboardNavigationEnabled";
const ENTER_ACTIVATION_ENABLED: &str = "EnterActivationEnabled";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
for i in 1..=9u8 {
app.register_fixed_bindings([FixedBinding::new(
format!("{i}"),
NumberShortcutButtonsAction::NumberSelect(i as usize - 1),
id!(NumberShortcutButtons::ui_name()) & id!(NUMBER_SELECT_ENABLED),
)]);
}
app.register_fixed_bindings([
FixedBinding::new(
"up",
NumberShortcutButtonsAction::ArrowUp,
id!(NumberShortcutButtons::ui_name()) & id!(KEYBOARD_NAVIGATION_ENABLED),
),
FixedBinding::new(
"down",
NumberShortcutButtonsAction::ArrowDown,
id!(NumberShortcutButtons::ui_name()) & id!(KEYBOARD_NAVIGATION_ENABLED),
),
FixedBinding::new(
"tab",
NumberShortcutButtonsAction::ArrowDown,
id!(NumberShortcutButtons::ui_name()) & id!(KEYBOARD_NAVIGATION_ENABLED),
),
FixedBinding::new(
"shift-tab",
NumberShortcutButtonsAction::ArrowUp,
id!(NumberShortcutButtons::ui_name()) & id!(KEYBOARD_NAVIGATION_ENABLED),
),
FixedBinding::new(
"enter",
NumberShortcutButtonsAction::ActivateSelected,
id!(NumberShortcutButtons::ui_name()) & id!(ENTER_ACTIVATION_ENABLED),
),
FixedBinding::new(
"numpadenter",
NumberShortcutButtonsAction::ActivateSelected,
id!(NumberShortcutButtons::ui_name()) & id!(ENTER_ACTIVATION_ENABLED),
),
]);
}
#[derive(Debug, Clone)]
pub enum NumberShortcutButtonsAction {
RowHovered(usize),
ListUnhovered,
ButtonClicked(usize),
NumberSelect(usize),
ArrowUp,
ArrowDown,
ActivateSelected,
}
pub enum NumberShortcutButtonsEvent {}
pub type ButtonBuilder = Box<dyn Fn(bool, &warpui::AppContext) -> Button>;
pub type OnButtonClickFn = Box<dyn Fn(&mut ViewContext<NumberShortcutButtons>)>;
#[derive(Clone, Default)]
pub struct NumberShortcutButtonsConfig {
enable_keyboard_navigation: bool,
enable_enter_to_activate: bool,
scroll_state: Option<ClippedScrollStateHandle>,
}
impl NumberShortcutButtonsConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_keyboard_navigation(mut self) -> Self {
self.enable_keyboard_navigation = true;
self
}
pub fn with_enter_to_activate(mut self, enabled: bool) -> Self {
self.enable_enter_to_activate = enabled;
self
}
pub fn with_scroll_state(mut self, scroll_state: ClippedScrollStateHandle) -> Self {
self.scroll_state = Some(scroll_state);
self
}
fn keyboard_navigation_enabled(&self) -> bool {
self.enable_keyboard_navigation
}
fn enter_activation_enabled(&self) -> bool {
self.enable_enter_to_activate && self.enable_keyboard_navigation
}
}
pub struct NumberShortcutButtonBuilder {
button_builder: ButtonBuilder,
on_click: OnButtonClickFn,
}
impl NumberShortcutButtonBuilder {
pub fn new(
button_builder: impl Fn(bool, &warpui::AppContext) -> Button + 'static,
on_click: impl Fn(&mut ViewContext<NumberShortcutButtons>) + 'static,
) -> Self {
Self {
button_builder: Box::new(button_builder),
on_click: Box::new(on_click),
}
}
}
pub fn numbered_shortcut_button<A: warpui::Action + Clone + 'static>(
number: usize,
text_label: String,
is_checked: bool,
recommended: bool,
use_markdown: bool,
mouse_state: MouseStateHandle,
action: A,
) -> NumberShortcutButtonBuilder {
NumberShortcutButtonBuilder::new(
move |is_selected, app| {
build_numbered_button(
number,
build_text_button_content(&text_label, recommended, use_markdown, app),
is_checked,
is_selected,
&mouse_state,
app,
)
},
move |ctx: &mut ViewContext<NumberShortcutButtons>| {
ctx.dispatch_typed_action_deferred(action.clone());
},
)
}
pub fn inline_input_shortcut_button(
number: usize,
input_view: ViewHandle<super::compact_agent_input::CompactAgentInput>,
mouse_state: MouseStateHandle,
) -> NumberShortcutButtonBuilder {
NumberShortcutButtonBuilder::new(
move |is_selected, app| {
build_numbered_button(
number,
build_inline_input_content(&input_view),
false,
is_selected,
&mouse_state,
app,
)
},
move |_ctx: &mut ViewContext<NumberShortcutButtons>| {
// Click on the input row is a no-op; the text input handles its own interactions.
},
)
}
pub struct NumberShortcutButtons {
button_builders: Vec<NumberShortcutButtonBuilder>,
selected_button_index: Option<usize>,
config: NumberShortcutButtonsConfig,
self_handle: WeakViewHandle<Self>,
mouse_state: MouseStateHandle,
}
impl NumberShortcutButtons {
pub fn new_with_config(
button_builders: Vec<NumberShortcutButtonBuilder>,
selected_button_index: Option<usize>,
config: NumberShortcutButtonsConfig,
ctx: &mut ViewContext<Self>,
) -> Self {
let selected_button_index = selected_button_index
.filter(|_| !button_builders.is_empty())
.map(|index| index.min(button_builders.len() - 1));
Self {
button_builders,
selected_button_index,
config,
self_handle: ctx.handle(),
mouse_state: MouseStateHandle::default(),
}
}
pub fn selected_button_index(&self) -> Option<usize> {
self.selected_button_index
}
fn selected_button_position_id(&self) -> Option<String> {
self.selected_button_index
.map(|selected_button_index| self.button_position_id(selected_button_index))
}
fn button_position_id(&self, index: usize) -> String {
format!(
"number_shortcut_buttons_{}_{}",
self.self_handle.id(),
index
)
}
fn has_descendent_focus(&self, app: &AppContext) -> bool {
self.self_handle.window_id(app).is_some_and(|window_id| {
!app.check_view_focused(window_id, &self.self_handle.id())
&& app.check_view_or_child_focused(window_id, &self.self_handle.id())
})
}
fn keyboard_shortcuts_enabled(&self, app: &AppContext) -> bool {
!self.has_descendent_focus(app)
}
fn select_prev(&mut self) {
if self.button_builders.is_empty() {
return;
}
let button_count = self.button_builders.len();
self.selected_button_index = Some(match self.selected_button_index {
// Arrow-up wraps from the first option back to the last option.
Some(selected_button_index) => {
(selected_button_index + button_count - 1) % button_count
}
None => 0,
});
}
fn select_next(&mut self) {
if self.button_builders.is_empty() {
return;
}
let button_count = self.button_builders.len();
self.selected_button_index = Some(match self.selected_button_index {
// Arrow-down wraps from the last option back to the first option.
Some(selected_button_index) => (selected_button_index + 1) % button_count,
None => 0,
});
}
fn scroll_selected_button_into_view(&self) {
let Some(scroll_state) = self.config.scroll_state.as_ref() else {
return;
};
let Some(position_id) = self.selected_button_position_id() else {
return;
};
scroll_state.scroll_to_position(ScrollTarget {
position_id,
mode: ScrollToPositionMode::FullyIntoView,
});
}
fn set_selected_button_index(&mut self, index: usize) -> bool {
if index >= self.button_builders.len() || self.selected_button_index == Some(index) {
return false;
}
self.selected_button_index = Some(index);
self.scroll_selected_button_into_view();
true
}
fn clear_selected_button_index(&mut self) -> bool {
let did_update = self.selected_button_index.is_some();
self.selected_button_index = None;
did_update
}
fn activate_button_at(&self, index: usize, ctx: &mut ViewContext<Self>) {
let Some(builder) = self.button_builders.get(index) else {
return;
};
(builder.on_click)(ctx);
}
}
impl View for NumberShortcutButtons {
fn ui_name() -> &'static str {
"NumberShortcutButtons"
}
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
Hoverable::new(self.mouse_state.clone(), |_| {
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, button_builder) in self.button_builders.iter().enumerate() {
let is_selected = self.selected_button_index == Some(index);
let button = (button_builder.button_builder)(is_selected, app);
let mut hoverable = button.build();
hoverable = hoverable.on_click(move |ctx, _app, _pos| {
ctx.dispatch_typed_action(NumberShortcutButtonsAction::ButtonClicked(index));
});
let hoverable = EventHandler::new(hoverable.finish())
.with_always_handle()
.on_mouse_in(
move |ctx, _, _| {
ctx.dispatch_typed_action(NumberShortcutButtonsAction::RowHovered(
index,
));
DispatchEventResult::PropagateToParent
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
}),
)
.finish();
let margin_bottom = if index == self.button_builders.len() - 1 {
0.
} else {
MARGIN_BETWEEN_BUTTONS
};
content.add_child(
SavePosition::new(
Container::new(hoverable)
.with_margin_bottom(margin_bottom)
.finish(),
&self.button_position_id(index),
)
.finish(),
);
}
content.finish()
})
.on_hover(|is_hovered, ctx, _app, _position| {
if !is_hovered {
ctx.dispatch_typed_action(NumberShortcutButtonsAction::ListUnhovered);
}
})
.finish()
}
fn keymap_context(&self, app: &AppContext) -> warpui::keymap::Context {
let mut context = Self::default_keymap_context();
if !self.button_builders.is_empty() && self.keyboard_shortcuts_enabled(app) {
context.set.insert(NUMBER_SELECT_ENABLED);
if self.config.keyboard_navigation_enabled() {
context.set.insert(KEYBOARD_NAVIGATION_ENABLED);
}
if self.config.enter_activation_enabled() {
context.set.insert(ENTER_ACTIVATION_ENABLED);
}
}
context
}
}
impl TypedActionView for NumberShortcutButtons {
type Action = NumberShortcutButtonsAction;
fn handle_action(&mut self, action: &NumberShortcutButtonsAction, ctx: &mut ViewContext<Self>) {
let should_update = match action {
NumberShortcutButtonsAction::RowHovered(index) => {
self.set_selected_button_index(*index)
}
NumberShortcutButtonsAction::ListUnhovered => self.clear_selected_button_index(),
NumberShortcutButtonsAction::ButtonClicked(index) => {
let did_update = self.set_selected_button_index(*index);
self.activate_button_at(*index, ctx);
did_update
}
NumberShortcutButtonsAction::NumberSelect(index) => {
if *index >= self.button_builders.len() {
false
} else {
let did_update = self.set_selected_button_index(*index);
self.activate_button_at(*index, ctx);
self.clear_selected_button_index() || did_update
}
}
NumberShortcutButtonsAction::ArrowUp => {
let previous_index = self.selected_button_index;
self.select_prev();
self.scroll_selected_button_into_view();
previous_index != self.selected_button_index
}
NumberShortcutButtonsAction::ArrowDown => {
let previous_index = self.selected_button_index;
self.select_next();
self.scroll_selected_button_into_view();
previous_index != self.selected_button_index
}
NumberShortcutButtonsAction::ActivateSelected => {
if let Some(selected_button_index) = self.selected_button_index {
self.activate_button_at(selected_button_index, ctx);
}
false
}
};
if should_update {
ctx.notify();
}
}
}
impl Entity for NumberShortcutButtons {
type Event = NumberShortcutButtonsEvent;
}
#[cfg(test)]
#[path = "number_shortcut_buttons_tests.rs"]
mod tests;
@@ -0,0 +1,437 @@
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::{cell::RefCell, rc::Rc};
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
new_scrollable::SingleAxisConfig, ChildView, Clipped, ClippedScrollStateHandle,
ConstrainedBox, Fill,
},
platform::WindowStyle,
App, Entity, Event, Presenter, TypedActionView, View, ViewContext, ViewHandle, WindowId,
WindowInvalidation,
};
use super::*;
fn initialize_test_app(app: &mut App) {
app.add_singleton_model(|_| Appearance::mock());
}
struct TestView {
buttons: ViewHandle<NumberShortcutButtons>,
scroll_state: ClippedScrollStateHandle,
selected_actions: Rc<RefCell<Vec<usize>>>,
}
impl TestView {
fn new(ctx: &mut ViewContext<Self>) -> Self {
let scroll_state = ClippedScrollStateHandle::new();
let selected_actions = Rc::new(RefCell::new(Vec::new()));
let button_builders = (0..10)
.map(|index| {
numbered_shortcut_button(
index + 1,
format!("Option {}", index + 1),
false,
false,
false,
MouseStateHandle::default(),
TestAction::Selected(index),
)
})
.collect();
let buttons = ctx.add_typed_action_view({
let scroll_state = scroll_state.clone();
move |ctx| {
NumberShortcutButtons::new_with_config(
button_builders,
None,
NumberShortcutButtonsConfig::new()
.with_keyboard_navigation()
.with_scroll_state(scroll_state),
ctx,
)
}
});
Self {
buttons,
scroll_state,
selected_actions,
}
}
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn ui_name() -> &'static str {
"NumberShortcutButtonsTestView"
}
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
let scrollable = warpui::elements::NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.scroll_state.clone(),
child: ChildView::new(&self.buttons).finish(),
},
Fill::None,
Fill::None,
Fill::None,
)
.finish();
ConstrainedBox::new(Clipped::new(scrollable).finish())
.with_height(96.)
.finish()
}
}
impl TypedActionView for TestView {
type Action = TestAction;
fn handle_action(&mut self, action: &Self::Action, _ctx: &mut ViewContext<Self>) {
match action {
TestAction::Selected(index) => {
self.selected_actions.borrow_mut().push(*index);
}
}
}
}
#[derive(Clone, Debug)]
enum TestAction {
Selected(usize),
}
fn button_position_id(buttons: &ViewHandle<NumberShortcutButtons>, index: usize) -> String {
format!("number_shortcut_buttons_{}_{}", buttons.id(), index)
}
fn mouse_moved_event(position: Vector2F, is_synthetic: bool) -> Event {
Event::MouseMoved {
position,
cmd: false,
shift: false,
is_synthetic,
}
}
fn visible_unselected_button_center(
app: &App,
window_id: WindowId,
buttons: &ViewHandle<NumberShortcutButtons>,
selected_index: Option<usize>,
) -> (usize, Vector2F) {
(0..10)
.filter(|index| Some(*index) != selected_index)
.find_map(|index| {
let position_id = button_position_id(buttons, index);
let position =
app.read(|ctx| ctx.element_position_by_id_at_last_frame(window_id, &position_id))?;
(position.max_y() > 0. && position.min_y() < 96.).then_some((index, position.center()))
})
.expect("expected a visible unselected option")
}
#[test]
fn first_arrow_down_selects_the_first_button() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), None);
});
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowDown, ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(0));
});
});
}
#[test]
fn first_arrow_up_selects_the_first_button() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowUp, ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(0));
});
});
}
#[test]
fn number_shortcut_activates_without_leaving_the_option_selected() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowDown, ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(0));
});
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::NumberSelect(4), ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), None);
});
view.read(&app, |view, _| {
assert_eq!(*view.selected_actions.borrow(), vec![4]);
});
});
}
#[test]
fn number_shortcut_scrolls_activated_button_into_view() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
let scroll_state = view.read(&app, |view, _| view.scroll_state.clone());
let root_view_id = app
.root_view_id(window_id)
.expect("window should have a root view");
let mut presenter = Presenter::new(window_id);
let invalidation = WindowInvalidation {
updated: [root_view_id, buttons.id()].into_iter().collect(),
..Default::default()
};
app.update(|ctx| {
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(vec2f(320., 240.), 1., None, ctx);
buttons.update(ctx, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::NumberSelect(6), ctx);
});
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(320., 240.), 1., None, ctx);
assert!(
scroll_state.scroll_start().as_f32() > 0.,
"expected the activated option to be scrolled into view",
);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), None);
});
view.read(&app, |view, _| {
assert_eq!(*view.selected_actions.borrow(), vec![6]);
});
});
}
#[test]
fn arrow_navigation_scrolls_selected_button_into_view() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
let scroll_state = view.read(&app, |view, _| view.scroll_state.clone());
let root_view_id = app
.root_view_id(window_id)
.expect("window should have a root view");
let mut presenter = Presenter::new(window_id);
let invalidation = WindowInvalidation {
updated: [root_view_id, buttons.id()].into_iter().collect(),
..Default::default()
};
app.update(|ctx| {
presenter.invalidate(invalidation.clone(), ctx);
presenter.build_scene(vec2f(320., 240.), 1., None, ctx);
buttons.update(ctx, |buttons, ctx| {
for _ in 0..6 {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowDown, ctx);
}
});
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(320., 240.), 1., None, ctx);
assert!(
scroll_state.scroll_start().as_f32() > 0.,
"expected the selected option to be scrolled into view",
);
});
});
}
#[test]
fn synthetic_mouse_move_does_not_override_keyboard_selection() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
let root_view_id = app
.root_view_id(window_id)
.expect("window should have a root view");
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let invalidation = WindowInvalidation {
updated: [root_view_id, buttons.id()].into_iter().collect(),
..Default::default()
};
app.update({
let buttons = buttons.clone();
let presenter = presenter.clone();
let invalidation = invalidation.clone();
move |ctx| {
presenter.borrow_mut().invalidate(invalidation.clone(), ctx);
presenter
.borrow_mut()
.build_scene(vec2f(320., 240.), 1., None, ctx);
buttons.update(ctx, |buttons, ctx| {
for _ in 0..6 {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowDown, ctx);
}
});
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(320., 240.), 1., None, ctx);
}
});
let selected_index = buttons.read(&app, |buttons, _| buttons.selected_button_index());
let (_hovered_index, hovered_position) =
visible_unselected_button_center(&app, window_id, &buttons, selected_index);
app.update({
let presenter = presenter.clone();
move |ctx| {
ctx.simulate_window_event(
mouse_moved_event(hovered_position, true),
window_id,
presenter,
);
}
});
app.update({
let presenter = presenter.clone();
let invalidation = invalidation.clone();
move |ctx| {
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(320., 240.), 1., None, ctx);
}
});
buttons.read(&app, |buttons, _| {
assert_eq!(selected_index, buttons.selected_button_index());
});
});
}
#[test]
fn hover_action_takes_over_after_keyboard_navigation() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
let root_view_id = app
.root_view_id(window_id)
.expect("window should have a root view");
let presenter = Rc::new(RefCell::new(Presenter::new(window_id)));
let invalidation = WindowInvalidation {
updated: [root_view_id, buttons.id()].into_iter().collect(),
..Default::default()
};
app.update({
let buttons = buttons.clone();
let presenter = presenter.clone();
let invalidation = invalidation.clone();
move |ctx| {
presenter.borrow_mut().invalidate(invalidation.clone(), ctx);
presenter
.borrow_mut()
.build_scene(vec2f(320., 240.), 1., None, ctx);
buttons.update(ctx, |buttons, ctx| {
for _ in 0..6 {
buttons.handle_action(&NumberShortcutButtonsAction::ArrowDown, ctx);
}
});
presenter.borrow_mut().invalidate(invalidation, ctx);
presenter
.borrow_mut()
.build_scene(vec2f(320., 240.), 1., None, ctx);
}
});
let selected_index = buttons.read(&app, |buttons, _| buttons.selected_button_index());
let (hovered_index, _hovered_position) =
visible_unselected_button_center(&app, window_id, &buttons, selected_index);
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::RowHovered(hovered_index), ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(hovered_index));
});
});
}
#[test]
fn hovered_out_clears_selection() {
App::test((), |mut app| async move {
initialize_test_app(&mut app);
let (_window_id, view) = app.add_window(WindowStyle::NotStealFocus, TestView::new);
let buttons = view.read(&app, |view, _| view.buttons.clone());
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::RowHovered(3), ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(3));
});
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::ListUnhovered, ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), None);
});
buttons.update(&mut app, |buttons, ctx| {
buttons.handle_action(&NumberShortcutButtonsAction::RowHovered(1), ctx);
});
buttons.read(&app, |buttons, _| {
assert_eq!(buttons.selected_button_index(), Some(1));
});
});
}
@@ -0,0 +1,180 @@
use crate::context_chips::spacing;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warpui::{
elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
DEFAULT_UI_LINE_HEIGHT_RATIO,
},
ui_components::{
button::{Button, ButtonVariant},
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, SingletonEntity, ViewHandle,
};
use super::compact_agent_input::CompactAgentInput;
fn render_number_badge(
number: usize,
is_checked: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
let (badge_background, badge_border_color) = if is_checked {
(theme.accent(), theme.accent().into_solid())
} else {
(theme.surface_1(), internal_colors::neutral_4(theme))
};
Container::new(
Text::new(
format!("{number}"),
appearance.monospace_font_family(),
font_size.max(4.) - 1.,
)
.with_color(theme.foreground().into())
.finish(),
)
.with_horizontal_padding(5.)
.with_vertical_padding(1.)
.with_border(Border::all(1.).with_border_color(badge_border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.with_background(badge_background)
.finish()
}
pub(super) fn render_recommended_badge(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
Container::new(
Text::new(
"Recommended".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.with_color(internal_colors::neutral_6(theme))
.finish(),
)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_vertical_padding(spacing::UDI_CHIP_VERTICAL_PADDING)
.with_horizontal_padding(spacing::UDI_CHIP_HORIZONTAL_PADDING)
.finish()
}
fn base_numbered_button(mouse_state: &MouseStateHandle, app: &AppContext) -> Button {
let appearance = Appearance::as_ref(app);
let font_size = appearance.monospace_font_size();
appearance
.ui_builder()
.button(ButtonVariant::Secondary, mouse_state.clone())
.with_style(UiComponentStyles {
font_size: Some(font_size),
..UiComponentStyles::default()
})
.with_hovered_styles(UiComponentStyles {
font_size: Some(font_size),
..UiComponentStyles::default()
})
}
pub(super) fn build_numbered_button(
number: usize,
content: Box<dyn Element>,
is_checked: bool,
is_highlighted: bool,
mouse_state: &MouseStateHandle,
app: &AppContext,
) -> Button {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut button = base_numbered_button(mouse_state, app);
if is_highlighted {
button = button.with_style(UiComponentStyles {
border_color: Some(theme.accent().into()),
border_width: Some(1.0),
background: Some(internal_colors::fg_overlay_2(theme).into()),
..UiComponentStyles::default()
});
} else if is_checked {
button = button.with_style(UiComponentStyles {
border_color: Some(theme.accent().into()),
border_width: Some(1.0),
background: Some(internal_colors::accent_overlay_1(theme).into()),
..UiComponentStyles::default()
});
}
let badge = render_number_badge(number, is_checked, appearance);
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(badge)
.with_child(
Shrinkable::new(1., Container::new(content).with_margin_left(8.).finish()).finish(),
)
.finish();
button.with_custom_label(row)
}
pub(super) fn build_text_button_content(
text_label: &str,
recommended: bool,
use_markdown: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = appearance.monospace_font_size();
let text_color = theme.foreground().into();
let label_element = if let (true, Ok(formatted_text)) =
(use_markdown, markdown_parser::parse_markdown(text_label))
{
FormattedTextElement::new(
formatted_text,
font_size,
appearance.ui_font_family(),
appearance.monospace_font_family(),
text_color,
Default::default(),
)
.with_line_height_ratio(DEFAULT_UI_LINE_HEIGHT_RATIO)
.disable_mouse_interaction()
.finish()
} else {
Text::new(
text_label.to_string(),
appearance.ui_font_family(),
font_size,
)
.soft_wrap(true)
.with_color(text_color)
.finish()
};
if !recommended {
return label_element;
}
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Expanded::new(1., label_element).finish())
.with_child(
Container::new(render_recommended_badge(appearance))
.with_margin_left(12.)
.finish(),
)
.finish()
}
pub(super) fn build_inline_input_content(
input_view: &ViewHandle<CompactAgentInput>,
) -> Box<dyn Element> {
warpui::presenter::ChildView::new(input_view).finish()
}
@@ -0,0 +1,175 @@
use warpui::{
elements::{ChildView, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
fonts::{Properties, Style, Weight},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use crate::{
ai::blocklist::block::view_impl::{
common::render_user_avatar, CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
},
appearance::Appearance,
ui_components::{blended_colors, icons::Icon},
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
};
/// Renders a pending user query block with dimmed text and a "Queued" badge.
/// Displayed when a follow-up prompt is queued via `/fork-and-compact <prompt>`,
/// `/compact-and <prompt>`, `/queue <prompt>`, or for the initial prompt of a
/// non-oz Cloud Mode run waiting for its harness CLI to start.
pub struct PendingUserQueryBlock {
prompt: String,
user_display_name: String,
profile_image_path: Option<String>,
close_button: Option<ViewHandle<ActionButton>>,
send_now_button: Option<ViewHandle<ActionButton>>,
}
impl PendingUserQueryBlock {
pub fn new(
prompt: String,
user_display_name: String,
profile_image_path: Option<String>,
show_close_button: bool,
show_send_now_button: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
let close_button = show_close_button.then(|| {
ctx.add_typed_action_view(|_| {
ActionButton::new("Remove queued prompt", NakedTheme)
.with_icon(Icon::X)
.with_size(ButtonSize::XSmall)
.on_click(|ctx| {
ctx.dispatch_typed_action(PendingUserQueryBlockAction::Dismiss);
})
})
});
let send_now_button = show_send_now_button.then(|| {
ctx.add_typed_action_view(|_| {
ActionButton::new("Send now", NakedTheme)
.with_icon(Icon::Play)
.with_size(ButtonSize::XSmall)
.on_click(|ctx| {
ctx.dispatch_typed_action(PendingUserQueryBlockAction::SendNow);
})
})
});
Self {
prompt,
user_display_name,
profile_image_path,
close_button,
send_now_button,
}
}
}
#[derive(Clone, Debug)]
pub enum PendingUserQueryBlockAction {
Dismiss,
SendNow,
}
pub enum PendingUserQueryBlockEvent {
Dismissed,
SendNow,
}
impl Entity for PendingUserQueryBlock {
type Event = PendingUserQueryBlockEvent;
}
impl TypedActionView for PendingUserQueryBlock {
type Action = PendingUserQueryBlockAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
PendingUserQueryBlockAction::Dismiss => {
ctx.emit(PendingUserQueryBlockEvent::Dismissed);
}
PendingUserQueryBlockAction::SendNow => {
ctx.emit(PendingUserQueryBlockEvent::SendNow);
}
}
}
}
impl View for PendingUserQueryBlock {
fn ui_name() -> &'static str {
"PendingUserQueryBlock"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let dimmed_color = blended_colors::text_sub(theme, theme.surface_1());
let avatar = Container::new(render_user_avatar(
&self.user_display_name,
self.profile_image_path.as_ref(),
None,
app,
))
.with_margin_right(16.)
.finish();
let properties = Properties {
style: Style::Normal,
weight: Weight::Bold,
};
let prompt_text = Text::new(
self.prompt.clone(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_style(properties)
.with_color(dimmed_color)
.with_selectable(false)
.finish();
let queued_badge = Text::new(
"Queued",
appearance.ui_font_family(),
appearance.monospace_font_size().max(4.) - 2.,
)
.with_style(Properties {
style: Style::Italic,
weight: Weight::Normal,
})
.with_color(dimmed_color)
.with_selectable(false)
.finish();
let text_column = Flex::column()
.with_child(prompt_text)
.with_child(Container::new(queued_badge).with_margin_top(4.).finish())
.finish();
let mut buttons_column = Flex::column().with_spacing(2.);
if let Some(close_button) = &self.close_button {
buttons_column.add_child(ChildView::new(close_button).finish());
}
if let Some(send_now_button) = &self.send_now_button {
buttons_column.add_child(ChildView::new(send_now_button).finish());
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(avatar)
.with_child(Expanded::new(1., text_column).finish());
if self.close_button.is_some() || self.send_now_button.is_some() {
let buttons = Container::new(buttons_column.finish())
.with_margin_left(8.)
.finish();
row.add_child(buttons);
}
let row = row.finish();
Container::new(row)
.with_horizontal_padding(CONTENT_HORIZONTAL_PADDING)
.with_padding_top(CONTENT_ITEM_VERTICAL_MARGIN)
.with_padding_bottom(CONTENT_ITEM_VERTICAL_MARGIN)
.finish()
}
}
@@ -0,0 +1,708 @@
use std::collections::HashMap;
use itertools::Itertools;
use similar::DiffableStr;
use warpui::elements::{MouseStateHandle, PartialClickableElement, SecretRange};
use warpui::platform::Cursor;
use crate::ai::agent::{AIAgentOutput, AIAgentTextSection, AgentOutputText};
use crate::terminal::model::secrets::{SecretLevel, REGEX_LEVEL_METADATA, SECRETS_REGEX};
use super::{AIBlockAction, TextLocation};
pub const SECRET_REDACTION_REPLACEMENT_CHARACTER: &str = "*";
/// Returns the ranges of detected secrets in the given text.
pub(crate) fn find_secrets_in_text(text: &str) -> Vec<SecretRange> {
find_secrets_in_text_with_levels(text)
.into_iter()
.map(|(range, _level)| range)
.collect()
}
/// Returns the ranges of detected secrets in the given text along with their SecretLevel.
pub(crate) fn find_secrets_in_text_with_levels(text: &str) -> Vec<(SecretRange, SecretLevel)> {
// Combine all regex patterns into a single regex pattern with non-capturing groups, for efficiency.
// Note that we purposely use regex::Regex instead of RegexDFAs since we are working a Text (containing
// a normal String) rather than the Grid (where text is in Cells with 1 character each).
let regex = SECRETS_REGEX.read();
let metadata = REGEX_LEVEL_METADATA.read();
let mut secret_ranges = vec![];
let mut byte_to_char_index = vec![0; text.len() + 1]; // Map byte index to char index
// Track the current character index while iterating through the string.
let mut char_index = 0;
for (byte_index, _) in text.char_indices() {
byte_to_char_index[byte_index] = char_index;
char_index += 1;
}
byte_to_char_index[text.len()] = char_index; // Map the last byte to the last character index
// Iterate over the text once, finding all matches against secret regex. Map the byte ranges
// to character ranges and store them.
for mat in regex.find_iter(text) {
let start_byte = mat.start();
let end_byte = mat.end();
let start_char = byte_to_char_index[start_byte];
let end_char = byte_to_char_index[end_byte];
// Determine which pattern matched by getting the pattern ID and map via counts
let pattern_id = mat.pattern().as_usize();
let total_patterns = metadata.enterprise_count + metadata.user_count;
if pattern_id >= total_patterns {
log::error!("Secret level not found for pattern ID {pattern_id}");
continue;
}
let secret_level = if pattern_id < metadata.enterprise_count {
SecretLevel::Enterprise
} else {
SecretLevel::User
};
secret_ranges.push((
SecretRange {
char_range: start_char..end_char,
byte_range: start_byte..end_byte,
},
secret_level,
));
}
// Merge overlapping ranges, preserving the highest priority SecretLevel
merge_sorted_ranges_with_levels(secret_ranges)
}
/// Merges overlapping ranges while preserving the highest priority SecretLevel
fn merge_sorted_ranges_with_levels(
ranges: Vec<(SecretRange, SecretLevel)>,
) -> Vec<(SecretRange, SecretLevel)> {
if ranges.is_empty() {
return ranges;
}
let mut merged_ranges = vec![];
let mut current_range = ranges[0].0.clone();
let mut current_level = ranges[0].1;
for (range, level) in ranges.into_iter().skip(1) {
// We can merge based on character ranges since non-overlapping character ranges result in non-overlapping byte ranges.
if range.char_range.start <= current_range.char_range.end {
// Extend the current range to include the overlapping range.
current_range.extend_range_end(&range);
// Keep the highest priority level
if level.priority() > current_level.priority() {
current_level = level;
}
} else {
// No overlap, push the current range and move to the next.
merged_ranges.push((current_range, current_level));
current_range = range;
current_level = level;
}
}
// Add the last range.
merged_ranges.push((current_range, current_level));
merged_ranges
}
#[derive(Debug, Eq, PartialEq)]
pub struct SecretLocation {
pub secret_range: SecretRange,
pub location: TextLocation,
}
#[derive(Clone, Debug)]
pub struct Secret {
pub secret: String,
pub is_obfuscated: bool,
pub mouse_state: MouseStateHandle,
pub secret_level: SecretLevel,
}
#[derive(Default, Debug)]
pub struct DetectedSecretsInTextLocation {
pub detected_secrets: HashMap<SecretRange, Secret>,
}
#[derive(Default, Debug)]
pub struct SecretRedactionState {
/// Last byte index of text we've scanned for secret redaction (avoid re-scanning in the context
/// of streaming output text). Note we want to redact secrets WHILE streaming to avoid any full secrets
/// ever being shown on the screen! This applies to the last output step we've currently received.
last_scanned_secret_redaction_byte_index: usize,
/// Buffer to hold the last word that's been scanned, since we may need to combine it with the next
/// tokens we're receiving (since a secret could be split apart across streaming chunks). Words are defined
/// to be separated by whitespace.
last_word_to_rescan_for_redaction: String,
/// Buffer to hold the current line. This is important, as markdown parsing could potentially mutate the entire line.
current_line_for_redaction: String,
/// Keeps track of the last step index we've run secret redaction scanning on, while streaming output.
last_text_section_index_scanned_for_redaction: usize,
last_line_index_scanned_for_redaction: usize,
detected_secrets: HashMap<TextLocation, DetectedSecretsInTextLocation>,
currently_hovered_secret_location: Option<SecretLocation>,
// This is separate from currently_hovered_secret_location because after clicking
// on a secret to open the tooltip, this secret should remain highlighted and the tooltip in place
// even if we hover over other secrets.
secret_location_open_tooltip: Option<SecretLocation>,
}
impl SecretRedactionState {
pub fn open_tooltip_location(&self) -> Option<&SecretLocation> {
self.secret_location_open_tooltip.as_ref()
}
pub fn hovered_location(&self) -> Option<&SecretLocation> {
self.currently_hovered_secret_location.as_ref()
}
pub fn has_open_tooltip(&self, location: &TextLocation, range: &SecretRange) -> bool {
self.open_tooltip_location()
.is_some_and(|tooltip_location| {
tooltip_location.location == *location && tooltip_location.secret_range == *range
})
}
pub fn is_hovered(&self, location: &TextLocation, range: &SecretRange) -> bool {
self.hovered_location().is_some_and(|tooltip_location| {
tooltip_location.location == *location && tooltip_location.secret_range == *range
})
}
pub fn reset(&mut self) {
self.last_text_section_index_scanned_for_redaction = 0;
self.last_scanned_secret_redaction_byte_index = 0;
self.last_word_to_rescan_for_redaction = Default::default();
}
/// Clears secret redaction state for `user_query` locations.
///
/// A bit of an edge case, but this is required when the user accepts a 'suggest new conversation' action
/// for an existing query. This query becomes the first query in a new conversation, and we prefix '/agent'
/// to all initial user queries, so detected secret ranges (if any) become stale.
pub fn clear_user_query_locations(&mut self) {
self.detected_secrets
.retain(|location, _| !matches!(location, TextLocation::Query { .. }));
if self
.currently_hovered_secret_location
.as_ref()
.is_some_and(|location| matches!(location.location, TextLocation::Query { .. }))
{
self.currently_hovered_secret_location = None;
}
if self
.secret_location_open_tooltip
.as_ref()
.is_some_and(|location| matches!(location.location, TextLocation::Query { .. }))
{
self.secret_location_open_tooltip = None;
}
}
pub fn show_secret_tooltip(
&mut self,
location: &TextLocation,
secret_range: &SecretRange,
) -> Option<&mut Secret> {
self.secret_location_open_tooltip = Some(SecretLocation {
secret_range: secret_range.clone(),
location: *location,
});
self.get_secret_mut(location, secret_range)
}
pub fn dismiss_tooltip(&mut self) {
self.secret_location_open_tooltip = None;
}
pub fn set_obfuscated(
&mut self,
location: &TextLocation,
secret_range: &SecretRange,
is_obfuscated: bool,
) {
if let Some(hoverable_secret_mut) = self.get_secret_mut(location, secret_range) {
hoverable_secret_mut.is_obfuscated = is_obfuscated;
}
}
pub fn set_hover_state_for_secret(
&mut self,
location: &TextLocation,
secret_range: &SecretRange,
is_hovering: bool,
) {
if is_hovering {
self.currently_hovered_secret_location = Some(SecretLocation {
secret_range: secret_range.clone(),
location: *location,
});
} else if self.currently_hovered_secret_location.as_ref().is_some_and(
|currently_hovered_secret| {
currently_hovered_secret.secret_range == *secret_range
&& currently_hovered_secret.location == *location
},
) {
self.currently_hovered_secret_location = None;
}
}
pub fn secrets_for_location(
&self,
location: &TextLocation,
) -> Option<&DetectedSecretsInTextLocation> {
self.detected_secrets.get(location)
}
fn get_secret_mut(
&mut self,
location: &TextLocation,
secret_range: &SecretRange,
) -> Option<&mut Secret> {
self.detected_secrets
.get_mut(location)
.and_then(|detected_location| detected_location.detected_secrets.get_mut(secret_range))
}
pub fn run_redaction_for_location(
&mut self,
text: &str,
location: TextLocation,
should_obfuscate: bool,
) {
// Detect secrets in user's query.
let secret_ranges_with_levels = find_secrets_in_text_with_levels(text);
for (secret_range, secret_level) in secret_ranges_with_levels {
if let Some(secret_text) =
text.get(secret_range.byte_range.start..secret_range.byte_range.end)
{
self.detected_secrets
.entry(location)
.or_default()
.detected_secrets
.insert(
secret_range,
Secret {
secret: secret_text.to_string(),
is_obfuscated: should_obfuscate,
mouse_state: Default::default(),
secret_level,
},
);
}
}
}
pub fn run_incremental_redaction_on_partial_output(
&mut self,
output: &AIAgentOutput,
should_obfuscate: bool,
) {
// Steps are sequentially streamed, hence we always check the last step.
// Important: all the *lines* in the context are markdown lines, which could be rendered as multiple lines on screen.
if let Some((section_index, text_section)) = output
.all_text()
.flat_map(|text| text.sections.iter())
.enumerate()
.last()
{
let line_index = match text_section {
AIAgentTextSection::PlainText {
text:
AgentOutputText {
formatted_lines: Some(text),
..
},
} => text
.lines()
.iter()
.enumerate()
.next_back()
.map_or(0, |(index, _)| index),
_ => 0,
};
let mut start_of_last_word_byte_index;
if section_index == self.last_text_section_index_scanned_for_redaction
&& line_index == self.last_line_index_scanned_for_redaction
{
// The boundary between what we're done scanning and what we still need to scan.
start_of_last_word_byte_index = self.last_scanned_secret_redaction_byte_index
- self.last_word_to_rescan_for_redaction.len();
self.detected_secrets
// We remove all secrets that are beyond the cutoff boundary where we start rescanning,
// specifically to avoid having duplicate secrets from the last word buffer.
.retain(|location, secrets| {
if let TextLocation::Output {
section_index: current_section_index,
line_index: current_line_index,
} = location
{
// Only clear secrets from the last step we scanned.
if *current_section_index == section_index
&& *current_line_index == line_index
{
secrets.detected_secrets.retain(|secret_range, _| {
secret_range.byte_range.start < start_of_last_word_byte_index
&& secret_range.byte_range.end
<= start_of_last_word_byte_index
});
}
!secrets.detected_secrets.is_empty()
} else {
true
}
});
} else {
// Addition needs to happen before subtraction to prevent an intermediate usize value
// from causing a numeric overflow and panicking, as Rust arithmetic operators are left-associative.
// TODO: Investigate why, sometimes, the section index is smaller than the last section index scanned.
let num_sections_to_scan = (section_index + 1)
.saturating_sub(self.last_text_section_index_scanned_for_redaction);
for (rerun_section_index, rerun_section) in output
.all_text()
.flat_map(|text| text.sections.iter())
.enumerate()
.skip(self.last_text_section_index_scanned_for_redaction)
.take(num_sections_to_scan)
{
if rerun_section_index == self.last_text_section_index_scanned_for_redaction {
// First step: rerun secret detection on the step which the secret detection last got ran.
if let AIAgentTextSection::PlainText { text } = rerun_section {
let end_line_index = if self
.last_text_section_index_scanned_for_redaction
== section_index
{
// Only step index changed, we're still in the same step. Run secret detection up to the current line.
line_index
} else {
// We're in a new step - run secret detection up to and include the last line.
text.formatted_lines
.as_ref()
.map(|lines| lines.lines().len())
.unwrap_or(0)
};
// Starting on the last detected line (rerun, in case markdown parsing altered the text)
// TODO: Optimization: only run secret detection on the newly part of the line if the parser didn't alter it
for rerun_line_index in
self.last_line_index_scanned_for_redaction..end_line_index
{
self.rerun_secret_detection_for_output_line(
rerun_section_index,
rerun_line_index,
text,
)
}
} else {
self.detected_secrets.remove(&TextLocation::Output {
section_index: rerun_section_index,
line_index: 0,
});
}
} else if rerun_section_index == section_index {
// Last step: rerun secret detection up until the current line.
// This part is already handled in the previous condition if we're in the same step.
if let AIAgentTextSection::PlainText { text } = rerun_section {
for rerun_line_index in 0..line_index {
self.rerun_secret_detection_for_output_line(
rerun_section_index,
rerun_line_index,
text,
)
}
}
// No need to clear secrets in an else case - these lines has not been scanned for secrets yet.
} else {
// Middle sections: run secret detection on all lines.
if let AIAgentTextSection::PlainText { text } = rerun_section {
for rerun_line_index in 0..text
.formatted_lines
.as_ref()
.map(|lines| lines.lines().len())
.unwrap_or(0)
{
self.rerun_secret_detection_for_output_line(
rerun_section_index,
rerun_line_index,
text,
)
}
}
// No need to clear secrets in an else case - these lines has not been scanned for secrets yet.
}
}
self.last_text_section_index_scanned_for_redaction = section_index;
self.last_line_index_scanned_for_redaction = line_index;
self.last_scanned_secret_redaction_byte_index = 0;
self.last_word_to_rescan_for_redaction.clear();
self.current_line_for_redaction.clear();
start_of_last_word_byte_index = 0;
}
if let AIAgentTextSection::PlainText { text } = &text_section {
let text = match &text.formatted_lines {
Some(text) => text.lines().iter().last().map(|line| line.raw_text()),
_ => None,
};
if let Some(text) = text {
// Trim the trailing newline as the parser automatically
// adds a newline to the end of the text.
let text = if text.ends_with_newline() {
&text[..text.len() - 1]
} else {
text
};
if text.len() >= self.current_line_for_redaction.len()
&& text.starts_with(&self.current_line_for_redaction)
{
// If the current line is a prefix of the new text, we can just append the new text.
self.current_line_for_redaction
.push_str(&text[self.current_line_for_redaction.len()..]);
} else {
// If the current line is not a prefix of the new text, we need to clear the current line and start over.
self.current_line_for_redaction.clear();
self.current_line_for_redaction.push_str(text);
self.last_scanned_secret_redaction_byte_index = 0;
self.last_word_to_rescan_for_redaction.clear();
start_of_last_word_byte_index = 0;
self.detected_secrets.retain(|location, _| {
if let TextLocation::Output {
section_index: cur_step_index,
line_index: cur_line_index,
} = location
{
// Clear all secrets of the current line in the current step.
!(*cur_step_index == section_index && *cur_line_index == line_index)
} else {
true
}
});
}
// Combine the last word in last word buffer with the new text to handle
// the case where a secret is split across streaming chunks.
let combined_text = format!(
"{}{}",
&self.last_word_to_rescan_for_redaction,
&text[self.last_scanned_secret_redaction_byte_index..]
);
let secret_ranges_with_levels =
find_secrets_in_text_with_levels(&combined_text);
for (secret_range, secret_level) in secret_ranges_with_levels {
// Adjust the ranges to map correctly within the new text.
let adjusted_byte_start =
start_of_last_word_byte_index + secret_range.byte_range.start;
let adjusted_byte_end =
start_of_last_word_byte_index + secret_range.byte_range.end;
let adjusted_char_start = text[..adjusted_byte_start].chars().count();
let adjusted_char_end = text[..adjusted_byte_end].chars().count();
let adjusted_secret_range = SecretRange {
char_range: adjusted_char_start..adjusted_char_end,
byte_range: adjusted_byte_start..adjusted_byte_end,
};
if let Some(secret_text) = text.get(adjusted_byte_start..adjusted_byte_end)
{
self.detected_secrets
.entry(TextLocation::Output {
section_index,
line_index,
})
.or_default()
.detected_secrets
.insert(
adjusted_secret_range,
Secret {
secret: secret_text.to_string(),
is_obfuscated: should_obfuscate,
mouse_state: Default::default(),
secret_level,
},
);
}
}
// Update the last scanned position to the end of the current text.
self.last_scanned_secret_redaction_byte_index = text.len();
// Extract and store the last word (whitespace-separated) in the last word buffer.
if let Some(last_space_byte_index) =
combined_text.rfind(|c: char| c.is_whitespace())
{
let slice_with_space = &combined_text[last_space_byte_index..];
let space_offset = slice_with_space
.chars()
.next()
.expect("The whitespace character should be present")
.len_utf8();
self.last_word_to_rescan_for_redaction =
combined_text[last_space_byte_index + space_offset..].to_string();
} else {
// If no whitespace is found, store the entire combined text as the prefix.
self.last_word_to_rescan_for_redaction = combined_text.clone();
}
}
}
}
}
pub fn run_redaction_on_complete_output(&mut self, output: &AIAgentOutput) {
// Delete all output secrets as we'll be rescanning the entire output.
self.detected_secrets
.retain(|location, _| !matches!(location, TextLocation::Output { .. }));
for (section_index, section) in output
.all_text()
.flat_map(|text| text.sections.iter())
.enumerate()
{
if let AIAgentTextSection::PlainText { text } = section {
let texts = match &text.formatted_lines {
Some(text) => text.lines().iter().map(|line| line.raw_text()).collect(),
_ => vec![text.text()],
};
for (line_index, text) in texts.iter().enumerate() {
let secret_ranges_with_levels = find_secrets_in_text_with_levels(text);
for (secret_range, secret_level) in secret_ranges_with_levels {
if let Some(secret_text) =
text.get(secret_range.byte_range.start..secret_range.byte_range.end)
{
self.detected_secrets
.entry(TextLocation::Output {
section_index,
line_index,
})
.or_default()
.detected_secrets
.insert(
secret_range,
Secret {
secret: secret_text.to_string(),
is_obfuscated: true,
mouse_state: Default::default(),
secret_level,
},
);
}
}
}
}
}
}
fn rerun_secret_detection_for_output_line(
&mut self,
section_index: usize,
line_index: usize,
text: &AgentOutputText,
) {
let text_line = text
.formatted_lines
.as_ref()
.and_then(|lines| lines.lines().get(line_index).map(|line| line.raw_text()));
let entry_location = TextLocation::Output {
section_index,
line_index,
};
self.detected_secrets.remove(&entry_location);
if let Some(line_text) = text_line {
let secret_ranges_with_levels = find_secrets_in_text_with_levels(line_text);
for (secret_range, secret_level) in secret_ranges_with_levels {
// No adjustment is needed - we're redoing this entire line
if let Some(secret_text) = line_text.get(secret_range.byte_range.clone()) {
self.detected_secrets
.entry(entry_location)
.or_default()
.detected_secrets
.insert(
secret_range,
Secret {
secret: secret_text.to_string(),
is_obfuscated: true,
mouse_state: Default::default(),
secret_level,
},
);
}
}
}
}
}
pub(crate) fn redact_secrets_in_element<T: PartialClickableElement>(
mut element: T,
detected_secrets: &DetectedSecretsInTextLocation,
location: TextLocation,
should_hide: bool,
) -> T {
// Collect the secrets into a Vec, so we can reverse sort them by starting byte position.
let secrets: std::iter::Rev<std::vec::IntoIter<(SecretRange, Secret)>> = detected_secrets
.detected_secrets
.iter()
.map(|(range, hoverable)| (range.clone(), hoverable.clone()))
.sorted_by_key(|(detected_secret_range, _)| detected_secret_range.byte_range.start)
.rev();
// Process the secrets in reverse order to avoid issues where we replace multibyte characters with single-width asterisks, changing
// indices of subsequent secrets.
for (detected_secret_range, hoverable_secret) in secrets {
let detected_secret_range_click_clone = detected_secret_range.clone();
element = element.with_clickable_char_range(
detected_secret_range_click_clone.char_range.clone(),
move |_modifiers, ctx, _app| {
ctx.dispatch_typed_action(AIBlockAction::OpenSecretTooltip {
secret_range: detected_secret_range_click_clone.clone(),
location,
});
},
);
if hoverable_secret.is_obfuscated && should_hide {
element.replace_text_range(
detected_secret_range.clone(),
SECRET_REDACTION_REPLACEMENT_CHARACTER
.repeat(
// Use character range length here! Even wide characters should be replaced by a single *, in rich content
// block secret redaction e.g. 码1234 -> *1234 not **1234.
detected_secret_range.char_range.end
- detected_secret_range.char_range.start,
)
.into(),
);
}
let detected_secret_range_hover_clone = detected_secret_range.clone();
element = element.with_hoverable_char_range(
detected_secret_range_hover_clone.char_range.clone(),
hoverable_secret.mouse_state.clone(),
Some(Cursor::PointingHand),
move |is_hovering, ctx, _app| {
ctx.dispatch_typed_action(AIBlockAction::ChangedHoverOnSecret {
secret_range: detected_secret_range_hover_clone.clone(),
location,
is_hovering,
})
},
);
}
element
}
#[cfg(test)]
#[path = "secret_redaction_test.rs"]
mod test;
@@ -0,0 +1,646 @@
use regex::Regex;
use serial_test::serial;
use warpui::elements::Text;
use warpui::fonts::FamilyId;
use crate::terminal::model::secrets::{self, SecretLevel};
use super::*;
#[test]
fn test_merge_no_ranges() {
let ranges: Vec<(SecretRange, SecretLevel)> = vec![];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(result, Vec::<(SecretRange, SecretLevel)>::new());
}
#[test]
fn test_merge_single_range() {
let ranges: Vec<(SecretRange, SecretLevel)> = vec![(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::User,
)];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::User
)]
);
}
#[test]
fn test_merge_non_overlapping_ranges() {
let ranges = vec![
(
SecretRange {
char_range: 0..3,
byte_range: 0..3,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 5..8,
byte_range: 5..8,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 10..15,
byte_range: 10..15,
},
SecretLevel::Enterprise,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![
(
SecretRange {
char_range: 0..3,
byte_range: 0..3,
},
SecretLevel::User
),
(
SecretRange {
char_range: 5..8,
byte_range: 5..8,
},
SecretLevel::User
),
(
SecretRange {
char_range: 10..15,
byte_range: 10..15,
},
SecretLevel::Enterprise
)
]
);
}
#[test]
fn test_merge_overlapping_ranges() {
let ranges = vec![
(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 3..10,
byte_range: 3..10,
},
SecretLevel::Enterprise,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![(
SecretRange {
char_range: 0..10,
byte_range: 0..10,
},
SecretLevel::Enterprise
)]
);
}
#[test]
fn test_merge_adjacent_ranges() {
let ranges = vec![
(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 5..10,
byte_range: 5..10,
},
SecretLevel::User,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![(
SecretRange {
char_range: 0..10,
byte_range: 0..10,
},
SecretLevel::User
)]
);
}
#[test]
fn test_merge_complex_merge() {
let ranges = vec![
(
SecretRange {
char_range: 1..3,
byte_range: 1..3,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 2..5,
byte_range: 2..5,
},
SecretLevel::Enterprise,
),
(
SecretRange {
char_range: 6..8,
byte_range: 6..8,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 7..10,
byte_range: 7..10,
},
SecretLevel::Enterprise,
),
(
SecretRange {
char_range: 12..15,
byte_range: 12..15,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 14..18,
byte_range: 14..18,
},
SecretLevel::User,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![
(
SecretRange {
char_range: 1..5,
byte_range: 1..5,
},
SecretLevel::Enterprise
),
(
SecretRange {
char_range: 6..10,
byte_range: 6..10,
},
SecretLevel::Enterprise
),
(
SecretRange {
char_range: 12..18,
byte_range: 12..18,
},
SecretLevel::User
)
]
);
}
#[test]
fn test_merge_ranges_with_same_start() {
let ranges = vec![
(
SecretRange {
char_range: 1..5,
byte_range: 1..5,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 1..3,
byte_range: 1..3,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 1..4,
byte_range: 1..4,
},
SecretLevel::Enterprise,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![(
SecretRange {
char_range: 1..5,
byte_range: 1..5,
},
SecretLevel::Enterprise
)]
);
}
#[test]
fn test_merge_ranges_with_same_end() {
let ranges = vec![
(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::User,
),
(
SecretRange {
char_range: 2..5,
byte_range: 2..5,
},
SecretLevel::Enterprise,
),
(
SecretRange {
char_range: 3..5,
byte_range: 3..5,
},
SecretLevel::User,
),
];
let result = merge_sorted_ranges_with_levels(ranges);
assert_eq!(
result,
vec![(
SecretRange {
char_range: 0..5,
byte_range: 0..5,
},
SecretLevel::Enterprise
)]
);
}
// Secret detection now only uses user-defined custom regexes that are populated when safe mode is enabled.
// Within this set of tests, we focus on testing the detect_secrets function that uses user-defined regexes,
// rather than system default regexes.
#[test]
fn test_detect_secrets_no_regexes_configured() {
// With no regexes configured, no secrets should be detected
let text = "foo warp-server-staging.firebaseapp.com bar";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(detected_secrets, vec![]);
}
// #[serial] is used to ensure custom regexes state does not interfere with other tests,
// as the custom regexes are global state.
#[test]
#[serial]
fn test_detect_secrets_single_secret_custom() {
// Set as user secret (enterprise secrets is empty)
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new("ABCD").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
let text = "foo ABCD bar";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![SecretRange {
char_range: 4..8,
byte_range: 4..8,
}]
);
}
#[test]
#[serial]
fn test_detect_secrets_single_secret_custom_with_multibyte() {
// Set a custom secret regex that matches a Chinese multibyte secret, e.g., "秘密"
// Set as user secret (enterprise secrets is empty)
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new("秘密").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
let text = "foo 秘密 bar";
let detected_secrets = find_secrets_in_text(text);
// The Chinese secret "秘密" starts at character index 4 and ends at character index 6
assert_eq!(
detected_secrets,
vec![SecretRange {
char_range: 4..6,
byte_range: 4..10, // multibyte chars take multiple bytes
}]
);
}
#[test]
#[serial]
fn test_detect_secrets_multiple_secrets() {
// Set custom regexes to include patterns that would previously have been system defaults
secrets::set_user_and_enterprise_secret_regexes(
[
&Regex::new("ABCD").expect("Should be able to construct regex"),
&Regex::new(r"\bghp_[A-Za-z0-9_]{36}\b").expect("Should be able to construct regex"),
&Regex::new(r"\b([a-z0-9-]){1,30}(\.firebaseapp\.com)\b")
.expect("Should be able to construct regex"),
&Regex::new(r"\b(?:r|s)k_(test|live)_[0-9a-zA-Z]{24}\b")
.expect("Should be able to construct regex"),
],
std::iter::empty(), // No enterprise secrets
);
// Using custom secret, github token, firebase domain, and stripe key as secrets.
let text = "ABCD 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 foo baz warp-server-staging.firebaseapp.com bar \n foo sk_live_4eC39HqLyjWDarjtT1zdp7dc qux foo";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![
SecretRange {
char_range: 0..4,
byte_range: 0..4,
},
SecretRange {
char_range: 5..45,
byte_range: 5..45,
},
SecretRange {
char_range: 54..89,
byte_range: 54..89,
},
SecretRange {
char_range: 100..132,
byte_range: 100..132,
}
]
);
}
#[test]
fn test_add_secret_redaction_to_text_no_secrets() {
let text = Text::new_inline("This is a test.", FamilyId(0), 12.0);
let detected_secrets_in_location = DetectedSecretsInTextLocation::default();
let location = TextLocation::Output {
section_index: 0,
line_index: 0,
};
let original_text = text.text().to_owned();
let result = redact_secrets_in_element(text, &detected_secrets_in_location, location, true);
// No changes should be made to the text.
assert_eq!(result.text().to_owned(), original_text);
}
#[test]
fn test_add_secret_redaction_to_text_with_redaction() {
let text = Text::new_inline("This is a secret: secret123.", FamilyId(0), 12.0);
let location = TextLocation::Output {
section_index: 0,
line_index: 0,
};
let secret_range = SecretRange {
char_range: 18..27, // "secret123"
byte_range: 18..27,
};
let hoverable_secret = Secret {
secret: "secret123".to_owned(),
is_obfuscated: true,
mouse_state: Default::default(),
secret_level: SecretLevel::User,
};
let mut detected_secrets_in_location = DetectedSecretsInTextLocation::default();
detected_secrets_in_location
.detected_secrets
.insert(secret_range.clone(), hoverable_secret);
let result = redact_secrets_in_element(text, &detected_secrets_in_location, location, true);
// The secret should be replaced with asterisks.
assert_eq!(result.text(), "This is a secret: *********.");
}
#[test]
fn test_add_secret_redaction_to_text_with_multibyte_characters() {
// Text with multibyte characters (e.g., Chinese characters).
let text = Text::new_inline("这是一个秘密: 密码1234.", FamilyId(0), 12.0);
let location = TextLocation::Output {
section_index: 0,
line_index: 0,
};
// Range for the secret "码1234" in the multibyte text.
let secret_range = SecretRange {
char_range: 9..14, // "码1234"
byte_range: 23..30, // Byte range will be larger due to multibyte characters
};
let hoverable_secret = Secret {
secret: "码1234".to_owned(),
is_obfuscated: true,
mouse_state: Default::default(),
secret_level: SecretLevel::User,
};
let mut detected_secrets_in_location = DetectedSecretsInTextLocation::default();
detected_secrets_in_location
.detected_secrets
.insert(secret_range.clone(), hoverable_secret);
let result = redact_secrets_in_element(text, &detected_secrets_in_location, location, true);
// The secret should be replaced with asterisks.
assert_eq!(result.text(), "这是一个秘密: 密*****.");
}
// Test case-sensitive matching by default
#[test]
#[serial]
fn test_detect_secrets_case_sensitive() {
// Set as user secret (enterprise secrets is empty)
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new("ABCD").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Should match exact case
let text = "foo ABCD bar";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![SecretRange {
char_range: 4..8,
byte_range: 4..8,
}]
);
// Should not match different case
let text = "foo abcd bar";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(detected_secrets, vec![]);
}
// Test opt-in case-insensitive matching with (?i) flag
#[test]
#[serial]
fn test_detect_secrets_case_insensitive_opt_in() {
// Set as user secret with case-insensitive flag
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new("(?i)ABCD").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Should match both cases when case-insensitive flag is used
let text = "foo ABCD bar abcd baz";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![
SecretRange {
char_range: 4..8,
byte_range: 4..8,
},
SecretRange {
char_range: 13..17,
byte_range: 13..17,
}
]
);
}
// Test case sensitivity for default regex patterns
#[test]
#[serial]
fn test_detect_secrets_default_regex_case_sensitivity() {
// Set user secret with a stripe-key like pattern, but enforce case sensitivity
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new(r"\bsk_test_[0-9a-z]{24}\b").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Only matches keys that use lowercase
let text = "API keys: sk_test_abcdef123456789012345678 SK_TEST_ABCDEF123456789012345678";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![SecretRange {
char_range: 10..42,
byte_range: 10..42,
}]
);
// When we want case-insensitive matching, we explicitly use [A-Za-z]
secrets::set_user_and_enterprise_secret_regexes(
[&Regex::new(r"\bsk_test_[0-9A-Za-z]{24}\b").expect("Should be able to construct regex")],
std::iter::empty(), // No enterprise secrets
);
// Now matches both cases because of the explicit character class [A-Za-z]
let text = "API keys: sk_test_abcdef123456789012345678 sk_test_ABCDEF123456789012345678";
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![
SecretRange {
char_range: 10..42,
byte_range: 10..42,
},
SecretRange {
char_range: 43..75,
byte_range: 43..75,
}
]
);
}
// Regression test for panic `assertion failed: self.is_char_boundary(n)`.
// End-to-end detection and redaction of custom multibyte secrets.
#[test]
#[serial]
fn test_detect_and_redact_custom_multibyte_secrets() {
// Set the custom secret regex to detect both "テストファイル" and "ABCD"
// Set as user secrets (enterprise secrets is empty)
secrets::set_user_and_enterprise_secret_regexes(
[
&Regex::new("テストファイル").expect("Should be able to construct regex"),
&Regex::new("ABCD").expect("Should be able to construct regex"),
],
std::iter::empty(), // No enterprise secrets
);
let text = "これはテストファイルです。 ABCD";
// Step 1: Detect secrets in the text
let detected_secrets = find_secrets_in_text(text);
assert_eq!(
detected_secrets,
vec![
SecretRange {
char_range: 3..10, // "テストファイル"
byte_range: 9..30, // Multibyte character byte range
},
SecretRange {
char_range: 14..18, // "ABCD"
byte_range: 40..44,
}
]
);
// Step 2: Prepare for redaction by inserting the detected secrets
let location = TextLocation::Output {
section_index: 0,
line_index: 0,
};
let mut detected_secrets_in_location = DetectedSecretsInTextLocation::default();
for secret_range in detected_secrets.iter() {
let hoverable_secret = Secret {
secret: text[secret_range.byte_range.clone()].to_owned(),
is_obfuscated: true,
mouse_state: Default::default(),
secret_level: SecretLevel::User,
};
detected_secrets_in_location
.detected_secrets
.insert(secret_range.clone(), hoverable_secret);
}
// Step 3: Redact the secrets in the text
let text_obj = Text::new_inline(text, FamilyId(0), 12.0);
let redacted_text =
redact_secrets_in_element(text_obj, &detected_secrets_in_location, location, true);
// The expected result after redaction
let expected_redacted_text = "これは*******です。 ****";
assert_eq!(redacted_text.text(), expected_redacted_text);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use warpui::{
elements::{
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
},
keymap::{macros::*, FixedBinding, Keystroke},
platform::Cursor,
ui_components::{
components::{Coords, UiComponent, UiComponentStyles},
keyboard_shortcut::KeyboardShortcut,
text::Span,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
type ItemLabelFn<T> = Box<dyn Fn(&T, &AppContext) -> Span>;
const HAS_ITEMS: &str = "HasItems";
pub fn init(app: &mut AppContext) {
let context = id!(ToggleableItemsView::<()>::ui_name()) & id!(HAS_ITEMS);
app.register_fixed_bindings([
FixedBinding::new("enter", ToggleableItemsAction::Submit, context.clone()),
FixedBinding::new(
"numpadenter",
ToggleableItemsAction::Submit,
context.clone(),
),
FixedBinding::new("up", ToggleableItemsAction::ArrowUp, context.clone()),
FixedBinding::new("down", ToggleableItemsAction::ArrowDown, context.clone()),
FixedBinding::new(
"cmdorctrl-enter",
ToggleableItemsAction::ToggleFocused,
context,
),
]);
}
/// Builder for configuring how individual items should be displayed and selected.
///
/// # Type Parameters
/// - `T`: The data type for each item
pub struct ToggleableItemBuilder<T> {
label_fn: ItemLabelFn<T>,
is_selected_fn: Box<dyn Fn(&T) -> bool>,
}
impl<T> ToggleableItemBuilder<T> {
pub fn new(
label_fn: impl Fn(&T, &AppContext) -> Span + 'static,
is_selected_fn: impl Fn(&T) -> bool + 'static,
) -> Self {
Self {
label_fn: Box::new(label_fn),
is_selected_fn: Box::new(is_selected_fn),
}
}
}
/// Internal action type for interacting with an item list.
#[derive(Debug, Clone, Copy)]
pub enum ToggleableItemsAction {
ToggleItem(usize),
ToggleFocused,
ArrowUp,
ArrowDown,
Submit,
}
/// A generic view for displaying multiple items with checkboxes.
///
/// # Type Parameters
/// - `T`: The data type for each item
pub struct ToggleableItemsView<T> {
items: Vec<T>,
selected_states: Vec<bool>,
label_fn: ItemLabelFn<T>,
checkbox_mouse_states: Vec<MouseStateHandle>,
row_mouse_states: Vec<MouseStateHandle>,
selected_item_index: usize,
}
impl<T> ToggleableItemsView<T> {
pub fn new(items: Vec<T>, builder: ToggleableItemBuilder<T>) -> Self {
let count = items.len();
let selected_states = items
.iter()
.map(|item| (builder.is_selected_fn)(item))
.collect();
Self {
items,
selected_states,
label_fn: builder.label_fn,
checkbox_mouse_states: (0..count).map(|_| MouseStateHandle::default()).collect(),
row_mouse_states: (0..count).map(|_| MouseStateHandle::default()).collect(),
selected_item_index: 0,
}
}
/// Get the currently selected items.
pub fn get_selected_items(&self) -> impl Iterator<Item = &T> + '_ {
self.items
.iter()
.zip(self.selected_states.iter())
.filter_map(|(item, selected)| selected.then_some(item))
}
}
pub enum ToggleableItemsEvent {
SelectionChanged,
SubmitRequested,
}
impl<T: 'static> Entity for ToggleableItemsView<T> {
type Event = ToggleableItemsEvent;
}
impl<T: 'static> View for ToggleableItemsView<T> {
fn ui_name() -> &'static str {
"ToggleableItemsView"
}
fn keymap_context(&self, _app: &AppContext) -> warpui::keymap::Context {
let mut context = Self::default_keymap_context();
if !self.items.is_empty() {
context.set.insert(HAS_ITEMS);
}
context
}
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(ctx);
let theme = appearance.theme();
let border_color = blended_colors::neutral_4(theme);
let mut outer_container =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let mut checkboxes_column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(4.);
// Render checkboxes for each item
for (index, item) in self.items.iter().enumerate() {
let is_selected = self.selected_states[index];
let is_focused = index == self.selected_item_index;
let label = (self.label_fn)(item, ctx);
let checkbox = appearance
.ui_builder()
.checkbox(self.checkbox_mouse_states[index].clone(), None)
.check(is_selected)
.with_label(label)
.build()
.finish();
let row_inner: Box<dyn Element> = if is_focused {
let toggle_keystroke = if cfg!(target_os = "macos") {
Keystroke::parse("cmd-enter").expect("can parse cmd-enter")
} else {
Keystroke::parse("ctrl-enter").expect("can parse ctrl-enter")
};
let hint_styles = UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.monospace_font_size() - 2.),
font_color: Some(blended_colors::text_sub(theme, theme.surface_1())),
..Default::default()
};
let shortcut = KeyboardShortcut::new(&toggle_keystroke, hint_styles)
.text_only()
.build()
.finish();
let hint_text = Span::new(
"to toggle selection",
UiComponentStyles {
margin: Some(Coords::default().left(6.)),
..hint_styles
},
)
.build()
.finish();
let hint = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(shortcut)
.with_child(hint_text)
.finish();
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., checkbox).finish())
.with_child(hint)
.finish()
} else {
checkbox
};
// Make the entire row clickable (not just the checkbox control) by wrapping the
// padded/bordered container in a separate Hoverable click target.
// Use accent border color when focused to show keyboard focus.
let row_border_color = if is_focused {
theme.accent().into()
} else {
border_color
};
let row = Hoverable::new(self.row_mouse_states[index].clone(), |_| {
Container::new(row_inner)
.with_horizontal_padding(12.)
.with_vertical_padding(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(Border::all(1.).with_border_fill(row_border_color))
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ToggleableItemsAction::ToggleItem(index));
})
.finish();
checkboxes_column.add_child(row);
}
outer_container.add_child(checkboxes_column.finish());
outer_container.finish()
}
}
impl<T: 'static> TypedActionView for ToggleableItemsView<T> {
type Action = ToggleableItemsAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ToggleableItemsAction::ToggleItem(index) => {
if let Some(selected) = self.selected_states.get_mut(*index) {
*selected = !*selected;
ctx.emit(ToggleableItemsEvent::SelectionChanged);
ctx.notify();
}
}
ToggleableItemsAction::ToggleFocused => {
if let Some(selected) = self.selected_states.get_mut(self.selected_item_index) {
*selected = !*selected;
ctx.emit(ToggleableItemsEvent::SelectionChanged);
ctx.notify();
}
}
ToggleableItemsAction::ArrowUp => {
if !self.items.is_empty() {
self.selected_item_index =
(self.selected_item_index + self.items.len() - 1) % self.items.len();
ctx.notify();
}
}
ToggleableItemsAction::ArrowDown => {
if !self.items.is_empty() {
self.selected_item_index = (self.selected_item_index + 1) % self.items.len();
ctx.notify();
}
}
ToggleableItemsAction::Submit => {
ctx.emit(ToggleableItemsEvent::SubmitRequested);
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::icons::addressed_comment_icon;
use crate::ai::blocklist::block::CommentElementState;
use crate::code_review::comments::ReviewCommentBatch;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warp_core::ui::Icon;
use warpui::elements::{
Axis, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
Expanded, Flex, Hoverable, MouseState, ParentElement, Radius, Text, Wrap, WrapFillEntireRun,
};
use warpui::{AppContext, Element, SingletonEntity};
const COMMENT_CHIP_MAX_HEIGHT: f32 = 200.;
/// Displays a series of chips for the "Address Comments" input type.
pub fn address_comment_chips(
review_request: &ReviewCommentBatch,
props: super::input::Props,
app_context: &AppContext,
) -> Box<dyn Element> {
Wrap::new(Axis::Horizontal)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(4.)
.with_run_spacing(4.)
.with_children(review_request.comments.iter().map(|comment| {
let agent_comment: ReviewComment = comment.clone().into();
comment_chip(agent_comment, props, app_context)
}))
.finish()
}
fn comment_chip(
review_comment: ReviewComment,
props: super::input::Props,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let review_comment_id = review_comment.id;
let is_addressed = props.addressed_comment_ids.contains(&review_comment_id);
let Some(comment_element_state) = props.comments.get(&review_comment_id) else {
log::warn!(
"Missing CommentElementState for review comment id {}",
review_comment.id
);
return Empty::new().finish();
};
let comment_chip = Hoverable::new(
comment_element_state.header_toggle_mouse_state.clone(),
|state| {
render_comment_chip_internal(
appearance,
review_comment,
comment_element_state,
state,
is_addressed,
app,
)
},
)
.finish();
WrapFillEntireRun::new(comment_chip).finish()
}
fn render_comment_chip_internal(
appearance: &Appearance,
review_comment: ReviewComment,
comment_element_state: &CommentElementState,
mouse_state: &MouseState,
is_addressed: bool,
app: &AppContext,
) -> Box<dyn Element> {
let flex_column = Flex::column()
.with_children([
changes_chip(
&review_comment,
comment_element_state,
mouse_state,
is_addressed,
appearance,
app,
),
comment_text(comment_element_state),
])
.finish();
let background_color = if mouse_state.is_hovered() {
internal_colors::neutral_2(appearance.theme())
} else {
internal_colors::neutral_1(appearance.theme())
};
Container::new(flex_column)
.with_background(background_color)
.with_uniform_padding(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(
Border::all(1.).with_border_fill(internal_colors::neutral_2(appearance.theme())),
)
.finish()
}
/// Returns the "changes" chip that displays the file / line number that was changed.
fn changes_chip(
review_comment: &ReviewComment,
element_state: &CommentElementState,
mouse_state: &MouseState,
is_addressed: bool,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let text_sub = appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid();
let comment_title = Text::new(
review_comment.title(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 2.,
)
.with_color(text_sub)
.finish();
// Show collapse/expand button on hover
let max_min_button = if mouse_state.is_hovered() {
ChildView::new(&element_state.maximize_minimize_button).finish()
} else {
// Render an empty element the exact size of the button to ensure there's no jitter
// as the user hovers over a chip.
let size = element_state
.maximize_minimize_button
.as_ref(app)
.height(app);
ConstrainedBox::new(Empty::new().finish())
.with_height(size)
.with_width(size)
.finish()
};
Flex::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_children([
comment_icon(is_addressed, appearance),
Expanded::new(1., comment_title).finish(),
Container::new(max_min_button)
.with_padding_top(-8.)
.with_padding_right(-8.)
.finish(),
])
.finish()
}
fn comment_icon(is_addressed: bool, appearance: &Appearance) -> Box<dyn Element> {
let icon_size = appearance.monospace_font_size() - 2.;
let icon = if is_addressed {
addressed_comment_icon(appearance).finish()
} else {
Icon::MessageText
.to_warpui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish()
};
let sized_icon = ConstrainedBox::new(icon)
.with_width(icon_size)
.with_height(icon_size)
.finish();
Container::new(sized_icon)
.with_background(internal_colors::fg_overlay_1(appearance.theme()))
.with_vertical_padding(1.)
.with_horizontal_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.finish()
}
fn comment_text(comment_element_state: &CommentElementState) -> Box<dyn Element> {
let editor_child_view =
Container::new(ChildView::new(&comment_element_state.rich_text_editor).finish())
.with_padding_top(4.)
.finish();
if comment_element_state.is_expanded {
editor_child_view
} else {
ConstrainedBox::new(editor_child_view)
.with_max_height(COMMENT_CHIP_MAX_HEIGHT)
.finish()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,294 @@
use itertools::Itertools;
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
#[cfg(feature = "local_fs")]
use warpui::assets::asset_cache::AssetSource;
use warpui::App;
#[cfg(feature = "local_fs")]
use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
use super::{
collect_visual_markdown_lightbox_collection, compute_visual_section_width,
inline_image_source_label, lightbox_trigger_for_section, query_prefix_highlight_len,
render_scrollable_collapsible_content, text_sections_with_indices, CollapsibleElementState,
CollapsibleExpansionState, VisualMarkdownLightboxCollection,
};
use crate::{
ai::agent::{
AIAgentInput, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
AgentOutputMermaidDiagram, MessageId, UserQueryMode,
},
features::FeatureFlag,
search::slash_command_menu::static_commands::commands,
};
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
use warpui::{elements::Empty, Element};
#[test]
fn query_prefix_highlight_len_highlights_invoke_skill_inputs() {
let input = AIAgentInput::InvokeSkill {
context: Arc::new([]),
skill: ParsedSkill {
path: PathBuf::from("/tmp/.agents/skills/review-pr/SKILL.md"),
name: "review-pr".to_string(),
description: "Review a pull request.".to_string(),
content: String::new(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Project,
},
user_query: None,
};
assert_eq!(
query_prefix_highlight_len(&input, "/review-pr tighten the summary"),
Some("/review-pr".len())
);
}
#[test]
fn query_prefix_highlight_len_does_not_guess_from_plain_user_query_text() {
let input = AIAgentInput::UserQuery {
query: "/review-pr tighten the summary".to_string(),
context: Arc::new([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
};
assert_eq!(
query_prefix_highlight_len(&input, "/review-pr tighten the summary"),
None
);
}
#[test]
fn query_prefix_highlight_len_keeps_existing_plan_highlighting() {
let input = AIAgentInput::UserQuery {
query: "write tests".to_string(),
context: Arc::new([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Plan,
running_command: None,
intended_agent: None,
};
assert_eq!(
query_prefix_highlight_len(&input, "/plan write tests"),
Some(commands::PLAN.name.len())
);
}
#[test]
fn text_sections_with_indices_preserve_image_section_alignment_after_empty_text_sections() {
let sections = vec![
AIAgentTextSection::PlainText {
text: "".to_string().into(),
},
AIAgentTextSection::PlainText {
text: "Before".to_string().into(),
},
AIAgentTextSection::Image {
image: AgentOutputImage {
alt_text: "One".to_string(),
source: "one.png".to_string(),
title: None,
markdown_source: "![One](one.png)".to_string(),
layout: AgentOutputImageLayout::Block,
},
},
AIAgentTextSection::PlainText {
text: " ".to_string().into(),
},
AIAgentTextSection::Image {
image: AgentOutputImage {
alt_text: "Two".to_string(),
source: "two.png".to_string(),
title: None,
markdown_source: "![Two](two.png)".to_string(),
layout: AgentOutputImageLayout::Block,
},
},
];
let rendered_image_indices = text_sections_with_indices(&sections, 0)
.filter_map(|(section_index, section)| match section {
AIAgentTextSection::PlainText { text } if text.text().trim().is_empty() => None,
AIAgentTextSection::Image { .. } => Some(section_index),
_ => None,
})
.collect_vec();
assert_eq!(rendered_image_indices, vec![2, 4]);
}
#[test]
fn render_scrollable_collapsible_content_returns_none_when_collapsed() {
let state = CollapsibleElementState {
expansion_state: CollapsibleExpansionState::Collapsed,
..Default::default()
};
let message_id = MessageId::new("message-1".to_string());
let content = render_scrollable_collapsible_content(
&message_id,
&state,
Empty::new().finish(),
false,
200.,
);
assert!(
content.is_none(),
"Expected no rendered content when collapsible state is collapsed",
);
}
#[test]
fn compute_visual_section_width_rejects_non_finite_dimensions() {
assert_eq!(compute_visual_section_width(f32::INFINITY, 20., 40.), None);
assert_eq!(compute_visual_section_width(20., f32::NAN, 40.), None);
assert_eq!(compute_visual_section_width(20., 40., f32::INFINITY), None);
assert_eq!(compute_visual_section_width(20., 40., 10.), Some(5.));
}
#[test]
fn render_scrollable_collapsible_content_returns_body_when_expanded() {
let message_id = MessageId::new("message-2".to_string());
let state = CollapsibleElementState::default();
let content = render_scrollable_collapsible_content(
&message_id,
&state,
Empty::new().finish(),
false,
200.,
);
assert!(
content.is_some(),
"Expected rendered content when collapsible state is expanded",
);
}
#[test]
fn lightbox_trigger_uses_source_order_index_for_clicked_visual() {
let collection = VisualMarkdownLightboxCollection {
section_indices: vec![2, 4, 7],
images: Arc::new(vec![
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("one".to_string()),
},
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("two".to_string()),
},
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("three".to_string()),
},
]),
};
let trigger = lightbox_trigger_for_section(&collection, 4)
.expect("Expected lightbox trigger for section present in collection");
assert_eq!(trigger.initial_index, 1);
assert_eq!(trigger.images.len(), 3);
}
#[test]
fn lightbox_trigger_returns_none_for_unknown_section() {
let collection = VisualMarkdownLightboxCollection {
section_indices: vec![1],
images: Arc::new(vec![LightboxImage {
source: LightboxImageSource::Loading,
description: None,
}]),
};
assert!(lightbox_trigger_for_section(&collection, 3).is_none());
}
#[test]
fn collect_visual_markdown_lightbox_collection_includes_mermaid_sections_in_source_order() {
App::test((), |mut app| async move {
app.update(|ctx| {
let _blocklist_markdown_images =
FeatureFlag::BlocklistMarkdownImages.override_enabled(true);
let _markdown_mermaid = FeatureFlag::MarkdownMermaid.override_enabled(true);
let sections = vec![
AIAgentTextSection::PlainText {
text: "before".to_string().into(),
},
AIAgentTextSection::MermaidDiagram {
diagram: AgentOutputMermaidDiagram {
source: "graph TD\nA-->B".to_string(),
markdown_source: "```mermaid\ngraph TD\nA-->B\n```".to_string(),
},
},
AIAgentTextSection::PlainText {
text: "between".to_string().into(),
},
AIAgentTextSection::MermaidDiagram {
diagram: AgentOutputMermaidDiagram {
source: "graph TD\nB-->C".to_string(),
markdown_source: "```mermaid\ngraph TD\nB-->C\n```".to_string(),
},
},
];
let indexed_sections = text_sections_with_indices(&sections, 10).collect_vec();
let collection = collect_visual_markdown_lightbox_collection(
&indexed_sections,
None,
#[cfg(feature = "local_fs")]
None,
ctx,
);
assert_eq!(collection.section_indices, vec![11, 13]);
assert_eq!(collection.images.len(), 2);
assert!(collection
.images
.iter()
.all(|image| image.description.is_none()));
});
});
}
#[test]
fn inline_image_source_label_uses_file_name() {
assert_eq!(
inline_image_source_label("/tmp/screenshots/classic_1.png"),
"classic_1.png"
);
}
#[cfg(feature = "local_fs")]
#[test]
fn blocklist_image_asset_source_uses_cached_resolution_when_available() {
let current_working_directory = "/tmp/session".to_string();
let cached_path = "/tmp/cached/diagram.png".to_string();
let resolved_sources = ResolvedBlocklistImageSources::from([(
"diagram.png".to_string(),
Some(AssetSource::LocalFile {
path: cached_path.clone(),
}),
)]);
let resolved = blocklist_image_asset_source(
"diagram.png",
Some(&current_working_directory),
Some(&resolved_sources),
);
match resolved {
Some(AssetSource::LocalFile { path }) => assert_eq!(path, cached_path),
other => panic!("expected cached local file asset source, got {other:?}"),
}
}
@@ -0,0 +1,262 @@
//! Renders the AI block "header", which includes a version of the AI "prompt" as it was rendered
//! when the query was submitted.
use warp_core::features::FeatureFlag;
use warp_util::path::user_friendly_path;
use warpui::elements::MouseStateHandle;
use warpui::elements::{ChildView, Hoverable, SavePosition};
use warpui::platform::Cursor;
use warpui::EntityId;
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Text,
},
AppContext, Element, SingletonEntity, ViewHandle,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::AIAgentExchangeId;
use crate::ai::blocklist::block::DirectoryContext;
use crate::ai::blocklist::{
get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id,
};
use crate::appearance::Appearance;
use crate::terminal::block_list_element::render_hoverable_block_button;
use crate::terminal::view::{TerminalAction, WARP_PROMPT_HEIGHT_LINES};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::ActionButton;
use warpui::elements::Icon as ElementIcon;
/// Data required to render the AI block header.
pub(super) struct Props<'a> {
pub(super) view_id: &'a EntityId,
pub(super) exchange_id: &'a AIAgentExchangeId,
pub(super) conversation_id: &'a AIConversationId,
pub(super) attached_blocks_chip_mouse_state: &'a MouseStateHandle,
pub(super) overflow_menu_mouse_state: &'a MouseStateHandle,
pub(super) rewind_button: &'a ViewHandle<ActionButton>,
pub(super) num_attached_context_blocks: usize,
pub(super) has_attached_context_selected_text: bool,
pub(super) directory_context: &'a DirectoryContext,
pub(super) is_selected_text_attached_as_context: bool,
pub(super) is_restored: bool,
}
/// Render the AI Block's header which is the "AI prompt" that displays context about the AI query.
pub(super) fn render(props: Props, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut did_render_child = false;
let mut left_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let font_size = prompt_font_size(appearance);
if !FeatureFlag::AgentView.is_enabled() {
if let Some(pwd) = &props.directory_context.pwd {
let current_directory =
user_friendly_path(pwd.as_str(), props.directory_context.home_dir.as_deref())
.to_string();
left_row.add_child(
Container::new(
Text::new_inline(
current_directory,
appearance.monospace_font_family(),
font_size,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selection_color(if props.is_selected_text_attached_as_context {
theme.text_selection_as_context_color().into_solid()
} else {
theme.text_selection_color().into_solid()
})
.finish(),
)
.with_margin_right(8.)
.finish(),
);
did_render_child |= true;
}
}
// When AgentViewBlockContext is enabled, blocks are auto-attached so we don't
// show the attached context chip for blocks.
let show_attached_blocks_chip =
props.num_attached_context_blocks > 0 && !FeatureFlag::AgentViewBlockContext.is_enabled();
if show_attached_blocks_chip || props.has_attached_context_selected_text {
let chip_display_text = match (
props.has_attached_context_selected_text,
props.num_attached_context_blocks,
) {
(true, _) => "selected text".to_owned(),
(false, 1) => "1 block".to_owned(),
(false, n) => format!("{n} blocks"),
};
left_row.add_child(render_attached_context_chip(
props.attached_blocks_chip_mouse_state.clone(),
chip_display_text,
*props.view_id,
*props.exchange_id,
*props.conversation_id,
app,
));
did_render_child |= true;
}
if FeatureFlag::AgentView.is_enabled() && !did_render_child {
return None;
}
let mut right_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if FeatureFlag::RevertToCheckpoints.is_enabled() && !props.is_restored {
right_row.add_child(
Container::new(ChildView::new(props.rewind_button).finish())
.with_margin_right(4.)
.finish(),
);
}
right_row.add_child(render_overflow_menu_button(
props.overflow_menu_mouse_state.clone(),
*props.view_id,
*props.exchange_id,
*props.conversation_id,
props.is_restored,
app,
));
Some(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(left_row.finish())
.with_child(right_row.finish())
.finish(),
)
}
/// Render the chip that shows what context (i.e. block(s), text, or none) was attached to this
/// AI query and can be clicked to show the list of attached blocks and/or selected text.
fn render_attached_context_chip(
attached_context_chip_mouse_state: MouseStateHandle,
display_text: String,
ai_block_view_id: EntityId,
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = prompt_font_size(appearance);
let block_count_color = blended_colors::text_sub(theme, theme.background());
SavePosition::new(
Hoverable::new(attached_context_chip_mouse_state, |_state| {
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
Icon::Paperclip
.to_warpui_icon(block_count_color.into())
.finish(),
)
.with_height(font_size)
.with_width(font_size)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
Text::new_inline(
display_text,
appearance.monospace_font_family(),
font_size,
)
.with_color(block_count_color)
.finish(),
)
.with_main_axis_size(MainAxisSize::Min)
.finish(),
)
.with_background(theme.surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_vertical_overdraw(2.)
.with_horizontal_padding(8.)
.with_vertical_padding(4.)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::OpenAIBlockAttachedBlocksMenu {
exchange_id,
conversation_id,
ai_block_view_id,
})
})
.finish(),
&get_attached_blocks_chip_element_position_id(ai_block_view_id),
)
.finish()
}
pub(super) const OVERFLOW_BUTTON_SIZE: f32 = 26.;
/// Render the overflow menu button (three dots icon) for the AI block
pub(super) fn render_overflow_menu_button(
overflow_menu_mouse_state: MouseStateHandle,
ai_block_view_id: EntityId,
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
is_restored: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let icon_color = theme.sub_text_color(theme.surface_2()).into_solid();
let icon = Container::new(
ConstrainedBox::new(ElementIcon::new("bundled/svg/overflow.svg", icon_color).finish())
.with_height(OVERFLOW_BUTTON_SIZE)
.with_width(OVERFLOW_BUTTON_SIZE)
.finish(),
);
SavePosition::new(
ConstrainedBox::new(render_hoverable_block_button(
icon,
None, // no tooltip
false, // don't ignore mouse events
true, // allow action
overflow_menu_mouse_state,
theme,
appearance.ui_builder(),
move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::OpenAIBlockOverflowMenu {
ai_block_view_id,
exchange_id,
conversation_id,
is_restored,
});
},
))
.with_width(OVERFLOW_BUTTON_SIZE)
.with_height(OVERFLOW_BUTTON_SIZE)
.finish(),
&get_ai_block_overflow_menu_element_position_id(ai_block_view_id),
)
.finish()
}
/// Returns the font size to be used to render text in the AI block "prompt" line.
///
/// This matches the font size used for the warp prompt in completed command blocks.
fn prompt_font_size(appearance: &Appearance) -> f32 {
appearance.monospace_font_size() * WARP_PROMPT_HEIGHT_LINES
}
@@ -0,0 +1,41 @@
use crate::ai::blocklist::block::ImportedCommentGroup;
use warpui::elements::{CrossAxisAlignment, Flex, ParentElement};
use warpui::prelude::ChildView;
use warpui::{AppContext, Element};
pub(crate) fn render_imported_comments(
group: &ImportedCommentGroup,
app: &AppContext,
) -> Box<dyn Element> {
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(12.);
for (card, state) in group.cards.iter().zip(group.element_states.iter()) {
let open_in_code_review = ChildView::new(&state.open_in_code_review_button).finish();
let chevron = ChildView::new(&state.chevron_button).finish();
let mut header_trailing = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.);
if let Some(open_in_github) = &state.open_in_github_button {
header_trailing.add_child(ChildView::new(open_in_github).finish());
}
let header_trailing = header_trailing
.with_child(open_in_code_review)
.with_child(chevron)
.finish();
column.add_child(card.render(
None,
Some(header_trailing),
None,
Some(&state.header_click_handler),
app,
));
}
column.finish()
}
@@ -0,0 +1,9 @@
use crate::ai::blocklist::block::CommentElementState;
use crate::code_review::comments::CommentId;
use std::collections::{HashMap, HashSet};
#[derive(Copy, Clone)]
pub(super) struct Props<'a> {
pub(super) comments: &'a HashMap<CommentId, CommentElementState>,
pub(super) addressed_comment_ids: &'a HashSet<CommentId>,
}
@@ -0,0 +1,751 @@
//! Rendering functions for orchestration-related output items (messaging & agent management).
use pathfinder_color::ColorU;
use warpui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable,
MouseStateHandle, ParentElement, Radius, Text,
};
use warpui::platform::Cursor;
use warpui::{AppContext, Element, SingletonEntity};
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warpui::elements::FormattedTextElement;
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, MessageId, ReceivedMessageDisplay,
SendMessageToAgentResult, StartAgentExecutionMode, StartAgentResult,
};
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
};
use crate::ai::blocklist::block::model::AIBlockModelHelper;
use crate::ai::blocklist::block::{AIBlockAction, CollapsibleExpansionState};
use crate::ai::blocklist::inline_action::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::{
render_requested_action_row, render_requested_action_row_for_text,
};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use super::common::render_scrollable_collapsible_content;
use super::output::{action_icon, Props};
use super::WithContentItemSpacing;
const GENERATING_TITLE_PLACEHOLDER: &str = "Generating title...";
const ORCHESTRATION_COLLAPSED_MAX_HEIGHT: f32 = 200.;
fn agent_display_name_from_id(
agent_id: &str,
orchestrator_agent_id: Option<&str>,
app: &AppContext,
) -> String {
if orchestrator_agent_id.is_some_and(|id| id == agent_id) {
return "Orchestrator agent".to_string();
}
if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
{
if let Some(agent_name) = conversation.agent_name() {
return agent_name.to_string();
}
}
}
"Unknown agent".to_string()
}
fn orchestrator_agent_id_for_conversation(
conversation: &AIConversation,
app: &AppContext,
) -> Option<String> {
match conversation.parent_conversation_id() {
Some(parent_id) => BlocklistAIHistoryModel::as_ref(app)
.conversation(&parent_id)
.and_then(|parent| parent.orchestration_agent_id()),
None => conversation.orchestration_agent_id(),
}
}
fn render_message_fields(
fields: &[(&str, &str)],
body: &str,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let label_color = blended_colors::text_disabled(theme, theme.surface_2());
let value_color: ColorU = theme.main_text_color(theme.background()).into();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (label, value) in fields {
let line = Flex::row()
.with_child(
Text::new(label.to_string(), font_family, font_size)
.with_color(label_color)
.finish(),
)
.with_child(
Text::new(value.to_string(), font_family, font_size)
.with_color(value_color)
.finish(),
)
.finish();
column.add_child(line);
}
if !body.is_empty() {
column.add_child(
Container::new(
Text::new(body.to_string(), font_family, font_size)
.with_color(value_color)
.finish(),
)
.with_margin_top(4.)
.finish(),
);
}
column.finish()
}
pub(super) fn render_messages_received_from_agents(
messages: &[ReceivedMessageDisplay],
props: Props,
message_id: &MessageId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
let chevron = render_collapse_chevron(message_id, props, app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Header row with icon and collapse chevron
let header = render_requested_action_row_for_text(
format!("Messages received ({})", messages.len()).into(),
appearance.ui_font_family(),
Some(status_icon),
chevron,
false,
false,
app,
);
column.add_child(header);
let orchestrator_agent_id = props
.model
.conversation(app)
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
// Collect all messages into a single collapsible body.
let mut messages_column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for msg in messages {
let sender_name =
agent_display_name_from_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app);
let recipients = msg
.addresses
.iter()
.map(|agent_id| {
agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app)
})
.collect::<Vec<_>>()
.join(", ");
let fields = [
("From: ", sender_name.as_str()),
("To: ", recipients.as_str()),
("Subject: ", msg.subject.as_str()),
];
let message_block = Container::new(render_message_fields(&fields, &msg.message_body, app))
.with_margin_top(8.)
.with_margin_left(8.)
.finish();
messages_column.add_child(message_block);
}
if let Some(body) = render_collapsible_body(message_id, messages_column.finish(), false, props)
{
column.add_child(body);
}
Container::new(column.finish())
.with_horizontal_padding(8.)
.with_vertical_padding(8.)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
.with_agent_output_item_spacing(app)
.finish()
}
pub(super) fn render_send_message(
props: Props,
action_id: &AIAgentActionId,
address: &[String],
subject: &str,
message: &str,
message_id: &MessageId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
let orchestrator_agent_id = props
.model
.conversation(app)
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
let recipients = address
.iter()
.map(|agent_id| agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app))
.collect::<Vec<_>>()
.join(", ");
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::SendMessageToAgent(result) = &result.result else {
log::error!(
"Unexpected action result type for send message action: {:?}",
result.result
);
return Empty::new().finish();
};
match result {
SendMessageToAgentResult::Success { .. } => {
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
let chevron = render_collapse_chevron(message_id, props, app);
let header = render_requested_action_row_for_text(
format!("Sent message to {recipients}: {subject}").into(),
appearance.ui_font_family(),
Some(status_icon),
chevron,
false,
false,
app,
);
let fields = [("To: ", recipients.as_str()), ("Subject: ", subject)];
let body_element = Container::new(render_message_fields(&fields, message, app))
.with_margin_top(4.)
.with_margin_left(8.)
.finish();
let mut column =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(header);
if let Some(body) = render_collapsible_body(message_id, body_element, false, props)
{
column.add_child(body);
}
return Container::new(column.finish())
.with_horizontal_padding(8.)
.with_vertical_padding(8.)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
.with_agent_output_item_spacing(app)
.finish();
}
SendMessageToAgentResult::Error(error) => {
let label = format!("Failed to send message to {recipients}: {error}");
let status_icon = inline_action_icons::red_x_icon(appearance).finish();
return render_requested_action_row_for_text(
label.into(),
appearance.ui_font_family(),
Some(status_icon),
None,
false,
false,
app,
)
.with_agent_output_item_spacing(app)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish();
}
SendMessageToAgentResult::Cancelled => {
let label = format!("Send message to {recipients} cancelled.");
let status_icon = inline_action_icons::cancelled_icon(appearance).finish();
return render_requested_action_row_for_text(
label.into(),
appearance.ui_font_family(),
Some(status_icon),
None,
false,
false,
app,
)
.with_agent_output_item_spacing(app)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish();
}
};
}
// Non-finished (streaming/queued) state.
let dimmed_text_color = blended_colors::text_disabled(theme, theme.surface_2());
let should_dim_text = (props.model.status(app).is_streaming()
&& !props.model.is_first_action_in_output(action_id, app))
|| status.as_ref().is_some_and(|s| s.is_queued());
let label_fragments = vec![
FormattedTextFragment::plain_text("Sending message to "),
FormattedTextFragment::bold(&recipients),
FormattedTextFragment::plain_text(format!(": {subject}")),
];
let mut header_text = render_formatted_text_element(label_fragments, app);
if should_dim_text {
header_text = header_text.with_color(dimmed_text_color);
}
let has_message = !message.is_empty();
let chevron = if has_message {
render_collapse_chevron(message_id, props, app)
} else {
None
};
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_requested_action_row(
header_text.into(),
Some(action_icon(action_id, props.action_model, props.model, app).finish()),
chevron,
false,
false,
app,
));
// Collapsible body: message text with max height
if has_message {
let message_color = if should_dim_text {
dimmed_text_color
} else {
blended_colors::text_disabled(theme, theme.surface_2())
};
let message_element = render_collapsible_text_body(message, message_color, true, app);
if let Some(body) = render_collapsible_body(
message_id,
message_element,
props.model.status(app).is_streaming(),
props,
) {
column.add_child(body);
}
}
column
.finish()
.with_agent_output_item_spacing(app)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
pub(super) fn render_start_agent(
props: Props,
action_id: &AIAgentActionId,
name: &str,
prompt: &str,
execution_mode: &StartAgentExecutionMode,
message_id: &MessageId,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id);
if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else {
log::error!(
"Unexpected action result type for start agent action: {:?}",
result.result
);
return Empty::new().finish();
};
let child_conversation_card_data = child_conversation_card_data_for_result(result, app);
let (label_fragments, status_icon) = match result {
StartAgentResult::Success { .. } => (
vec![
FormattedTextFragment::plain_text("Started agent "),
FormattedTextFragment::bold(name),
FormattedTextFragment::plain_text(start_agent_success_suffix(execution_mode)),
],
inline_action_icons::green_check_icon(appearance).finish(),
),
StartAgentResult::Error { error, .. } => (
vec![
FormattedTextFragment::plain_text(start_agent_error_prefix(execution_mode)),
FormattedTextFragment::bold(name),
FormattedTextFragment::plain_text(format!(": {error}")),
],
inline_action_icons::red_x_icon(appearance).finish(),
),
StartAgentResult::Cancelled { .. } => (
vec![
FormattedTextFragment::plain_text(start_agent_cancelled_prefix(execution_mode)),
FormattedTextFragment::bold(name),
FormattedTextFragment::plain_text(" cancelled."),
],
inline_action_icons::cancelled_icon(appearance).finish(),
),
};
let has_prompt = !prompt.is_empty();
let chevron = if has_prompt {
render_collapse_chevron(message_id, props, app)
} else {
None
};
let header_text = render_formatted_text_element(label_fragments, app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_requested_action_row(
header_text.into(),
Some(status_icon),
chevron,
false,
false,
app,
));
if has_prompt {
let prompt_element = render_collapsible_text_body(
prompt,
blended_colors::text_disabled(theme, theme.surface_2()),
true,
app,
);
if let Some(body) = render_collapsible_body(message_id, prompt_element, false, props) {
column.add_child(body);
}
}
if let Some(card_data) = child_conversation_card_data {
let navigation_card_handle = props
.state_handles
.orchestration_navigation_card_handles
.get(action_id)
.cloned()
.unwrap_or_else(|| {
log::error!(
"Missing orchestration navigation card handle for StartAgent action {:?}",
action_id
);
MouseStateHandle::default()
});
let status_icon = card_data.status.status_icon_and_color(theme);
column.add_child(render_conversation_navigation_card_row(
&card_data.agent_name,
Some(&card_data.title),
Some(status_icon),
card_data.conversation_id,
navigation_card_handle,
true,
app,
));
}
return column
.finish()
.with_agent_output_item_spacing(app)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish();
}
// Non-finished (streaming/queued) state.
let dimmed_text_color = blended_colors::text_disabled(theme, theme.surface_2());
let should_dim_text = (props.model.status(app).is_streaming()
&& !props.model.is_first_action_in_output(action_id, app))
|| status.as_ref().is_some_and(|s| s.is_queued());
let label_fragments = vec![
FormattedTextFragment::plain_text(start_agent_in_progress_prefix(execution_mode)),
FormattedTextFragment::bold(name),
FormattedTextFragment::plain_text(" ..."),
];
let mut header_text = render_formatted_text_element(label_fragments, app);
if should_dim_text {
header_text = header_text.with_color(dimmed_text_color);
}
let has_prompt = !prompt.is_empty();
let chevron = if has_prompt {
render_collapse_chevron(message_id, props, app)
} else {
None
};
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_requested_action_row(
header_text.into(),
Some(action_icon(action_id, props.action_model, props.model, app).finish()),
chevron,
false,
false,
app,
));
// Collapsible body: prompt text with max height
if has_prompt {
let prompt_color = if should_dim_text {
dimmed_text_color
} else {
blended_colors::text_disabled(theme, theme.surface_2())
};
let prompt_element = render_collapsible_text_body(prompt, prompt_color, true, app);
if let Some(body) = render_collapsible_body(
message_id,
prompt_element,
props.model.status(app).is_streaming(),
props,
) {
column.add_child(body);
}
}
column
.finish()
.with_agent_output_item_spacing(app)
.with_background_color(blended_colors::neutral_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
fn start_agent_success_suffix(execution_mode: &StartAgentExecutionMode) -> &'static str {
match execution_mode {
StartAgentExecutionMode::Local { .. } => " locally.",
StartAgentExecutionMode::Remote { .. } => " remotely.",
}
}
fn start_agent_error_prefix(execution_mode: &StartAgentExecutionMode) -> &'static str {
match execution_mode {
StartAgentExecutionMode::Local { .. } => "Failed to start agent ",
StartAgentExecutionMode::Remote { .. } => "Failed to start remote agent ",
}
}
fn start_agent_cancelled_prefix(execution_mode: &StartAgentExecutionMode) -> &'static str {
match execution_mode {
StartAgentExecutionMode::Local { .. } => "Start agent ",
StartAgentExecutionMode::Remote { .. } => "Start remote agent ",
}
}
fn start_agent_in_progress_prefix(execution_mode: &StartAgentExecutionMode) -> &'static str {
match execution_mode {
StartAgentExecutionMode::Local { .. } => "Starting agent ",
StartAgentExecutionMode::Remote { .. } => "Starting remote agent ",
}
}
/// Renders a selectable text block below an orchestration action header, using a muted color.
/// Used for both StartAgent prompts and SendMessageToAgent message bodies.
fn render_collapsible_text_body(
text: &str,
text_color: ColorU,
align_with_status_row_text: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut container = Container::new(
Text::new(
text.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(text_color)
.with_selectable(true)
.finish(),
)
.with_margin_top(4.);
if align_with_status_row_text {
container = container
.with_margin_left(INLINE_ACTION_HORIZONTAL_PADDING + icon_size(app) + ICON_MARGIN)
.with_margin_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_margin_bottom(INLINE_ACTION_HEADER_VERTICAL_PADDING);
}
container.finish()
}
/// Card data for a child conversation navigation link.
#[derive(Debug, PartialEq)]
struct ChildConversationCardData {
conversation_id: AIConversationId,
agent_name: String,
title: String,
status: ConversationStatus,
}
fn child_conversation_card_data_for_result(
result: &StartAgentResult,
app: &AppContext,
) -> Option<ChildConversationCardData> {
match result {
StartAgentResult::Success { agent_id, .. } => {
let conversation_id = conversation_id_for_agent_id(agent_id, app)?;
let conversation =
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)?;
let agent_name = conversation.agent_name().unwrap_or("Agent").to_string();
let status = conversation.status().clone();
let title = available_conversation_title_for_id(conversation_id, app)?;
Some(ChildConversationCardData {
conversation_id,
agent_name,
title,
status,
})
}
StartAgentResult::Error { .. } | StartAgentResult::Cancelled { .. } => None,
}
}
fn available_conversation_title_for_id(
conversation_id: AIConversationId,
app: &AppContext,
) -> Option<String> {
let conversation = BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)?;
let title = conversation.title().filter(|title| !title.is_empty());
match title {
Some(title) if conversation.initial_query().as_deref() != Some(title.as_str()) => {
Some(title)
}
_ => Some(GENERATING_TITLE_PLACEHOLDER.to_string()),
}
}
/// Renders a chevron toggle for collapsing/expanding orchestration block bodies.
fn render_collapse_chevron(
message_id: &MessageId,
props: Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let state = props.collapsible_block_states.get(message_id)?;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_color = theme.foreground();
let icon_sz = icon_size(app);
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let chevron_icon = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let toggle_mouse_state = state.expansion_toggle_mouse_state.clone();
let message_id_clone = message_id.clone();
Some(
Hoverable::new(toggle_mouse_state, move |_| {
ConstrainedBox::new(chevron_icon.to_warpui_icon(text_color).finish())
.with_width(icon_sz)
.with_height(icon_sz)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id_clone.clone(),
));
})
.finish(),
)
}
/// Renders the collapsible body content with max height and scroll, or None if collapsed.
fn render_collapsible_body(
message_id: &MessageId,
body: Box<dyn Element>,
is_streaming: bool,
props: Props,
) -> Option<Box<dyn Element>> {
let Some(state) = props.collapsible_block_states.get(message_id) else {
log::error!(
"Missing collapsible state for orchestration message {:?}",
message_id
);
return None;
};
render_scrollable_collapsible_content(
message_id,
state,
body,
is_streaming,
ORCHESTRATION_COLLAPSED_MAX_HEIGHT,
)
}
/// Builds a `FormattedTextElement` from a list of mixed plain/bold fragments.
fn render_formatted_text_element(
fragments: Vec<FormattedTextFragment>,
app: &AppContext,
) -> FormattedTextElement {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let formatted_text = FormattedText::new(vec![FormattedTextLine::Line(fragments)]);
FormattedTextElement::new(
formatted_text,
appearance.monospace_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_main(theme, theme.background()),
Default::default(),
)
.set_selectable(true)
}
fn render_conversation_navigation_card_row(
title: &str,
subtitle: Option<&str>,
icon: Option<(Icon, pathfinder_color::ColorU)>,
conversation_id: AIConversationId,
mouse_state: MouseStateHandle,
align_with_status_row_text: bool,
app: &AppContext,
) -> Box<dyn Element> {
let card = conversation_navigation_card_with_icon(
icon,
title.to_string(),
subtitle.map(|s| s.to_string()),
move |ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
},
mouse_state,
true,
None,
app,
);
let mut container = Container::new(card).with_margin_top(6.);
if align_with_status_row_text {
container = container
.with_margin_left(INLINE_ACTION_HORIZONTAL_PADDING + icon_size(app) + ICON_MARGIN)
.with_margin_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_margin_bottom(INLINE_ACTION_HEADER_VERTICAL_PADDING);
}
container.finish()
}
#[cfg(test)]
#[path = "orchestration_tests.rs"]
mod tests;
@@ -0,0 +1,241 @@
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
use crate::BlocklistAIHistoryModel;
use ai::agent::action_result::StartAgentVersion;
use warp_core::ui::appearance::Appearance;
use warpui::elements::MouseStateHandle;
use warpui::{App, EntityId};
use super::{
agent_display_name_from_id, child_conversation_card_data_for_result,
render_conversation_navigation_card_row, start_agent_cancelled_prefix,
start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix,
ChildConversationCardData,
};
#[test]
fn child_conversation_card_data_for_success_result_returns_conversation_id_and_title() {
App::test((), |mut app| async move {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
);
history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_fallback_display_title("Generated child title".to_string());
conversation_id
});
let result = StartAgentResult::Success {
agent_id: "child-agent-id".to_string(),
version: StartAgentVersion::V1,
};
let actual = app.read(|ctx| child_conversation_card_data_for_result(&result, ctx));
assert_eq!(
actual,
Some(ChildConversationCardData {
conversation_id,
agent_name: "Agent".to_string(),
title: "Generated child title".to_string(),
status: ConversationStatus::InProgress,
})
);
});
}
#[test]
fn start_agent_copy_uses_local_labels_for_local_children() {
let execution_mode = StartAgentExecutionMode::local_harness("claude-code".to_string());
assert_eq!(start_agent_success_suffix(&execution_mode), " locally.");
assert_eq!(
start_agent_error_prefix(&execution_mode),
"Failed to start agent "
);
assert_eq!(
start_agent_cancelled_prefix(&execution_mode),
"Start agent "
);
assert_eq!(
start_agent_in_progress_prefix(&execution_mode),
"Starting agent "
);
}
#[test]
fn start_agent_copy_uses_remote_labels_for_remote_children() {
let execution_mode = StartAgentExecutionMode::Remote {
environment_id: "env-123".to_string(),
skill_references: vec![],
model_id: String::new(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: String::new(),
title: String::new(),
};
assert_eq!(start_agent_success_suffix(&execution_mode), " remotely.");
assert_eq!(
start_agent_error_prefix(&execution_mode),
"Failed to start remote agent "
);
assert_eq!(
start_agent_cancelled_prefix(&execution_mode),
"Start remote agent "
);
assert_eq!(
start_agent_in_progress_prefix(&execution_mode),
"Starting remote agent "
);
}
#[test]
fn child_conversation_card_data_for_success_result_without_available_title_uses_placeholder() {
App::test((), |mut app| async move {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
);
conversation_id
});
let result = StartAgentResult::Success {
agent_id: "child-agent-id".to_string(),
version: StartAgentVersion::V1,
};
let actual = app.read(|ctx| child_conversation_card_data_for_result(&result, ctx));
assert_eq!(
actual,
Some(ChildConversationCardData {
conversation_id,
agent_name: "Agent".to_string(),
title: "Generating title...".to_string(),
status: ConversationStatus::InProgress,
})
);
});
}
#[test]
fn child_conversation_card_data_for_non_success_result_returns_none() {
App::test((), |app| async move {
let error_result = StartAgentResult::Error {
error: "boom".to_string(),
version: StartAgentVersion::V1,
};
let error_actual =
app.read(|ctx| child_conversation_card_data_for_result(&error_result, ctx));
assert_eq!(error_actual, None);
let cancelled_actual = app.read(|ctx| {
child_conversation_card_data_for_result(
&StartAgentResult::Cancelled {
version: StartAgentVersion::V1,
},
ctx,
)
});
assert_eq!(cancelled_actual, None);
});
}
#[test]
fn child_conversation_card_data_returns_none_for_unknown_agent_id() {
App::test((), |app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let result = StartAgentResult::Success {
agent_id: "missing-agent-id".to_string(),
version: StartAgentVersion::V1,
};
let actual = app.read(|ctx| child_conversation_card_data_for_result(&result, ctx));
assert_eq!(actual, None);
});
}
#[test]
fn agent_display_name_from_id_returns_child_agent_name() {
App::test((), |mut app| async move {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
history_model.set_server_conversation_token_for_conversation(
conversation_id,
"child-agent-id".to_string(),
);
history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_agent_name("Agent 1".to_string());
});
let actual = app.read(|ctx| {
agent_display_name_from_id("child-agent-id", Some("orchestrator-agent-id"), ctx)
});
assert_eq!(actual, "Agent 1");
});
}
#[test]
fn agent_display_name_from_id_returns_orchestrator_label() {
App::test((), |mut app| async move {
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
history_model.update(&mut app, |history_model, ctx| {
let conversation_id =
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
let conversation = history_model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
conversation.set_server_conversation_token("orchestrator-agent-id".to_string());
conversation.set_agent_name("Agent 0".to_string());
});
let actual = app.read(|ctx| {
agent_display_name_from_id("orchestrator-agent-id", Some("orchestrator-agent-id"), ctx)
});
assert_eq!(actual, "Orchestrator agent");
});
}
#[test]
fn agent_display_name_from_id_returns_unknown_fallback() {
App::test((), |app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let actual =
app.read(|ctx| agent_display_name_from_id("missing-agent-id", Some("other-id"), ctx));
assert_eq!(actual, "Unknown agent");
});
}
#[test]
fn conversation_navigation_card_row_renders_title_without_legacy_subtitle() {
App::test((), |app| async move {
app.add_singleton_model(|_| Appearance::mock());
let element = app.read(|ctx| {
render_conversation_navigation_card_row(
"Child conversation",
None,
None,
AIConversationId::new(),
MouseStateHandle::default(),
false,
ctx,
)
});
let text_content = element.debug_text_content().unwrap_or_default();
assert!(
text_content.contains("Child conversation"),
"Expected child conversation title in rendered text: {text_content}",
);
assert!(
!text_content.contains("Open in agent mode"),
"Legacy subtitle should not appear in rendered card text: {text_content}",
);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
use ai::agent::action::UploadArtifactRequest;
use crate::ai::agent::UploadArtifactResult;
use super::format_upload_artifact_text;
#[test]
fn format_upload_artifact_text_includes_request_details() {
let request = UploadArtifactRequest {
file_path: "reports/daily.txt".to_string(),
description: Some("Daily summary".to_string()),
};
let text = format_upload_artifact_text(&request, None);
assert_eq!(
text,
"Upload artifact: reports/daily.txt\nDescription: Daily summary"
);
}
#[test]
fn format_upload_artifact_text_includes_success_summary() {
let request = UploadArtifactRequest {
file_path: "reports/daily.txt".to_string(),
description: Some("Daily summary".to_string()),
};
let result = UploadArtifactResult::Success {
artifact_uid: "artifact-123".to_string(),
filepath: Some("reports/daily.txt".to_string()),
mime_type: "text/plain".to_string(),
description: Some("Daily summary".to_string()),
size_bytes: 128,
};
let text = format_upload_artifact_text(&request, Some(&result));
assert_eq!(
text,
"Upload artifact: reports/daily.txt\nDescription: Daily summary\nStatus: uploaded artifact artifact-123\nUploaded file: reports/daily.txt"
);
}
#[test]
fn format_upload_artifact_text_includes_terminal_status() {
let request = UploadArtifactRequest {
file_path: "reports/daily.txt".to_string(),
description: None,
};
let error_text = format_upload_artifact_text(
&request,
Some(&UploadArtifactResult::Error(
"permission denied".to_string(),
)),
);
assert_eq!(
error_text,
"Upload artifact: reports/daily.txt\nStatus: upload failed: permission denied"
);
let cancelled_text =
format_upload_artifact_text(&request, Some(&UploadArtifactResult::Cancelled));
assert_eq!(cancelled_text, "Upload artifact: reports/daily.txt");
}
@@ -0,0 +1,176 @@
//! Renders the user query portion of the AI block, if there is one.
//!
//! Queries are not rendered in blocks corresponding to requested command or requested action responses.
use warp_core::{features::FeatureFlag, ui::theme::color::internal_colors};
use warpui::{
elements::{
Container, CornerRadius, Flex, MainAxisAlignment, MainAxisSize, ParentElement, Radius,
Shrinkable, Wrap,
},
fonts::{Properties, Style, Weight},
ui_components::{
chip::Chip,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, SingletonEntity,
};
use crate::ai::blocklist::block::view_impl::common::UserQueryProps;
use crate::ai::blocklist::AttachmentType;
use crate::appearance::Appearance;
use crate::{
ai::blocklist::block::{DetectedLinksState, SecretRedactionState},
ui_components::{blended_colors, icons::Icon},
};
use pathfinder_color::ColorU;
use super::common::{render_query_text, render_user_avatar, FindContext};
/// Data required to render the AI block query component.
#[derive(Copy, Clone, Debug)]
pub(super) struct Props<'a> {
pub(super) user_display_name: &'a String,
pub(super) profile_image_path: Option<&'a String>,
pub(super) avatar_color: Option<ColorU>,
pub(super) query_and_index: Option<(&'a str, usize)>,
pub(super) query_prefix_highlight_len: Option<usize>,
pub(super) detected_links_state: &'a DetectedLinksState,
pub(super) secret_redaction_state: &'a SecretRedactionState,
pub(super) is_selecting_text: bool,
pub(super) is_ai_input_enabled: bool,
pub(super) attachments: &'a [(AttachmentType, String)],
pub(super) find_context: Option<FindContext<'a>>,
}
pub(super) fn maybe_render(props: Props, app: &AppContext) -> Option<Box<dyn Element>> {
props.query_and_index.map(|(query, input_index)| {
render_query(
query,
props.user_display_name,
props.profile_image_path,
props.avatar_color,
props.detected_links_state,
props.secret_redaction_state,
input_index,
props.query_prefix_highlight_len,
props.is_selecting_text,
props.is_ai_input_enabled,
props.attachments,
props.find_context,
app,
)
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_query(
query: &str,
user_display_name: &str,
profile_image_path: Option<&String>,
avatar_color: Option<ColorU>,
detected_links_state: &DetectedLinksState,
secret_redaction_state: &SecretRedactionState,
input_index: usize,
query_prefix_highlight_len: Option<usize>,
is_selecting: bool,
is_ai_input_enabled: bool,
attachments: &[(AttachmentType, String)],
find_context: Option<FindContext>,
app: &AppContext,
) -> Box<dyn Element> {
let avatar = Container::new(render_user_avatar(
user_display_name,
profile_image_path,
avatar_color,
app,
))
.with_margin_right(16.)
.finish();
let properties = Properties {
style: Style::Normal,
weight: Weight::Bold,
};
// The query already includes the /plan prefix when in plan mode via display_user_query()
let text_element = render_query_text(
UserQueryProps {
text: query.to_owned(),
query_prefix_highlight_len,
detected_links_state,
secret_redaction_state,
input_index,
is_selecting,
is_ai_input_enabled,
find_context,
font_properties: &properties,
},
app,
);
let appearance = Appearance::as_ref(app);
let mut query = Flex::column().with_child(text_element.finish());
if FeatureFlag::ImageAsContext.is_enabled() {
query = query.with_child(render_attachments(attachments, appearance));
}
Flex::row()
.with_cross_axis_alignment(warpui::elements::CrossAxisAlignment::Start)
.with_child(avatar)
.with_child(Shrinkable::new(1., query.finish()).finish())
.finish()
}
fn render_attachments(
attachments: &[(AttachmentType, String)],
appearance: &Appearance,
) -> Box<dyn Element> {
let chips = attachments.iter().map(|(attachment_type, file_name)| {
let icon = match attachment_type {
AttachmentType::Image => Icon::Image,
AttachmentType::File => Icon::File,
};
Chip::new(
file_name.clone(),
UiComponentStyles {
margin: Some(Coords {
top: 0.,
bottom: 0.,
left: 0.,
right: 6.,
}),
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.monospace_font_size()),
font_color: Some(blended_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
)),
border_width: Some(1.),
border_color: Some(internal_colors::neutral_4(appearance.theme()).into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
..Default::default()
},
)
.with_icon(icon.to_warpui_icon(
blended_colors::text_sub(appearance.theme(), appearance.theme().background()).into(),
))
.build()
.finish()
});
if attachments.is_empty() {
Flex::row().finish()
} else {
let wrapping_section = Wrap::row()
.with_run_spacing(8.)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Min)
.with_children(chips)
.finish();
Container::new(wrapping_section)
.with_padding_top(7.)
.finish()
}
}
@@ -0,0 +1,255 @@
//! Rendering logic for todo list components in AI blocks.
use warpui::fonts::Properties;
use warpui::text_layout::TextStyle;
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
Highlight, ParentElement, Radius, Shrinkable, Text,
},
AppContext, Element, SingletonEntity,
};
use crate::ai::agent::conversation::{AIConversation, TodoStatus};
use crate::ai::agent::icons::{gray_stop_icon, in_progress_icon, pending_icon, succeeded_icon};
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::{AIAgentTodo, MessageId};
use crate::ai::blocklist::inline_action::inline_action_icons::cancelled_icon;
use crate::{
ai::{
agent::icons::todo_list_icon,
blocklist::{
block::{AIBlockAction, TodoListElementState},
inline_action::{
inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HORIZONTAL_PADDING,
},
inline_action_icons::icon_size,
},
},
},
appearance::Appearance,
ui_components::{blended_colors, icons::Icon},
};
use super::WithContentItemSpacing;
pub(super) fn render_todos(
id: &MessageId,
todos: &[AIAgentTodo],
conversation: &AIConversation,
state: &TodoListElementState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Add collapsible header.
let id = id.clone();
let mut header_config = HeaderConfig::new("Tasks", app)
.with_interaction_mode(InteractionMode::ManuallyExpandable(
ExpandedConfig::new(state.is_expanded, state.header_toggle_mouse_state.clone())
.with_toggle_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::ToggleTodoListExpanded(id.clone()));
}),
))
.with_icon(todo_list_icon(appearance));
let mut has_cancelled_todo = false;
let mut rendered_todos = vec![];
for todo in todos.iter() {
let status = conversation
.todo_status(&todo.id)
.unwrap_or(TodoStatus::Cancelled);
if status.is_cancelled() {
has_cancelled_todo = true;
}
rendered_todos.push(render_todo(todo, status, app));
}
let is_list_outdated = has_cancelled_todo
|| todos.len() != conversation.active_todo_list().map_or(0, |list| list.len());
if is_list_outdated {
header_config = header_config.with_badge("Outdated".to_string());
}
let header_element = header_config.render(app);
let mut container = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header_element);
// Render todo list items.
if state.is_expanded {
container.add_child(
Container::new(Flex::column().with_children(rendered_todos).finish())
.with_padding_top(12.)
.with_border(
Border::new(1.)
.with_sides(false, true, true, true)
.with_border_fill(theme.outline()),
)
.with_background_color(theme.background().into_solid())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
container
.finish()
.with_agent_output_item_spacing(app)
.finish()
}
fn render_todo(todo: &AIAgentTodo, status: TodoStatus, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_color = blended_colors::text_main(theme, theme.surface_1());
let icon = match status {
TodoStatus::Pending => pending_icon(appearance),
TodoStatus::InProgress => in_progress_icon(appearance),
TodoStatus::Completed => succeeded_icon(appearance),
TodoStatus::Cancelled => cancelled_icon(appearance),
TodoStatus::Stopped => gray_stop_icon(appearance),
};
let item_icon = Container::new(
ConstrainedBox::new(icon.finish())
.with_width(icon_size(app) - 4.)
.with_height(icon_size(app) - 4.)
.finish(),
)
.with_margin_right(12.)
.finish();
let mut item_text = Text::new(
todo.title.clone(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_style(Properties::default().weight(appearance.monospace_font_weight()));
if status.is_cancelled() {
let title = todo.title.clone();
let highlight_indices = (0..title.chars().count()).collect();
let strikethrough_highlight = Highlight::new().with_text_style(
TextStyle::new()
.with_show_strikethrough(true)
.with_foreground_color(blended_colors::neutral_5(theme)),
);
item_text = item_text.with_single_highlight(strikethrough_highlight, highlight_indices);
} else {
item_text = item_text.with_color(text_color);
}
let item_text = item_text.finish();
let item_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(item_icon)
.with_child(Shrinkable::new(1., item_text).finish())
.finish();
Container::new(item_row)
.with_margin_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_margin_bottom(12.)
.finish()
}
/// Renders a completed todo item with a check mark and a divider line.
pub(super) fn render_completed_todo_items(
completed_items: &[AIAgentTodo],
current_todo_list: Option<&AIAgentTodoList>,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let sub_text_color = blended_colors::text_sub(theme, theme.surface_1());
// Create a check mark icon
let check_icon = Container::new(
ConstrainedBox::new(
warpui::elements::Icon::new(
Icon::Check.into(),
warp_core::ui::theme::Fill::Solid(sub_text_color),
)
.finish(),
)
.with_width(icon_size(app) - 4.)
.with_height(icon_size(app) - 4.)
.finish(),
)
.with_margin_right(6.)
.finish();
// Create the content row
let mut content_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(check_icon);
let mut completed_text = "".to_owned();
for (i, completed_item) in completed_items.iter().enumerate() {
let index_and_len = current_todo_list.and_then(|list| {
list.get_item_index(&completed_item.id)
.map(|i| (i, list.len()))
});
if i == 0 {
if let Some((index, list_len)) = index_and_len {
completed_text += format!(
"Completed {} ({}/{})",
completed_item.title,
index + 1,
list_len
)
.as_str()
} else {
completed_text += format!("Completed {}", completed_item.title).as_str()
}
} else if let Some((index, list_len)) = index_and_len {
completed_text +=
format!(", {} ({}/{})", completed_item.title, index + 1, list_len).as_str()
} else {
completed_text += format!(", {}", completed_item.title).as_str()
}
}
if completed_text.is_empty() {
return None;
}
content_row.add_child(
Shrinkable::new(
1.,
Text::new(
completed_text,
appearance.ui_font_family(),
(appearance.ui_font_size() - 2.) * appearance.monospace_ui_scalar(),
)
.with_color(sub_text_color)
.with_style(Properties::default().weight(appearance.monospace_font_weight()))
.finish(),
)
.finish(),
);
// Create a divider line that extends to the full width using negative margins
let divider = Container::new(
ConstrainedBox::new(Empty::new().finish())
.with_height(1.)
.finish(),
)
.with_background_color(theme.outline().into_solid())
.with_margin_top(6.)
.with_margin_bottom(6.)
.with_margin_left(-20.)
.with_margin_right(-20.)
.finish();
// Combine content and divider - structure to allow full-width divider
let complete_item = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(content_row.finish())
.with_child(divider)
.finish();
Some(complete_item.with_agent_output_item_spacing(app).finish())
}
+87
View File
@@ -0,0 +1,87 @@
use super::{CollapsibleElementState, CollapsibleExpansionState};
use crate::settings::AISettings;
use crate::test_util::settings::initialize_settings_for_tests;
use settings::Setting;
use warpui::{App, SingletonEntity};
#[test]
fn reasoning_auto_collapses_when_user_has_not_manually_toggled() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut state = CollapsibleElementState::default();
app.update(|ctx| {
state.finish_reasoning(ctx);
});
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
});
}
#[test]
fn always_show_thinking_stays_expanded_after_finish() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.thinking_display_mode
.set_value(crate::settings::ThinkingDisplayMode::AlwaysShow, ctx)
.unwrap();
});
let mut state = CollapsibleElementState::default();
app.update(|ctx| {
state.finish_reasoning(ctx);
});
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
});
}
#[test]
fn manual_collapse_while_streaming_stays_collapsed_after_finish() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
app.update(|ctx| {
state.finish_reasoning(ctx);
});
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
});
}
#[test]
fn manual_reexpand_while_streaming_stays_expanded_after_finish() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
state.toggle_expansion();
app.update(|ctx| {
state.finish_reasoning(ctx);
});
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
});
}
+477
View File
@@ -0,0 +1,477 @@
use crate::ai::blocklist::inline_action::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::code::editor_management::CodeSource;
use crate::search::files::icon::icon_from_file_path;
use crate::search::ItemHighlightState;
use std::iter;
use warp_core::ui::theme::Fill;
use warpui::elements::{ChildView, HighlightedRange, MouseStateHandle};
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable, Text,
},
ui_components::components::UiComponent,
AppContext, Element, SingletonEntity,
};
use warpui::{EventContext, ViewHandle};
use crate::code::editor::view::CodeEditorView;
use crate::ui_components::blended_colors;
use crate::ui_components::buttons::icon_button;
use crate::{ai::agent::ProgrammingLanguage, ui_components::buttons::icon_button_with_color};
use crate::{appearance::Appearance, ui_components::icons::Icon};
use std::path::Path;
const CODE_BLOCK_CORNER_RADIUS: f32 = 8.0;
#[derive(Default, Clone)]
pub struct CodeSnippetButtonHandles {
pub open_button: MouseStateHandle,
pub copy_button: MouseStateHandle,
pub insert_button: MouseStateHandle,
}
impl CodeSnippetButtonHandles {
// Resets the hover state of all buttons that trigger a focus change.
pub fn reset_hover_state_on_focus_change(&self) {
if let Ok(mut state) = self.open_button.lock() {
state.reset_hover_state();
}
}
}
pub type HandleCode = Box<dyn FnMut(String, &mut EventContext)>;
fn render_file_icon(path: &Path, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(icon_from_file_path(
path.to_str().unwrap_or_default(),
appearance,
ItemHighlightState::Default,
))
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish(),
)
.with_margin_right(8.)
.finish()
}
fn render_button<F>(
appearance: &Appearance,
icon: Icon,
tooltip_text: &str,
mouse_handle: MouseStateHandle,
formatted_text: String,
on_click: F,
color: Option<Fill>,
) -> Container
where
F: FnMut(String, &mut EventContext) + 'static,
{
let ui_builder = appearance.ui_builder().clone();
let tooltip_text = tooltip_text.to_owned();
let mut on_click = on_click;
let button_element = if let Some(color) = color {
icon_button_with_color(appearance, icon, false, mouse_handle, color)
} else {
icon_button(appearance, icon, false, mouse_handle)
};
Container::new(
button_element
.with_tooltip(move || ui_builder.tool_tip(tooltip_text.clone()).build().finish())
.build()
.on_click(move |ctx, _, _| {
on_click(formatted_text.clone(), ctx);
})
.finish(),
)
}
pub struct CodeBlockOptions {
pub on_open: Option<HandleCode>,
pub on_execute: Option<HandleCode>,
pub on_copy: Option<HandleCode>,
pub on_insert: Option<HandleCode>,
pub footer_element: Option<Box<dyn Element>>,
pub mouse_handles: Option<CodeSnippetButtonHandles>,
pub file_path: Option<String>,
}
pub fn render_code_block_with_warp_text(
options: CodeBlockOptions,
view: &ViewHandle<CodeEditorView>,
app: &AppContext,
source: Option<CodeSource>,
) -> Box<dyn Element> {
let code = view.as_ref(app).text(app);
let code_element = ChildView::new(view).finish();
render_code_block_internal(code.as_str(), code_element, options, app, source, true)
}
pub fn render_code_block_plain(
code: &str,
find_highlight_ranges: impl Iterator<Item = HighlightedRange>,
options: CodeBlockOptions,
app: &AppContext,
source: Option<CodeSource>,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let code_element = Text::new(
code.to_owned(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_main(theme, theme.surface_1()))
.with_highlights(find_highlight_ranges)
.with_selectable(true)
.finish();
render_code_block_internal(code, code_element, options, app, source, false)
}
/// Renders a code snippet with a language label and optional buttons.
/// This command did not come from Agent Mode.
pub fn render_runnable_code_snippet(
code_snippet: &str,
language: Option<&ProgrammingLanguage>,
on_execute: Option<HandleCode>,
on_copy: Option<HandleCode>,
mouse_handles: Option<CodeSnippetButtonHandles>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let language_text = language.map(|language| {
Text::new_inline(
language.display_name(),
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_sub(theme, theme.surface_3()))
.finish()
});
let allow_execution = language.is_none_or(|lang| lang.is_shell());
render_code_block_plain(
code_snippet,
Box::new(iter::empty()),
CodeBlockOptions {
on_open: None,
on_execute: if allow_execution { on_execute } else { None },
on_copy,
on_insert: None,
footer_element: language_text,
mouse_handles,
file_path: None,
},
app,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn render_linked_code_block_internal(
file_icon: Box<dyn Element>,
file_path_text: Box<dyn Element>,
code: &str,
code_element: Box<dyn Element>,
on_open: Option<HandleCode>,
on_copy: Option<HandleCode>,
on_insert: Option<HandleCode>,
insert_text: Option<String>,
mouse_handles: Option<CodeSnippetButtonHandles>,
appearance: &Appearance,
) -> Flex {
let theme = appearance.theme();
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
header_row.add_child(file_icon);
header_row.add_child(
Shrinkable::new(
0.9,
Container::new(file_path_text)
.with_margin_right(8.)
.finish(),
)
.finish(),
);
header_row.add_child(Expanded::new(0.1, Empty::new().finish()).finish());
if let Some(mouse_handles) = mouse_handles {
let mut action_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let code_clone = code.to_owned();
if let (Some(on_insert), Some(insert_text)) = (on_insert, insert_text) {
let insert_button = render_button(
appearance,
Icon::AtSign,
"Add as Context",
mouse_handles.insert_button,
insert_text,
on_insert,
Some(Fill::from(blended_colors::text_main(
theme,
theme.surface_1(),
))),
)
.with_margin_left(8.)
.finish();
action_row.add_child(insert_button);
}
if let Some(on_copy) = on_copy {
let copy_button = render_button(
appearance,
Icon::Copy,
"Copy",
mouse_handles.copy_button,
code_clone.clone(),
on_copy,
Some(Fill::from(blended_colors::text_main(
theme,
theme.surface_1(),
))),
)
.with_margin_left(8.)
.finish();
action_row.add_child(copy_button);
}
if let Some(on_open) = on_open {
let open_button = render_button(
appearance,
Icon::LinkExternal,
"Open in Warp",
mouse_handles.open_button,
code_clone.clone(),
on_open,
Some(Fill::from(blended_colors::text_main(
theme,
theme.surface_1(),
))),
)
.with_margin_left(8.)
.finish();
action_row.add_child(open_button);
}
header_row.add_child(action_row.finish());
}
content.add_child(
Container::new(header_row.finish())
.with_background(theme.surface_2())
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(
CODE_BLOCK_CORNER_RADIUS,
)))
.finish(),
);
content.add_child(
Container::new(code_element)
.with_background(theme.background())
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
CODE_BLOCK_CORNER_RADIUS,
)))
.finish(),
);
content
}
#[allow(clippy::too_many_arguments)]
fn render_plain_code_block_internal(
code: &str,
code_element: Box<dyn Element>,
footer_element: Option<Box<dyn Element>>,
on_execute: Option<HandleCode>,
on_copy: Option<HandleCode>,
mouse_handles: Option<CodeSnippetButtonHandles>,
appearance: &Appearance,
without_extra_padding_between_code_and_footer: bool,
) -> Flex {
let theme = appearance.theme();
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let mut footer_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_child(footer_element.unwrap_or_else(|| Empty::new().finish()));
if let Some(mouse_handles) = mouse_handles {
let mut action_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let code_clone = code.to_owned();
if let Some(on_copy) = on_copy {
let copy_button = render_button(
appearance,
Icon::Copy,
"Copy",
mouse_handles.copy_button,
code_clone.clone(),
on_copy,
None,
)
.finish();
action_row.add_child(copy_button);
}
if let Some(on_execute) = on_execute {
let insert_button = render_button(
appearance,
Icon::TerminalInput,
"Run in terminal",
mouse_handles.insert_button,
code_clone.clone(),
on_execute,
None,
)
.with_margin_left(8.)
.finish();
action_row.add_child(insert_button);
}
footer_row.add_child(action_row.finish());
}
let code_padding_bottom = if without_extra_padding_between_code_and_footer {
0.
} else {
INLINE_ACTION_HEADER_VERTICAL_PADDING
};
let footer_padding_top = if without_extra_padding_between_code_and_footer {
0.
} else {
6.
};
content.add_child(
Container::new(code_element)
.with_background(theme.surface_2())
.with_padding_top(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_padding_bottom(code_padding_bottom)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(
CODE_BLOCK_CORNER_RADIUS,
)))
.finish(),
);
content.add_child(
Container::new(
footer_row
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.finish(),
)
.with_background(theme.surface_2())
.with_padding_top(footer_padding_top)
.with_padding_bottom(12.)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
CODE_BLOCK_CORNER_RADIUS,
)))
.finish(),
);
content
}
fn render_code_block_internal(
code: &str,
code_element: Box<dyn Element>,
CodeBlockOptions {
on_open,
on_execute,
on_copy,
on_insert,
footer_element,
mouse_handles,
file_path,
}: CodeBlockOptions,
app: &AppContext,
source: Option<CodeSource>,
without_extra_padding_between_code_and_footer: bool,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let content = match (source.as_ref(), file_path) {
(
Some(CodeSource::Link {
path,
range_start,
range_end,
}),
Some(file_path),
) => {
let file_path_text = Text::new_inline(
format!(
"{}{}",
file_path,
match (range_start, range_end) {
(Some(ls), Some(le)) => format!(" ({}-{})", ls.line_num, le.line_num),
_ => String::new(),
}
),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_main(theme, theme.background()))
.finish();
let formatted_insert_text = {
let line_number =
range_start.map_or(String::new(), |start| format!(":{}", start.line_num));
Some(format!("{file_path}{line_number}"))
};
render_linked_code_block_internal(
render_file_icon(path, appearance, app),
file_path_text,
code,
code_element,
on_open,
on_copy,
on_insert,
formatted_insert_text,
mouse_handles,
appearance,
)
}
_ => render_plain_code_block_internal(
code,
code_element,
footer_element,
on_execute,
on_copy,
mouse_handles,
appearance,
without_extra_padding_between_code_and_footer,
),
};
Container::new(content.finish())
.with_border(Border::all(1.).with_border_fill(theme.outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
CODE_BLOCK_CORNER_RADIUS,
)))
.finish()
}
@@ -0,0 +1,360 @@
use std::path::PathBuf;
use crate::appearance::Appearance;
use crate::terminal::view::{InlineBannerId, TerminalAction};
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use warpui::elements::{Align, ConstrainedBox};
use warpui::ui_components::button::ButtonVariant;
use warpui::ui_components::components::Coords;
use warpui::{
elements::{
Container, CrossAxisAlignment, Flex, MainAxisSize, MouseStateHandle, ParentElement,
Shrinkable,
},
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
Element,
};
const SPEEDBUMP_HEADER: &str = "Index Codebase?";
const SPEEDBUMP_TEXT: &str = "Indexing helps agents quickly understand context and provide targeted solutions. Code is never stored on the server.";
/// Uniform padding around the banner
const PADDING: f32 = 12.;
/// Text for the button that allows execution
const ALLOW_BUTTON_TEXT: &str = "Index codebase";
const ALLOW_SETTINGS_TEXT: &str = "Allow automatic indexing";
const DISMISS_FOREVER_BUTTON_TEXT: &str = "Don't show again";
const INDEXING_HEADER: &str = "Indexing codebase";
const VIEW_STATUS_BUTTON_TEXT: &str = "View status";
#[derive(PartialEq, Clone)]
pub enum VisibilityState {
Speedbump,
Indexing,
}
#[derive(Clone, Copy, Debug)]
pub enum CodebaseIndexSpeedbumpBannerAction {
ToggleAlwaysAllow,
AllowIndexing,
Close,
ViewStatus,
DismissForever,
}
/// Maintains the state for the input speedbump banner
/// This banner appears when a user attempts to run a command that might be unsafe
/// and requires explicit confirmation before execution.
pub struct CodebaseIndexSpeedbumpBannerState {
pub id: InlineBannerId,
// Mouse state for the checkbox to always allow indexing
pub checkbox_mouse_state: MouseStateHandle,
// Mouse state for the allow button that confirms and executes indexing.
pub allow_button_mouse_state: MouseStateHandle,
// Mouse state for the close button that dismisses the banner without executing indexing.
pub close_button_mouse_state: MouseStateHandle,
// Mouse state for the view status button that shows the code indexing settings page.
pub view_status_button_mouse_state: MouseStateHandle,
// Mouse state for the "Don't show again" button that globally dismisses the banner.
pub dont_show_again_mouse_state: MouseStateHandle,
// Whether the "Always allow" checkbox is checked.
pub always_allow_checked: bool,
// Whether the speedbump banner is visible.
pub visibility_state: VisibilityState,
// The path to the repo that the banner is for.
pub repo_path: PathBuf,
}
impl CodebaseIndexSpeedbumpBannerState {
#[cfg_attr(not(feature = "local_fs"), expect(unused))]
pub fn new(id: InlineBannerId, repo_path: PathBuf) -> Self {
Self {
id,
checkbox_mouse_state: Default::default(),
allow_button_mouse_state: Default::default(),
close_button_mouse_state: Default::default(),
view_status_button_mouse_state: Default::default(),
dont_show_again_mouse_state: Default::default(),
always_allow_checked: true,
visibility_state: VisibilityState::Speedbump,
repo_path,
}
}
pub fn toggle_always_allow_checked(&mut self) {
self.always_allow_checked = !self.always_allow_checked;
}
pub fn show_indexing_banner(&mut self) {
self.visibility_state = VisibilityState::Indexing;
}
fn render_text_column(&self, appearance: &Appearance) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let theme = appearance.theme();
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Start);
let title = ui_builder
.span(if self.visibility_state == VisibilityState::Speedbump {
SPEEDBUMP_HEADER
} else {
INDEXING_HEADER
})
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into_solid()),
font_size: Some(appearance.ui_font_size() * 1.2),
..Default::default()
})
.build()
.finish();
col.add_child(title);
let body = ui_builder
.span(SPEEDBUMP_TEXT)
.with_style(UiComponentStyles {
font_color: Some(blended_colors::text_sub(theme, theme.surface_1())),
font_size: Some(appearance.ui_font_size()),
..Default::default()
})
.build()
.finish();
col.add_child(body);
Container::new(col.finish())
.with_horizontal_padding(4.)
.finish()
}
pub fn render_codebase_index_speedbump_banner(
&self,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_builder = appearance.ui_builder();
let mut banner = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
let mut left = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
let info_icon = Container::new(
ConstrainedBox::new(
Icon::Info
.to_warpui_icon(theme.active_ui_text_color())
.finish(),
)
.with_width(appearance.ui_font_size() * 1.2)
.with_height(appearance.ui_font_size() * 1.2)
.finish(),
)
.with_padding_right(4.)
.finish();
left.add_child(info_icon);
left.add_child(
Shrinkable::new(
1.,
Align::new(self.render_text_column(appearance))
.left()
.finish(),
)
.finish(),
);
banner.add_child(Shrinkable::new(1., left.finish()).finish());
if self.visibility_state == VisibilityState::Speedbump {
let checkbox = ui_builder
.checkbox(
self.checkbox_mouse_state.clone(),
Some(appearance.ui_font_size()),
)
.check(self.always_allow_checked)
.with_style(UiComponentStyles {
padding: Some(Coords::default().right(2.)),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::CodebaseIndexSpeedbumpBanner(
CodebaseIndexSpeedbumpBannerAction::ToggleAlwaysAllow,
));
})
.with_cursor(Cursor::PointingHand)
.finish();
let checkbox_text = ui_builder
.span(ALLOW_SETTINGS_TEXT)
.with_style(UiComponentStyles {
font_color: Some(blended_colors::text_disabled(theme, theme.surface_1())),
font_size: Some(appearance.ui_font_size()),
..Default::default()
})
.build()
.finish();
banner.add_child(
Container::new(
Align::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(checkbox_text)
.finish(),
)
.finish(),
)
.with_horizontal_padding(4.)
.finish(),
);
}
match self.visibility_state {
VisibilityState::Speedbump => {
banner.add_child(
Container::new(
Align::new(
ui_builder
.button(
ButtonVariant::Outlined,
self.dont_show_again_mouse_state.clone(),
)
.with_text_label(DISMISS_FOREVER_BUTTON_TEXT.to_string())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into_solid()),
font_size: Some(appearance.ui_font_size()),
padding: Some(Coords {
top: 5.0,
bottom: 5.0,
left: 8.0,
right: 8.0,
}),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
TerminalAction::CodebaseIndexSpeedbumpBanner(
CodebaseIndexSpeedbumpBannerAction::DismissForever,
),
);
})
.finish(),
)
.finish(),
)
.with_horizontal_padding(4.)
.finish(),
);
banner.add_child(
Container::new(
Align::new(
ui_builder
.button(
ButtonVariant::Outlined,
self.allow_button_mouse_state.clone(),
)
.with_text_label(ALLOW_BUTTON_TEXT.to_string())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into_solid()),
font_size: Some(appearance.ui_font_size()),
padding: Some(Coords {
top: 5.0,
bottom: 5.0,
left: 8.0,
right: 8.0,
}),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
TerminalAction::CodebaseIndexSpeedbumpBanner(
CodebaseIndexSpeedbumpBannerAction::AllowIndexing,
),
);
})
.finish(),
)
.finish(),
)
.with_horizontal_padding(4.)
.finish(),
);
}
VisibilityState::Indexing => {
banner.add_child(
Container::new(
Align::new(
ui_builder
.button(
ButtonVariant::Outlined,
self.view_status_button_mouse_state.clone(),
)
.with_text_label(VIEW_STATUS_BUTTON_TEXT.to_string())
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into_solid()),
font_size: Some(appearance.ui_font_size()),
padding: Some(Coords {
top: 5.0,
bottom: 5.0,
left: 8.0,
right: 8.0,
}),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
TerminalAction::CodebaseIndexSpeedbumpBanner(
CodebaseIndexSpeedbumpBannerAction::ViewStatus,
),
);
})
.finish(),
)
.finish(),
)
.with_horizontal_padding(4.)
.finish(),
);
}
}
// Add the close button
banner.add_child(
Container::new(
ui_builder
.close_button(
appearance.ui_font_size(),
self.close_button_mouse_state.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::CodebaseIndexSpeedbumpBanner(
CodebaseIndexSpeedbumpBannerAction::Close,
));
})
.finish(),
)
.with_padding_left(4.)
.finish(),
);
// Create the final container with background and padding
Container::new(banner.finish())
.with_background(theme.surface_1())
.with_uniform_padding(PADDING)
.finish()
}
}
+993
View File
@@ -0,0 +1,993 @@
//! This module contains state management logic for pending context, where "pending context"
//! is defined as additional context to be attached to the next AI query.
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use crate::ai::{
agent::{AnyFileContent, FileContext},
block_context::BlockContext,
};
use super::agent_view::{AgentViewController, AgentViewEntryOrigin, EnterAgentViewError};
use ai::project_context::model::ProjectContextModel;
use parking_lot::FairMutex;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, ConversationStatus};
use crate::{
ai::{
agent::todos::AIAgentTodoList,
agent::{
conversation::{AIConversation, AIConversationId},
AIAgentAttachment, AIAgentContext, ImageContext,
},
document::ai_document_model::AIDocumentId,
llms::{LLMPreferences, LLMPreferencesEvent},
outline::RepoOutlines,
},
terminal::{
event::{BlockCompletedEvent, BlockType},
model::{block::BlockId, session::Sessions},
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
block::DirectoryContext, history_model::BlocklistAIHistoryModel, BlocklistAIHistoryEvent,
};
/// A non-image file picked via the "attach file" button, stored until query submission.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingFile {
pub file_name: String,
pub file_path: PathBuf,
pub mime_type: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachmentType {
Image,
File,
}
/// A pending attachment — either an image (base64 in memory) or a file (path reference).
#[derive(Clone, Debug)]
pub enum PendingAttachment {
Image(ImageContext),
File(PendingFile),
}
impl PendingAttachment {
pub fn file_name(&self) -> &str {
match self {
PendingAttachment::Image(img) => &img.file_name,
PendingAttachment::File(file) => &file.file_name,
}
}
pub fn attachment_type(&self) -> AttachmentType {
match self {
PendingAttachment::Image(_) => AttachmentType::Image,
PendingAttachment::File(_) => AttachmentType::File,
}
}
}
/// The state the pending query is in.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum PendingQueryState {
/// The next query will continue an existing conversation.
Existing { conversation_id: AIConversationId },
New {
/// Autoexecute override for the new conversation to be started.
autoexecute_override: AIConversationAutoexecuteMode,
},
}
impl Default for PendingQueryState {
fn default() -> Self {
Self::New {
autoexecute_override: AIConversationAutoexecuteMode::default(),
}
}
}
impl PendingQueryState {
pub fn targets_existing_conversation(&self) -> bool {
matches!(self, PendingQueryState::Existing { .. })
}
}
/// Model responsible for keeping track of session context to be attached to the next AI query.
pub struct BlocklistAIContextModel {
terminal_model: Arc<FairMutex<TerminalModel>>,
directory_context: DirectoryContext,
/// `BlockId`s corresponding to blocks to be included as context with the next AI query.
pending_context_block_ids: HashSet<BlockId>,
/// Selected text to be included as context with the next AI query.
pending_context_selected_text: Option<String>,
/// Images and files to be included as attachments with the next AI query.
pending_attachments: Vec<PendingAttachment>,
/// Storage for diff hunk attachments that can be referenced in queries
pending_inline_diff_hunk_attachments: HashMap<String, AIAgentAttachment>,
/// The pending query could be new, which means it starts a new conversation, or follow-up, which means
/// it continues the selected conversation.
///
/// Note that this is intentionally decoupled from the active conversation in the HistoryModel.
/// The active conversation (the one that agent outputs are being streamed to) can be different from the
/// conversation we're following up in for the next query.
pending_query_state: PendingQueryState,
/// The ID of the terminal view this controller is associated with.
terminal_view_id: EntityId,
/// AI document ID to be included as context with the next AI query.
/// When set, the document content will be attached as plain text context.
pending_document_id: Option<AIDocumentId>,
agent_view_controller: ModelHandle<AgentViewController>,
/// Block IDs of user-executed commands to be auto-attached as context.
/// When `AgentViewBlockContext` is enabled, completed user commands are tracked here
/// and automatically included as context with the next user query.
auto_attached_agent_view_user_block_ids: Vec<BlockId>,
/// When true, submitting a prompt while the agent is responding will queue it
/// instead of sending it immediately.
/// Persists across exchanges in the same conversation (like fast-forward).
queue_next_prompt_enabled: bool,
}
pub fn block_context_from_terminal_model(
terminal_model: &TerminalModel,
block_id: &BlockId,
is_auto_attached: bool,
) -> Option<BlockContext> {
let block = terminal_model
.block_list()
.block_index_for_id(block_id)
.and_then(|block_id| terminal_model.block_list().block_at(block_id))?;
// Note, if the user has explicitly asked Agent Mode to include a block as context, we do NOT
// _force_ secrets to be obfuscated. It will respect the user's settings for secret redaction.
let output = block.output_grid().content_summary(5000, 5000, false);
Some(BlockContext {
id: block_id.clone(),
index: block.index(),
command: block.command_to_string(),
output,
exit_code: block.exit_code(),
is_auto_attached,
started_ts: block.start_ts().cloned(),
finished_ts: block.completed_ts().cloned(),
pwd: None,
shell: None,
username: None,
hostname: None,
git_branch: None,
os: None,
session_id: None,
})
}
impl BlocklistAIContextModel {
pub fn new(
sessions: ModelHandle<Sessions>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| match event {
ModelEvent::BlockCompleted(BlockCompletedEvent {
block_type: BlockType::User(user_block_completed),
block_id,
..
}) => {
// If AgentViewBlockContext is enabled and we're in agent view, track user-executed
// blocks for auto-attachment as context.
if FeatureFlag::AgentViewBlockContext.is_enabled()
&& me.agent_view_controller.as_ref(ctx).is_fullscreen()
&& !user_block_completed.was_part_of_agent_interaction
{
me.auto_attached_agent_view_user_block_ids
.push(block_id.clone());
}
// If the block that finished was part of an agent interaction (i.e. LRC finishing),
// we should preserve input context.
if !FeatureFlag::AgentViewBlockContext.is_enabled()
&& !user_block_completed.was_part_of_agent_interaction
{
me.reset_context_to_default(ctx);
}
}
ModelEvent::BlockMetadataReceived(block_metadata_received) => {
let pwd = block_metadata_received
.block_metadata
.current_working_directory()
.map(|s| PathBuf::from(s.to_owned()));
let session_id = block_metadata_received.block_metadata.session_id();
if let Some(session_id) = session_id {
let active_session = sessions.as_ref(ctx).get(session_id);
if let Some(active_session) = active_session {
me.update_directory_context(
pwd.map(|p| p.to_string_lossy().to_string()),
active_session.home_dir().map(|sq| sq.to_owned()),
ctx,
);
}
}
}
_ => {}
});
ctx.subscribe_to_model(&BlocklistAIHistoryModel::handle(ctx), |me, event, ctx| {
if event
.terminal_view_id()
.is_some_and(|id| id != me.terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => {
me.set_pending_query_state(PendingQueryState::default(), ctx);
if FeatureFlag::AgentView.is_enabled() {
me.agent_view_controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
});
}
}
BlocklistAIHistoryEvent::SplitConversation {
new_conversation_id,
..
} => {
me.set_pending_query_state_for_existing_conversation(
*new_conversation_id,
AgentViewEntryOrigin::AgentRequestedNewConversation,
ctx,
);
}
_ => {}
}
});
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event {
let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported = llm_prefs.vision_supported(ctx, Some(me.terminal_view_id));
if !vision_supported {
me.clear_pending_images(ctx);
}
}
});
// Clear auto-attached blocks when exiting agent view or switching conversations
ctx.subscribe_to_model(&agent_view_controller, |me, event, _ctx| {
use super::agent_view::AgentViewControllerEvent;
match event {
AgentViewControllerEvent::ExitedAgentView { .. }
| AgentViewControllerEvent::EnteredAgentView { .. } => {
me.auto_attached_agent_view_user_block_ids.clear();
}
AgentViewControllerEvent::ExitConfirmed { .. } => {}
}
});
// In sandboxed/autonomous mode (SDK mode with --sandboxed flag), automatically set
// conversations to RunToCompletion mode so they don't wait for user confirmation.
let pending_query_state =
if warp_core::execution_mode::AppExecutionMode::as_ref(ctx).is_sandboxed() {
PendingQueryState::New {
autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion,
}
} else {
Default::default()
};
Self {
terminal_model,
directory_context: Default::default(),
pending_context_block_ids: HashSet::new(),
pending_context_selected_text: None,
pending_attachments: Default::default(),
pending_query_state,
terminal_view_id,
agent_view_controller,
pending_inline_diff_hunk_attachments: Default::default(),
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
queue_next_prompt_enabled: false,
}
}
/// Resets the set of blocks to be included as context to an empty list.
/// Also removes any selected text that was to be included as context.
pub fn reset_context_to_default(&mut self, ctx: &mut ModelContext<Self>) {
self.set_pending_context_block_ids(vec![], true, ctx);
self.set_pending_context_selected_text(None, true, ctx);
self.clear_pending_attachments(ctx);
self.clear_diff_hunk_attachments();
self.set_pending_document(None, ctx);
self.auto_attached_agent_view_user_block_ids.clear();
}
/// Returns the set `BlockId`s corresponding to blocks to be included as context with the next
/// query.
pub fn pending_context_block_ids(&self) -> &HashSet<BlockId> {
&self.pending_context_block_ids
}
/// Returns selected text to be included as context with the next query.
pub fn pending_context_selected_text(&self) -> Option<&String> {
self.pending_context_selected_text.as_ref()
}
/// Returns all pending attachments (images and files) for the next query.
pub fn pending_attachments(&self) -> &[PendingAttachment] {
&self.pending_attachments
}
/// Returns only the pending images for the next query.
pub fn pending_images(&self) -> Vec<&ImageContext> {
self.pending_attachments
.iter()
.filter_map(|a| match a {
PendingAttachment::Image(img) => Some(img),
PendingAttachment::File(_) => None,
})
.collect()
}
/// Returns only the pending files for the next query.
pub fn pending_files(&self) -> Vec<&PendingFile> {
self.pending_attachments
.iter()
.filter_map(|a| match a {
PendingAttachment::File(file) => Some(file),
PendingAttachment::Image(_) => None,
})
.collect()
}
/// Given a block ID, transform it into an AIAgentContext::Block.
pub fn transform_block_to_context(
&self,
block_id: &BlockId,
is_auto_attached_in_agent_view: bool,
) -> Option<AIAgentContext> {
let terminal_model = self.terminal_model.lock();
block_context_from_terminal_model(&terminal_model, block_id, is_auto_attached_in_agent_view)
.map(Box::new)
.map(AIAgentContext::Block)
}
/// Returns `AIAgentContext` for the blocks to be included in the current AI query.
/// If `is_user_query` is true, includes blocks, selected text, and images as context.
/// If false, excludes these user-specific contexts but includes everything else.
pub fn pending_context(&self, app: &AppContext, is_user_query: bool) -> Vec<AIAgentContext> {
let pwd = self.current_pwd();
let is_pwd_indexed = if cfg!(feature = "agent_mode_evals") {
// In evals, we want to disable file outline based search. Full
// source code embedding based context is still available.
false
} else {
UserWorkspaces::as_ref(app).is_codebase_context_enabled(app)
&& pwd.as_ref().is_some_and(|pwd| {
RepoOutlines::as_ref(app).is_directory_indexed(Path::new(&pwd))
})
};
let project_rules = if let Some(pwd) = pwd.clone().and_then(|path| {
PathBuf::from_str(&path)
.ok()
.and_then(|s| s.canonicalize().ok())
}) {
ProjectContextModel::as_ref(app).find_applicable_rules(&pwd)
} else {
None
};
let mut context = Vec::new();
// Always include directory context
context.push(AIAgentContext::Directory {
pwd,
home_dir: self.home_directory(),
are_file_symbols_indexed: is_pwd_indexed,
});
let (head, branch) = {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
(
active_block.git_branch().cloned(),
active_block.git_branch_name().cloned(),
)
};
if head.is_some() || branch.is_some() {
context.push(AIAgentContext::Git {
head: head.unwrap_or_default(),
branch,
});
}
// Always include project rules if available
if let Some(rules) = project_rules {
context.push(AIAgentContext::ProjectRules {
root_path: rules.root_path.to_string_lossy().into(),
active_rules: rules
.active_rules
.into_iter()
.map(|rule| {
let line_count = rule.content.lines().count();
FileContext {
file_name: rule.path.to_string_lossy().into(),
content: AnyFileContent::StringContent(rule.content.clone()),
line_range: None,
last_modified: None,
line_count,
}
})
.collect(),
additional_rule_paths: rules.additional_rule_paths,
});
}
// If this is a user query, add user-selected contexts
if is_user_query {
// Add selected blocks (manually attached)
for block_id in &self.pending_context_block_ids {
if let Some(block_context) = self.transform_block_to_context(block_id, false) {
context.push(block_context);
}
}
// Add auto-attached user-executed blocks (when AgentViewBlockContext is enabled)
if FeatureFlag::AgentViewBlockContext.is_enabled() {
for block_id in &self.auto_attached_agent_view_user_block_ids {
// Skip if already in pending_context_block_ids to avoid duplicates
if !self.pending_context_block_ids.contains(block_id) {
if let Some(block_context) = self.transform_block_to_context(block_id, true)
{
context.push(block_context);
}
}
}
}
// Add selected text
if let Some(selected_text) = &self.pending_context_selected_text {
context.push(AIAgentContext::SelectedText(selected_text.clone()));
}
// Add images from pending attachments
for attachment in &self.pending_attachments {
if let PendingAttachment::Image(image) = attachment {
context.push(AIAgentContext::Image(image.clone()));
}
}
}
context
}
pub fn current_pwd(&self) -> Option<String> {
self.directory_context.pwd.clone()
}
pub fn home_directory(&self) -> Option<String> {
self.directory_context.home_dir.clone()
}
/// Updates the context model's stored directory context.
pub fn update_directory_context(
&mut self,
pwd: Option<String>,
home_dir: Option<String>,
ctx: &mut ModelContext<Self>,
) {
self.directory_context = DirectoryContext { pwd, home_dir };
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: true,
requires_text_resync: false,
});
}
/// Set `requires_visual_resync` to `false` only if the pending context was modified as a result
/// of manual user selections. In such cases, a visual resync won't be required because the
/// pending context was synchronized to the manual selection.
pub fn set_pending_context_block_ids(
&mut self,
ids: impl IntoIterator<Item = BlockId>,
requires_visual_resync: bool,
ctx: &mut ModelContext<Self>,
) {
// Filter out blocks that can't be used as AI context
let filtered_ids: Vec<BlockId> = {
let terminal_model = self.terminal_model.lock();
ids.into_iter()
.filter(|block_id| {
terminal_model
.block_list()
.block_with_id(block_id)
.map(|block| {
block.can_be_ai_context(terminal_model.block_list().agent_view_state())
})
.unwrap_or(false)
})
.collect()
};
let new_pending_context_block_ids = HashSet::from_iter(filtered_ids);
// Maintain the invariant that we can't simultaneously use both blocks and selected text
// as context for the next AI request.
if !new_pending_context_block_ids.is_empty() {
self.pending_context_selected_text = None;
}
if new_pending_context_block_ids != self.pending_context_block_ids {
let previous_block_ids = self.pending_context_block_ids.clone();
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids,
requires_block_resync: requires_visual_resync,
requires_text_resync: !new_pending_context_block_ids.is_empty(),
});
}
self.pending_context_block_ids = new_pending_context_block_ids;
}
/// Set `requires_visual_resync` to `false` only if the pending context was modified as a result
/// of manual user selections. In such cases, a visual resync won't be required because the
/// pending context was synchronized to the manual selection.
pub fn set_pending_context_selected_text(
&mut self,
text: Option<String>,
requires_visual_resync: bool,
ctx: &mut ModelContext<Self>,
) {
// It doesn't make sense to allow empty text as AI context.
// Enforcing this assertion here ensures we don't run into weird behaviour with `Some("")` later.
debug_assert!(!matches!(text.as_deref(), Some("")));
let previous_block_ids = self.pending_context_block_ids.clone();
// Maintain the invariant that we can't simultaneously use both blocks and selected text
// as context for the next AI request.
if text.is_some() {
self.pending_context_block_ids = HashSet::new();
}
if text != self.pending_context_selected_text {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids,
requires_block_resync: text.is_some(),
requires_text_resync: requires_visual_resync,
});
}
self.pending_context_selected_text = text;
}
/// Set the pending AI document to be included as context with the next AI query.
pub fn set_pending_document(
&mut self,
document_id: Option<AIDocumentId>,
ctx: &mut ModelContext<Self>,
) {
if document_id != self.pending_document_id {
self.pending_document_id = document_id;
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
/// Get the pending AI document ID if one is set.
pub fn pending_document_id(&self) -> Option<AIDocumentId> {
self.pending_document_id
}
pub fn clear_pending_images(&mut self, ctx: &mut ModelContext<Self>) {
let original_attachment_count = self.pending_attachments.len();
self.pending_attachments
.retain(|a| !matches!(a, PendingAttachment::Image(_)));
if self.pending_attachments.len() < original_attachment_count {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
pub fn append_pending_images(
&mut self,
images: Vec<ImageContext>,
ctx: &mut ModelContext<Self>,
) {
if !images.is_empty() {
let attachments: Vec<PendingAttachment> =
images.into_iter().map(PendingAttachment::Image).collect();
self.append_pending_attachments(attachments, ctx);
}
}
pub fn remove_pending_image(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
// Find the nth image in the combined list and remove it.
let position = self
.pending_attachments
.iter()
.enumerate()
.filter(|(_, a)| matches!(a, PendingAttachment::Image(_)))
.nth(index)
.map(|(i, _)| i);
if let Some(pos) = position {
self.remove_pending_attachment(pos, ctx);
}
}
/// Returns the number of images removed
pub fn remove_last_pending_images(
&mut self,
images_to_remove: usize,
ctx: &mut ModelContext<Self>,
) -> usize {
let image_indices: Vec<usize> = self
.pending_attachments
.iter()
.enumerate()
.filter(|(_, a)| matches!(a, PendingAttachment::Image(_)))
.map(|(i, _)| i)
.collect();
let len = image_indices.len();
if images_to_remove == 0 || len == 0 {
return 0;
}
let to_remove = images_to_remove.min(len);
// Remove from the end to avoid shifting indices.
for &idx in image_indices.iter().rev().take(to_remove) {
self.pending_attachments.remove(idx);
}
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
to_remove
}
pub fn pending_query_state(&self) -> &PendingQueryState {
&self.pending_query_state
}
/// Convenience function to set pending query state to continue an existing conversation by ID.
pub fn set_pending_query_state_for_existing_conversation(
&mut self,
conversation_id: AIConversationId,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) {
self.set_pending_query_state(PendingQueryState::Existing { conversation_id }, ctx);
if FeatureFlag::AgentView.is_enabled() {
if let Err(e) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(Some(conversation_id), origin, ctx)
}) {
log::error!("Failed to enter agent view for existing conversation: {e}");
}
}
}
/// Sets the pending query state to the defaults for a *new* conversation (i.e. not a
/// followup).
pub fn set_pending_query_state_for_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) {
self.set_pending_query_state(PendingQueryState::default(), ctx);
if FeatureFlag::AgentView.is_enabled() {
if let Err(e) = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
}) {
log::error!("Failed to enter agent view for new conversation: {e}");
}
}
}
/// Attempts to enter agent view for a new conversation and returns the conversation ID.
/// This should be used when a slash command needs to create a new conversation
/// and the AgentView feature flag is enabled.
///
/// Returns `Ok(conversation_id)` on success, or `Err` if entry is blocked.
pub fn try_enter_agent_view_for_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Self>,
) -> Result<AIConversationId, EnterAgentViewError> {
let conversation_id = self.agent_view_controller.update(ctx, |controller, ctx| {
controller.try_enter_agent_view(None, origin, ctx)
})?;
self.set_pending_query_state(PendingQueryState::default(), ctx);
Ok(conversation_id)
}
/// Sets the value of `pending_query_state`, emitting an event if it changed.
fn set_pending_query_state(&mut self, state: PendingQueryState, ctx: &mut ModelContext<Self>) {
if self.pending_query_state != state {
self.pending_query_state = state;
ctx.emit(BlocklistAIContextEvent::PendingQueryStateUpdated);
}
}
/// Returns `true` if a new conversation may be created.
pub fn can_start_new_conversation(&self) -> bool {
let terminal_model = self.terminal_model.lock();
if FeatureFlag::AgentView.is_enabled() {
!terminal_model
.block_list()
.active_block()
.is_active_and_long_running()
} else {
!terminal_model
.block_list()
.active_block()
.is_agent_in_control()
}
}
/// Returns the conversation ID the pending query is following up for, if any.
/// None if the pending query should start a new conversation.
pub fn selected_conversation_id(&self, ctx: &AppContext) -> Option<AIConversationId> {
if FeatureFlag::AgentView.is_enabled() {
return self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
}
match self.pending_query_state {
PendingQueryState::Existing {
conversation_id, ..
} => Some(conversation_id),
PendingQueryState::New { .. } => None,
}
}
pub fn selected_conversation<'a>(&self, ctx: &'a AppContext) -> Option<&'a AIConversation> {
self.selected_conversation_id(ctx)
.as_ref()
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id)
})
}
pub fn selected_conversation_todolist<'a>(
&self,
ctx: &'a AppContext,
) -> Option<&'a AIAgentTodoList> {
self.selected_conversation(ctx)
.and_then(|c| c.active_todo_list())
.and_then(|todo_list| {
// Don't show todo list if it's empty or finished
if todo_list.is_empty() || todo_list.is_finished() {
None
} else {
Some(todo_list)
}
})
}
pub fn pending_query_autoexecute_override(
&self,
ctx: &AppContext,
) -> AIConversationAutoexecuteMode {
match &self.pending_query_state {
PendingQueryState::New {
autoexecute_override,
} => *autoexecute_override,
PendingQueryState::Existing {
conversation_id, ..
} => BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default(),
}
}
pub fn is_queue_next_prompt_enabled(&self) -> bool {
self.queue_next_prompt_enabled
}
pub fn toggle_queue_next_prompt(&mut self, ctx: &mut ModelContext<Self>) {
self.queue_next_prompt_enabled = !self.queue_next_prompt_enabled;
ctx.emit(BlocklistAIContextEvent::QueueNextPromptToggled);
}
pub fn toggle_pending_query_autoexecute(&mut self, ctx: &mut ModelContext<Self>) {
// When AgentView is enabled, the autoexecution toggle should apply to the active agent view
// conversation -- even when starting a new conversation, the agent view always has a conversation
// ID.
if FeatureFlag::AgentView.is_enabled() {
if let Some(conversation_id) = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_view_id,
ctx,
);
});
}
return;
}
match &mut self.pending_query_state {
PendingQueryState::New {
autoexecute_override,
} => {
*autoexecute_override = if *autoexecute_override
== AIConversationAutoexecuteMode::RespectUserSettings
{
AIConversationAutoexecuteMode::RunToCompletion
} else {
AIConversationAutoexecuteMode::RespectUserSettings
};
ctx.emit(BlocklistAIContextEvent::PendingQueryStateUpdated);
}
PendingQueryState::Existing {
conversation_id, ..
} => {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
conversation_id,
self.terminal_view_id,
ctx,
);
});
}
}
}
/// Returns true if the pending query targets an existing conversation
/// (as opposed to starting a new one).
pub fn is_targeting_existing_conversation(&self) -> bool {
self.pending_query_state.targets_existing_conversation()
}
/// Returns the status of the selected conversation for purposes of rendering the input hint
/// text, or `None` if there is no selected conversation to display (either because no
/// conversation is selected, or because the selected conversation is empty/passive/untitled
/// and should be treated as a "new" conversation). Mirrors the `agent_indicator` pattern in
/// `app/src/tab.rs`.
pub fn selected_conversation_status_for_hint(
&self,
app: &AppContext,
) -> Option<ConversationStatus> {
let conversation = self.selected_conversation(app)?;
if conversation.is_empty()
|| conversation.is_entirely_passive()
|| conversation.title().is_none()
{
return None;
}
Some(conversation.status().clone())
}
/// Returns true if there are any blocks that can be used as AI context.
pub fn can_attach_blocks(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.blocks()
.iter()
.any(|block| block.can_be_ai_context(terminal_model.block_list().agent_view_state()))
}
/// Register a diff hunk attachment that can be referenced in future queries
pub fn register_diff_hunk_attachment(
&mut self,
diff_hunk_id: String,
attachment: AIAgentAttachment,
) {
self.pending_inline_diff_hunk_attachments
.insert(diff_hunk_id, attachment);
}
/// Get a diff hunk attachment by its ID
pub fn get_diff_hunk_attachment(&self, diff_hunk_id: &str) -> Option<&AIAgentAttachment> {
self.pending_inline_diff_hunk_attachments.get(diff_hunk_id)
}
/// Clear all diff hunk attachments (should be called after each request)
pub fn clear_diff_hunk_attachments(&mut self) {
self.pending_inline_diff_hunk_attachments.clear();
}
/// Appends attachments to the pending list.
pub fn append_pending_attachments(
&mut self,
attachments: Vec<PendingAttachment>,
ctx: &mut ModelContext<Self>,
) {
if !attachments.is_empty() {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
self.pending_attachments.extend(attachments);
}
/// Removes an attachment by index.
pub fn remove_pending_attachment(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
if index < self.pending_attachments.len() {
self.pending_attachments.remove(index);
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
}
/// Clears all pending attachments.
pub fn clear_pending_attachments(&mut self, ctx: &mut ModelContext<Self>) {
if !self.pending_attachments.is_empty() {
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}
self.pending_attachments.clear();
}
}
pub enum BlocklistAIContextEvent {
/// The bool fields determine whether a visual resync is needed for each respective selection type.
/// For example, if selected text is cleared via the `BlocklistAIContextModel` **only**, then
/// the `TerminalView`'s current text selection should be visually cleared as well.
UpdatedPendingContext {
previous_block_ids: HashSet<BlockId>,
requires_block_resync: bool,
requires_text_resync: bool,
},
/// Emitted whenever the value changes.
PendingQueryStateUpdated,
QueueNextPromptToggled,
}
impl Entity for BlocklistAIContextModel {
type Event = BlocklistAIContextEvent;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,343 @@
use std::{collections::HashMap, path::Path, sync::Arc};
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use chrono::Local;
use lazy_static::lazy_static;
use regex::Regex;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAttachment, AIAgentContext,
DocumentContentAttachmentSource, DriveObjectPayload,
},
block_context::BlockContext,
blocklist::BlocklistAIContextModel,
document::ai_document_model::{AIDocumentId, AIDocumentModel},
facts::CloudAIFactModel,
skills::list_skills_if_changed,
},
cloud_object::{
model::{
generic_string_model::{CloudStringObject, GenericStringObjectId},
persistence::CloudModel,
},
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType,
},
terminal::{
model::{block::BlockId, session::active_session::ActiveSession},
TerminalView,
},
};
use warp_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
lazy_static! {
// Regex to match <block:[block_id]> patterns
pub static ref BLOCK_CONTEXT_ATTACHMENT_REGEX: Regex = Regex::new(r"<block:([^>]+)>")
.expect("Block context attachment regex should be parsed");
// Regex to match warp drive objects inserted via at-context. Ex: <notebook:[workflow_id]>
pub static ref DRIVE_OBJECT_ATTACHMENT_REGEX: Regex = Regex::new(r"<(workflow|notebook|plan|rule):([^>]+)>")
.expect("Drive object attachment regex should be parsed");
// Regex to match <change:filename:line_start-line_end> patterns
pub static ref DIFF_HUNK_ATTACHMENT_REGEX: Regex = Regex::new(r"<change:([^>]+)>")
.expect("Diff hunk attachment regex should be parsed");
}
// Returns the context to be attached to the AIAgentInput sent in a request.
// If `is_user_query` is true, includes selected blocks, text, and images from the context model.
// Always includes base context like current time, execution environment, and codebase info.
pub(super) fn input_context_for_request(
is_user_query: bool,
context_model: &BlocklistAIContextModel,
active_session: &ActiveSession,
conversation_id: Option<AIConversationId>,
additional_context: Vec<AIAgentContext>,
app: &AppContext,
) -> Arc<[AIAgentContext]> {
let mut context = context_model.pending_context(app, is_user_query);
context.push(AIAgentContext::CurrentTime {
current_time: Local::now(),
});
if let Some(env) = active_session.ai_execution_environment(app) {
context.push(AIAgentContext::ExecutionEnvironment(env));
}
if FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& FeatureFlag::CrossRepoContext.is_enabled()
{
for (codebase_path, status) in
CodebaseIndexManager::as_ref(app).get_codebase_index_statuses(app)
{
// TODO(daniel): We should figure out a mechanism for handling stale codebases.
if status.has_synced_version() {
// For now, we pass the name of the directory as the name of the
// codebase.
let codebase_name = codebase_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
context.push(AIAgentContext::Codebase {
name: codebase_name.into(),
path: codebase_path.to_string_lossy().into(),
})
}
}
}
if FeatureFlag::ListSkills.is_enabled() {
let skills = list_skills_if_changed(
active_session.current_working_directory().map(Path::new),
conversation_id,
app,
);
if let Some(skills) = skills {
context.push(AIAgentContext::Skills { skills });
}
}
context.extend(additional_context);
context.into()
}
/// Parses context reference strings like <block:123> from the user query and returns
/// a map of reference strings to AIAgentAttachment objects.
///
/// This searches across ALL TerminalModels, not just the active session, to find
/// the requested blocks.
pub(super) fn parse_context_attachments(
query: &str,
context_model: &BlocklistAIContextModel,
ctx: &AppContext,
) -> HashMap<String, AIAgentAttachment> {
let mut referenced_attachments = HashMap::new();
// Parse block attachments
for capture in BLOCK_CONTEXT_ATTACHMENT_REGEX.captures_iter(query) {
if let (Some(full_match), Some(block_id_match)) = (capture.get(0), capture.get(1)) {
let reference_string = full_match.as_str().to_string();
let block_id_str = block_id_match.as_str();
let block_id = BlockId::from(block_id_str.to_string());
// Search across ALL TerminalModels to find the block
if let Some(attachment) = find_block_attachment_in_all_terminals(&block_id, ctx) {
referenced_attachments.insert(reference_string, attachment);
}
}
}
// Parse drive object attachments (notebooks, workflows, etc)
for capture in DRIVE_OBJECT_ATTACHMENT_REGEX.captures_iter(query) {
if let (Some(full_match), Some(object_type_match), Some(object_id_match)) =
(capture.get(0), capture.get(1), capture.get(2))
{
let reference_string = full_match.as_str().to_string();
let object_type_str = object_type_match.as_str();
let id_str = object_id_match.as_str();
if object_type_str == "plan" {
// For plans, id_str is ai_document_id
let ai_doc_id = match AIDocumentId::try_from(id_str) {
Ok(id) => id,
Err(_) => {
log::warn!("Invalid ai_document_id in plan reference: {id_str}");
continue;
}
};
// Prefer live editor content from AIDocumentModel (picks up unsaved user edits).
// Fall back to the synced CloudModel notebook if the document isn't loaded in
// the current session.
let content = AIDocumentModel::as_ref(ctx)
.get_document_content(&ai_doc_id, ctx)
.or_else(|| {
CloudModel::as_ref(ctx)
.get_all_active_notebooks()
.find(|nb| nb.model().ai_document_id.as_ref() == Some(&ai_doc_id))
.map(|nb| nb.model().data.clone())
});
if let Some(content) = content {
let attachment = AIAgentAttachment::DocumentContent {
document_id: id_str.to_string(),
content,
source: DocumentContentAttachmentSource::UserAttached,
line_range: None,
};
referenced_attachments.insert(reference_string, attachment);
} else {
log::warn!("Plan not found for ai_document_id: {ai_doc_id}");
}
} else {
let object_type = match object_type_str {
"workflow" => ObjectType::Workflow,
"notebook" => ObjectType::Notebook,
"rule" => ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::AIFact,
)),
_ => continue, // Skip unknown object types
};
// Try to get the object data from CloudModel
let payload = get_object_attachment_payload(id_str, object_type, ctx);
// Create a DriveObject attachment with the object UID and payload
let attachment = AIAgentAttachment::DriveObject {
uid: id_str.to_string(),
payload,
};
referenced_attachments.insert(reference_string, attachment);
}
}
}
// Parse diff hunk attachments
for capture in DIFF_HUNK_ATTACHMENT_REGEX.captures_iter(query) {
if let (Some(full_match), Some(diff_hunk_match)) = (capture.get(0), capture.get(1)) {
let reference_string = full_match.as_str().to_string();
let diff_hunk_key = diff_hunk_match.as_str();
// Check if we have a stored diff hunk attachment for this key
if let Some(attachment) = context_model.get_diff_hunk_attachment(diff_hunk_key) {
referenced_attachments.insert(reference_string, attachment.clone());
}
}
}
// Add pending file attachments as FilePathReference.
// Duplicate basenames get a (1), (2), ... suffix to avoid collisions,
// matching the pattern in build_file_attachment_map.
for file in context_model.pending_files().iter() {
let attachment = AIAgentAttachment::FilePathReference {
file_id: uuid::Uuid::new_v4().to_string(),
file_name: file.file_name.clone(),
file_path: file.file_path.to_string_lossy().to_string(),
};
let mut key = file.file_name.clone();
if referenced_attachments.contains_key(&key) {
let mut suffix = 1;
loop {
key = format!("{} ({suffix})", file.file_name);
if !referenced_attachments.contains_key(&key) {
break;
}
suffix += 1;
}
}
referenced_attachments.insert(key, attachment);
}
// Add pending AI document as attachment if present
if let Some(document_id) = context_model.pending_document_id() {
if let Some(content) = AIDocumentModel::as_ref(ctx).get_document_content(&document_id, ctx)
{
let document_id_str = document_id.to_string();
let attachment = AIAgentAttachment::DocumentContent {
document_id: document_id_str.clone(),
content,
source: DocumentContentAttachmentSource::PlanEdited,
line_range: None,
};
// Use the document ID as the reference key
referenced_attachments.insert(document_id_str, attachment);
}
}
referenced_attachments
}
/// Searches for a block across all terminal models in the application.
/// Returns an AIAgentAttachment if the block is found.
fn find_block_attachment_in_all_terminals(
block_id: &BlockId,
ctx: &AppContext,
) -> Option<AIAgentAttachment> {
// Iterate over all window IDs to search across all terminal views
for window_id in ctx.window_ids() {
// Try to get all terminal views for this window
if let Some(terminal_views) = ctx.views_of_type::<TerminalView>(window_id) {
for terminal_view_handle in terminal_views {
let terminal_view = terminal_view_handle.as_ref(ctx);
let terminal_model = terminal_view.model.lock();
let block_list = terminal_model.block_list();
if let Some(block) = block_list.block_with_id(block_id) {
// Create an AIAgentAttachment for the block
return Some(AIAgentAttachment::Block(BlockContext {
id: block.id().clone(),
index: block.index(),
command: block.command_to_string(),
output: block.output_to_string(),
exit_code: block.exit_code(),
is_auto_attached: false,
started_ts: block.start_ts().cloned(),
finished_ts: block.completed_ts().cloned(),
pwd: None,
shell: None,
username: None,
hostname: None,
git_branch: None,
os: None,
session_id: None,
}));
}
}
}
}
None
}
/// Gets the object payload from CloudModel for the given UID and object type.
/// Returns None if the object is not found.
fn get_object_attachment_payload(
uid: &str,
object_type: ObjectType,
ctx: &AppContext,
) -> Option<DriveObjectPayload> {
match object_type {
ObjectType::Workflow => CloudModel::as_ref(ctx)
.get_workflow_by_uid(uid)
.map(|workflow| {
let workflow_data = &workflow.model().data;
DriveObjectPayload::Workflow {
name: workflow_data.name().to_string(),
description: workflow_data.description().cloned().unwrap_or_default(),
command: workflow_data.content().to_string(),
}
}),
ObjectType::Notebook => CloudModel::as_ref(ctx)
.get_notebook_by_uid(uid)
.map(|notebook| DriveObjectPayload::Notebook {
title: notebook.model().title.clone(),
content: notebook.model().data.clone(),
}),
ObjectType::GenericStringObject(_) => {
// For generic string objects, we only support AI facts (rules) for now
CloudModel::as_ref(ctx)
.get_by_uid(&uid.to_string())
.and_then(|object| {
if let Some(ai_fact) = object.as_any().downcast_ref::<GenericCloudObject<GenericStringObjectId, CloudAIFactModel>>() {
let string_object = ai_fact as &dyn CloudStringObject;
// Convert the format to GraphQL format since that's what the server expects
let graphql_format: GraphQLFormat =
string_object.generic_string_object_format().into();
Some(DriveObjectPayload::GenericStringObject {
payload: string_object.serialized().model_as_str().to_string(),
object_type: graphql_format.to_string(),
})
} else {
None
}
})
}
_ => None, // Other object types not supported for drive object attachments
}
}
@@ -0,0 +1,109 @@
use std::collections::HashMap;
use itertools::Itertools;
use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity};
use crate::{
ai::agent::{conversation::AIConversationId, CancellationReason},
BlocklistAIHistoryModel,
};
use super::{
response_stream::{ResponseStream, ResponseStreamId},
BlocklistAIController,
};
pub(super) struct PendingResponseStreams {
streams: HashMap<ResponseStreamId, ModelHandle<ResponseStream>>,
}
impl PendingResponseStreams {
pub fn new() -> Self {
Self {
streams: HashMap::new(),
}
}
pub fn has_active_stream_for_conversation(
&self,
conversation_id: AIConversationId,
app: &AppContext,
) -> bool {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&conversation_id) else {
return false;
};
self.streams
.keys()
.any(|stream_id| conversation.is_processing_response_stream(stream_id))
}
pub fn register_new_stream(
&mut self,
stream_id: ResponseStreamId,
conversation_id: AIConversationId,
stream: ModelHandle<ResponseStream>,
reason: CancellationReason,
ctx: &mut ModelContext<BlocklistAIController>,
) {
self.try_cancel_streams_for_conversation(conversation_id, reason, ctx);
self.streams.insert(stream_id, stream);
}
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id);
}
pub fn try_cancel_stream(
&mut self,
stream_id: &ResponseStreamId,
reason: CancellationReason,
ctx: &mut ModelContext<BlocklistAIController>,
) -> bool {
if let Some(stream) = self.streams.remove(stream_id) {
// Look up which conversation owns this stream
let Some(conversation_id) =
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(stream_id)
else {
log::warn!("Could not find conversation for stream {stream_id:?}, cannot cancel");
return false;
};
stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
return true;
}
false
}
/// Cancels all streams for the given conversation
pub fn try_cancel_streams_for_conversation(
&mut self,
conversation_id: AIConversationId,
reason: CancellationReason,
ctx: &mut ModelContext<BlocklistAIController>,
) -> bool {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(&conversation_id) else {
return false;
};
let streams_to_cancel = self
.streams
.extract_if(|stream_id, _| conversation.is_processing_response_stream(stream_id))
.map(|(_, stream)| stream)
.collect_vec();
if streams_to_cancel.is_empty() {
false
} else {
for response_stream in streams_to_cancel.into_iter() {
response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
}
true
}
}
}
@@ -0,0 +1,394 @@
use std::{cell::RefCell, rc::Rc};
use anyhow::anyhow;
use chrono::{DateTime, Local, TimeDelta};
use futures::channel::oneshot;
use uuid::Uuid;
use warp_multi_agent_api::response_event;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::{
ai::agent::{
api::{self, generate_multi_agent_output, ConvertToAPITypeError},
conversation::AIConversationId,
AIIdentifiers, CancellationReason,
},
network::NetworkStatus,
report_error, send_telemetry_from_ctx,
server::server_api::ServerApiProvider,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResponseStreamId(String);
impl ResponseStreamId {
pub fn for_shared_session(init_event: &response_event::StreamInit) -> Self {
// Make the stream ID unique per viewing by appending a local UUID
// This prevents collisions when replaying the same conversation multiple times
// (either on close-and-reopen or when viewing the same shared session from multiple terminals)
Self(format!("{}-{}", init_event.request_id, Uuid::new_v4()))
}
#[cfg(test)]
pub fn new_for_test() -> Self {
Self(Uuid::new_v4().to_string())
}
}
/// Model wrapping an agent API response stream.
///
/// Emits events when the output corresponding to the stream is updated, typically after receiving
/// each response chunk.
///
/// Handles retries internally - retries are only attempted if no ClientActions events have been
/// received yet, ensuring we don't retry after the AI has started executing actions.
pub struct ResponseStream {
id: ResponseStreamId,
params: api::RequestParams,
retry_count: usize,
start_time: DateTime<Local>,
time_to_latest_event: TimeDelta,
cancellation_tx: Option<oneshot::Sender<()>>,
/// Store the original error for telemetry when retries succeed
original_error: Option<String>,
/// Track whether we've received any client actions
/// If true, we cannot retry on subsequent errors since actions may have been executed
has_received_client_actions: bool,
/// AI identifiers for telemetry emission
ai_identifiers: AIIdentifiers,
/// Whether this request can attempt to resume the conversation on error.
/// This is true for all requests except those that are themselves the result of a resume
/// triggered by a previous error.
can_attempt_resume_on_error: bool,
/// Whether we should attempt to resume the conversation after the stream finishes.
///
/// This is set when we receive a retryable error after client actions have been received
/// and `can_attempt_resume_on_error` is true.
should_resume_conversation_after_stream_finished: bool,
/// Unique, internal id for the current request.
///
/// This ensures that the model never emits events for a request that was already cancelled (or
/// retried) and is still receiving lagging events.
///
/// Note this is unique compared to `id`; this is unique across retry requests while the response
/// stream id remains stable.
current_request_id: Option<Uuid>,
}
impl ResponseStream {
pub fn new(
params: api::RequestParams,
ai_identifiers: AIIdentifiers,
can_attempt_resume_on_error: bool,
ctx: &mut ModelContext<Self>,
) -> Self {
let server_api = ServerApiProvider::as_ref(ctx).get();
let (cancellation_tx, cancellation_rx) = oneshot::channel();
let start_time = Local::now();
let request_id = Uuid::new_v4();
let params_clone = params.clone();
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(server_api, params_clone, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
Self {
id: ResponseStreamId(Uuid::new_v4().to_string()),
params: params.clone(),
start_time,
time_to_latest_event: TimeDelta::seconds(0),
cancellation_tx: Some(cancellation_tx),
retry_count: 0,
original_error: None,
has_received_client_actions: false,
ai_identifiers,
can_attempt_resume_on_error,
should_resume_conversation_after_stream_finished: false,
current_request_id: Some(request_id),
}
}
pub fn id(&self) -> &ResponseStreamId {
&self.id
}
/// Returns true if we should attempt to resume the conversation after the stream finishes.
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
self.should_resume_conversation_after_stream_finished
}
/// Helper function to emit AgentModeError telemetry for error that is retryable (not user visible).
fn emit_retryable_agent_mode_error_telemetry(
&self,
error: String,
ctx: &mut ModelContext<Self>,
) {
send_telemetry_from_ctx!(
crate::TelemetryEvent::AgentModeError {
identifiers: self.ai_identifiers.clone(),
error,
is_user_visible: false,
will_attempt_to_resume: false,
},
ctx
);
}
fn retry(&mut self, ctx: &mut ModelContext<Self>) {
self.retry_count += 1;
self.has_received_client_actions = false; // Reset for the new attempt
let (cancellation_tx, cancellation_rx) = oneshot::channel();
if let Some(old_cancellation_tx) = self.cancellation_tx.take() {
let _ = old_cancellation_tx.send(());
}
self.cancellation_tx = Some(cancellation_tx);
let request_id = Uuid::new_v4();
self.current_request_id = Some(request_id);
let params = self.params.clone();
let server_api = ServerApiProvider::as_ref(ctx).get();
let _ = ctx.spawn(
async move { generate_multi_agent_output(server_api, params, cancellation_rx).await },
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
/// Cancels the stream. The conversation_id is preserved in the emitted event for async handling.
pub(super) fn cancel(
&mut self,
reason: CancellationReason,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.current_request_id = None;
let Some(cancellation_tx) = self.cancellation_tx.take() else {
return;
};
let _ = cancellation_tx.send(());
ctx.emit(ResponseStreamEvent::AfterStreamFinished {
cancellation: Some(StreamCancellation {
reason,
conversation_id,
}),
});
}
fn handle_response_stream_result(
&mut self,
request_id: Uuid,
stream_result: Result<api::ResponseStream, ConvertToAPITypeError>,
ctx: &mut ModelContext<Self>,
) {
match stream_result {
Ok(stream) => {
ctx.spawn_stream_local(
stream,
move |me, event, ctx| {
me.handle_response_stream_event(request_id, event, ctx);
},
move |me, ctx| {
me.on_response_stream_complete(request_id, ctx);
},
);
}
Err(e) => {
log::error!("Failed to send request to multi-agent API: {e:?}");
self.on_response_stream_complete(request_id, ctx);
}
}
}
fn handle_response_stream_event(
&mut self,
request_id: Uuid,
event: api::Event,
ctx: &mut ModelContext<Self>,
) {
if self.current_request_id.is_none_or(|id| id != request_id) {
return;
}
self.time_to_latest_event = Local::now().signed_duration_since(self.start_time);
match &event {
Ok(response_event) => {
if let Some(event_type) = &response_event.r#type {
match event_type {
warp_multi_agent_api::response_event::Type::Init(init_event) => {
// Capture server_output_id from StreamInit event
self.ai_identifiers.server_output_id =
Some(crate::ai::agent::ServerOutputId::new(
init_event.request_id.clone(),
));
}
warp_multi_agent_api::response_event::Type::ClientActions(_) => {
// Mark that we've received client actions
self.has_received_client_actions = true;
}
warp_multi_agent_api::response_event::Type::Finished(finished_event) => {
// Emit retry success telemetry on successful completion
if matches!(
finished_event.reason,
Some(warp_multi_agent_api::response_event::stream_finished::Reason::Done(_)) | None
) {
// Emit retry success telemetry if this was a successful completion after retries
if self.retry_count > 0 {
if let Some(original_error) = &self.original_error {
send_telemetry_from_ctx!(
crate::TelemetryEvent::AgentModeRequestRetrySucceeded {
identifiers: self.ai_identifiers.clone(),
retry_count: self.retry_count,
original_error: original_error.clone(),
},
ctx
);
}
}
}
}
}
}
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
}
Err(e) => {
// Store original error if this is the first error
if self.retry_count == 0 {
self.original_error = Some(format!("{e:?}"));
}
// Only retry if:
// 1. We haven't received any client actions yet (this is the first event or only init events)
// 2. The error is retryable
// 3. We haven't exceeded max retries
// 4. We're online
const MAX_RETRIES: usize = 3;
let network_status = NetworkStatus::as_ref(ctx);
let is_online = network_status.is_online();
let is_retryable = e.is_retryable();
let should_retry = !self.has_received_client_actions
&& is_retryable
&& self.retry_count < MAX_RETRIES
&& is_online;
if should_retry {
log::warn!(
"MultiAgent request failed, retrying (attempt {}/{}) - Error: {e:?}",
self.retry_count + 1,
MAX_RETRIES
);
// Only emit error telemetry here if we're retrying.
// Final errors that aren't being retried are emitted elsewhere.
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
self.retry(ctx);
// Don't emit the error event, we're retrying
// TODO: emit a separate event if controller needs to know about failures that are being retried
return;
}
// If we can't retry (because client actions were received) but the error is
// retryable and we're allowed to attempt a resume, signal that the controller
// should resume the conversation after the stream completes.
let should_attempt_resume = self.has_received_client_actions
&& is_retryable
&& self.can_attempt_resume_on_error;
if should_attempt_resume {
self.should_resume_conversation_after_stream_finished = true;
}
#[cfg(feature = "crash_reporting")]
sentry::with_scope(
|scope| {
scope.set_tag(
"has_received_client_actions",
self.has_received_client_actions,
);
scope.set_tag("error", format!("{e:?}"));
scope.set_tag("is_retryable", e.is_retryable());
scope.set_tag("is_online", is_online);
scope.set_tag("retry_count", self.retry_count);
},
|| {
report_error!(anyhow!(e.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
},
);
#[cfg(not(feature = "crash_reporting"))]
{
report_error!(anyhow!(e.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
}
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
}
}
}
fn on_response_stream_complete(&mut self, request_id: Uuid, ctx: &mut ModelContext<Self>) {
if self.current_request_id.is_none_or(|id| id != request_id) {
return;
}
ctx.emit(ResponseStreamEvent::AfterStreamFinished { cancellation: None });
self.cancellation_tx = None;
}
}
#[derive(Debug)]
pub struct Consumable<T> {
value: Rc<RefCell<Option<T>>>,
}
impl<T> Consumable<T> {
fn new(value: T) -> Self {
Consumable {
value: Rc::new(RefCell::new(Some(value))),
}
}
pub(super) fn consume(&self) -> Option<T> {
self.value.borrow_mut().take()
}
}
impl<T> Clone for Consumable<T> {
fn clone(&self) -> Self {
Consumable {
value: Rc::clone(&self.value),
}
}
}
/// Cancellation context preserved for async event handling.
/// Includes conversation_id because truncation can remove exchange mappings before the event is processed.
#[derive(Debug, Clone)]
pub struct StreamCancellation {
pub reason: CancellationReason,
pub conversation_id: AIConversationId,
}
#[derive(Debug, Clone)]
pub enum ResponseStreamEvent {
ReceivedEvent(Consumable<api::Event>),
AfterStreamFinished {
/// Some for cancellation (with context), None for natural completion (uses dynamic lookup).
cancellation: Option<StreamCancellation>,
},
}
impl Entity for ResponseStream {
type Event = ResponseStreamEvent;
}
@@ -0,0 +1,773 @@
// Session-sharing specific logic for BlocklistAIController.
// This module extends BlocklistAIController with methods used when viewing a shared session
// and defines state used only for session sharing.
use std::collections::HashMap;
use itertools::Itertools;
use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken};
use warp_core::features::FeatureFlag;
use warp_multi_agent_api::response_event::{stream_finished, ClientActions};
use warp_multi_agent_api::{client_action::Action, message::Message};
use super::response_stream::ResponseStreamId;
use super::{BlocklistAIController, RequestInput};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{AIAgentActionId, AIAgentAttachment, EntrypointType};
use crate::ai::attachment_utils::{
build_file_attachment_map, download_file, sanitize_filename, DownloadedAttachment,
};
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::server::server_api::ServerApiProvider;
use crate::terminal::model::block::BlockId;
use warpui::{AppContext, ModelContext, SingletonEntity};
#[derive(Default)]
pub(super) struct SharedSessionState {
// The current active request id for the shared session (used if subsequent events do not provide a request id)
current_response_id: Option<ResponseStreamId>,
// The participant who initiated the current response stream
current_response_initiator: Option<ParticipantId>,
// The sharer's participant ID (set when session sharing starts)
sharer_participant_id: Option<ParticipantId>,
}
impl BlocklistAIController {
/// Returns the current conversation ID for the active shared session stream.
/// Returns None if there's no active shared session conversation.
pub(crate) fn get_current_shared_session_conversation_id(
&self,
app: &AppContext,
) -> Option<AIConversationId> {
self.shared_session_state
.current_response_id
.as_ref()
.and_then(|response_id| {
BlocklistAIHistoryModel::as_ref(app).conversation_for_response_stream(response_id)
})
}
/// Handle a shared cancel control action and cancel the provided conversation
/// (if it exists and is live).
pub fn handle_shared_session_cancel_action(
&mut self,
server_conversation_token: ServerConversationToken,
ctx: &mut ModelContext<Self>,
) {
let Some(conversation_id) = self.find_existing_conversation_by_server_token(
&server_conversation_token.to_string(),
ctx,
) else {
return;
};
if BlocklistAIHistoryModel::as_ref(ctx).is_conversation_live(conversation_id) {
self.cancel_conversation_progress(
conversation_id,
super::CancellationReason::ManuallyCancelled,
ctx,
);
}
}
/// Apply agent session events to the current conversation state.
pub fn handle_shared_session_response_event(
&mut self,
resp: warp_multi_agent_api::ResponseEvent,
ctx: &mut ModelContext<Self>,
) {
let Some(kind) = resp.r#type else {
return;
};
match kind {
warp_multi_agent_api::response_event::Type::Init(init) => {
self.on_shared_init(init, ctx)
}
warp_multi_agent_api::response_event::Type::ClientActions(actions) => {
self.on_shared_client_actions(actions, ctx)
}
warp_multi_agent_api::response_event::Type::Finished(finished) => {
self.on_shared_finished(finished, ctx);
}
}
}
fn on_shared_init(
&mut self,
init_event: warp_multi_agent_api::response_event::StreamInit,
ctx: &mut ModelContext<Self>,
) {
let stream_id = ResponseStreamId::for_shared_session(&init_event);
self.shared_session_state.current_response_id = Some(stream_id.clone());
let terminal_view_id = self.terminal_view_id;
let history = BlocklistAIHistoryModel::handle(ctx);
// If the server conversation already exists locally (matched by server_conversation_token), reuse it.
// Otherwise, if we're currently in an empty agent view conversation, reuse that
// local conversation ID and bind the incoming server token to it.
// This preserves block visibility for terminal blocks created in the given agent view.
let conversation_id = self
.find_existing_conversation_by_server_token(&init_event.conversation_id, ctx)
.or_else(|| {
let selected_conversation_id = self
.context_model
.as_ref(ctx)
.selected_conversation_id(ctx)?;
// If the current agent view's conversation is completely empty,
// we should just associate it with the incoming request/token.
let should_reuse_selected_conversation = history
.as_ref(ctx)
.conversation(&selected_conversation_id)
.is_some_and(|conversation| {
conversation.exchange_count() == 0
&& conversation.server_conversation_token().is_none()
});
if !should_reuse_selected_conversation {
return None;
}
history.update(ctx, |history, ctx| {
history.set_server_conversation_token_for_conversation(
selected_conversation_id,
init_event.conversation_id.clone(),
);
history.set_viewing_shared_session_for_conversation(
selected_conversation_id,
true,
);
ctx.notify();
});
Some(selected_conversation_id)
})
.unwrap_or_else(|| {
history.update(ctx, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, true, ctx)
})
});
let Some(conversation) = history.as_ref(ctx).conversation(&conversation_id) else {
log::error!(
"Tried to initialize shared session stream for non-existent conversation {conversation_id:?}"
);
return;
};
let task_id = conversation.get_root_task_id().clone();
// Ensure the action executor is in view-only mode for shared-session viewers.
self.action_model.update(ctx, |action_model, _ctx| {
action_model.set_view_only(true);
});
// Eagerly create an exchange for this request (with empty inputs) and initialize output.
history.update(ctx, |history_model, ctx| {
let _ = history_model.update_conversation_for_new_request_input(
RequestInput::for_task(
vec![],
task_id,
&self.active_session,
self.get_current_response_initiator(),
conversation_id,
self.terminal_view_id,
ctx,
),
stream_id.clone(),
self.terminal_view_id,
ctx,
);
history_model.initialize_output_for_response_stream(
&stream_id,
conversation_id,
self.terminal_view_id,
init_event.clone(),
ctx,
);
// Mark conversation as in progress and active/selected
history_model.update_conversation_status(
self.terminal_view_id,
conversation_id,
ConversationStatus::InProgress,
ctx,
);
history_model.set_active_conversation_id(conversation_id, self.terminal_view_id, ctx);
});
self.context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::SharedSessionSelection,
ctx,
);
});
}
fn on_shared_client_actions(
&mut self,
actions: warp_multi_agent_api::response_event::ClientActions,
ctx: &mut ModelContext<Self>,
) {
let Some(stream_id) = self.shared_session_state.current_response_id.clone() else {
log::warn!("Received shared session client actions with no active response stream id.");
return;
};
let Some(conversation_id) =
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(&stream_id)
else {
log::warn!(
"No conversation ID for shared session response stream with id={stream_id:?}"
);
return;
};
self.update_directory_context_from_client_actions(&actions, ctx);
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, ctx| {
if let Err(e) = history_model.apply_client_actions(
&stream_id,
actions.actions,
conversation_id,
self.terminal_view_id,
ctx,
) {
log::error!(
"Failed to apply client actions to conversation for shared session: {e:?}"
);
}
});
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
log::error!("Failed to find conversation with id: {conversation_id:?}");
return;
};
let new_action_results_to_apply = conversation
.new_exchange_ids_for_response(&stream_id)
.filter_map(|exchange_id| conversation.exchange_with_id(exchange_id))
.flat_map(|exchange| {
exchange
.input
.iter()
.filter_map(|i| i.action_result().cloned())
})
.collect_vec();
// Apply finished results to unfinished actions.
for result in new_action_results_to_apply.into_iter() {
if self
.action_model
.as_ref(ctx)
.get_action_result(&result.id)
.is_none()
{
self.action_model.update(ctx, |action_model, ctx| {
action_model.apply_finished_action_result(conversation_id, result, ctx);
});
}
}
}
/// Update the context model's working directory context using the most recent message context.
fn update_directory_context_from_client_actions(
&mut self,
actions: &ClientActions,
ctx: &mut ModelContext<Self>,
) {
for client_action in &actions.actions {
if let Some(Action::AddMessagesToTask(add)) = &client_action.action {
for message in &add.messages {
if let Some(inner) = &message.message {
let ctx_opt = match inner {
Message::UserQuery(uq) => uq.context.as_ref(),
Message::SystemQuery(sq) => sq.context.as_ref(),
Message::ToolCallResult(tcr) => tcr.context.as_ref(),
_ => None,
};
if let Some(input_ctx) = ctx_opt {
if let Some(dir) = &input_ctx.directory {
self.context_model.update(ctx, |context_model, ctx| {
context_model.update_directory_context(
if dir.pwd.is_empty() {
None
} else {
Some(dir.pwd.clone())
},
if dir.home.is_empty() {
None
} else {
Some(dir.home.clone())
},
ctx,
);
});
}
}
}
}
}
}
}
fn on_shared_finished(
&mut self,
finished: warp_multi_agent_api::response_event::StreamFinished,
ctx: &mut ModelContext<Self>,
) {
let Some(stream_id) = self.shared_session_state.current_response_id.take() else {
log::warn!("Shared Finished missing request_id");
return;
};
let Some(conversation_id) =
BlocklistAIHistoryModel::as_ref(ctx).conversation_for_response_stream(&stream_id)
else {
log::warn!(
"No conversation ID for shared session response stream with id={stream_id:?}"
);
return;
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
log::error!("Failed to find conversation with id: {conversation_id:?}");
return;
};
// Queue actions for viewer UI in view-only mode
let mut actions_to_queue = vec![];
let mut did_exchange_contain_user_query = false;
for new_exchange_id in conversation.new_exchange_ids_for_response(&stream_id) {
let Some(exchange) = conversation.exchange_with_id(new_exchange_id) else {
continue;
};
did_exchange_contain_user_query |=
exchange.input.iter().any(|input| input.is_user_query());
if let Some(output) = exchange.output_status.output() {
actions_to_queue.extend(output.get().actions().cloned().collect_vec().into_iter());
}
}
if !actions_to_queue.is_empty() {
self.action_model.update(ctx, |action_model, ctx| {
action_model.queue_actions(actions_to_queue, conversation_id, ctx);
});
}
self.handle_response_stream_finished(
&stream_id,
finished,
conversation_id,
did_exchange_contain_user_query,
ctx,
);
}
/// Finds an existing client conversation whose server_conversation_token matches `server_token`.
/// Searches only live conversations for this terminal view. Returns None if no match is found.
pub fn find_existing_conversation_by_server_token(
&self,
server_token: &str,
ctx: &mut ModelContext<Self>,
) -> Option<AIConversationId> {
let history = BlocklistAIHistoryModel::handle(ctx);
history
.as_ref(ctx)
.all_live_conversations_for_terminal_view(self.terminal_view_id)
.find_map(|conv| {
conv.server_conversation_token()
.and_then(|t| (t.as_str() == server_token).then_some(conv.id()))
})
}
/// Sends a synthetic cancellation event to viewers when the sharer cancels a conversation.
/// This ensures viewers see the conversation as cancelled and update their UI accordingly.
pub(super) fn send_cancellation_to_viewers(&mut self, ctx: &mut ModelContext<Self>) {
if !self
.terminal_model
.lock()
.shared_session_status()
.is_sharer()
{
return;
}
// Get the current conversation and build usage metadata from it.
let conversation_id = self.get_current_shared_session_conversation_id(ctx);
let usage_metadata = conversation_id.and_then(|conv_id| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conv_id)
.map(|conversation| stream_finished::ConversationUsageMetadata {
context_window_usage: conversation.context_window_usage(),
credits_spent: conversation.credits_spent(),
summarized: conversation.was_summarized(),
#[allow(deprecated)]
token_usage: conversation
.token_usage()
.iter()
.map(|u| u.to_proto_combined())
.collect(),
tool_usage_metadata: Some(conversation.tool_usage_metadata().into()),
warp_token_usage: conversation
.token_usage()
.iter()
.filter_map(|u| u.to_proto_warp_usage())
.collect(),
byok_token_usage: conversation
.token_usage()
.iter()
.filter_map(|u| u.to_proto_byok_usage())
.collect(),
})
});
// Create a synthetic StreamFinished event to notify viewers of the cancellation.
// We use "Done" reason rather than a specific cancellation reason because
// the proto doesn't have explicit variants for UserCommandExecuted or ManuallyCancelled.
// TODO: we should probably add representations for said variants in the proto for this usecase.
let finished_event = warp_multi_agent_api::ResponseEvent {
r#type: Some(warp_multi_agent_api::response_event::Type::Finished(
warp_multi_agent_api::response_event::StreamFinished {
reason: Some(stream_finished::Reason::Done(stream_finished::Done {})),
conversation_usage_metadata: usage_metadata,
token_usage: vec![],
should_refresh_model_config: false,
request_cost: None,
},
)),
};
// Send the cancellation event to viewers.
// If no initiator is tracked, fall back to the sharer's participant ID.
let forked_from_token = conversation_id.and_then(|conv_id| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conv_id)
.and_then(|conv| {
conv.forked_from_server_conversation_token()
.map(|t| t.as_str().to_string())
})
});
self.terminal_model
.lock()
.send_agent_response_for_shared_session(
&finished_event,
self.get_current_response_initiator()
.or_else(|| self.get_sharer_participant_id()),
forked_from_token,
);
}
/// Marks an action as remotely executing when a viewer receives a CommandExecutionStarted event.
/// This allows the viewer's UI to show the action as running rather than queued.
pub fn mark_action_as_remotely_executing_in_shared_session(
&mut self,
action_id: &AIAgentActionId,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.action_model.update(ctx, |action_model, ctx| {
action_model.mark_action_as_remotely_executing(action_id, conversation_id, ctx);
});
}
/// Sets the participant ID for the current response.
/// This should be called when initiating a query to track who sent it.
pub fn set_current_response_initiator(&mut self, participant_id: ParticipantId) {
self.shared_session_state.current_response_initiator = Some(participant_id);
}
/// Gets the participant ID for the current response.
pub(super) fn get_current_response_initiator(&self) -> Option<ParticipantId> {
self.shared_session_state.current_response_initiator.clone()
}
/// Sets the sharer's participant ID. Should be called when a shared session is created.
pub fn set_sharer_participant_id(&mut self, participant_id: ParticipantId) {
self.shared_session_state.sharer_participant_id = Some(participant_id);
}
/// Gets the sharer's participant ID.
pub(super) fn get_sharer_participant_id(&self) -> Option<ParticipantId> {
self.shared_session_state.sharer_participant_id.clone()
}
/// Links a forked conversation's new token to an existing conversation.
/// This is called on the viewer side when receiving a response for a forked conversation
/// so that new responses are added to the correct conversation.
pub fn link_forked_conversation_token(
&mut self,
forked_from_token: &str,
event: &warp_multi_agent_api::ResponseEvent,
ctx: &mut ModelContext<Self>,
) {
// Extract the new server conversation id from the StreamInit event
let new_conversation_id = match &event.r#type {
Some(warp_multi_agent_api::response_event::Type::Init(init)) => {
init.conversation_id.as_str()
}
// Only StreamInit events have conversation_id.
_ => return,
};
// Find the conversation with the forked_from token
if let Some(conversation_id) =
self.find_existing_conversation_by_server_token(forked_from_token, ctx)
{
// Update the conversation's server_conversation_token to the new one
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.set_server_conversation_token_for_conversation(
conversation_id,
new_conversation_id.to_string(),
);
ctx.notify();
});
}
}
/// Execute an agent prompt on behalf of the viewer.
pub fn execute_agent_prompt_for_shared_session(
&mut self,
prompt: String,
server_conversation_token: Option<ServerConversationToken>,
attachments: Vec<AgentAttachment>,
participant_id: ParticipantId,
ctx: &mut ModelContext<Self>,
) {
// Map server token to sharer's local conversation ID
let conversation_id = server_conversation_token
.and_then(|id| self.find_existing_conversation_by_server_token(&id.to_string(), ctx))
.and_then(
|id| match BlocklistAIHistoryModel::as_ref(ctx).conversation(&id) {
Some(c) => Some(c),
None => {
log::error!(
"Tried to execute prompt for non-existent conversation: {id:?}",
);
None
}
},
)
.map(|conversation| conversation.id());
// Process attachments and set them in the context model
let mut block_ids = Vec::new();
let mut selected_text_parts = Vec::new();
let mut file_downloads: Vec<(String, String)> = Vec::new();
for attachment in attachments {
match attachment {
AgentAttachment::BlockReference { block_id } => {
// Convert protocol BlockId to app BlockId
block_ids.push(BlockId::from(block_id.to_string()));
}
AgentAttachment::PlainText { content } => {
selected_text_parts.push(content);
}
AgentAttachment::FileReference {
attachment_id,
file_name,
} => {
file_downloads.push((attachment_id, file_name));
}
}
}
// Set block and text attachments in the context model.
self.context_model.update(ctx, |context_model, ctx| {
// Set block IDs if any were provided
if !block_ids.is_empty() {
context_model.set_pending_context_block_ids(block_ids, false, ctx);
}
// Set selected text if any was provided
if !selected_text_parts.is_empty() {
let combined_text = selected_text_parts.join("\n");
context_model.set_pending_context_selected_text(Some(combined_text), false, ctx);
}
});
// If there are no file downloads (or the feature is disabled), send the query immediately.
if file_downloads.is_empty() || !FeatureFlag::CloudModeImageContext.is_enabled() {
self.send_shared_session_query(
prompt,
conversation_id,
participant_id,
HashMap::new(),
ctx,
);
return;
}
// We have file downloads — ensure both the download dir and task ID are available.
let Some(attachment_dir) = self.attachments_download_dir.clone() else {
log::error!(
"No attachments_download_dir set on controller, cannot process file attachments"
);
self.send_shared_session_query(
prompt,
conversation_id,
participant_id,
HashMap::new(),
ctx,
);
return;
};
let Some(task_id) = self.ambient_agent_task_id else {
log::error!("No task_id available to download attachments");
self.send_shared_session_query(
prompt,
conversation_id,
participant_id,
HashMap::new(),
ctx,
);
return;
};
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_api = ServerApiProvider::as_ref(ctx).get();
let attachment_ids: Vec<String> = file_downloads.iter().map(|(id, _)| id.clone()).collect();
// Fetch presigned download URLs from the server, download files to disk,
// then build the attachment map from only the successfully downloaded files.
ctx.spawn(
async move {
let download_urls = match ai_client
.download_task_attachments(&task_id, &attachment_ids)
.await
{
Ok(resp) => resp
.attachments
.into_iter()
.map(|att| (att.attachment_id, att.download_url))
.collect::<std::collections::HashMap<_, _>>(),
Err(e) => {
log::error!("Failed to get download URLs for task {task_id}: {e}");
return vec![];
}
};
if let Err(e) = async_fs::create_dir_all(&attachment_dir).await {
log::error!("Failed to create attachments directory: {e}");
return vec![];
}
let mut downloaded = Vec::new();
for (attachment_id, file_name) in &file_downloads {
let Some(url) = download_urls.get(attachment_id) else {
log::warn!("No download URL for attachment {attachment_id}");
continue;
};
let safe_name = sanitize_filename(file_name).to_string();
let dest = attachment_dir.join(format!("{attachment_id}_{safe_name}"));
match download_file(server_api.http_client(), url, &dest).await {
Ok(_) => {
downloaded.push(DownloadedAttachment {
file_id: attachment_id.clone(),
file_name: safe_name,
file_path: dest.to_string_lossy().into_owned(),
});
}
Err(e) => {
log::error!("Failed to download {safe_name}: {e}");
}
}
}
downloaded
},
move |controller, downloaded, ctx| {
let file_attachments = build_file_attachment_map(&downloaded);
controller.send_shared_session_query(
prompt,
conversation_id,
participant_id,
file_attachments,
ctx,
);
},
);
}
/// Helper to send a shared-session query, used both for immediate sends
/// (no file attachments) and deferred sends (after file downloads complete).
fn send_shared_session_query(
&mut self,
prompt: String,
conversation_id: Option<AIConversationId>,
participant_id: ParticipantId,
file_attachments: HashMap<String, AIAgentAttachment>,
ctx: &mut ModelContext<Self>,
) {
if let Some(conversation_id) = conversation_id {
if FeatureFlag::AgentView.is_enabled() {
// Enter agent view for this conversation so the sharer's UI state is correct
// and updates are sent to the viewer.
self.context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::SharedSessionSelection,
ctx,
);
});
}
self.send_user_query_in_conversation_with_attachments(
prompt,
conversation_id,
Some(participant_id),
file_attachments,
ctx,
);
} else {
if FeatureFlag::AgentView.is_enabled() {
// If we're already in an empty agent view conversation, reuse it
// (so that any command blocks remain visible). Otherwise create a new one for the given prompt.
let history = BlocklistAIHistoryModel::handle(ctx);
let origin = AgentViewEntryOrigin::SharedSessionSelection;
let Some(conversation_id) = self
.context_model
.as_ref(ctx)
.selected_conversation_id(ctx)
.filter(|conversation_id| {
history
.as_ref(ctx)
.conversation(conversation_id)
.is_some_and(|conversation| {
conversation.exchange_count() == 0
&& conversation.server_conversation_token().is_none()
})
})
.or_else(|| {
self.context_model.update(ctx, |context_model, ctx| {
context_model
.try_enter_agent_view_for_new_conversation(origin, ctx)
.ok()
})
})
else {
log::error!("Failed to get conversation id for shared session prompt");
return;
};
self.send_user_query_in_conversation_with_attachments(
prompt,
conversation_id,
Some(participant_id),
file_attachments,
ctx,
);
return;
}
self.send_user_query_in_new_conversation(
prompt,
None,
EntrypointType::SharedSession,
Some(participant_id),
ctx,
);
}
}
}
@@ -0,0 +1,271 @@
use std::sync::Arc;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentContext, AIAgentInput, CloneRepositoryURL,
EntrypointType, RequestMetadata,
},
blocklist::agent_view::AgentViewEntryOrigin,
},
search::slash_command_menu::static_commands::commands,
terminal::input::slash_commands::SlashCommandTrigger,
BlocklistAIHistoryModel,
};
use super::{
input_context_for_request, parse_context_attachments, BlocklistAIController,
BlocklistAIControllerEvent, RequestInput,
};
pub enum SlashCommandRequest {
CreateNewProject {
query: String,
},
CloneRepository {
url: String,
},
InitProjectRules,
CreateEnvironment {
repos: Vec<String>,
use_current_dir: bool,
},
Summarize {
prompt: Option<String>,
},
FetchReviewComments {
repo_path: String,
},
/// Invoke a skill.
InvokeSkill {
skill: ai::skills::ParsedSkill,
user_query: Option<String>,
},
}
impl SlashCommandRequest {
/// Parses user input into a SlashCommandRequest for slash commands that are handled
/// via the AI query flow (as opposed to action-based slash commands handled in input.rs).
pub fn from_query(query: &str) -> Option<SlashCommandRequest> {
// Check if this is an exact /init query and route it to InitProjectRules instead
if query == "/init" {
return Some(Self::InitProjectRules);
}
// Check if query starts with /compact and route to summarize conversation
if let Some(prompt) = query.strip_prefix(commands::COMPACT.name) {
return Some(Self::Summarize {
prompt: prompt.strip_prefix(' ').map(String::from),
});
}
None
}
pub(super) fn send_request(
self,
controller: &mut BlocklistAIController,
is_queued_prompt: bool,
ctx: &mut ModelContext<BlocklistAIController>,
) {
let conversation_id = self.conversation_id(controller, ctx);
// For skill invocations, include user-attached context (images, blocks, and selected
// text) so the skill's agent sees the same attachments a non-slash-command user query
// would. Other slash commands continue to pass `false` to preserve existing behavior.
let is_invoke_skill = matches!(self, Self::InvokeSkill { .. });
let context = input_context_for_request(
is_invoke_skill,
controller.context_model.as_ref(ctx),
controller.active_session.as_ref(ctx),
conversation_id,
vec![],
ctx,
);
let entrypoint = self.entrypoint();
let is_summarize = matches!(self, Self::Summarize { .. });
let inputs = self.input(context, controller.context_model.as_ref(ctx), ctx);
if inputs.is_empty() {
return;
}
// If no existing conversation, create a new one.
// When AgentView is enabled, enter agent view which creates the conversation
// and ensures AI blocks render correctly in the agent view.
let Some(conversation_id) = conversation_id.or_else(|| {
if FeatureFlag::AgentView.is_enabled() {
controller.context_model.update(ctx, |context_model, ctx| {
context_model
.try_enter_agent_view_for_new_conversation(
AgentViewEntryOrigin::SlashCommand {
trigger: SlashCommandTrigger::input(),
},
ctx,
)
.ok()
})
} else {
Some(controller.start_new_conversation_for_request(ctx).id())
}
}) else {
log::error!("Failed to get conversation ID for slash command request");
return;
};
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
else {
return;
};
let request_input = RequestInput::for_task(
inputs,
conversation.get_root_task_id().clone(),
&controller.active_session,
controller.get_current_response_initiator(),
conversation_id,
controller.terminal_view_id,
ctx,
);
let model_id = request_input.model_id.clone();
match controller.send_request_input(
request_input,
Some(RequestMetadata {
is_autodetected_user_query: false,
entrypoint,
is_auto_resume_after_error: false,
}),
/*default_to_follow_up_on_success*/ true,
/*can_attempt_resume_on_error*/ true,
is_queued_prompt,
ctx,
) {
Ok((_, stream_id)) => {
// Skill invocations now consume user-attached context (images, blocks, and
// selected text) the same way regular user queries do. `send_request_input`
// only clears that context for `AIAgentInput::UserQuery`, so we mirror its
// reset here for `InvokeSkill` to avoid pending attachments sticking around
// and getting re-sent on subsequent messages.
if is_invoke_skill {
controller.context_model.update(ctx, |context_model, ctx| {
context_model.reset_context_to_default(ctx);
});
}
// Emit SentRequest event to trigger buffer clearing
if is_summarize {
ctx.emit(BlocklistAIControllerEvent::SentRequest {
contains_user_query: true,
is_queued_prompt,
model_id,
stream_id,
});
}
}
Err(e) => log::error!("Failed to send agent slash command request: {e:?}"),
}
}
fn conversation_id(
&self,
controller: &BlocklistAIController,
app: &AppContext,
) -> Option<AIConversationId> {
match self {
Self::Summarize { .. }
| Self::CreateEnvironment { .. }
| Self::InvokeSkill { .. }
| Self::FetchReviewComments { .. } => controller
.context_model
.as_ref(app)
.selected_conversation_id(app),
_ => None,
}
}
fn input(
self,
context: Arc<[AIAgentContext]>,
context_model: &crate::ai::blocklist::BlocklistAIContextModel,
app: &AppContext,
) -> Vec<AIAgentInput> {
match self {
SlashCommandRequest::CreateNewProject { query } => {
vec![AIAgentInput::CreateNewProject { query, context }]
}
SlashCommandRequest::CloneRepository { url } => {
vec![AIAgentInput::CloneRepository {
clone_repo_url: CloneRepositoryURL::new(url),
context,
}]
}
SlashCommandRequest::InitProjectRules => vec![AIAgentInput::InitProjectRules {
context,
display_query: Some("/init".to_string()),
}],
SlashCommandRequest::CreateEnvironment {
mut repos,
use_current_dir,
} => {
let display_query = if repos.is_empty() {
"/create-environment".to_string()
} else {
format!("/create-environment {}", repos.join(" "))
};
// Add "." to represent the current working directory
if use_current_dir {
repos.push(String::from("."));
}
vec![AIAgentInput::CreateEnvironment {
context,
display_query: Some(display_query),
repo_paths: repos,
}]
}
SlashCommandRequest::Summarize { prompt, .. } => {
vec![AIAgentInput::SummarizeConversation { prompt }]
}
SlashCommandRequest::FetchReviewComments { repo_path } => {
vec![AIAgentInput::FetchReviewComments { repo_path, context }]
}
SlashCommandRequest::InvokeSkill { skill, user_query } => {
let user_query = if FeatureFlag::SkillArguments.is_enabled() {
user_query
.map(|query| query.trim().to_string())
.filter(|query| !query.is_empty())
.map(|query| crate::ai::agent::InvokeSkillUserQuery {
referenced_attachments: parse_context_attachments(
&query,
context_model,
app,
),
query,
})
} else {
None
};
vec![AIAgentInput::InvokeSkill {
skill,
user_query,
context,
}]
}
}
}
fn entrypoint(&self) -> EntrypointType {
match self {
SlashCommandRequest::CloneRepository { .. } => EntrypointType::CloneRepository,
SlashCommandRequest::InitProjectRules => EntrypointType::InitProjectRules,
SlashCommandRequest::CreateNewProject { .. }
| SlashCommandRequest::CreateEnvironment { .. }
| SlashCommandRequest::Summarize { .. }
| SlashCommandRequest::FetchReviewComments { .. }
| SlashCommandRequest::InvokeSkill { .. } => EntrypointType::UserInitiated,
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,595 @@
//! This module contains functions for loading, fetching, and merging conversation data
//! from local database and server sources.
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use futures::FutureExt;
use itertools::Itertools as _;
use persistence::model::AgentConversationRecord;
use warp_core::features::FeatureFlag;
use warpui::{AppContext, SingletonEntity};
use crate::ai::agent::api::convert_conversation::{
convert_conversation_data_to_ai_conversation, RestorationMode,
};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{
AIAgentHarness, AIConversation, AIConversationId, ServerAIConversationMetadata,
};
use crate::ai::agent::task::Task;
use crate::persistence::model::{AgentConversation, AgentConversationData};
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::ServerApiProvider;
use crate::terminal::model::block::SerializedBlock;
#[cfg(feature = "local_fs")]
use crate::persistence::agent::read_agent_conversation_by_id;
use super::{AIConversationMetadata, BlocklistAIHistoryModel, MAX_HISTORICAL_CONVERSATIONS};
/// A conversation transcript from a CLI agent harness (e.g. Claude Code).
#[derive(Debug, Clone)]
pub struct CLIAgentConversation {
/// Server metadata about this conversation.
pub metadata: ServerAIConversationMetadata,
/// A snapshot of the final agent TUI state.
pub block: SerializedBlock,
}
/// Representation of the conversation data that can be fetched from cloud storage.
///
/// The exact format depends on the agent harness that produced the conversation.
pub enum CloudConversationData {
/// A conversation produced by the Oz harness, which we can materialize into the
/// [`AIConversation`] data model.
Oz(Box<AIConversation>),
/// A conversation produced by an external CLI agent harness.
CLIAgent(Box<CLIAgentConversation>),
}
/// Converts an `AgentConversation` from the database to an `AIConversation`.
/// This utility function extracts the conversion logic that was originally embedded
/// in the terminal view restoration process.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub fn convert_persisted_conversation_to_ai_conversation(
persisted_conversation: AgentConversation,
) -> Option<AIConversation> {
convert_persisted_conversation_to_ai_conversation_with_metadata(persisted_conversation)
}
/// Enhanced version of the conversion function with additional metadata.
/// This version supports the full feature set needed by terminal view restoration.
pub fn convert_persisted_conversation_to_ai_conversation_with_metadata(
persisted_conversation: AgentConversation,
) -> Option<AIConversation> {
let AgentConversation {
tasks,
conversation:
AgentConversationRecord {
conversation_id,
conversation_data,
..
},
} = persisted_conversation;
let conversation_id = match AIConversationId::try_from(conversation_id) {
Ok(id) => id,
Err(e) => {
log::warn!("Failed to convert conversation ID: {e:?}");
return None;
}
};
let conversation_data = serde_json::from_str::<AgentConversationData>(&conversation_data).ok();
match AIConversation::new_restored(conversation_id, tasks, conversation_data) {
Ok(conversation) => Some(conversation),
Err(e) => {
log::warn!("Failed to convert persisted conversation to AIConversation: {e:?}");
None
}
}
}
/// Loads a conversation from the server asynchronously.
/// This is a free-floating function that can be called without a model reference.
pub async fn load_conversation_from_server(
conversation_id: AIConversationId,
server_conversation_token: ServerConversationToken,
server_api: Arc<dyn AIClient>,
) -> Option<CloudConversationData> {
if !FeatureFlag::CloudConversations.is_enabled() {
return None;
}
log::info!(
"Loading full conversation data for {conversation_id} from server using token {}",
server_conversation_token.as_str()
);
match server_api
.get_ai_conversation(server_conversation_token.clone())
.await
{
Ok((conversation_data, server_metadata)) => {
match server_metadata.harness {
AIAgentHarness::Oz => {
// Convert Oz conversations to an AIConversation.
match convert_conversation_data_to_ai_conversation(
conversation_id,
&conversation_data,
server_metadata,
RestorationMode::Continue,
) {
Some(conversation) => {
log::info!("Loaded Oz conversation {conversation_id} from server");
Some(CloudConversationData::Oz(Box::new(conversation)))
}
None => {
log::warn!(
"Failed to convert Oz server conversation data for {conversation_id}"
);
None
}
}
}
AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini => {
if !FeatureFlag::AgentHarness.is_enabled() {
log::warn!("Ignoring non-Oz conversation {conversation_id}: AgentHarness flag is disabled");
return None;
}
// Fetch snapshot data for third-party harness conversations.
match server_api
.get_block_snapshot(server_conversation_token)
.await
{
Ok(block) => {
log::info!("Loaded CLI agent block snapshot for {conversation_id}");
Some(CloudConversationData::CLIAgent(Box::new(
CLIAgentConversation {
metadata: server_metadata,
block,
},
)))
}
Err(e) => {
log::warn!(
"Failed to fetch block snapshot for {conversation_id}: {e:#}"
);
None
}
}
}
AIAgentHarness::Unknown => {
log::warn!(
"Ignoring conversation {conversation_id}: server reported an unknown harness; this client may be out of date"
);
None
}
}
}
Err(e) => {
log::warn!("Failed to load conversation {conversation_id} from server: {e:#}");
None
}
}
}
/// Boxes a future with the right type for the platform.
/// On WASM, futures must not implement Send.
fn box_future<F>(f: F) -> warpui::r#async::BoxFuture<'static, Option<CloudConversationData>>
where
F: Future<Output = Option<CloudConversationData>> + warpui::r#async::Spawnable,
{
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
f.boxed_local()
} else {
f.boxed()
}
}
}
impl BlocklistAIHistoryModel {
/// Loads conversation data from the appropriate source (DB or server).
///
/// This method automatically determines whether to load from the local database or
/// the server based on the conversation's metadata:
/// - If the conversation is already in memory, returns it immediately
/// - If has_local_data is true, loads from the local database synchronously
/// - Otherwise, loads from the server asynchronously
///
/// Note: This does NOT insert the conversation into memory. Callers are responsible
/// for inserting the loaded conversation if needed.
pub fn load_conversation_data(
&self,
conversation_id: AIConversationId,
ctx: &AppContext,
) -> warpui::r#async::BoxFuture<'static, Option<CloudConversationData>> {
// First check if the conversation is already in memory
if let Some(conversation) = self.conversations_by_id.get(&conversation_id) {
return box_future(futures::future::ready(Some(CloudConversationData::Oz(
Box::new(conversation.clone()),
))));
}
// Check metadata to determine the source
let Some(metadata) = self
.all_conversations_metadata
.get(&conversation_id)
.cloned()
else {
log::warn!("No metadata found for conversation {conversation_id}");
return box_future(futures::future::ready(None));
};
if metadata.has_local_data {
// Load from local database synchronously
let result = self
.load_conversation_from_db(&conversation_id)
.map(|c| CloudConversationData::Oz(Box::new(c)));
box_future(futures::future::ready(result))
} else {
// Load from server asynchronously
if let Some(server_token) = metadata.server_conversation_token {
// Extract the server API before creating the async future
let server_api = ServerApiProvider::as_ref(ctx).get_ai_client();
box_future(load_conversation_from_server(
conversation_id,
server_token,
server_api,
))
} else {
log::warn!(
"Cannot load conversation {conversation_id}: no local data and no server token"
);
box_future(futures::future::ready(None))
}
}
}
/// Loads a conversation by its server token, with a server fallback.
///
/// First attempts to find the conversation in local metadata and load it
/// via `load_conversation_data`. If the token is not present locally
/// (e.g. cloud metadata hasn't been merged yet), falls back to loading
/// the conversation directly from the server.
///
/// Note: This does NOT insert the conversation into memory. Callers are responsible
/// for inserting the loaded conversation if needed.
pub fn load_conversation_by_server_token(
&self,
server_token: &ServerConversationToken,
ctx: &AppContext,
) -> warpui::r#async::BoxFuture<'static, Option<CloudConversationData>> {
// Fast path: token is known locally.
if let Some(conversation_id) = self.find_conversation_id_by_server_token(server_token) {
return self.load_conversation_data(conversation_id, ctx);
}
// Fallback: load directly from the server. This handles cases where
// cloud metadata hasn't been merged into the local history model yet
// (e.g. timing on startup, or conversations only surfaced via
// AgentConversationsModel).
log::warn!(
"No local metadata for server token {}, falling back to server fetch",
server_token.as_str()
);
let server_api = ServerApiProvider::as_ref(ctx).get_ai_client();
// Ephemeral ID — this conversation is not inserted into the history model.
let fallback_id = AIConversationId::new();
box_future(load_conversation_from_server(
fallback_id,
server_token.clone(),
server_api,
))
}
/// Loads a conversation from local DB and returns it.
/// This is a private helper method. Use `get_load_conversation_data_future` instead.
///
/// Note: This does NOT insert the conversation into memory. Callers are responsible
/// for inserting the loaded conversation if needed.
pub(super) fn load_conversation_from_db(
&self,
conversation_id: &AIConversationId,
) -> Option<AIConversation> {
// First check if the conversation is in memory
if let Some(conversation) = self.conversations_by_id.get(conversation_id) {
return Some(conversation.clone());
}
// If not in memory, try to load from the database
#[cfg(feature = "local_fs")]
{
let persisted_ai_conversation = self.db_connection.clone().and_then(|conn| {
let mut conn = conn.lock().ok()?;
let id_str = conversation_id.to_string();
log::info!("Loading conversation {id_str} from db");
match read_agent_conversation_by_id(&mut conn, &id_str) {
Ok(Some(conv)) => Some(conv),
Ok(None) => {
log::warn!("No AgentConversation found with id {id_str}");
None
}
Err(e) => {
log::warn!("Failed to read AgentConversation {id_str}: {e:?}");
None
}
}
});
// Convert the persisted conversation to an AIConversation
if let Some(persisted_conversation) = persisted_ai_conversation {
if let Some(conversation) =
convert_persisted_conversation_to_ai_conversation(persisted_conversation)
{
return Some(conversation);
}
}
}
None
}
/// Merges cloud conversation metadata with existing local metadata.
/// Deduplicates by conversation_id and server_conversation_token.
/// Also updates server_metadata on any already-restored conversations that match by token.
pub fn merge_cloud_conversation_metadata(
&mut self,
cloud_metadata_list: Vec<ServerAIConversationMetadata>,
) {
let local_count = self.all_conversations_metadata.len();
let mut local_matched_with_server_count = 0;
let mut new_cloud_count = 0;
let mut restored_conversations_updated = 0;
// Build a map from server_conversation_token to conversation_id
let mut token_to_conv_id: HashMap<String, AIConversationId> = HashMap::new();
for (conv_id, meta) in self.all_conversations_metadata.iter() {
if let Some(token) = &meta.server_conversation_token {
token_to_conv_id.insert(token.as_str().to_string(), *conv_id);
}
}
// Build a map from server_conversation_token to conversation_id for restored conversations,
// and collect tokens belonging to child agent conversations so we can skip them.
let mut token_to_restored_conv_id: HashMap<String, AIConversationId> = HashMap::new();
let mut child_conversation_tokens: HashSet<String> = HashSet::new();
for (conv_id, conv) in self.conversations_by_id.iter() {
if let Some(token) = conv.server_conversation_token() {
token_to_restored_conv_id.insert(token.as_str().to_string(), *conv_id);
if conv.is_child_agent_conversation() {
child_conversation_tokens.insert(token.as_str().to_string());
}
}
}
// Now iterate through cloud metadata once, using the map for O(1) lookups
for server_meta in cloud_metadata_list {
let server_token = server_meta.server_conversation_token.clone();
let server_token_str = server_token.as_str();
// Child agent conversations are managed by their parent's status card
// and should not appear in navigation/history.
if child_conversation_tokens.contains(server_token_str) {
continue;
}
// Update any already-restored conversations that match by server token
if let Some(conv_id) = token_to_restored_conv_id.get(server_token_str) {
if let Some(conversation) = self.conversations_by_id.get_mut(conv_id) {
if conversation.server_metadata().is_none() {
conversation.set_server_metadata(server_meta.clone());
restored_conversations_updated += 1;
log::debug!(
"Updated server metadata for restored conversation {conv_id} with token {server_token_str}"
);
}
}
}
if let Some(conv_id) = token_to_conv_id.get(server_token_str) {
// Found a match by token - update this entry with server metadata
let conversation_id = *conv_id;
let metadata =
AIConversationMetadata::from_server_metadata(conversation_id, server_meta);
self.server_token_to_conversation_id
.insert(server_token.clone(), conversation_id);
self.all_conversations_metadata
.insert(conversation_id, metadata);
local_matched_with_server_count += 1;
log::debug!(
"Matched local conversation {conversation_id} with server token {server_token_str}"
);
} else {
// This is a new cloud-only conversation
// We need to create a local AIConversationId for it
let conversation_id = AIConversationId::new();
let metadata =
AIConversationMetadata::from_server_metadata(conversation_id, server_meta);
self.server_token_to_conversation_id
.insert(server_token.clone(), conversation_id);
self.all_conversations_metadata
.insert(conversation_id, metadata);
new_cloud_count += 1;
log::debug!(
"Added new cloud-only conversation with local ID {conversation_id} and server token {server_token_str}"
);
}
}
log::info!(
"Merged cloud conversations: {} local, {} found matched cloud metadata, {} new cloud-only added, {} restored conversations updated. Total: {}",
local_count,
local_matched_with_server_count,
new_cloud_count,
restored_conversations_updated,
self.all_conversations_metadata.len()
);
}
/// Initializes historical conversations from restored agent conversations.
pub(super) fn initialize_historical_conversations(
&mut self,
conversations: &[AgentConversation],
) {
let conversations = conversations
.iter()
.sorted_by_key(|c| c.conversation.last_modified_at)
.rev();
let collected: HashMap<AIConversationId, AIConversationMetadata> = conversations
.take(MAX_HISTORICAL_CONVERSATIONS)
.filter_map(|agent_conv| {
// Try to convert the conversation ID
let conversation_id = match AIConversationId::try_from(
agent_conv.conversation.conversation_id.clone(),
) {
Ok(id) => id,
Err(e) => {
log::warn!("Failed to convert conversation ID: {e:?}");
return None;
}
};
if !agent_conv.is_restorable() {
return None;
}
// Child agent conversations are managed by their parent's
// status card and should not appear in navigation/history.
// Record the parent→child mapping before filtering so that
// create_missing_child_agent_panes can discover children
// before they are loaded into conversations_by_id.
let conversation_data = serde_json::from_str::<AgentConversationData>(
&agent_conv.conversation.conversation_data,
)
.ok();
if let Some(parent_id_str) = conversation_data
.as_ref()
.and_then(|data| data.parent_conversation_id.as_deref())
{
if let Ok(parent_id) = AIConversationId::try_from(parent_id_str.to_string()) {
self.children_by_parent
.entry(parent_id)
.or_default()
.push(conversation_id);
}
return None;
}
// Skip conversations that contain AutoCodeDiff system queries but do not contain any UserQuery messages.
// These are passive, auto-initiated diffs that were never interacted with (past accepting or rejecting the diff),
// so we don't want to list them as historical conversations.
let mut has_user_query = false;
let mut has_autocodediff = false;
for task in &agent_conv.tasks {
for message in &task.messages {
match &message.message {
Some(warp_multi_agent_api::message::Message::UserQuery(_)) => {
has_user_query = true;
}
Some(warp_multi_agent_api::message::Message::SystemQuery(sys)) => {
if let Some(warp_multi_agent_api::message::system_query::Type::AutoCodeDiff(_)) = &sys.r#type {
has_autocodediff = true;
}
}
_ => {}
}
}
}
if has_autocodediff && !has_user_query {
return None;
}
let root_task = agent_conv.tasks.iter().find(|task| {
task.dependencies.is_none()
});
let initial_query = root_task.map(|task| {
// find the first task with a user query
// (or in the case of a passive code diff, the summary of the diff)
task.messages.iter().find_map(|msg| {
match &msg.message {
Some(warp_multi_agent_api::message::Message::UserQuery(user_query)) => {
Some(user_query.query.clone())
}
Some(warp_multi_agent_api::message::Message::ToolCall(tool_call)) => {
let Some(tool) = &tool_call.tool else {
return None;
};
if let warp_multi_agent_api::message::tool_call::Tool::ApplyFileDiffs(diff_suggestion) = tool {
Some(diff_suggestion.summary.clone())
} else {
None
}
}
_ => None,
}
}).unwrap_or_default()
}).unwrap_or_default();
if initial_query.is_empty() {
log::warn!(
"Failed to record conversation with ID {conversation_id} because it was missing an initial query"
);
return None;
}
// We derive the title from the description of the root task,
// falling back to initial_query if the description is empty
let title = root_task
.map(|task| task.description.clone())
.filter(|desc| !desc.is_empty())
.unwrap_or_else(|| initial_query.clone());
// Extract working directory from the first UserQuery message in the tasks
// TODO: search tasks in correct order once we've implemented task ordering.
let initial_working_directory = agent_conv.tasks.iter().find_map(Task::api_task_initial_working_directory);
let credits_spent = conversation_data
.as_ref()
.and_then(|data| data.conversation_usage_metadata.as_ref())
.map(|m| m.credits_spent);
let artifacts = conversation_data
.as_ref()
.and_then(|data| data.artifacts_json.as_ref())
.and_then(|json| serde_json::from_str(json).ok())
.unwrap_or_default();
let server_conversation_token = conversation_data
.and_then(|data| data.server_conversation_token)
.map(ServerConversationToken::new);
Some((conversation_id, AIConversationMetadata {
id: conversation_id,
title,
initial_query,
last_modified_at: agent_conv.conversation.last_modified_at,
initial_working_directory,
credits_spent,
// If we have a server token, the conversation was synced to cloud
has_cloud_data: server_conversation_token.is_some(),
server_conversation_token,
has_local_data: true,
artifacts,
// Only populated when loading from server, not from local DB
server_conversation_metadata: None,
}))
})
.collect();
// Populate the token → conversation reverse index alongside the
// forward metadata map.
for (conversation_id, metadata) in &collected {
if let Some(token) = &metadata.server_conversation_token {
self.server_token_to_conversation_id
.insert(token.clone(), *conversation_id);
}
}
self.all_conversations_metadata = collected;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More