use super::{MissionControlApp, actions::ActionContext, display_reducer};
use crate::tui::{input, state};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub(super) fn skill_candidates(
candidates: &[state::AutocompleteCandidate],
) -> Vec<state::AutocompleteCandidate> {
candidates
.iter()
.filter(|candidate| candidate.kind == state::AutocompleteKind::SkillTag)
.cloned()
.collect()
}
impl MissionControlApp {
pub(super) fn edit_subagent_prompt(
&mut self,
key: KeyEvent,
viewport: input::PromptViewport,
ui_state: &mut state::MissionControlState,
context: &mut ActionContext<'_>,
) {
let Some(control) = ui_state
.selected_subagent_control()
.filter(|control| control.is_active())
else {
return;
};
let candidates = skill_candidates(context.autocomplete_candidates);
let copy = ui_state
.with_selected_subagent_prompt(|state| {
if key.code == KeyCode::Enter
&& key.modifiers == KeyModifiers::NONE
&& !state.autocomplete_visible()
{
submit_steering(state, &control.steering);
return None;
}
edit_with_shared_prompt_keys(key, viewport, state, &candidates)
})
.flatten();
if let Some(text) = copy {
Self::apply_copy_request(
&text,
ui_state,
context.clipboard,
std::time::Instant::now(),
);
}
}
}
fn edit_with_shared_prompt_keys(
key: KeyEvent,
viewport: input::PromptViewport,
state: &mut state::MissionControlState,
candidates: &[state::AutocompleteCandidate],
) -> Option<String> {
let viewer = state.modals.subagent_viewer.take();
let command = input::classify_key_with_all_viewports(
key,
state,
input::KeyViewports {
prompt: Some(viewport),
..Default::default()
},
candidates,
);
state.modals.subagent_viewer = viewer;
let (action, display) = command.into_parts();
if let Some(display) = display {
apply_editor_display(display, state, candidates);
}
match action {
input::InputAction::CopySelectedPrompt(text) => Some(text),
_ => None,
}
}
fn apply_editor_display(
command: input::DisplayCommand,
state: &mut state::MissionControlState,
candidates: &[state::AutocompleteCandidate],
) {
match command {
input::DisplayCommand::Batch(commands) => {
for command in commands {
apply_editor_display(command, state, candidates);
}
}
input::DisplayCommand::Prompt(input::PromptCommand::TakeForSubmit) => {}
command @ (input::DisplayCommand::Prompt(_)
| input::DisplayCommand::RefreshAutocomplete) => {
display_reducer::apply_display_command(command, state, candidates);
}
_ => {}
}
}
fn submit_steering(
state: &mut state::MissionControlState,
steering: &crate::agent::steering::AgentSteering,
) {
use crate::agent::steering::SteeringRejected;
let prompt = state.prompt_plain_text();
if prompt.trim_start().starts_with('/') {
state.status = "Slash commands are not available for subagents".into();
return;
}
if prompt.trim().is_empty() {
return;
}
match steering.try_enqueue_nonblocking(prompt) {
Ok(_) => {
state.clear_prompt_input();
state.hide_autocomplete();
state.status = "Subagent steering queued".into();
}
Err(SteeringRejected::Full { capacity }) => {
state.status = format!("Steering queue full ({capacity} max)");
}
Err(SteeringRejected::Closed) => {
state.status = "Subagent no longer accepts steering".into();
}
Err(SteeringRejected::Busy) => {
state.status = "Steering is being saved; try again".into();
}
Err(SteeringRejected::Empty) => {}
}
}
pub(super) fn cancel_or_recall_selected(state: &mut state::MissionControlState) {
let Some(control) = state.selected_subagent_control() else {
return;
};
if !recall_selected_steering(state, &control.steering) && control.is_active() {
control.cancel();
state.status = "Subagent cancellation requested".into();
}
state.set_subagent_prompt_notice(state.status.clone());
}
fn recall_selected_steering(
state: &mut state::MissionControlState,
steering: &crate::agent::steering::AgentSteering,
) -> bool {
let mut recall_rejected = false;
let recalled = steering.restore_pending_messages(|messages| {
let restored = state
.with_selected_subagent_prompt(|state| {
let draft = state.prompt_plain_text();
let text = if draft.is_empty() {
messages.to_owned()
} else {
format!("{messages}\n{draft}")
};
let restored = matches!(
state.set_prompt_text(&text, text.len()),
state::PromptEditResult::Changed | state::PromptEditResult::Unchanged
);
if restored {
state.hide_autocomplete();
}
restored
})
.unwrap_or(false);
recall_rejected = !restored;
restored
});
state.status = match recalled {
Ok(_) if recall_rejected => "Subagent draft is too large to recall steering".into(),
Ok(true) => "Queued steering restored to subagent prompt".into(),
Ok(false) => return false,
Err(()) => "Steering is being injected; try Alt-C again".into(),
};
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subagent_prompt_recall_preserves_draft_primary_and_other_queues() {
use crate::output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus,
};
let mut state = state::MissionControlState::default();
let batch = ActivityId::new("batch");
let task = batch.child("child");
for (id, parent_id, kind) in [
(batch.clone(), None, ActivityKind::SubagentBatch),
(
task.clone(),
Some(batch.clone()),
ActivityKind::SubagentTask,
),
] {
state.apply_activity_event(ActivityEvent::Started {
id,
parent_id,
kind,
status: ActivityStatus::Running,
metadata: ActivityMetadata::new("test"),
});
}
state.set_prompt_text("primary draft", 13);
assert!(state.open_subagent_viewer(batch, task));
state.with_selected_subagent_prompt(|state| {
state.set_prompt_text("child draft", 11);
});
let selected = crate::agent::steering::AgentSteering::default();
let other = crate::agent::steering::AgentSteering::default();
selected.try_enqueue("queued update".into()).unwrap();
other.try_enqueue("other child".into()).unwrap();
assert!(recall_selected_steering(&mut state, &selected));
assert_eq!(
state.selected_subagent_prompt().unwrap().editor.text(),
"queued update\nchild draft"
);
assert_eq!(state.prompt_plain_text(), "primary draft");
assert_eq!(selected.pending_count(), 0);
assert_eq!(other.pending_count(), 1);
assert!(!recall_selected_steering(&mut state, &selected));
selected.try_enqueue("must remain queued".into()).unwrap();
state.with_selected_subagent_prompt(|state| {
state.set_prompt_text(&"x".repeat(state::MAX_PROMPT_BYTES), 0);
});
assert!(recall_selected_steering(&mut state, &selected));
assert_eq!(selected.pending_count(), 1);
assert!(state.status.contains("too large"));
selected.close();
state.with_selected_subagent_prompt(|state| {
state.clear_prompt_input();
});
assert!(!state.subagent_prompt_editable());
assert!(recall_selected_steering(&mut state, &selected));
assert_eq!(
state.selected_subagent_prompt().unwrap().editor.text(),
"must remain queued"
);
state.with_selected_subagent_prompt(|state| submit_steering(state, &selected));
assert_eq!(
state.selected_subagent_prompt().unwrap().editor.text(),
"must remain queued"
);
assert_eq!(selected.pending_count(), 0);
}
#[test]
fn subagent_prompt_shared_keys_complete_skills_and_keep_editor_undo() {
let mut state = state::MissionControlState::default();
let candidates = skill_candidates(&[
state::AutocompleteCandidate::skill_tag("rust-dev"),
state::AutocompleteCandidate::slash_command("quit", "exit"),
]);
let viewport = input::PromptViewport {
visible_rows: 3,
wrap_width: 40,
};
for ch in "$rust".chars() {
edit_with_shared_prompt_keys(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
viewport,
&mut state,
&candidates,
);
}
assert!(state.autocomplete_visible());
edit_with_shared_prompt_keys(
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
viewport,
&mut state,
&candidates,
);
assert_eq!(state.prompt_plain_text().trim(), "$rust-dev");
edit_with_shared_prompt_keys(
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
viewport,
&mut state,
&candidates,
);
assert!(state.prompt_is_empty());
edit_with_shared_prompt_keys(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
viewport,
&mut state,
&candidates,
);
assert_eq!(state.prompt_plain_text().trim(), "$rust-dev");
state.set_prompt_text("/qu", 3);
state.recompute_autocomplete(&candidates);
assert!(!state.autocomplete_visible());
}
#[test]
fn subagent_prompt_submission_rejects_commands_and_never_executes_bash() {
let steering = crate::agent::steering::AgentSteering::default();
let mut state = state::MissionControlState::default();
state.set_prompt_text(" /quit", 6);
submit_steering(&mut state, &steering);
assert_eq!(state.prompt_plain_text(), " /quit");
assert_eq!(steering.pending_count(), 0);
assert!(state.status.contains("Slash commands"));
state.set_prompt_text("!echo hello", 11);
submit_steering(&mut state, &steering);
assert!(state.prompt_is_empty());
assert!(steering.drain_collapsed().unwrap().contains("!echo hello"));
steering.close();
state.set_prompt_text("keep this", 9);
submit_steering(&mut state, &steering);
assert_eq!(state.prompt_plain_text(), "keep this");
}
}