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
@@ -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())
}