pub(crate) mod child_pipe_writer;
mod compact;
pub(crate) mod runtime;
use self::compact::handle_compact_builtin;
use crate::{
auth::AuthState,
checkpoints::{
ChangeClassification, ChangesReadModel, CheckpointStore, ParsedRewindTarget, RestorePlan,
RestoreStatus, parse_rewind_target, redacted_path_for_display,
},
commands::{BuiltInCommand, FastModeCommand, parse_fast_mode_command},
config::EffectiveConfig,
herdr::{HerdrReporter, HerdrSessionStartSource},
sessions::{
Session, SessionExportReport, SessionManager, export_session, parse_prune_sessions_days,
},
};
use std::{collections::HashMap, path::PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShellFastObservation {
pub(crate) observation: crate::fast::FastObservation,
pub(crate) prompt_order: u64,
}
#[derive(Debug, Clone)]
pub struct ShellState {
pub session_manager: SessionManager,
pub current_session: Option<Session>,
pub cwd: PathBuf,
pub model: String,
pub auth_state: AuthState,
pub config: Option<EffectiveConfig>,
pub(crate) herdr_reporter: Option<HerdrReporter>,
fast_observations: HashMap<(String, String), ShellFastObservation>,
next_prompt_order: u64,
}
impl ShellState {
pub fn new(
session_manager: SessionManager,
current_session: Option<Session>,
cwd: PathBuf,
model: String,
auth_state: AuthState,
) -> Self {
Self {
session_manager,
current_session,
cwd,
model,
auth_state,
config: None,
herdr_reporter: None,
fast_observations: HashMap::new(),
next_prompt_order: 0,
}
}
pub(crate) fn with_herdr_reporter(mut self, herdr_reporter: Option<HerdrReporter>) -> Self {
self.herdr_reporter = herdr_reporter;
self
}
pub fn with_config(mut self, config: EffectiveConfig) -> Self {
self.auth_state = config.auth_state();
self.config = Some(config);
self
}
pub fn active_session_id(&self) -> Option<&str> {
self.current_session.as_ref().map(|session| session.id())
}
pub(crate) fn next_prompt_order(&mut self) -> u64 {
self.next_prompt_order = self.next_prompt_order.saturating_add(1);
self.next_prompt_order
}
pub(crate) fn fast_observation(
&self,
provider_id: &str,
model: &str,
) -> Option<&ShellFastObservation> {
let key = crate::fast::canonical_fast_observation_key(provider_id, model);
self.fast_observations.get(&key)
}
pub(crate) fn accept_fast_observation(
&mut self,
provider_id: &str,
model: &str,
requested_service_tier: &str,
outcome: &crate::fast::FastOutcome,
prompt_order: u64,
request_sequence: u64,
) -> bool {
let key = crate::fast::canonical_fast_observation_key(provider_id, model);
if self
.fast_observations
.get(&key)
.is_some_and(|current| prompt_order <= current.prompt_order)
{
return false;
}
self.fast_observations.insert(
key,
ShellFastObservation {
observation: crate::fast::FastObservation {
requested_service_tier: requested_service_tier.to_string(),
outcome: outcome.clone(),
request_sequence,
run_order: None,
},
prompt_order,
},
);
true
}
pub(crate) fn clear_fast_observations(&mut self) {
self.fast_observations.clear();
}
}
pub fn handle_builtin(
command: BuiltInCommand,
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match command {
BuiltInCommand::Update => {
anyhow::bail!(
"/update requires idle Mission Control; use magi-code --update from your terminal"
)
}
BuiltInCommand::Side => {
anyhow::bail!("/side is only available in Mission Control TUI")
}
BuiltInCommand::Fast => handle_fast_mode_builtin(arg, false, state),
BuiltInCommand::Ultrafast => handle_fast_mode_builtin(arg, true, state),
BuiltInCommand::Compact => handle_compact_builtin(arg, state),
BuiltInCommand::Changes => handle_changes_builtin(state),
BuiltInCommand::Export => handle_export_builtin(arg, state),
BuiltInCommand::Rewind => handle_rewind_builtin_noninteractive(arg, state),
BuiltInCommand::New => {
let session = state.session_manager.create()?.admit_standalone_writer()?;
let session_id = session.id().to_string();
state.current_session = Some(session);
state.clear_fast_observations();
if let Some(reporter) = state.herdr_reporter.as_ref() {
reporter
.report_agent_session_with_source(&session_id, HerdrSessionStartSource::New);
reporter.report_title(None);
}
Ok(format!(
"new session: {}",
state.active_session_id().unwrap_or("<disabled>")
))
}
BuiltInCommand::Login => {
anyhow::bail!("/login requires the Mission Control login dialog")
}
BuiltInCommand::Logout => {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/logout requires loaded runtime config"))?;
let Some(provider_id) = arg else {
return crate::auth::login::logout_provider_list_text(&config.paths);
};
let removal =
crate::commands::runtime::remove_logout_target(&config.paths, provider_id)?;
crate::commands::runtime::reconcile_runtime_after_logout(state, None, &removal);
Ok(removal.message())
}
BuiltInCommand::Model => {
let Some(model_id) = arg else {
return Ok(crate::model_catalog::model_usage().to_string());
};
let result = crate::commands::runtime::switch_shell_model(state, model_id)?;
let suffix = result
.notice
.map(|notice| format!(" ({notice})"))
.unwrap_or_default();
Ok(format!("model switched to {model_id}{suffix}"))
}
BuiltInCommand::Usage => {
anyhow::bail!("/usage is only available in Mission Control TUI")
}
BuiltInCommand::PruneSessions => handle_prune_sessions_builtin(arg, state),
BuiltInCommand::Help => {
anyhow::bail!("/help is only available in Mission Control TUI")
}
BuiltInCommand::SystemPrompt => {
anyhow::bail!("/system-prompt is only available in Mission Control TUI")
}
BuiltInCommand::Skills => {
anyhow::bail!("/skills is only available in Mission Control TUI")
}
BuiltInCommand::Subagents => {
anyhow::bail!("/subagents is only available in Mission Control TUI")
}
BuiltInCommand::Mcp => {
anyhow::bail!("/mcp is only available in Mission Control TUI")
}
BuiltInCommand::Sessions => {
if arg.is_some() {
anyhow::bail!("usage: /sessions (Mission Control TUI only)")
}
anyhow::bail!("/sessions is only available in Mission Control TUI")
}
BuiltInCommand::SummarizeStart | BuiltInCommand::SummarizeStop => {
anyhow::bail!("summary controls are only available in Mission Control TUI")
}
BuiltInCommand::Quit => Ok("bye".to_string()),
BuiltInCommand::Settings => {
anyhow::bail!("/settings is only available in Mission Control TUI")
}
BuiltInCommand::Theme => {
anyhow::bail!("/theme is only available in Mission Control TUI")
}
}
}
fn handle_fast_mode_builtin(
arg: Option<&str>,
ultrafast: bool,
state: &mut ShellState,
) -> anyhow::Result<String> {
let command = parse_fast_mode_command(arg).map_err(|_| {
anyhow::anyhow!(if ultrafast {
crate::commands::ULTRAFAST_MODE_USAGE
} else {
crate::commands::FAST_MODE_USAGE
})
})?;
let enabled = {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
match command {
FastModeCommand::Toggle => crate::config::toggle_fast_mode(&config.paths, ultrafast)?,
FastModeCommand::On => crate::config::set_fast_mode(&config.paths, true, ultrafast)?,
FastModeCommand::Off => crate::config::set_fast_mode(&config.paths, false, ultrafast)?,
FastModeCommand::Status => crate::config::fast_mode_enabled(&config.paths, ultrafast)?,
}
};
if !matches!(command, FastModeCommand::Status) && !enabled {
state.clear_fast_observations();
}
let settings = {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
crate::config::read_settings(&config.paths)?
};
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/fast requires loaded runtime config"))?;
let provider = config.provider_id();
let model = &state.model;
let capability = crate::fast::resolve_fast_capability(
&settings,
provider,
model,
&config.paths,
config.custom_providers.get(provider),
crate::fast::FastWorkload::Primary,
);
let observation = state
.fast_observation(provider, model)
.map(|stored| stored.observation.clone());
Ok(crate::fast::speed_command_status(
&settings.fast,
ultrafast,
provider,
model,
&capability,
observation.as_ref(),
))
}
fn handle_export_builtin(arg: Option<&str>, state: &mut ShellState) -> anyhow::Result<String> {
if arg.is_some() {
anyhow::bail!("usage: /export")
}
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/export requires loaded runtime config"))?;
let session = state
.current_session
.as_ref()
.ok_or_else(|| anyhow::anyhow!("/export requires an active persisted session"))?;
match std::fs::symlink_metadata(session.path()) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => anyhow::bail!("/export requires an active persisted session"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("/export requires an active persisted session")
}
Err(error) => return Err(error.into()),
}
let destination = crate::sessions::export_destination(&config.paths.root, session.id())?;
let report = export_session(&state.session_manager, session, &destination)?;
Ok(format_export_status(&report))
}
fn format_export_status(report: &SessionExportReport) -> String {
const MAX_PATH_CHARS: usize = 240;
const MAX_DETAILS_CHARS: usize = 600;
let path = report.destination.display().to_string();
let path = path.chars().take(MAX_PATH_CHARS).collect::<String>();
let completeness = if report.warnings.is_empty() {
"complete"
} else {
"partial"
};
let warnings = report
.warnings
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join("; ");
let details = format!(
"session export {completeness}: {path} ({} members, {} source bytes); WARNING: ZIP is unencrypted and contains raw potentially sensitive session/project/tool data; private permissions do not make it safe to share{}",
report.member_count,
report.source_bytes,
if warnings.is_empty() {
String::new()
} else {
format!("; warnings: {warnings}")
}
);
let details = crate::output::sanitize_display_text(&details);
details.chars().take(MAX_DETAILS_CHARS).collect()
}
fn handle_changes_builtin(state: &ShellState) -> anyhow::Result<String> {
let (store, session_id) = checkpoint_store_and_session(state)?;
Ok(format_changes_for_tui(&store.changes(&session_id)))
}
pub(crate) fn format_rewind_prompt_preview(
session: &Session,
plan: &RestorePlan,
) -> anyhow::Result<String> {
let prompt = session
.rewind_prompts()?
.into_iter()
.find(|prompt| prompt.turn == plan.target_turn)
.ok_or_else(|| anyhow::anyhow!("prompt target is not in retained session history"))?;
Ok(format_rewind_prompt_text(plan, &prompt.text))
}
pub(crate) fn format_rewind_prompt_text(plan: &RestorePlan, prompt: &str) -> String {
format!(
"Prompt {}: {}\n\nFile preview (files/both only):\n{}",
plan.target_turn,
crate::output::sanitize_display_text(prompt)
.chars()
.take(500)
.collect::<String>(),
format_rewind_plan_for_tui(plan)
)
}
fn handle_rewind_builtin_noninteractive(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
match parse_rewind_target(arg).map_err(|usage| anyhow::anyhow!(rewind_usage(&usage)))? {
ParsedRewindTarget::NeedsSelection => {
anyhow::bail!("usage: /rewind --to <turn> [--dry-run] [--mode conversation|files|both]")
}
ParsedRewindTarget::Target {
target_turn,
dry_run,
mode,
} => {
let (store, session_id) = checkpoint_store_and_session(state)?;
let plan = store.plan_rewind(&session_id, &state.cwd, target_turn);
let session = state
.current_session
.as_ref()
.ok_or_else(|| anyhow::anyhow!("rewind requires a persisted session"))?;
let preview = format!(
"Mode: {}\n{}",
mode.label(),
format_rewind_prompt_preview(session, &plan)?
);
if dry_run {
Ok(preview)
} else {
anyhow::bail!("/rewind requires interactive confirmation; use --dry-run to preview")
}
}
}
}
fn checkpoint_store_and_session(state: &ShellState) -> anyhow::Result<(CheckpointStore, String)> {
let config = state
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require loaded runtime config"))?;
let session_id = state
.active_session_id()
.ok_or_else(|| anyhow::anyhow!("checkpoint commands require an active persisted session"))?
.to_string();
Ok((CheckpointStore::from_paths(&config.paths), session_id))
}
fn rewind_usage(reason: &str) -> String {
format!("usage: /rewind [--dry-run] --to <turn> [--mode conversation|files|both]; {reason}")
}
pub(crate) fn format_changes_for_tui(changes: &ChangesReadModel) -> String {
if changes.turns.is_empty() {
return "no rewindable file changes".to_string();
}
let mut lines = vec!["rewindable file changes:".to_string()];
for turn in &changes.turns {
lines.push(format!("turn {}", turn.user_turn));
for change in &turn.changes {
let status = if change.rewindable {
format!("{:?}", change.classification).to_lowercase()
} else {
format!(
"non-rewindable ({})",
change
.unavailable_reason
.as_deref()
.unwrap_or("unavailable")
)
};
let kind = match change.classification {
ChangeClassification::Created => "created",
ChangeClassification::Modified => "modified",
ChangeClassification::Deleted => "deleted",
ChangeClassification::NonRewindable => "non-rewindable",
};
lines.push(format!(
" - {kind}: {} [{status}]",
redacted_path_for_display(&change.relative_path)
));
}
}
lines.extend(
changes
.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_plan_for_tui(plan: &RestorePlan) -> String {
if plan.operations.is_empty() {
return format!("no rewind operations for turn {}", plan.target_turn);
}
let latest = plan.latest_turn.unwrap_or(plan.target_turn);
let mut lines = vec![format!(
"rewind preview: turns {}..{} ({} operations)",
plan.target_turn,
latest,
plan.operations.len()
)];
for operation in &plan.operations {
lines.push(format!(
" - {}: {}",
redacted_path_for_display(&operation.relative_path),
operation.kind.label()
));
}
lines.extend(
plan.diagnostics
.iter()
.map(|diagnostic| format!("warning: {diagnostic}")),
);
lines.join("\n")
}
pub(crate) fn format_rewind_execution_for_tui(
execution: &crate::checkpoints::RewindExecution,
) -> String {
let mut lines = vec!["rewind result:".to_string()];
for result in &execution.results {
let status = match result.status {
RestoreStatus::Restored => "restored",
RestoreStatus::DeletedCreated => "deleted-created",
RestoreStatus::ResurrectedDeleted => "resurrected-deleted",
RestoreStatus::SkipConflict => "skip-conflict",
RestoreStatus::SkipUnavailable => "skip-unavailable",
};
let reason = result
.reason
.as_deref()
.map(|reason| format!(" ({})", crate::output::redact_sensitive_text(reason)))
.unwrap_or_default();
lines.push(format!(
" - {}: {status}{reason}",
redacted_path_for_display(&result.relative_path)
));
}
lines.join("\n")
}
fn handle_prune_sessions_builtin(
arg: Option<&str>,
state: &mut ShellState,
) -> anyhow::Result<String> {
let retention_days = parse_prune_sessions_days(arg).map_err(|usage| anyhow::anyhow!(usage))?;
let report = state
.session_manager
.prune_sessions(retention_days, state.active_session_id())?;
Ok(report.summary())
}