use super::super::*;
use super::startup::join_provider_worker;
use super::{MissionControlApp, completion_worker::CompletionWorker, display_reducer};
use crate::output::InvocationMode;
use crate::tui::render::TranscriptModal;
type ConversationMcp = Option<Arc<std::sync::Mutex<crate::mcp::manager::McpManager>>>;
struct CreatedSide {
state: ShellState,
mcp: ConversationMcp,
}
struct ConversationExecution {
state: ShellState,
events: Sender<TuiEvent>,
receiver: Receiver<TuiEvent>,
worker: Option<WorkerState>,
mcp: ConversationMcp,
active_run: bool,
steering: crate::agent::steering::AgentSteering,
last_steering_pending_count: usize,
}
impl ConversationExecution {
fn with_app<T>(
&mut self,
app: &mut MissionControlApp,
action: impl FnOnce(&mut MissionControlApp) -> T,
) -> T {
self.exchange(app);
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| action(app)));
self.exchange(app);
match result {
Ok(result) => result,
Err(payload) => panic::resume_unwind(payload),
}
}
fn exchange(&mut self, app: &mut MissionControlApp) {
std::mem::swap(&mut self.state, &mut app.state);
std::mem::swap(&mut self.events, &mut app.events);
std::mem::swap(&mut self.worker, &mut app.worker);
std::mem::swap(&mut self.mcp, &mut app.mcp);
std::mem::swap(&mut self.active_run, &mut app.active_run);
std::mem::swap(&mut self.steering, &mut app.steering);
std::mem::swap(
&mut self.last_steering_pending_count,
&mut app.last_steering_pending_count,
);
}
fn cancel(&self) {
if let Some(worker) = &self.worker {
worker.cancel.store(true, Ordering::SeqCst);
worker.steering.clear();
}
}
}
#[derive(Default)]
pub(super) struct SideRuntime {
conversation: Option<ConversationExecution>,
creation: Option<CompletionWorker<CreatedSide>>,
creation_cancel: Option<crate::cancellation::AgentCancellationHandle>,
reset_requested: bool,
selection: crate::config::SideAgentSettings,
enabled: bool,
last_cursor_blink: Option<Instant>,
}
impl SideRuntime {
pub(super) fn new(selection: crate::config::SideAgentSettings, enabled: bool) -> Self {
Self {
selection,
enabled,
..Default::default()
}
}
pub(super) fn is_pending(&self) -> bool {
self.creation.is_some()
|| self.reset_requested
|| self
.conversation
.as_ref()
.is_some_and(|side| side.worker.is_some() || !side.receiver.is_empty())
}
pub(super) fn request_shutdown(&self) {
if let Some(cancel) = &self.creation_cancel {
cancel.cancel();
}
if let Some(side) = &self.conversation {
side.cancel();
}
}
pub(super) fn cleanup(&mut self) -> Vec<String> {
self.request_shutdown();
let mut errors = Vec::new();
let worker = self
.conversation
.take()
.and_then(|mut side| side.worker.take());
if let Some(worker) = worker
&& let Some(error) = join_provider_worker(worker)
{
errors.push(format!("side: {error}"));
}
if let Some(worker) = self.creation.take() {
let deadline = Instant::now() + WORKER_EXIT_JOIN_TIMEOUT;
while !worker.is_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
if worker.is_finished() {
if let Err(error) = worker.join() {
errors.push(format!("side: {error}"));
}
} else {
errors.push("side session creation did not finish before shutdown".into());
}
}
errors
}
}
impl MissionControlApp {
pub(super) fn open_side(&mut self, ui: &mut state::MissionControlState) {
if !self.side.enabled {
ui.status = "/side is unavailable with --no-session".into();
return;
}
if self.startup_readiness != StartupReadiness::Ready {
ui.status = "Wait for startup before opening /side".into();
return;
}
if let Some(modal) = &mut ui.conversation_modal {
if !modal.visible {
std::mem::swap(&mut modal.state.images, &mut ui.images);
}
modal.visible = true;
modal.unread = false;
if self.side.conversation.is_none() && self.side.creation.is_none() {
self.start_side_creation(ui);
}
return;
}
let mut side_ui = state::MissionControlState {
theme: ui.theme,
color_enabled: ui.color_enabled,
thinking_level: ui.thinking_level,
status: "Creating side session…".into(),
..Default::default()
};
side_ui.focus_prompt();
std::mem::swap(&mut side_ui.images, &mut ui.images);
ui.conversation_modal = Some(Box::new(TranscriptModal::new(side_ui)));
self.start_side_creation(ui);
}
fn start_side_creation(&mut self, ui: &mut state::MissionControlState) {
self.start_side_creation_with_cleanup(ui, None);
}
fn start_side_creation_with_cleanup(
&mut self,
ui: &mut state::MissionControlState,
previous_mcp: ConversationMcp,
) {
if self.side.creation.is_some() {
return;
}
let mut config = self
.state
.config
.clone()
.unwrap_or_else(|| self.config.clone());
config.model = Some(self.state.model.clone());
let selection = self.side.selection.clone();
let cwd = self.state.cwd.clone();
let mcp_settings = self.settings.mcp_servers.clone();
let mc_home = self.config.paths.root.clone();
let (cancellation, cancel) =
crate::cancellation::AgentCancellation::default().child_token();
let task = move || -> Result<CreatedSide, String> {
drop(previous_mcp);
cancellation.check().map_err(|error| error.to_string())?;
if let (Some(provider), Some(model)) = (selection.provider, selection.model) {
config = crate::config::load_effective_provider_selection(
&config.paths,
&provider,
&model,
)
.map_err(|error| error.to_string())?;
}
let mcp = (!mcp_settings.is_empty())
.then(|| {
crate::mcp::manager::McpManager::from_settings_strict_cancellable(
&mcp_settings,
Some(&mc_home),
&cancellation,
)
.map(|manager| Arc::new(std::sync::Mutex::new(manager)))
})
.transpose()
.map_err(|error| error.to_string())?;
cancellation.check().map_err(|error| error.to_string())?;
let manager = crate::sessions::SessionManager::new(config.paths.sessions.join("side"));
let session = manager
.create()
.and_then(|session| session.admit_standalone_writer())
.map_err(|error| error.to_string())?;
let model = config.model.clone().unwrap_or_default();
Ok(CreatedSide {
state: ShellState::new(manager, Some(session), cwd, model, config.auth_state())
.with_config(config),
mcp,
})
};
match CompletionWorker::spawn("magi-side-session", self.events.clone(), task) {
Ok(worker) => {
self.side.creation = Some(worker);
self.side.creation_cancel = Some(cancel);
}
Err(error) => {
if let Some(modal) = &mut ui.conversation_modal {
modal.state.status = error.to_string();
}
}
}
}
fn poll_side_creation(&mut self, ui: &mut state::MissionControlState) -> bool {
let Some(worker) = self.side.creation.as_mut() else {
return false;
};
let result = worker.take_result();
let changed = result.is_some();
if let Some(result) = result {
match result {
Ok(CreatedSide { state: shell, mcp }) => {
let (events, receiver) = bounded(1024);
if let Some(modal) = &mut ui.conversation_modal {
modal.state.reset_for_new_session();
modal.state.model = shell.model.clone();
modal.state.provider = shell.auth_state.provider().to_string();
modal.state.provider_ready = shell.auth_state.is_ready();
if let Some(config) = &shell.config {
modal.state.thinking_level = config.thinking_level;
}
crate::tui::sessions::commands::apply_footer_context(
&mut modal.state,
shell.current_session.as_ref(),
None,
&shell.cwd,
);
modal.state.status = "Side ready".into();
modal.state.focus_prompt();
modal.active = false;
}
self.side.conversation = Some(ConversationExecution {
state: shell,
mcp,
events,
receiver,
worker: None,
active_run: false,
steering: crate::agent::steering::AgentSteering::new(),
last_steering_pending_count: 0,
});
}
Err(error) => {
if let Some(modal) = &mut ui.conversation_modal {
modal.state.status = error;
}
}
}
}
if worker.ready_to_reap()
&& let Some(worker) = self.side.creation.take()
{
let _ = worker.join();
self.side.creation_cancel = None;
}
changed
}
pub(super) fn poll_side(&mut self, ui: &mut state::MissionControlState) -> bool {
let mut changed = self.poll_side_creation(ui);
let Some(mut side) = self.side.conversation.take() else {
if self.side.reset_requested && self.side.creation.is_none() {
self.side.reset_requested = false;
self.start_side_creation(ui);
return true;
}
return changed;
};
if let Some(modal) = &mut ui.conversation_modal {
let outcome = drain_tui_events_budgeted_from(
&side.receiver,
&mut modal.state,
UI_DRAIN_BUDGET,
0,
Instant::now(),
Instant::now,
);
changed |= outcome.result.changed;
changed |= side.with_app(self, |app| {
let finished = app.finish_worker_if_ready(&mut modal.state);
app.sync_steering_feedback(&mut modal.state) || finished
});
modal.active = side.worker.is_some();
if modal.visible {
let last_blink = self.side.last_cursor_blink.get_or_insert_with(Instant::now);
if prompt_cursor_blink_due(&modal.state, *last_blink) {
modal.state.toggle_prompt_cursor();
*last_blink = Instant::now();
changed = true;
}
modal.state.images.borrow_mut().sync_policy(
&side.state.cwd,
self.settings.tools.view_image.absolute_paths,
self.settings.tools.view_image.max_image_bytes,
side.state.active_session_id(),
);
changed |= modal.state.images.borrow_mut().poll();
changed |= modal.state.expire_toast(Instant::now());
let elapsed = modal
.state
.session_active_time
.elapsed(Instant::now())
.as_secs();
changed |= modal.state.displayed_session_active_seconds != elapsed;
modal.state.displayed_session_active_seconds = elapsed;
}
if changed && !modal.visible {
modal.unread = true;
}
changed |= modal.state.tick_activity_motion(Instant::now());
}
let reset_ready = self.side.reset_requested
&& self.side.creation.is_none()
&& side.worker.is_none()
&& side.receiver.is_empty();
if reset_ready {
let previous_mcp = side.mcp.take();
drop(side);
self.side.reset_requested = false;
self.start_side_creation_with_cleanup(ui, previous_mcp);
true
} else {
self.side.conversation = Some(side);
changed
}
}
fn submit_side(&mut self, prompt: String, modal: &mut TranscriptModal) {
if prompt.trim() == "/reset" {
modal.state.commit_prompt_submission();
modal.state.clear_prompt_input();
self.side.reset_requested = true;
self.side.request_shutdown();
modal.state.status = "Resetting side; waiting for cleanup…".into();
return;
}
if prompt.trim_start().starts_with('/') {
modal.state.restore_rejected_prompt(prompt);
modal.state.status = "Only /reset is available in the side conversation".into();
return;
}
if self.side.reset_requested || self.side.creation.is_some() {
modal.state.restore_rejected_prompt(prompt);
modal.state.status = "Side session is still being prepared".into();
return;
}
let Some(mut side) = self.side.conversation.take() else {
modal.state.restore_rejected_prompt(prompt);
modal.state.status = "Side session unavailable; close and retry /side".into();
return;
};
side.with_app(self, |app| {
app.submit_conversation_prompt(prompt, &mut modal.state, InvocationMode::Side);
});
modal.active = side.worker.is_some();
self.side.conversation = Some(side);
}
pub(super) fn handle_side_input(
&mut self,
event: TerminalInputEvent,
ui: &mut state::MissionControlState,
area: ratatui::layout::Rect,
clipboard: &mut impl input::clipboard::ClipboardWriter,
primary_candidates: &[state::AutocompleteCandidate],
) {
let Some(mut modal) = ui.conversation_modal.take() else {
return;
};
let area = TranscriptModal::content_area(area);
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
let close = matches!(&event, TerminalInputEvent::Event(Event::Key(key))
if key.code == KeyCode::Esc && key.kind != KeyEventKind::Release
&& modal.state.top_modal().is_none()
&& !modal.state.autocomplete_visible());
let cancel = matches!(&event, TerminalInputEvent::Event(Event::Key(key))
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::ALT)
&& key.kind != KeyEventKind::Release
&& modal.state.modals.subagent_viewer.is_none());
if close {
modal.visible = false;
modal.unread = false;
} else if cancel {
if let Some(mut side) = self.side.conversation.take() {
side.with_app(self, |app| {
app.cancel_running_prompt(&mut modal.state);
});
self.side.conversation = Some(side);
}
} else {
let candidates = side_autocomplete_candidates(primary_candidates);
let command = match event {
TerminalInputEvent::Event(Event::Key(key)) => {
input::classify_key_with_all_viewports(
key,
&modal.state,
viewports::key_viewports(area, &modal.state),
&candidates,
)
}
TerminalInputEvent::Event(Event::Paste(text)) => {
input::classify_paste_with_all_viewports(
&text,
&modal.state,
viewports::paste_viewports(area, &modal.state),
&candidates,
)
}
TerminalInputEvent::Event(Event::Mouse(mouse)) => {
input::mouse::classify_mouse_input(
mouse,
area,
&modal.state,
state::MOUSE_WHEEL_SCROLL_ROWS,
)
}
event
@ (TerminalInputEvent::KeyBurst(_) | TerminalInputEvent::KeyBurstTooLarge) => {
super::classify_prompt_key_burst_event(event, &modal.state, area, &candidates)
.unwrap_or(input::InputCommand::None)
}
_ => input::InputCommand::None,
};
self.apply_side_input(command, &mut modal, &candidates, clipboard);
}
if !modal.visible {
std::mem::swap(&mut modal.state.images, &mut ui.images);
}
ui.conversation_modal = Some(modal);
}
fn apply_side_input(
&mut self,
command: input::InputCommand,
modal: &mut TranscriptModal,
candidates: &[state::AutocompleteCandidate],
clipboard: &mut impl input::clipboard::ClipboardWriter,
) {
let (action, display) = command.into_parts();
if matches!(
action,
input::InputAction::SubagentPromptKey(..)
| input::InputAction::SubagentPromptPaste(..)
| input::InputAction::CancelSelectedSubagent
) {
if let Some(mut side) = self.side.conversation.take() {
let receiver = side.receiver.clone();
let mut candidates = candidates.to_vec();
let mut context = super::actions::ActionContext {
receiver: &receiver,
autocomplete_candidates: &mut candidates,
clipboard,
last_area: ratatui::layout::Rect::default(),
};
side.with_app(self, |app| {
app.apply_input_action(action, &mut modal.state, &mut context);
});
self.side.conversation = Some(side);
}
return;
}
let action = if action == input::InputAction::Exit
&& modal
.state
.prompt_plain_text()
.trim_start()
.starts_with('/')
{
input::InputAction::Submit(modal.state.prompt_plain_text())
} else {
action
};
if !matches!(
action,
input::InputAction::None
| input::InputAction::Submit(_)
| input::InputAction::CopySelectedPrompt(_)
| input::InputAction::CancelRunningPrompt
| input::InputAction::Exit
) {
modal.state.status = "Only /reset is available in the side conversation".into();
return;
}
if let Some(display) = display {
let result =
display_reducer::apply_display_command(display, &mut modal.state, candidates);
if let Some(text) = result.copy {
Self::apply_copy_request(&text, &mut modal.state, clipboard, Instant::now());
}
}
match action {
input::InputAction::Submit(prompt) => self.submit_side(prompt, modal),
input::InputAction::CancelRunningPrompt => self.side.request_shutdown(),
input::InputAction::Exit => modal.visible = false,
input::InputAction::CopySelectedPrompt(text) => {
Self::apply_copy_request(&text, &mut modal.state, clipboard, Instant::now());
}
_ => {}
}
}
}
fn side_autocomplete_candidates(
primary: &[state::AutocompleteCandidate],
) -> Vec<state::AutocompleteCandidate> {
let mut candidates = primary
.iter()
.filter(|candidate| candidate.kind != state::AutocompleteKind::SlashCommand)
.cloned()
.collect::<Vec<_>>();
candidates.push(state::AutocompleteCandidate::slash_command(
"reset",
"Start a fresh side conversation",
));
candidates
}
impl Drop for ConversationExecution {
fn drop(&mut self) {
self.cancel();
}
}
#[cfg(test)]
mod tests;