Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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".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: "".to_string(),
|
||||
layout: AgentOutputImageLayout::Block,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let rendered_image_indices = text_sections_with_indices(§ions, 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(§ions, 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(¤t_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())
|
||||
}
|
||||
Reference in New Issue
Block a user