From dec90208a4137653636e5379b6ae8e7a7a22d8e9 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Tue, 18 Aug 2026 13:06:30 -0500 Subject: [PATCH] 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 --- app/src/ai/blocklist/block.rs | 14 ++- app/src/ai/blocklist/block/cli.rs | 38 ++++--- app/src/ai/blocklist/block/status_bar.rs | 25 +++++ app/src/ai/blocklist/controller.rs | 9 +- .../inline_action/requested_command.rs | 103 +++++++++++++++--- app/src/ai/runtime/rig_request.rs | 2 +- app/src/ai/runtime/rig_request_tests.rs | 2 +- app/src/terminal/view.rs | 84 +++++++++++++- app/src/terminal/view_tests.rs | 49 +++++++++ 9 files changed, 279 insertions(+), 47 deletions(-) diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index f2e40dbd..fb9647ec 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -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(); } diff --git a/app/src/ai/blocklist/block/cli.rs b/app/src/ai/blocklist/block/cli.rs index 00148595..8d80efd2 100644 --- a/app/src/ai/blocklist/block/cli.rs +++ b/app/src/ai/blocklist/block/cli.rs @@ -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 } diff --git a/app/src/ai/blocklist/block/status_bar.rs b/app/src/ai/blocklist/block/status_bar.rs index 50dc9615..533e949a 100644 --- a/app/src/ai/blocklist/block/status_bar.rs +++ b/app/src/ai/blocklist/block/status_bar.rs @@ -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, + ) { + 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) { ctx.notify(); self.agent_message_bar.update(ctx, |_, ctx| ctx.notify()); diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index a975b9e8..61eac0cc 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -2941,10 +2941,7 @@ impl BlocklistAIController { ctx: &mut ModelContext, ) { 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, diff --git a/app/src/ai/blocklist/inline_action/requested_command.rs b/app/src/ai/blocklist/inline_action/requested_command.rs index ecd11a23..b89da101 100644 --- a/app/src/ai/blocklist/inline_action/requested_command.rs +++ b/app/src/ai/blocklist/inline_action/requested_command.rs @@ -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) { + fn set_is_header_expanded( + &mut self, + value: bool, + is_user_initiated: bool, + ctx: &mut ViewContext, + ) { 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); }) } } diff --git a/app/src/ai/runtime/rig_request.rs b/app/src/ai/runtime/rig_request.rs index bc14036f..7abe1d50 100644 --- a/app/src/ai/runtime/rig_request.rs +++ b/app/src/ai/runtime/rig_request.rs @@ -816,7 +816,7 @@ fn build_system_prompt( "## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n", ), RigRequestMode::Cli => prompt.push_str( - "## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. If it says the command is still running, the next assistant output MUST be a tool call. Use `read_shell_command_output` with a short delay for normal progress. If the snapshot clearly shows an interactive pager or editor, do not keep polling: an alternate screen containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input `q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode `line`. Poll briefly after sending quit input to verify the outcome. Use `interrupt_shell_command` immediately when the user's explicit stop condition is met. Do not end a still-running monitor turn with prose, a status message, or a request for the user to say continue. Never choose a poll interval that crosses a user-specified deadline or stop condition. After an interrupt, poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", + "## Running Command Monitor\nKeep an eye on the existing command while continuing the user's request. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. If it says the command is still running, the next assistant output MUST be a tool call. Use `read_shell_command_output` with a short delay for normal progress. If the snapshot clearly shows an interactive pager or editor, do not keep polling: an alternate screen containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input `q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode `line`. Poll briefly after sending quit input to verify the outcome. Use `interrupt_shell_command` immediately when the user's explicit stop condition is met. The next response must be a tool call, not a progress update. Never choose a poll interval that crosses a user-specified deadline or stop condition. After an interrupt, poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", ), RigRequestMode::CompletedCommandAssessment => prompt.push_str( "## Completed Command Assessment\nThe monitored command has finished. Use its command, command ID, final terminal output, and the assessment instruction in the latest hidden input to provide the final user-facing outcome. Do not continue polling, request more terminal output, or call tools.\n\n", diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index 7644e94e..b404e9fd 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -453,7 +453,7 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { assert!(prompt.contains("next assistant output MUST be a tool call")); assert!(prompt.contains("alternate screen containing `(END)` is `less`")); assert!(prompt.contains("`write_to_long_running_shell_command` with input `q` and mode `raw`")); - assert!(prompt.contains("Do not end a still-running monitor turn with prose")); + assert!(prompt.contains("The next response must be a tool call, not a progress update")); assert!(prepared .request .tools diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index f9f870b4..2a98f089 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -8730,6 +8730,47 @@ impl TerminalView { } } + /// Returns the conversation shown by AgentView when it has work that Ctrl+C should stop. + /// The latest exchange can already be complete while a provider run, child agent, or command + /// monitor is still alive, so looking only at the status bar's active exchange is insufficient. + fn active_agent_conversation_to_cancel( + &self, + has_input_buffer: bool, + ctx: &AppContext, + ) -> Option { + if !FeatureFlag::AgentView.is_enabled() + || !self.agent_view_controller.as_ref(ctx).is_active() + { + return None; + } + + let conversation_id = self + .agent_view_controller + .as_ref(ctx) + .agent_view_state() + .active_conversation_id() + .or_else(|| { + BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(self.view_id) + .map(|conversation| conversation.id()) + })?; + + let conversation_has_progress = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .is_some_and(|conversation| { + conversation.status().is_in_progress() + && (conversation.exchange_count() > 0 || has_input_buffer) + }); + let command_is_monitored = { + let model = self.model.lock(); + let active_block = model.block_list().active_block(); + active_block.is_active_and_long_running() + && active_block.ai_conversation_id() == Some(conversation_id) + }; + + (conversation_has_progress || command_is_monitored).then_some(conversation_id) + } + fn user_write_ctrl_c_to_pty(&mut self, ctx: &mut ViewContext) { self.write_user_bytes_to_pty(vec![escape_sequences::C0::ETX], ctx); } @@ -8754,15 +8795,32 @@ impl TerminalView { if FeatureFlag::AgentView.is_enabled() && self.agent_view_controller.as_ref(ctx).is_active() { + if let Some(conversation_id) = + self.active_agent_conversation_to_cancel(cleared_buffer_len > 0, ctx) + { + self.agent_view_controller.update(ctx, |controller, ctx| { + controller.clear_pending_exit_confirmation(ctx); + }); + // Cancel by conversation identity. This remains reliable when the latest exchange + // has completed but a provider run, child agent, or command monitor is active. + let command_is_for_conversation = { + let model = self.model.lock(); + let active_block = model.block_list().active_block(); + active_block.is_active_and_long_running() + && active_block.ai_conversation_id() == Some(conversation_id) + }; + if command_is_for_conversation { + self.stop_local_agent_conversation(conversation_id, ctx); + } else { + self.cancel_active_conversation_via_status_bar(ctx); + } + return; + } + if cleared_buffer_len > 0 { self.agent_view_controller.update(ctx, |controller, ctx| { controller.clear_pending_exit_confirmation(ctx); }); - // Also cancel any in-progress conversation so that Ctrl+C while - // composing a message (or after submitting when the buffer hasn't - // cleared yet) properly stops the agent query and returns the - // terminal to a ready state. - self.cancel_active_conversation_via_status_bar(ctx); return; } @@ -9013,9 +9071,23 @@ impl TerminalView { }); } + let conversation_id = self + .agent_view_controller + .as_ref(ctx) + .agent_view_state() + .active_conversation_id() + .or_else(|| { + BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(self.view_id) + .map(|conversation| conversation.id()) + }); let status_bar = self.input.as_ref(ctx).agent_status_bar().clone(); status_bar.update(ctx, |status_bar, ctx| { - status_bar.handle_ctrl_c(ctx); + if let Some(conversation_id) = conversation_id { + status_bar.handle_ctrl_c_for_conversation(conversation_id, ctx); + } else { + status_bar.handle_ctrl_c(ctx); + } }); } diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index d9fb1e62..da5b30c0 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -6024,6 +6024,55 @@ fn ctrl_c_buffer_clear_then_exit_requires_three_presses_in_agent_view() { }) } +#[test] +fn ctrl_c_with_nonempty_agent_input_cancels_active_conversation() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + FeatureFlag::AgentView.set_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + + let conversation_id = terminal.update(&mut app, |view, ctx| { + let conversation_id = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.start_new_conversation(view.view_id, false, false, false, ctx) + }); + let stream_id = ResponseStreamId::new_for_test(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history + .conversation_mut(&conversation_id) + .expect("conversation should exist") + .append_reassigned_exchange( + &stream_id, + exchange_with_inputs(vec![]), + view.view_id, + ctx, + ) + .expect("exchange should append"); + }); + conversation_id + }); + + terminal.update(&mut app, |view, ctx| { + view.handle_input_event( + &InputEvent::CtrlC { + cleared_buffer_len: 4, + }, + ctx, + ); + }); + + terminal.read(&app, |_, ctx| { + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .map(|conversation| conversation.status()), + Some(&ConversationStatus::Cancelled), + "Ctrl+C must cancel the agent instead of only clearing its draft" + ); + }); + }) +} + #[test] fn terminal_action_ctrl_c_exit_agent_view_requires_confirmation() { App::test((), |mut app| async move {