Improve CLI command inline pane and monitoring behavior

- Track user-initiated expansion state to avoid auto-collapse conflicts
- Invert inline action visibility so terminal block hides while inline pane owns the expanded body
- Add scrollable output rendering in requested command expanded view
- Simplify CLI monitor nudge message and extract to reusable method
- Show only latest user query and assistant text in monitor task transcript
- Add handle_ctrl_c_for_conversation to status bar for child agent cancellation
- Update rig_request tests for adjusted monitor nudge wording
This commit is contained in:
Ryan Ward
2026-08-18 13:06:30 -05:00
parent a93b80adb4
commit dec90208a4
9 changed files with 279 additions and 47 deletions
+11 -3
View File
@@ -3692,7 +3692,10 @@ impl AIBlock {
self.cancel_action(action_id, ctx);
self.yield_requested_action_focus_if_focused(&view, ctx);
}
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
RequestedCommandViewEvent::UpdatedExpansionState {
is_expanded,
is_user_initiated,
} => {
// We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block).
let action_status = self
@@ -3715,15 +3718,20 @@ impl AIBlock {
// If the requested command is being expanded, we don't need to auto-expand anymore.
if *is_expanded {
self.abort_auto_expand_requested_command_timer();
} else {
if *is_user_initiated {
self.requested_commands_to_auto_collapse.remove(action_id);
}
} else if *is_user_initiated {
// Remove requested command from list of requested commands to auto-collapse if the user manually collapses it.
// In the edge-case where the user then manually expands the requested command, we won't auto-collapse it again.
self.requested_commands_to_auto_collapse.remove(action_id);
}
// The terminal block remains the source of command output, but is hidden while
// the inline pane owns the expanded body so the content cannot detach below it.
ctx.emit(AIBlockEvent::UpdateInlineActionVisibility {
action_id: action_id.clone(),
is_visible: *is_expanded,
is_visible: !*is_expanded,
});
ctx.notify();
}
+20 -18
View File
@@ -494,18 +494,22 @@ impl CLISubagentView {
.and_then(|conversation| conversation.get_task(&self.task_id))
.map(|task| {
task.exchanges()
.flat_map(|exchange| exchange.input.iter().cloned())
.flat_map(|exchange| exchange.input.iter())
.filter_map(|input| {
matches!(input, AIAgentInput::UserQuery { .. }).then(|| input.clone())
})
.last()
.into_iter()
.collect()
})
.unwrap_or_else(|| self.model.inputs_to_render(app).to_vec())
}
/// Builds the visible CLI transcript across every exchange in the monitor task.
/// Builds the compact visible transcript for the monitor task.
///
/// User queries and assistant text remain visible across automatic polling exchanges. Internal
/// `ActionResult` inputs remain absent because input rendering still explicitly accepts only
/// `UserQuery`. Historical tool activity is omitted to avoid a growing stack of repeated poll
/// cards; only the newest exchange's live action is retained.
/// The latest user query and the newest assistant text remain visible while historical tool
/// activity is omitted to avoid a growing stack of repeated poll cards. Only the newest
/// exchange's live action is retained.
fn task_output_to_render(&self, app: &AppContext) -> AIAgentOutput {
let Some(task) = BlocklistAIHistoryModel::as_ref(app)
.conversation(&self.conversation_id)
@@ -529,18 +533,16 @@ impl CLISubagentView {
continue;
};
let output = output.get();
visible_output.messages.extend(
output
.messages
.iter()
.filter(|message| {
should_retain_task_output_message(
&message.message,
exchange.id == last_exchange_id,
)
})
.cloned(),
);
for message in output.messages.iter().filter(|message| {
should_retain_task_output_message(&message.message, exchange.id == last_exchange_id)
}) {
if matches!(message.message, AIAgentOutputMessageType::Text(_)) {
visible_output.messages.retain(|existing| {
!matches!(existing.message, AIAgentOutputMessageType::Text(_))
});
}
visible_output.messages.push(message.clone());
}
}
visible_output
}
+25
View File
@@ -476,6 +476,31 @@ impl BlocklistAIStatusBar {
ctx.notify();
}
/// Handles Ctrl+C for a conversation even when its latest exchange has already
/// completed but a child agent or another orchestration task is still running.
pub fn handle_ctrl_c_for_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let active_exchange_matches = self
.active_exchange_model
.as_ref()
.and_then(|model| model.conversation_id(ctx))
== Some(conversation_id);
if active_exchange_matches {
self.handle_ctrl_c(ctx);
} else {
self.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
}
pub fn notify_and_notify_children(&mut self, ctx: &mut ViewContext<Self>) {
ctx.notify();
self.agent_message_bar.update(ctx, |_, ctx| ctx.notify());
+5 -4
View File
@@ -2941,10 +2941,7 @@ impl BlocklistAIController {
ctx: &mut ModelContext<Self>,
) {
self.send_user_query_in_conversation_internal(
"The command is still running. Continue monitoring now: call `read_shell_command_output` \
with the existing command ID instead of replying with a status message. If the user's \
explicit stop condition is met, call `interrupt_shell_command` immediately."
.to_owned(),
Self::cli_monitor_nudge_message().to_owned(),
conversation_id,
None,
RunningCommandDetection::Detect,
@@ -2956,6 +2953,10 @@ impl BlocklistAIController {
);
}
pub(crate) fn cli_monitor_nudge_message() -> &'static str {
"The command is still running. Please check its latest output and keep monitoring it."
}
#[allow(clippy::too_many_arguments)]
fn send_user_query_in_conversation_internal(
&mut self,
@@ -7,10 +7,12 @@ use std::sync::Arc;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
Align, Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Flex, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius,
ScrollbarWidth, SelectableArea, SelectionHandle, Stack, Text,
Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle,
NewScrollable, OffsetPositioning, ParentElement, Radius, ScrollbarWidth, SelectableArea,
SelectionHandle, Stack, Text,
};
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
use galaxyui::ui_components::components::UiComponent as _;
@@ -42,6 +44,7 @@ use crate::ai::blocklist::inline_action::inline_action_header::{
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::requested_action::render_requested_action_body_text;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::{
@@ -259,7 +262,10 @@ pub enum RequestedCommandViewEvent {
Accepted,
EnableAutoexecuteMode,
Rejected,
UpdatedExpansionState { is_expanded: bool },
UpdatedExpansionState {
is_expanded: bool,
is_user_initiated: bool,
},
TextSelected,
CopiedEmptyText,
EditorFocused,
@@ -319,7 +325,10 @@ pub struct RequestedCommandView {
// Header expansion state components
is_header_expanded: bool,
// User expansion must survive execution and completion; only automatic expansion is transient.
is_user_expanded: bool,
header_mouse_state: MouseStateHandle,
output_scroll_state: ClippedScrollStateHandle,
is_editing: bool,
// A requested command can either be copied directly off of one citation (such as a Warp Drive
@@ -433,7 +442,7 @@ impl RequestedCommandView {
if me.action_type.is_requested_command() {
me.ensure_editor(ctx);
}
me.set_is_header_expanded(true, ctx);
me.set_is_header_expanded(true, false, ctx);
ctx.notify();
}
BlocklistAIActionEvent::ExecutingAction {
@@ -470,8 +479,8 @@ impl RequestedCommandView {
me.destroy_editor();
if me.is_header_expanded {
me.set_is_header_expanded(false, ctx);
if me.is_header_expanded && !me.is_user_expanded {
me.set_is_header_expanded(false, false, ctx);
}
ctx.notify();
}
@@ -512,8 +521,8 @@ impl RequestedCommandView {
.is_none_or(|block| block.finished())
{
drop(terminal_model);
if me.is_header_expanded {
me.set_is_header_expanded(false, ctx);
if me.is_header_expanded && !me.is_user_expanded {
me.set_is_header_expanded(false, false, ctx);
}
}
}
@@ -576,7 +585,9 @@ impl RequestedCommandView {
is_editing: false,
autonomy_setting_speedbump,
is_header_expanded: false,
is_user_expanded: false,
header_mouse_state: Default::default(),
output_scroll_state: ClippedScrollStateHandle::new(),
copied_from_citation: None,
derived_from_citations: Default::default(),
citation_state_handles: Default::default(),
@@ -680,14 +691,23 @@ impl RequestedCommandView {
}
}
fn set_is_header_expanded(&mut self, value: bool, ctx: &mut ViewContext<Self>) {
fn set_is_header_expanded(
&mut self,
value: bool,
is_user_initiated: bool,
ctx: &mut ViewContext<Self>,
) {
if value == self.is_header_expanded {
return;
}
self.is_header_expanded = value;
if is_user_initiated {
self.is_user_expanded = value;
}
ctx.emit(RequestedCommandViewEvent::UpdatedExpansionState {
is_expanded: self.is_header_expanded,
is_user_initiated,
});
ctx.notify();
}
@@ -1525,10 +1545,34 @@ impl View for RequestedCommandView {
&& self.action_type.is_mcp_tool()
&& !self.command_text.is_empty();
// Requested command blocks are hidden terminal blocks. Keep their output in this pane
// instead of revealing the terminal block below the pane when the header is expanded.
let command_output = if self.is_header_expanded && self.action_type.is_requested_command() {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_for_ai_action_id(&self.action_id)
.map(|block| {
let (command, output) = block.command_and_output_with_secret_obfuscated(false);
if output.is_empty() {
command
} else {
format!("{command}\n\n{output}")
}
})
.filter(|output| !output.is_empty())
} else {
None
};
let should_render_command_output = command_output.is_some();
let has_citations_footer =
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
let header_element = self.render_header(
!should_render_editor && !should_render_mcp_content && !has_citations_footer,
!should_render_editor
&& !should_render_mcp_content
&& !should_render_command_output
&& !has_citations_footer,
app,
);
@@ -1610,6 +1654,37 @@ impl View for RequestedCommandView {
);
}
if let Some(output) = command_output {
let output_text = render_requested_action_body_text(
output.as_str().into(),
appearance.monospace_font_family(),
app,
)
.finish();
let scrollable = NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: self.output_scroll_state.clone(),
child: output_text,
},
Fill::None,
Fill::None,
Fill::None,
)
.with_propagate_mousewheel_if_not_handled(true)
.finish();
let output_body = ConstrainedBox::new(scrollable)
.with_max_height(320.)
.finish();
content.add_child(
Container::new(output_body)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(REQUESTED_COMMAND_BODY_VERTICAL_PADDING)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
if let Some(footer) = self.maybe_render_footer(app) {
content.add_child(Clipped::new(footer).finish());
}
@@ -1725,7 +1800,7 @@ impl TypedActionView for RequestedCommandView {
}
}
RequestedCommandViewAction::ToggleExpanded => {
self.set_is_header_expanded(!self.is_header_expanded, ctx)
self.set_is_header_expanded(!self.is_header_expanded, true, ctx)
}
RequestedCommandViewAction::OpenActiveAgentProfileEditor => {
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)
@@ -1764,13 +1839,13 @@ impl RequestedCommand {
pub fn force_expand(&self, ctx: &mut impl UpdateView) {
self.view.update(ctx, |command, ctx| {
command.set_is_header_expanded(true, ctx);
command.set_is_header_expanded(true, false, ctx);
})
}
pub fn force_collapse(&self, ctx: &mut impl UpdateView) {
self.view.update(ctx, |command, ctx| {
command.set_is_header_expanded(false, ctx);
command.set_is_header_expanded(false, false, ctx);
})
}
}