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:
@@ -3692,7 +3692,10 @@ impl AIBlock {
|
|||||||
self.cancel_action(action_id, ctx);
|
self.cancel_action(action_id, ctx);
|
||||||
self.yield_requested_action_focus_if_focused(&view, 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
|
// We only care about expansion state updates when the command
|
||||||
// is running or finished (i.e. when it has a block).
|
// is running or finished (i.e. when it has a block).
|
||||||
let action_status = self
|
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 the requested command is being expanded, we don't need to auto-expand anymore.
|
||||||
if *is_expanded {
|
if *is_expanded {
|
||||||
self.abort_auto_expand_requested_command_timer();
|
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.
|
// 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.
|
// 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);
|
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 {
|
ctx.emit(AIBlockEvent::UpdateInlineActionVisibility {
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
is_visible: *is_expanded,
|
is_visible: !*is_expanded,
|
||||||
});
|
});
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,18 +494,22 @@ impl CLISubagentView {
|
|||||||
.and_then(|conversation| conversation.get_task(&self.task_id))
|
.and_then(|conversation| conversation.get_task(&self.task_id))
|
||||||
.map(|task| {
|
.map(|task| {
|
||||||
task.exchanges()
|
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()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| self.model.inputs_to_render(app).to_vec())
|
.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
|
/// The latest user query and the newest assistant text remain visible while historical tool
|
||||||
/// `ActionResult` inputs remain absent because input rendering still explicitly accepts only
|
/// activity is omitted to avoid a growing stack of repeated poll cards. Only the newest
|
||||||
/// `UserQuery`. Historical tool activity is omitted to avoid a growing stack of repeated poll
|
/// exchange's live action is retained.
|
||||||
/// cards; only the newest exchange's live action is retained.
|
|
||||||
fn task_output_to_render(&self, app: &AppContext) -> AIAgentOutput {
|
fn task_output_to_render(&self, app: &AppContext) -> AIAgentOutput {
|
||||||
let Some(task) = BlocklistAIHistoryModel::as_ref(app)
|
let Some(task) = BlocklistAIHistoryModel::as_ref(app)
|
||||||
.conversation(&self.conversation_id)
|
.conversation(&self.conversation_id)
|
||||||
@@ -529,18 +533,16 @@ impl CLISubagentView {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let output = output.get();
|
let output = output.get();
|
||||||
visible_output.messages.extend(
|
for message in output.messages.iter().filter(|message| {
|
||||||
output
|
should_retain_task_output_message(&message.message, exchange.id == last_exchange_id)
|
||||||
.messages
|
}) {
|
||||||
.iter()
|
if matches!(message.message, AIAgentOutputMessageType::Text(_)) {
|
||||||
.filter(|message| {
|
visible_output.messages.retain(|existing| {
|
||||||
should_retain_task_output_message(
|
!matches!(existing.message, AIAgentOutputMessageType::Text(_))
|
||||||
&message.message,
|
});
|
||||||
exchange.id == last_exchange_id,
|
}
|
||||||
)
|
visible_output.messages.push(message.clone());
|
||||||
})
|
}
|
||||||
.cloned(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
visible_output
|
visible_output
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,6 +476,31 @@ impl BlocklistAIStatusBar {
|
|||||||
ctx.notify();
|
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>) {
|
pub fn notify_and_notify_children(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
self.agent_message_bar.update(ctx, |_, ctx| ctx.notify());
|
self.agent_message_bar.update(ctx, |_, ctx| ctx.notify());
|
||||||
|
|||||||
@@ -2941,10 +2941,7 @@ impl BlocklistAIController {
|
|||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
self.send_user_query_in_conversation_internal(
|
self.send_user_query_in_conversation_internal(
|
||||||
"The command is still running. Continue monitoring now: call `read_shell_command_output` \
|
Self::cli_monitor_nudge_message().to_owned(),
|
||||||
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(),
|
|
||||||
conversation_id,
|
conversation_id,
|
||||||
None,
|
None,
|
||||||
RunningCommandDetection::Detect,
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn send_user_query_in_conversation_internal(
|
fn send_user_query_in_conversation_internal(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ use std::sync::Arc;
|
|||||||
use galaxy_core::ui::appearance::Appearance;
|
use galaxy_core::ui::appearance::Appearance;
|
||||||
use galaxy_core::ui::Icon;
|
use galaxy_core::ui::Icon;
|
||||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||||
|
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Align, Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container,
|
||||||
Expanded, Flex, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius,
|
CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle,
|
||||||
ScrollbarWidth, SelectableArea, SelectionHandle, Stack, Text,
|
NewScrollable, OffsetPositioning, ParentElement, Radius, ScrollbarWidth, SelectableArea,
|
||||||
|
SelectionHandle, Stack, Text,
|
||||||
};
|
};
|
||||||
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
|
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
|
||||||
use galaxyui::ui_components::components::UiComponent as _;
|
use galaxyui::ui_components::components::UiComponent as _;
|
||||||
@@ -42,6 +44,7 @@ use crate::ai::blocklist::inline_action::inline_action_header::{
|
|||||||
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
|
ExpandedConfig, HeaderConfig, InteractionMode, RightClickConfig,
|
||||||
INLINE_ACTION_HORIZONTAL_PADDING,
|
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::inline_action::tool_pane::render_tool_pane_shell;
|
||||||
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
|
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
|
||||||
use crate::ai::blocklist::{
|
use crate::ai::blocklist::{
|
||||||
@@ -259,7 +262,10 @@ pub enum RequestedCommandViewEvent {
|
|||||||
Accepted,
|
Accepted,
|
||||||
EnableAutoexecuteMode,
|
EnableAutoexecuteMode,
|
||||||
Rejected,
|
Rejected,
|
||||||
UpdatedExpansionState { is_expanded: bool },
|
UpdatedExpansionState {
|
||||||
|
is_expanded: bool,
|
||||||
|
is_user_initiated: bool,
|
||||||
|
},
|
||||||
TextSelected,
|
TextSelected,
|
||||||
CopiedEmptyText,
|
CopiedEmptyText,
|
||||||
EditorFocused,
|
EditorFocused,
|
||||||
@@ -319,7 +325,10 @@ pub struct RequestedCommandView {
|
|||||||
|
|
||||||
// Header expansion state components
|
// Header expansion state components
|
||||||
is_header_expanded: bool,
|
is_header_expanded: bool,
|
||||||
|
// User expansion must survive execution and completion; only automatic expansion is transient.
|
||||||
|
is_user_expanded: bool,
|
||||||
header_mouse_state: MouseStateHandle,
|
header_mouse_state: MouseStateHandle,
|
||||||
|
output_scroll_state: ClippedScrollStateHandle,
|
||||||
is_editing: bool,
|
is_editing: bool,
|
||||||
|
|
||||||
// A requested command can either be copied directly off of one citation (such as a Warp Drive
|
// 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() {
|
if me.action_type.is_requested_command() {
|
||||||
me.ensure_editor(ctx);
|
me.ensure_editor(ctx);
|
||||||
}
|
}
|
||||||
me.set_is_header_expanded(true, ctx);
|
me.set_is_header_expanded(true, false, ctx);
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
BlocklistAIActionEvent::ExecutingAction {
|
BlocklistAIActionEvent::ExecutingAction {
|
||||||
@@ -470,8 +479,8 @@ impl RequestedCommandView {
|
|||||||
|
|
||||||
me.destroy_editor();
|
me.destroy_editor();
|
||||||
|
|
||||||
if me.is_header_expanded {
|
if me.is_header_expanded && !me.is_user_expanded {
|
||||||
me.set_is_header_expanded(false, ctx);
|
me.set_is_header_expanded(false, false, ctx);
|
||||||
}
|
}
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
@@ -512,8 +521,8 @@ impl RequestedCommandView {
|
|||||||
.is_none_or(|block| block.finished())
|
.is_none_or(|block| block.finished())
|
||||||
{
|
{
|
||||||
drop(terminal_model);
|
drop(terminal_model);
|
||||||
if me.is_header_expanded {
|
if me.is_header_expanded && !me.is_user_expanded {
|
||||||
me.set_is_header_expanded(false, ctx);
|
me.set_is_header_expanded(false, false, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,7 +585,9 @@ impl RequestedCommandView {
|
|||||||
is_editing: false,
|
is_editing: false,
|
||||||
autonomy_setting_speedbump,
|
autonomy_setting_speedbump,
|
||||||
is_header_expanded: false,
|
is_header_expanded: false,
|
||||||
|
is_user_expanded: false,
|
||||||
header_mouse_state: Default::default(),
|
header_mouse_state: Default::default(),
|
||||||
|
output_scroll_state: ClippedScrollStateHandle::new(),
|
||||||
copied_from_citation: None,
|
copied_from_citation: None,
|
||||||
derived_from_citations: Default::default(),
|
derived_from_citations: Default::default(),
|
||||||
citation_state_handles: 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 {
|
if value == self.is_header_expanded {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.is_header_expanded = value;
|
self.is_header_expanded = value;
|
||||||
|
if is_user_initiated {
|
||||||
|
self.is_user_expanded = value;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.emit(RequestedCommandViewEvent::UpdatedExpansionState {
|
ctx.emit(RequestedCommandViewEvent::UpdatedExpansionState {
|
||||||
is_expanded: self.is_header_expanded,
|
is_expanded: self.is_header_expanded,
|
||||||
|
is_user_initiated,
|
||||||
});
|
});
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
@@ -1525,10 +1545,34 @@ impl View for RequestedCommandView {
|
|||||||
&& self.action_type.is_mcp_tool()
|
&& self.action_type.is_mcp_tool()
|
||||||
&& !self.command_text.is_empty();
|
&& !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 =
|
let has_citations_footer =
|
||||||
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
|
!self.derived_from_citations.is_empty() && !self.block_model.status(app).is_streaming();
|
||||||
let header_element = self.render_header(
|
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,
|
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) {
|
if let Some(footer) = self.maybe_render_footer(app) {
|
||||||
content.add_child(Clipped::new(footer).finish());
|
content.add_child(Clipped::new(footer).finish());
|
||||||
}
|
}
|
||||||
@@ -1725,7 +1800,7 @@ impl TypedActionView for RequestedCommandView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RequestedCommandViewAction::ToggleExpanded => {
|
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 => {
|
RequestedCommandViewAction::OpenActiveAgentProfileEditor => {
|
||||||
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)
|
ctx.emit(RequestedCommandViewEvent::OpenActiveAgentProfileEditor)
|
||||||
@@ -1764,13 +1839,13 @@ impl RequestedCommand {
|
|||||||
|
|
||||||
pub fn force_expand(&self, ctx: &mut impl UpdateView) {
|
pub fn force_expand(&self, ctx: &mut impl UpdateView) {
|
||||||
self.view.update(ctx, |command, ctx| {
|
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) {
|
pub fn force_collapse(&self, ctx: &mut impl UpdateView) {
|
||||||
self.view.update(ctx, |command, ctx| {
|
self.view.update(ctx, |command, ctx| {
|
||||||
command.set_is_header_expanded(false, ctx);
|
command.set_is_header_expanded(false, false, ctx);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
|
||||||
),
|
),
|
||||||
RigRequestMode::Cli => prompt.push_str(
|
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(
|
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",
|
"## 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",
|
||||||
|
|||||||
@@ -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("next assistant output MUST be a tool call"));
|
||||||
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
|
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("`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
|
assert!(prepared
|
||||||
.request
|
.request
|
||||||
.tools
|
.tools
|
||||||
|
|||||||
@@ -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<AIConversationId> {
|
||||||
|
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>) {
|
fn user_write_ctrl_c_to_pty(&mut self, ctx: &mut ViewContext<Self>) {
|
||||||
self.write_user_bytes_to_pty(vec![escape_sequences::C0::ETX], ctx);
|
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 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 {
|
if cleared_buffer_len > 0 {
|
||||||
self.agent_view_controller.update(ctx, |controller, ctx| {
|
self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||||
controller.clear_pending_exit_confirmation(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;
|
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();
|
let status_bar = self.input.as_ref(ctx).agent_status_bar().clone();
|
||||||
status_bar.update(ctx, |status_bar, ctx| {
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn terminal_action_ctrl_c_exit_agent_view_requires_confirmation() {
|
fn terminal_action_ctrl_c_exit_agent_view_requires_confirmation() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
|
|||||||
Reference in New Issue
Block a user