Files
galaxy/app/src/ai/blocklist/block/status_bar.rs
T
Ryan Ward d9cf0d8ae3 Fix agent run completion and follow-up handling
Ensure parallel tool results finish before follow-ups, preserve warping
status across exchanges, clean up streams before crosscheck reviews, and
restore terminal focus when conversations complete. Remove temporary
debug
logging.
2026-07-31 16:33:08 -05:00

1465 lines
60 KiB
Rust

use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon as CoreIcon;
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
use galaxyui::elements::{Border, Container, Empty, Flex, MouseStateHandle, ParentElement, Text};
use galaxyui::keymap::Keystroke;
use galaxyui::presenter::ChildView;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use instant::Instant;
use markdown_parser::FormattedTextFragment;
use parking_lot::FairMutex;
use pathfinder_color::ColorU;
use warp_multi_agent_api as api;
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
use super::model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus};
use super::view_impl::common::{
render_switch_control_to_user_button, render_warping_indicator, render_warping_indicator_base,
AutoExecuteButtonProps, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
WarpingIndicatorProps, WarpingProps, WAITING_FOR_USER_INPUT_MESSAGE,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
icons, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessageType, CancellationReason,
SummarizationType,
};
use crate::ai::agent_tips::AITipModel;
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
use crate::ai::blocklist::agent_view::{
agent_view_bg_fill, is_in_cloud_context, AgentMessageBar, AgentViewController,
EphemeralMessageModel,
};
use crate::ai::blocklist::block::view_impl::common::random_load_output_message;
use crate::ai::blocklist::model::AIBlockModelHelper;
use crate::ai::blocklist::summarization_cancel_dialog::{
self, SummarizationCancelDialog, SummarizationCancelDialogEvent,
};
use crate::ai::blocklist::{
ai_brand_color, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent,
BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIInputEvent,
BlocklistAIInputModel, QueuedQueryEvent, QueuedQueryModel, ResponseStreamId,
};
use crate::ai::llms::LLMPreferences;
use crate::ai::AgentTip;
use crate::server::telemetry::TelemetryEvent;
use crate::settings::{InputModeSettings, InputSettings};
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::message_bar::common::render_wrapping_standard_message_bar;
use crate::terminal::input::slash_command_model::SlashCommandModel;
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
use crate::terminal::input::{HandoffComposeState, SET_INPUT_MODE_TERMINAL_ACTION_NAME};
use crate::terminal::model::block::LONG_RUNNING_COMMAND_DURATION_MS;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::view::ambient_agent::{
is_cloud_agent_pre_first_exchange, AmbientAgentViewModel, AmbientAgentViewModelEvent,
};
use crate::terminal::warpify::render::LEFT_STRIPE_WIDTH;
use crate::terminal::{
TerminalModel, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
};
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::{send_telemetry_from_app_ctx, BlocklistAIHistoryModel};
pub fn init(app: &mut AppContext) {
summarization_cancel_dialog::init(app);
}
#[derive(Default)]
struct StateHandles {
autoexecute_button: MouseStateHandle,
queue_next_prompt_button: MouseStateHandle,
stop_button: MouseStateHandle,
take_over_button: MouseStateHandle,
hide_cli_responses_button: MouseStateHandle,
/// Tracks hover/press state for the inline `Check now` affordance rendered next to
/// `Last seen by agent ...` while the agent is polling a long-running command.
force_refresh_button: MouseStateHandle,
}
pub struct BlocklistAIStatusBar {
active_exchange_model: Option<Box<dyn AIBlockModel<View = BlocklistAIStatusBar>>>,
action_model: ModelHandle<BlocklistAIActionModel>,
controller: ModelHandle<BlocklistAIController>,
cli_subagent_controller: ModelHandle<CLISubagentController>,
context_model: ModelHandle<BlocklistAIContextModel>,
input_model: ModelHandle<BlocklistAIInputModel>,
agent_view_controller: ModelHandle<AgentViewController>,
terminal_model: Arc<FairMutex<TerminalModel>>,
shimmering_text_handle: ShimmeringTextStateHandle,
state_handles: StateHandles,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
autoexecute_keystroke: Option<Keystroke>,
queue_next_prompt_keystroke: Option<Keystroke>,
stop_keystroke: Option<Keystroke>,
set_terminal_input_keystroke: Option<Keystroke>,
hide_cli_responses_keystroke: Option<Keystroke>,
// Whether the summarization cancellation confirmation dialog is open.
is_summarization_cancel_dialog_open: bool,
summarization_cancel_dialog: ViewHandle<SummarizationCancelDialog>,
/// Handle for the periodic timer that updates the summarization timer UI.
summarization_timer_handle: Option<SpawnedFutureHandle>,
summarization_start_time: Option<Instant>,
/// Handle for the 1-second periodic timer that refreshes the "Last read …" suffix in
/// the warping indicator while the active block has a recorded LRC snapshot.
last_read_refresh_handle: Option<SpawnedFutureHandle>,
/// The time the warping indicator started showing for the current conversation run.
/// Preserved across model follow-up exchanges triggered by tool calls.
warping_start_time: Option<Instant>,
/// The random loading message chosen for the current conversation run, stable across
/// re-renders and tool-call follow-up exchanges.
warping_message: Option<&'static str>,
/// Handle for the periodic timer that updates the warping elapsed timer UI.
warping_timer_handle: Option<SpawnedFutureHandle>,
latest_response_stream_id: Option<ResponseStreamId>,
/// Agent tip to display below the warping indicator.
current_tip: Option<AgentTip>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
agent_message_bar: ViewHandle<AgentMessageBar>,
}
impl BlocklistAIStatusBar {
#[allow(clippy::too_many_arguments)]
pub fn new(
controller: ModelHandle<BlocklistAIController>,
agent_view_controller: ModelHandle<AgentViewController>,
cli_subagent_controller: ModelHandle<CLISubagentController>,
action_model: ModelHandle<BlocklistAIActionModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
input_model: ModelHandle<BlocklistAIInputModel>,
input_buffer_model: ModelHandle<InputBufferModel>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_model: Arc<FairMutex<TerminalModel>>,
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
slash_command_model: ModelHandle<SlashCommandModel>,
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
handoff_compose_state: ModelHandle<HandoffComposeState>,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
if event
.terminal_surface_id()
.is_some_and(|id| id != terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::AppendedExchange {
response_stream_id,
exchange_id,
conversation_id,
..
} => {
if let Some(response_stream_id) = response_stream_id.clone() {
me.latest_response_stream_id = Some(response_stream_id);
}
me.reset_model_for_exchange(*exchange_id, *conversation_id, ctx);
}
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
me.active_exchange_model = None;
me.stop_warping_timer();
ctx.notify();
}
BlocklistAIHistoryEvent::ClearedActiveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
} => {
if me.active_exchange_model.as_ref().is_some_and(|model| {
model
.conversation_id(ctx)
.is_some_and(|id| id == *conversation_id)
}) {
me.active_exchange_model = None;
me.stop_warping_timer();
ctx.notify();
}
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id,
new_status,
..
} => {
let is_active_conversation = me
.active_exchange_model
.as_ref()
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id));
if is_active_conversation && !new_status.is_in_progress() {
me.stop_warping_timer();
}
ctx.notify();
}
BlocklistAIHistoryEvent::SetActiveConversation {
conversation_id, ..
} => {
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id)
else {
return;
};
let Some(new_latest_exchange_id) =
conversation.latest_exchange().map(|exchange| exchange.id)
else {
return;
};
me.reset_model_for_exchange(new_latest_exchange_id, conversation.id(), ctx);
}
BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } => ctx.notify(),
_ => (),
}
});
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
if matches!(event, BlocklistAIContextEvent::PendingQueryStateUpdated) {
ctx.notify();
}
});
ctx.subscribe_to_model(&QueuedQueryModel::handle(ctx), |_, _, event, ctx| {
if matches!(
event,
QueuedQueryEvent::QueueNextPromptToggled { .. }
| QueuedQueryEvent::DefaultModeChanged
) {
ctx.notify();
}
});
ctx.subscribe_to_model(&input_model, |_, _, event, ctx| {
if let BlocklistAIInputEvent::InputTypeChanged { .. } = event {
ctx.notify();
}
});
ctx.subscribe_to_model(
&cli_subagent_controller,
move |me, _, event, ctx| match event {
CLISubagentEvent::UpdatedControl { .. }
| CLISubagentEvent::ToggledHideResponses => {
ctx.notify();
}
CLISubagentEvent::UpdatedLastSnapshot => {
let has_active_snapshot = me.should_refresh_last_read_timer(ctx);
if has_active_snapshot {
me.start_last_read_timer(ctx);
} else {
me.stop_last_read_timer();
}
ctx.notify();
}
_ => {}
},
);
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
let InputBufferUpdateEvent {
old_content: old,
new_content: new,
..
} = event;
if !me.input_model.as_ref(ctx).is_ai_input_enabled() && old.is_empty() != new.is_empty()
{
ctx.notify();
}
});
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
let input_settings = InputSettings::handle(ctx);
ctx.subscribe_to_model(&input_settings, |_, _, _, ctx| ctx.notify());
let input_mode_settings = InputModeSettings::handle(ctx);
ctx.subscribe_to_model(&input_mode_settings, |_, _, _, ctx| ctx.notify());
let stop_keystroke = keybinding_name_to_keystroke(CANCEL_COMMAND_KEYBINDING, ctx);
let autoexecute_keystroke =
keybinding_name_to_keystroke(TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, ctx);
let queue_next_prompt_keystroke =
keybinding_name_to_keystroke(TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING, ctx);
let set_terminal_input_keystroke =
keybinding_name_to_keystroke(SET_INPUT_MODE_TERMINAL_ACTION_NAME, ctx);
let hide_cli_responses_keystroke =
keybinding_name_to_keystroke(TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, ctx);
ctx.subscribe_to_model(&KeybindingChangedNotifier::handle(ctx), |me, _, _, ctx| {
me.stop_keystroke = keybinding_name_to_keystroke(CANCEL_COMMAND_KEYBINDING, ctx);
me.autoexecute_keystroke =
keybinding_name_to_keystroke(TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, ctx);
me.queue_next_prompt_keystroke =
keybinding_name_to_keystroke(TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING, ctx);
me.set_terminal_input_keystroke =
keybinding_name_to_keystroke(SET_INPUT_MODE_TERMINAL_ACTION_NAME, ctx);
ctx.notify();
});
ctx.observe(&AITipModel::handle(ctx), |me, tip_model, ctx| {
me.current_tip = tip_model.as_ref(ctx).current_tip().cloned();
ctx.notify();
});
let summarization_cancel_dialog =
ctx.add_typed_action_view(|_| SummarizationCancelDialog::default());
ctx.subscribe_to_view(
&summarization_cancel_dialog,
|me, _, event, ctx| match event {
SummarizationCancelDialogEvent::ConfirmCancel => {
me.cancel_active_request_or_action(ctx);
me.close_summarization_cancel_dialog(ctx);
}
SummarizationCancelDialogEvent::Continue => {
me.close_summarization_cancel_dialog(ctx);
}
},
);
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..)
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (),
});
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => {
let terminal_model = me.terminal_model.lock();
if terminal_model
.block_list()
.block_with_id(block_id)
.is_some_and(|block| block.agent_interaction_metadata().is_some())
{
ctx.spawn(
Timer::after(Duration::from_millis(LONG_RUNNING_COMMAND_DURATION_MS)),
|_, _, ctx| ctx.notify(),
);
}
}
ModelEvent::BlockCompleted(_) => {
ctx.notify();
}
_ => (),
});
ctx.subscribe_to_model(&ephemeral_message_model, |_, _, _, ctx| {
ctx.notify();
});
let agent_message_bar = ctx.add_typed_action_view(|ctx| {
AgentMessageBar::new(
agent_view_controller.clone(),
ephemeral_message_model.clone(),
shortcut_view_model.clone(),
input_buffer_model,
input_model.clone(),
input_suggestions_model,
slash_command_model,
context_model.clone(),
handoff_compose_state,
terminal_model.clone(),
ctx,
)
});
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
ctx.subscribe_to_model(ambient_agent_view_model, |me, _, event, ctx| match event {
AmbientAgentViewModelEvent::DispatchedAgent
| AmbientAgentViewModelEvent::ProgressUpdated => {
me.update_agent_tip(ctx);
ctx.notify();
}
AmbientAgentViewModelEvent::SessionReady { .. }
| AmbientAgentViewModelEvent::Failed { .. }
| AmbientAgentViewModelEvent::NeedsGithubAuth
| AmbientAgentViewModelEvent::Cancelled => {
ctx.notify();
}
_ => (),
});
}
Self {
active_exchange_model: None,
shimmering_text_handle: ShimmeringTextStateHandle::new(),
action_model,
context_model,
input_model,
terminal_model,
controller,
agent_view_controller,
cli_subagent_controller,
state_handles: Default::default(),
autoexecute_keystroke,
queue_next_prompt_keystroke,
stop_keystroke,
set_terminal_input_keystroke,
hide_cli_responses_keystroke,
summarization_cancel_dialog,
latest_response_stream_id: None,
is_summarization_cancel_dialog_open: false,
summarization_timer_handle: None,
summarization_start_time: None,
last_read_refresh_handle: None,
warping_start_time: None,
warping_message: None,
warping_timer_handle: None,
ambient_agent_view_model,
current_tip: None,
ephemeral_message_model,
agent_message_bar,
}
}
pub fn should_show_summarization_cancel_dialog(&self, app: &AppContext) -> bool {
self.is_summarization_cancel_dialog_open
&& self
.active_exchange_model
.as_ref()
.is_some_and(|model| model.is_conversation_summarization_active(app))
}
pub fn summarization_cancel_dialog_handle(&self) -> &ViewHandle<SummarizationCancelDialog> {
&self.summarization_cancel_dialog
}
pub fn handle_ctrl_c(&mut self, ctx: &mut ViewContext<Self>) {
let Some(model) = self.active_exchange_model.as_ref() else {
return;
};
// Show confirmation dialog if summarization is active and feature flag enabled
if FeatureFlag::SummarizationCancellationConfirmation.is_enabled()
&& model.is_conversation_summarization_active(ctx)
{
// If the dialog is already open, treat Ctrl-C as confirm-cancel
if self.is_summarization_cancel_dialog_open {
// Confirm cancel the running/pending action/request, then close the dialog overlay.
self.cancel_active_request_or_action(ctx);
self.close_summarization_cancel_dialog(ctx);
return;
}
self.open_summarization_cancel_dialog(ctx);
return;
}
// If summarization isn't active, fall back to canceling the running request/action directly
self.cancel_active_request_or_action(ctx);
ctx.notify();
}
pub fn notify_and_notify_children(&mut self, ctx: &mut ViewContext<Self>) {
ctx.notify();
self.agent_message_bar.update(ctx, |_, ctx| ctx.notify());
}
fn reset_model_for_exchange(
&mut self,
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let conversation = history_model.conversation(&conversation_id);
let exchange =
conversation.and_then(|conversation| conversation.exchange_with_id(exchange_id));
if self.active_exchange_model.as_ref().is_none_or(|model| {
model.exchange_id(ctx).is_none_or(|id| id != exchange_id)
|| model
.conversation(ctx)
.is_none_or(|conversation| conversation.id() != conversation_id)
}) {
let Some(conversation) = conversation else {
self.active_exchange_model = None;
self.stop_warping_timer();
ctx.notify();
return;
};
self.active_exchange_model = exchange
.and_then(|e| {
AIBlockModelImpl::<BlocklistAIStatusBar>::new(
e.id,
conversation.id(),
false,
false,
ctx,
)
.ok()
})
.map(|model| {
model.on_updated_output(
Box::new(|me, ctx| me.on_updated_active_exchange_output(ctx)),
ctx,
);
Box::new(model) as Box<dyn AIBlockModel<View = BlocklistAIStatusBar>>
});
self.is_summarization_cancel_dialog_open = false;
self.stop_summarization_timer();
self.start_warping_timer(ctx);
if FeatureFlag::AgentTips.is_enabled() {
self.update_agent_tip(ctx);
}
}
}
fn on_updated_active_exchange_output(&mut self, ctx: &mut ViewContext<Self>) {
let Some(model) = self.active_exchange_model.as_ref() else {
return;
};
let status = model.status(ctx);
let is_finished = matches!(
status,
AIBlockOutputStatus::Complete { .. }
| AIBlockOutputStatus::Cancelled { .. }
| AIBlockOutputStatus::Failed { .. }
);
// Auto-clear summarization confirmation dialog if summarization is no longer active
if self.is_summarization_cancel_dialog_open
&& !model.is_conversation_summarization_active(ctx)
{
self.is_summarization_cancel_dialog_open = false;
ctx.emit(
BlocklistAIStatusBarEvent::SummarizationCancelDialogToggled { is_open: false },
);
self.stop_summarization_timer();
}
match status {
AIBlockOutputStatus::PartiallyReceived { output }
| AIBlockOutputStatus::Complete { output } => {
let output = output.get();
self.handle_updated_output(&output, ctx);
}
AIBlockOutputStatus::Cancelled { partial_output, .. } => {
if let Some(output) = partial_output.as_ref() {
let output = output.get();
self.handle_updated_output(&output, ctx);
}
}
AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (),
}
// An exchange finishing does not necessarily mean the logical run is complete: tool calls
// can execute and append a follow-up exchange. Conversation status is the authoritative
// lifecycle signal, so preserve the timer and phrase while it remains in progress.
// Placed after the match so `model` (which borrows self.active_exchange_model) is no longer
// used, avoiding borrow conflicts with &mut self.
if is_finished {
let conversation_is_in_progress = self
.active_exchange_model
.as_ref()
.and_then(|model| model.conversation(ctx))
.is_some_and(|conversation| conversation.status().is_in_progress());
if !conversation_is_in_progress {
self.stop_warping_timer();
}
}
ctx.notify();
}
/// Closes the summarization cancel dialog.
fn close_summarization_cancel_dialog(&mut self, ctx: &mut ViewContext<Self>) {
self.is_summarization_cancel_dialog_open = false;
ctx.focus_self();
ctx.emit(BlocklistAIStatusBarEvent::SummarizationCancelDialogToggled { is_open: false });
ctx.notify();
}
/// Opens the summarization cancel dialog.
fn open_summarization_cancel_dialog(&mut self, ctx: &mut ViewContext<Self>) {
self.is_summarization_cancel_dialog_open = true;
ctx.focus(&self.summarization_cancel_dialog);
ctx.emit(BlocklistAIStatusBarEvent::SummarizationCancelDialogToggled { is_open: true });
ctx.notify();
}
fn handle_updated_output(&mut self, output: &AIAgentOutput, ctx: &mut ViewContext<Self>) {
// Register element state for reasoning messages and track summarization timing.
for message in &output.messages {
// Track summarization start time and token count when summarization message arrives
if let AIAgentOutputMessageType::Summarization {
finished_duration,
summarization_type,
..
} = &message.message
{
// Only track conversation summarization, not tool call result summarization
if matches!(summarization_type, SummarizationType::ConversationSummary) {
if finished_duration.is_none() {
// Starting summarization - record start time and start periodic updates
if self.summarization_start_time.is_none() {
self.summarization_start_time = Some(instant::Instant::now());
self.start_summarization_timer(ctx);
ctx.notify();
}
} else if self.summarization_start_time.is_some() {
self.stop_summarization_timer();
}
}
}
}
}
/// Cancels either the in-flight request stream or a pending/running action if present.
/// If neither is found but the conversation is still in progress (e.g., a subagent is running),
/// cancels the entire conversation's progress.
fn cancel_active_request_or_action(&mut self, ctx: &mut ViewContext<Self>) {
let Some(model) = self.active_exchange_model.as_ref() else {
return;
};
if model.status(ctx).is_streaming() {
if let Some(response_stream_id) = self.latest_response_stream_id.as_ref() {
self.controller.update(ctx, |controller, ctx| {
controller.cancel_request(
response_stream_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
} else {
let Some(conversation_id) = model.conversation_id(ctx) else {
return;
};
let Some(output) = model.status(ctx).output_to_render() else {
return;
};
let actions = output
.get()
.actions()
.map(|action| action.id.clone())
.collect::<HashSet<_>>();
if let Some(active_action_id) = self
.action_model
.as_ref(ctx)
.get_pending_or_running_action_id(ctx)
.filter(|id| actions.contains(id))
.cloned()
{
self.action_model.update(ctx, |action_model, ctx| {
action_model.cancel_action_with_id(
conversation_id,
&active_action_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
} else if model
.conversation(ctx)
.is_some_and(|c| c.status().is_in_progress())
{
// No streaming request or pending action, but conversation is still in progress.
// This happens when a subagent (e.g., computer use or advice) is running.
// Cancel the entire conversation's progress.
self.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
}
}
/// Starts the periodic timer that updates the summarization UI while summarization is active.
fn start_summarization_timer(&mut self, ctx: &mut ViewContext<Self>) {
// Don't start a new timer if one is already running
if self.summarization_timer_handle.is_some() {
return;
}
// Start a new timer that keeps the elapsed-time indicator fresh.
let handle = ctx.spawn(
async move {
Timer::after(Duration::from_secs(1)).await;
},
|me, _unit, ctx| {
// Clear the handle first so we can restart
me.summarization_timer_handle = None;
// Check if summarization is still active
if me.summarization_start_time.is_some() {
ctx.notify();
// Restart the timer for the next update
me.start_summarization_timer(ctx);
}
},
);
self.summarization_timer_handle = Some(handle);
}
fn stop_summarization_timer(&mut self) {
self.summarization_start_time = None;
if let Some(handle) = self.summarization_timer_handle.take() {
handle.abort();
}
}
/// Starts a 1-second periodic timer that keeps the warping elapsed-time indicator fresh.
///
/// A tool call can append several exchanges during one logical agent run. Keep the original
/// phrase and start time across those exchanges so the status does not thrash and the elapsed
/// time represents the complete run.
fn start_warping_timer(&mut self, ctx: &mut ViewContext<Self>) {
self.warping_start_time.get_or_insert_with(Instant::now);
self.warping_message
.get_or_insert_with(random_load_output_message);
// Don't start a new timer if one is already running
if self.warping_timer_handle.is_some() {
return;
}
let handle = ctx.spawn(
async move {
Timer::after(Duration::from_secs(1)).await;
},
|me, _, ctx| {
me.warping_timer_handle = None;
if me.warping_start_time.is_some() {
ctx.notify();
me.restart_warping_timer(ctx);
}
},
);
self.warping_timer_handle = Some(handle);
}
/// Restarts the warping timer for the next tick (called from within the timer callback).
fn restart_warping_timer(&mut self, ctx: &mut ViewContext<Self>) {
if self.warping_timer_handle.is_some() {
return;
}
let handle = ctx.spawn(
async move {
Timer::after(Duration::from_secs(1)).await;
},
|me, _, ctx| {
me.warping_timer_handle = None;
if me.warping_start_time.is_some() {
ctx.notify();
me.restart_warping_timer(ctx);
}
},
);
self.warping_timer_handle = Some(handle);
}
/// Stops the warping elapsed-time timer.
fn stop_warping_timer(&mut self) {
self.warping_start_time = None;
self.warping_message = None;
if let Some(handle) = self.warping_timer_handle.take() {
handle.abort();
}
}
fn should_refresh_last_read_timer(&self, ctx: &ViewContext<Self>) -> bool {
let active_block_id = self
.terminal_model
.lock()
.block_list()
.active_block()
.id()
.clone();
self.cli_subagent_controller
.as_ref(ctx)
.last_snapshot_at(&active_block_id)
.is_some()
}
/// Starts the 1-second periodic timer that keeps the elapsed "Last read Xs ago" suffix
/// updating in real time. No-ops if the timer is already running or if the active block
/// no longer has a recorded snapshot.
fn start_last_read_timer(&mut self, ctx: &mut ViewContext<Self>) {
if self.last_read_refresh_handle.is_some() || !self.should_refresh_last_read_timer(ctx) {
return;
}
let handle = ctx.spawn(
async move {
Timer::after(Duration::from_secs(1)).await;
},
|me, _, ctx| {
me.last_read_refresh_handle = None;
ctx.notify();
me.start_last_read_timer(ctx);
},
);
self.last_read_refresh_handle = Some(handle);
}
/// Stops and discards the last-read refresh timer.
fn stop_last_read_timer(&mut self) {
if let Some(handle) = self.last_read_refresh_handle.take() {
handle.abort();
}
}
fn update_agent_tip(&mut self, ctx: &mut ViewContext<Self>) {
if FeatureFlag::AgentTips.is_enabled() && *InputSettings::as_ref(ctx).show_agent_tips {
let current_working_directory = self
.terminal_model
.lock()
.active_block_metadata()
.current_working_directory()
.map(|cwd| cwd.to_string());
// Update the tip using the model's cooldown-based API
let tip_model = AITipModel::<AgentTip>::handle(ctx);
tip_model.update(ctx, |model, model_ctx| {
model.maybe_refresh_tip(current_working_directory.as_deref(), model_ctx);
});
// Get the current tip from the model
self.current_tip = tip_model.as_ref(ctx).current_tip().cloned();
if let Some(tip) = self.current_tip.as_ref() {
send_telemetry_from_app_ctx!(
TelemetryEvent::AgentTipShown {
tip: tip.description.clone()
},
ctx
);
}
} else {
self.current_tip = None;
}
}
fn render_tip(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if FeatureFlag::AgentTips.is_enabled() && *InputSettings::as_ref(app).show_agent_tips {
self.current_tip
.as_ref()
.map(|tip| render_agent_tip(tip, app))
} else {
None
}
}
fn render_warping_indicator_for_latest_exchange(
&self,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let model = self.active_exchange_model.as_ref()?;
let conversation = model.conversation(app)?;
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
let has_expanded_requested_command_with_no_subagent = active_block
.is_active_and_long_running()
&& active_block
.agent_interaction_metadata()
.is_some_and(|metadata| {
!metadata.should_hide_block() && metadata.long_running_control_state().is_none()
});
let should_render_warping = !model.request_type(app).is_passive()
&& !has_expanded_requested_command_with_no_subagent
&& (conversation.status().is_in_progress()
|| (active_block.is_agent_in_control() && !active_block.is_agent_blocked()));
if !should_render_warping {
return None;
}
let is_agent_in_control = active_block.is_agent_in_control();
let should_hide_responses = active_block.should_hide_responses();
let active_block_id = active_block.id().clone();
let is_agent_blocked = active_block.is_agent_blocked();
let last_snapshot_at = self
.cli_subagent_controller
.as_ref(app)
.last_snapshot_at(&active_block_id);
// Surface the inline `Check now` affordance only when the agent has sent at
// least one snapshot (so there's a known poll in flight) and still holds
// control of the command. Hidden while the agent is blocked on user
// confirmation, since no poll future is active.
let force_refresh_button = (last_snapshot_at.is_some()
&& is_agent_in_control
&& !is_agent_blocked)
.then_some(ForceRefreshButtonProps {
button_handle: &self.state_handles.force_refresh_button,
block_id: active_block_id,
});
let output_status = model.status(app);
let output_to_render = output_status.output_to_render();
let (current_is_fallback, current_display_name) = output_to_render
.as_ref()
.and_then(|o| {
let o = o.get();
let m = o.model_info.as_ref()?;
Some((Some(m.is_fallback), Some(m.display_name.clone())))
})
.unwrap_or((None, None));
let fallback_warping_text = resolve_fallback_warping_message(
current_is_fallback,
current_display_name,
model.as_ref(),
app,
);
let default_warping_text = fallback_warping_text
.as_deref()
.or(self.warping_message)
.unwrap_or_else(|| random_load_output_message())
.to_owned();
let secondary_element = if fallback_warping_text.is_some() {
Some(render_fallback_explanation(model.as_ref(), app))
} else {
self.render_tip(app)
};
Some(render_warping_indicator(
WarpingProps {
model: model.as_ref(),
terminal_model: &terminal_model,
action_model: self.action_model.as_ref(app),
shimmering_text_handle: &self.shimmering_text_handle,
summarization_start_time: self.summarization_start_time,
auto_execute_button: (!model.request_type(app).is_passive_code_diff()).then_some(
AutoExecuteButtonProps {
button_handle: &self.state_handles.autoexecute_button,
keystroke: self.autoexecute_keystroke.as_ref(),
is_active: model
.conversation(app)
.map(|c| c.autoexecute_any_action())
.unwrap_or(false),
is_locked: is_in_cloud_context(
terminal_model.block_list().agent_view_state(),
&terminal_model,
),
},
),
queue_next_prompt_button: FeatureFlag::QueueSlashCommand.is_enabled().then_some(
ButtonProps {
button_handle: &self.state_handles.queue_next_prompt_button,
keystroke: self.queue_next_prompt_keystroke.as_ref(),
is_active: QueuedQueryModel::as_ref(app).is_queue_next_prompt_enabled(
conversation.id(),
active_block,
app,
),
},
),
stop_button: Some(ButtonProps {
button_handle: &self.state_handles.stop_button,
keystroke: self.stop_keystroke.as_ref(),
is_active: false,
}),
take_over_lrc_control_button: is_agent_in_control.then_some(ButtonProps {
button_handle: &self.state_handles.take_over_button,
keystroke: self.set_terminal_input_keystroke.as_ref(),
is_active: false,
}),
hide_responses_button: is_agent_in_control.then_some((
ButtonProps {
button_handle: &self.state_handles.hide_cli_responses_button,
keystroke: self.hide_cli_responses_keystroke.as_ref(),
is_active: false,
},
should_hide_responses,
)),
force_refresh_button,
default_warping_text,
secondary_element,
last_snapshot_at,
warping_start_time: self.warping_start_time,
},
app,
))
}
fn render_cloud_mode_setup_status(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return None;
}
let ambient_agent_model = self
.ambient_agent_view_model
.as_ref()
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
// The step indicator is only meaningful while a spawn is in flight. Terminal states
// (`Failed`, `NeedsGithubAuth`, `Cancelled`) still carry an `AgentProgress` for
// telemetry purposes, so guard on `is_waiting_for_session()` rather than relying on
// `agent_progress()` being `None`.
if !ambient_agent_model.is_waiting_for_session() {
return None;
}
let progress = ambient_agent_model.agent_progress()?;
let progress_text = progress.setup_status_text();
Some(render_warping_indicator_base(
WarpingIndicatorProps {
icon: None,
warping_indicator_text: MaybeShimmeringText::Shimmering {
text: progress_text.into(),
shimmering_text_handle: self.shimmering_text_handle.clone(),
},
non_shimmering_text: None,
non_shimmering_suffix: None,
buttons: None,
is_passive_code_diff: false,
secondary_element: self.render_tip(app),
},
app,
))
}
fn render_cloud_mode_setup_terminal_message(
&self,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return None;
}
let ambient_agent_model = self
.ambient_agent_view_model
.as_ref()
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
let theme = Appearance::as_ref(app).theme();
let error_color = theme.ansi_fg_red();
if let Some(auth_url) = ambient_agent_model.github_auth_url() {
let error_message = ambient_agent_model
.github_auth_error_message()
.unwrap_or("Missing GitHub authentication.");
return Some(render_wrapping_standard_message_bar(
CoreIcon::Triangle,
error_color,
error_color,
vec![
FormattedTextFragment::plain_text(format!("{error_message} ")),
FormattedTextFragment::hyperlink("Authenticate GitHub", auth_url.to_owned()),
],
app,
));
}
if ambient_agent_model.is_cancelled() {
let color = theme.disabled_text_color(theme.background()).into_solid();
return Some(render_wrapping_standard_message_bar(
CoreIcon::StopFilled,
color,
color,
vec![FormattedTextFragment::plain_text(
"Cloud agent run cancelled",
)],
app,
));
}
if let Some(error_message) = ambient_agent_model.error_message() {
return Some(render_wrapping_standard_message_bar(
CoreIcon::Triangle,
error_color,
error_color,
vec![FormattedTextFragment::plain_text(error_message.to_owned())],
app,
));
}
None
}
}
/// Checks only the immediately previous exchange for model info (from ModelUsed messages
/// during streaming). We intentionally limit this to a single exchange to avoid reaching
/// back to stale fallback data from much earlier in the conversation.
fn latest_model_used_before_exchange<V: View>(
model: &dyn AIBlockModel<View = V>,
app: &AppContext,
) -> Option<api::message::ModelUsed> {
let conversation = model.conversation(app)?;
conversation
.exchanges_reversed()
.nth(1)
.and_then(|exchange| {
let output = exchange.output_status.output()?;
let output = output.get();
let model_info = output.model_info.as_ref()?;
Some(api::message::ModelUsed {
model_id: model_info.model_id.to_string(),
model_display_name: model_info.display_name.clone(),
is_fallback: model_info.is_fallback,
prompt_cache_expires_at: None,
})
})
}
fn render_agent_tip(tip: &AgentTip, app: &AppContext) -> Box<dyn Element> {
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
use crate::ai::agent_tips::AITip;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let tip_description = tip.description.clone();
let action_text = tip.action.clone().and_then(|action| action.display_text());
let mut fragments = tip.to_formatted_text(app);
if let (Some(action), Some(text)) = (tip.action.clone(), action_text.clone()) {
fragments.push(FormattedTextFragment::plain_text(" "));
fragments.push(FormattedTextFragment::hyperlink_action(text, action));
} else if let Some(link_target) = tip.link.clone() {
fragments.push(FormattedTextFragment::plain_text(" "));
fragments.push(FormattedTextFragment::hyperlink("Learn more", link_target));
}
let formatted_text =
markdown_parser::FormattedText::new(vec![FormattedTextLine::Line(fragments)]);
galaxyui::elements::FormattedTextElement::new(
formatted_text,
appearance.monospace_font_size() - 3.,
appearance.ui_font_family(),
appearance.monospace_font_family(),
theme.disabled_ui_text_color().into_solid(),
Default::default(),
)
.with_hyperlink_font_color(theme.accent().into())
.set_selectable(true)
.with_clip(ClipConfig::ellipsis())
.register_default_click_handlers_with_action_support(move |link, evt, app| {
use galaxyui::elements::HyperlinkLens;
match link {
HyperlinkLens::Url(url) => {
send_telemetry_from_app_ctx!(
TelemetryEvent::AgentTipClicked {
tip: tip_description.clone(),
click_target: url.to_string(),
},
app
);
app.open_url(url);
}
HyperlinkLens::Action(action_ref) => {
if let Some(action) = action_ref
.as_any()
.downcast_ref::<crate::workspace::WorkspaceAction>()
{
send_telemetry_from_app_ctx!(
TelemetryEvent::AgentTipClicked {
tip: tip_description.clone(),
click_target: action_text.clone().unwrap_or_default(),
},
app
);
evt.dispatch_typed_action(action.clone());
}
}
}
})
.finish()
}
fn render_fallback_explanation<V: View>(
model: &dyn AIBlockModel<View = V>,
app: &AppContext,
) -> Box<dyn Element> {
let llm_prefs = LLMPreferences::as_ref(app);
let base_model_id = model.base_model(app);
let primary_name = base_model_id
.and_then(|base_id| llm_prefs.get_llm_info(base_id))
.map(|info| info.base_model_name.as_str());
let text = match primary_name {
Some(primary) => {
format!("The primary model ({primary}) failed. Retrying with the fallback model.")
}
None => "The primary model failed. Retrying with the fallback model.".to_owned(),
};
let appearance = Appearance::as_ref(app);
Text::new_inline(
text,
appearance.ui_font_family(),
appearance.monospace_font_size() - 3.,
)
.with_color(appearance.theme().disabled_ui_text_color().into_solid())
.with_selectable(false)
.soft_wrap(false)
.finish()
}
/// If the current exchange is using a fallback model, returns the warping message to display
/// (e.g. "Warping with Claude 3.5 Haiku."). When the current exchange's output doesn't have
/// model info yet (the ModelUsed message hasn't arrived), we check the most recent previous
/// exchange as a best guess — if the conversation already fell back, the next exchange likely
/// will too. This avoids a flicker from "Warping..." to "Warping with {name}." on follow-ups.
///
/// We skip the lookback for new user queries because the underlying model may have recovered
/// since the previous exchange. For agent-initiated follow-up exchanges (action results, etc.)
/// the lookback is still applied.
fn resolve_fallback_warping_message<V: View>(
current_is_fallback: Option<bool>,
current_display_name: Option<String>,
model: &dyn AIBlockModel<View = V>,
app: &AppContext,
) -> Option<String> {
if !FeatureFlag::FallbackModelLoadOutputMessaging.is_enabled() {
return None;
}
let mut is_fallback = current_is_fallback;
let mut display_name = current_display_name;
let is_new_user_query = model
.conversation(app)
.and_then(|conv| {
let exchange_id = model.exchange_id(app)?;
conv.exchange_with_id(exchange_id)
})
.is_some_and(|exchange| exchange.has_user_query());
if is_fallback.is_none() && !is_new_user_query {
if let Some(prev) = latest_model_used_before_exchange(model, app) {
is_fallback = Some(prev.is_fallback);
if !prev.model_display_name.is_empty() {
display_name = Some(prev.model_display_name);
}
}
}
if !is_fallback.unwrap_or(false) {
return None;
}
Some(match display_name.as_deref() {
Some(name) => format!("Warping with {name}."),
None => "Warping with another model.".to_owned(),
})
}
impl View for BlocklistAIStatusBar {
fn ui_name() -> &'static str {
"BlocklistAIStatusBar"
}
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
let appearance = Appearance::as_ref(app);
let agent_view_controller = self.agent_view_controller.as_ref(app);
if let Some(cloud_mode_setup_terminal_message) =
self.render_cloud_mode_setup_terminal_message(app)
{
return cloud_mode_setup_terminal_message;
}
let status_element =
if let Some(cloud_mode_setup_status) = self.render_cloud_mode_setup_status(app) {
cloud_mode_setup_status
} else if FeatureFlag::CloudModeSetupV2.is_enabled()
&& self
.ambient_agent_view_model
.as_ref()
.is_some_and(|ambient_agent_view_model| {
let terminal_model = self.terminal_model.lock();
is_cloud_agent_pre_first_exchange(
Some(ambient_agent_view_model),
&self.agent_view_controller,
&terminal_model,
app,
)
})
{
render_warping_indicator_base(
WarpingIndicatorProps {
icon: None,
warping_indicator_text: MaybeShimmeringText::Shimmering {
text: "Setting up environment".into(),
shimmering_text_handle: self.shimmering_text_handle.clone(),
},
non_shimmering_text: None,
non_shimmering_suffix: None,
buttons: None,
is_passive_code_diff: false,
secondary_element: self.render_tip(app),
},
app,
)
} else if self
.terminal_model
.lock()
.block_list()
.active_block()
.is_agent_tagged_in()
&& self
.ephemeral_message_model
.as_ref(app)
.current_message()
.is_none()
{
render_warping_indicator_base(
WarpingIndicatorProps {
icon: Some(icons::gray_clock_icon(appearance).finish()),
warping_indicator_text: MaybeShimmeringText::Static(
WAITING_FOR_USER_INPUT_MESSAGE.into(),
),
non_shimmering_text: None,
non_shimmering_suffix: None,
buttons: Some(render_switch_control_to_user_button(
"Exit",
"Exit agent input",
ButtonProps {
button_handle: &self.state_handles.take_over_button,
keystroke: self.set_terminal_input_keystroke.as_ref(),
is_active: false,
},
appearance,
)),
is_passive_code_diff: false,
secondary_element: self.render_tip(app),
},
app,
)
} else if let (Some(warping_indicator), true) = (
self.render_warping_indicator_for_latest_exchange(app),
self.ephemeral_message_model
.as_ref(app)
.current_message()
.is_none(),
) {
warping_indicator
} else if self.ambient_agent_view_model.as_ref().is_some_and(
|ambient_agent_view_model| {
ambient_agent_view_model
.as_ref(app)
.is_waiting_for_session()
},
) {
// Don't render warping indicator - the loading screen is shown in the main view
return Empty::new().finish();
} else if agent_view_controller.is_active() {
// The orchestration pill bar in the agent view header
// replaces the legacy child-agent status card rows;
// render only the message bar here.
return Flex::column()
.with_child(ChildView::new(&self.agent_message_bar).finish())
.finish();
} else {
return Empty::new().finish();
};
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let background = if agent_view_controller.is_inline() {
agent_view_bg_fill(app)
} else if InputSettings::as_ref(app).is_universal_developer_input_enabled(app)
|| FeatureFlag::AgentView.is_enabled()
{
// Use a fully transparent background for universal developer input (or unconditionally, if the new
// modality is enabled)
Fill::Solid(ColorU::transparent_black())
} else {
theme.ai_blocks_overlay()
};
let mut container = Container::new(status_element).with_background(background);
let is_passive_code_diff = self
.active_exchange_model
.as_ref()
.is_some_and(|model| model.request_type(app).is_passive_code_diff());
let is_active_exchange_in_selected_conversation = self
.active_exchange_model
.as_ref()
.and_then(|model| model.conversation_id(app))
.is_some_and(|id| {
self.context_model.as_ref(app).selected_conversation_id(app) == Some(id)
});
if !FeatureFlag::AgentView.is_enabled()
&& self.input_model.as_ref(app).is_ai_input_enabled()
&& !is_passive_code_diff
&& is_active_exchange_in_selected_conversation
&& !self.terminal_model.lock().is_alt_screen_active()
{
container = container
.with_border(
Border::left(LEFT_STRIPE_WIDTH)
.with_border_color(ai_brand_color(appearance.theme())),
)
// Offset the horizontal layout shift caused by the border.
.with_padding_left(-LEFT_STRIPE_WIDTH);
}
let is_input_pinned_to_top = InputModeSettings::as_ref(app).is_pinned_to_top();
let is_udi_enabled = InputSettings::as_ref(app).is_universal_developer_input_enabled(app);
if !FeatureFlag::AgentView.is_enabled() && is_udi_enabled {
if is_input_pinned_to_top {
// Use 2px padding on the top, so combined with the 6px padding on the universal
// input it's an equal 8px on both sides.
container = container.with_padding_top(2.).with_padding_bottom(8.);
} else {
// Use 2px padding on the bottom, so combined with the 6px padding on the universal
// input it's an equal 8px on both sides.
container = container.with_padding_top(8.).with_padding_bottom(2.);
}
} else {
container = container.with_vertical_padding(8.);
}
container.finish()
}
}
#[derive(Debug, Clone)]
pub enum BlocklistAIStatusBarEvent {
SummarizationCancelDialogToggled { is_open: bool },
Stop,
}
impl Entity for BlocklistAIStatusBar {
type Event = BlocklistAIStatusBarEvent;
}
#[derive(Debug, Clone)]
pub enum BlocklistAIStatusBarAction {
ToggleHideResponses,
SwitchCommandControlToUser,
Stop,
/// Force the agent's pending poll for a long-running command to resolve
/// immediately with a fresh snapshot, bypassing its agent-set timer. Dispatched
/// by the inline `Check now` affordance in the warping indicator.
ForceRefreshAgentView {
block_id: crate::terminal::model::block::BlockId,
},
}
impl TypedActionView for BlocklistAIStatusBar {
type Action = BlocklistAIStatusBarAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
BlocklistAIStatusBarAction::ToggleHideResponses => {
self.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.toggle_hide_responses(ctx);
});
}
BlocklistAIStatusBarAction::SwitchCommandControlToUser => {
self.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.switch_control_to_user(UserTakeOverReason::Manual, ctx);
});
}
BlocklistAIStatusBarAction::Stop => {
ctx.emit(BlocklistAIStatusBarEvent::Stop);
}
BlocklistAIStatusBarAction::ForceRefreshAgentView { block_id } => {
let block_id = block_id.clone();
self.cli_subagent_controller.update(ctx, |controller, ctx| {
controller.request_force_refresh(&block_id, ctx);
});
}
}
}
}