mod approve;
mod ask;
mod command;
mod doctor;
mod frontdoor;
mod mail;
mod outbox;
mod polls;
mod tools;
mod transcript;
mod triggers;
use crate::{setup, GlobalOpts};
use anyhow::{Context, Result};
use command::mode_name;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event,
EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures::StreamExt;
use mecha_core::agent::{Agent, AgentEvent, Conversation, Phase, RunOutcome};
use mecha_core::config::PermissionMode;
use mecha_core::message::{Message, Usage};
use mecha_core::session::{Record, RunConfig, Session, SessionMeta};
use mecha_core::tool::{Approver, ModeApprover};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use transcript::{Entry, Transcript};
type RunResult = (Result<RunOutcome>, Conversation);
struct Running {
handle: JoinHandle<RunResult>,
cancel: CancellationToken,
queue: Arc<Mutex<VecDeque<String>>>,
started: std::time::Instant,
cancelling: bool,
persisted: Vec<Message>,
outbox_before: Option<std::collections::HashSet<String>>,
}
enum Watch {
Send {
id: String,
error_before: Option<String>,
since: std::time::Instant,
},
Request {
seq: i64,
state_before: String,
since: std::time::Instant,
},
Remedy {
child: std::process::Child,
argv_line: String,
since: std::time::Instant,
notices: u32,
},
Examine {
child: std::process::Child,
since: std::time::Instant,
},
RestartProbe {
rx: std::sync::mpsc::Receiver<bool>,
argv: Vec<String>,
unit: String,
since: std::time::Instant,
},
}
struct Live {
agent: Arc<Agent>,
model: String,
provider: String,
opts: GlobalOpts,
todo: Option<Arc<mecha_core::tool::todo::TodoTool>>,
_mcp: Vec<Arc<mecha_core::mcp::McpClient>>,
}
impl Live {
fn new(p: setup::Prepared, opts: GlobalOpts) -> Self {
Live {
agent: Arc::new(p.agent),
model: p.model,
provider: p.provider_name,
opts,
todo: p.todo,
_mcp: p._mcp,
}
}
}
struct Picker {
title: String,
items: Vec<(String, command::Command)>,
selected: usize,
}
impl Picker {
fn move_by(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as isize;
self.selected = (((self.selected as isize + delta) % len + len) % len) as usize;
}
}
#[derive(Debug, Clone)]
enum Switch {
Model(String),
Provider(String),
Mode(PermissionMode),
Mcp(bool),
McpServer(String, bool),
}
struct App {
transcript: Transcript,
input: String,
cursor: usize,
history: Vec<String>,
history_pos: Option<usize>,
convo: Conversation,
running: Option<Running>,
pending: Option<approve::Request>,
usage: Usage,
prompt_tokens: u64,
context_window: Option<u64>,
should_quit: bool,
quit_armed: bool,
pending_switch: Option<Switch>,
mode: PermissionMode,
mcp_on: bool,
mcp_servers: Vec<(String, bool)>,
phase: Phase,
asking: Option<ask::Question>,
picker: Option<Picker>,
help: bool,
tools: Option<tools::ToolsModal>,
scheduled: Option<triggers::TriggersModal>,
staged: Option<outbox::OutboxModal>,
requests: Option<frontdoor::FrontdoorModal>,
mail: Option<mail::MailModal>,
poll_monitor: Option<polls::PollsModal>,
health: Option<doctor::DoctorModal>,
pending_doctor_remedy: Option<mecha_core::doctor::Remedy>,
pending_trigger_edit: Option<String>,
pending_outbox_edit: Option<String>,
outbox_pending: usize,
watches: Vec<Watch>,
review: command::ReviewMode,
shell_tx: mpsc::UnboundedSender<Entry>,
sandbox_line: String,
workspace: std::path::PathBuf,
todo_visible: bool,
pending_editor: bool,
providers: Vec<(String, String)>,
kitty_keyboard: bool,
}
impl App {
fn status(&self, model: &str, provider: &str, tools: usize) -> Line<'static> {
let mut spans = vec![
Span::styled(
format!(" {model} "),
Style::new().fg(Color::Black).bg(Color::Cyan),
),
Span::styled(
format!(" {provider} · {tools} tools "),
Style::new().fg(Color::DarkGray),
),
];
if self.phase == Phase::Plan {
spans.push(Span::styled(
" plan ",
Style::new().fg(Color::Black).bg(Color::Magenta),
));
}
if self.outbox_pending > 0 {
spans.push(Span::styled(
format!(" outbox {} ", self.outbox_pending),
Style::new().fg(Color::Black).bg(Color::Yellow),
));
}
match &self.running {
Some(run) => {
let secs = run.started.elapsed().as_secs();
spans.push(Span::styled(
if run.cancelling {
format!(" stopping… {secs}s ")
} else {
format!(" working {secs}s ")
},
Style::new().fg(Color::Yellow),
));
spans.push(Span::styled(
"· type to steer · ^C to stop ",
Style::new().fg(Color::DarkGray),
));
}
None => {
spans.push(Span::styled(
format!(
" {} in / {} out ",
self.usage.total_input(),
self.usage.output_tokens
),
Style::new().fg(Color::DarkGray),
));
if self.prompt_tokens > 0 {
let (text, colour) = match self.context_window {
Some(window) if window > 0 => {
let pct = (self.prompt_tokens * 100 / window).min(999);
let colour = match pct {
0..=74 => Color::DarkGray,
75..=89 => Color::Yellow,
_ => Color::Red,
};
(
format!(
"· context {}/{} ({pct}%) ",
human_tokens(self.prompt_tokens),
human_tokens(window)
),
colour,
)
}
_ => (
format!("· context {} ", human_tokens(self.prompt_tokens)),
Color::DarkGray,
),
};
spans.push(Span::styled(text, Style::new().fg(colour)));
}
}
}
if !self.transcript.follow {
spans.push(Span::styled("· scrolled ", Style::new().fg(Color::Yellow)));
}
Line::from(spans)
}
}
pub async fn execute(global: &GlobalOpts, resume: Option<String>, no_session: bool) -> Result<()> {
let (tui_approver, mut approvals) = approve::TuiApprover::new();
let (asker, mut questions) = ask::TuiAsker::new();
let asker: Arc<dyn mecha_core::tool::ask::Asker> = Arc::new(asker);
let approver: Arc<dyn Approver> = Arc::new(tui_approver);
let mut prepared = setup::prepare_with_approver(global, Arc::clone(&approver)).await?;
prepared
.agent
.registry_mut()
.insert(Arc::new(mecha_core::tool::ask::AskUserTool::new(
Arc::clone(&asker),
)));
let session_dir = Session::default_dir()?;
let mut convo = Conversation::new();
let mut session = None;
if let Some(id) = &resume {
let path = Session::find(&session_dir, id)?;
let (meta, prior) = Session::load(&path)?;
convo = prior;
session = Some(Session { meta, path });
} else if !no_session {
session = Some(Session::create(
&session_dir,
SessionMeta {
id: Session::new_id(),
created_at: chrono::Utc::now(),
provider: prepared.provider_name.clone(),
model: prepared.model.clone(),
workspace: prepared.workspace.clone(),
title: None,
},
)?);
}
if let Some(s) = &session {
setup::register_recall(&mut prepared.agent, s);
s.append(&Record::Config(RunConfig::of(
&prepared.agent,
&prepared.config,
&prepared.provider_name,
)))?;
if let Some(route) = &prepared.agent.context().outbox {
route.set_session_id(&s.meta.id);
}
if let Some(mb) = &prepared.mailbox {
mb.attach("chat", &s.meta.id);
}
}
let (shell_tx, mut shell_rx) = mpsc::unbounded_channel::<Entry>();
let mut app = App {
transcript: Transcript::new(global.verbose),
input: String::new(),
cursor: 0,
history: Vec::new(),
history_pos: None,
convo,
running: None,
pending: None,
usage: Usage::default(),
prompt_tokens: 0,
context_window: prepared.agent.context_window(),
should_quit: false,
quit_armed: false,
pending_switch: None,
mode: prepared.config.tools.permission_mode,
mcp_on: !global.no_mcp && !prepared.config.mcp.is_empty(),
mcp_servers: prepared
.config
.mcp
.iter()
.map(|m| {
let off = m.disabled
|| global.no_mcp
|| global.no_mcp_servers.iter().any(|n| n == &m.name);
(m.name.clone(), !off)
})
.collect(),
phase: Phase::default(),
asking: None,
picker: None,
help: false,
tools: None,
sandbox_line: setup::sandbox_line(&prepared.sandbox),
workspace: prepared.workspace.clone(),
todo_visible: true,
pending_editor: false,
scheduled: None,
staged: None,
requests: None,
mail: None,
poll_monitor: None,
health: None,
pending_doctor_remedy: None,
pending_trigger_edit: None,
pending_outbox_edit: None,
outbox_pending: outbox_pending_count(),
review: command::ReviewMode::default(),
watches: Vec::new(),
shell_tx,
providers: prepared
.config
.providers
.iter()
.map(|(name, cfg)| (name.clone(), cfg.model.clone().unwrap_or_default()))
.collect(),
kitty_keyboard: false,
};
if !app.convo.is_empty() {
let carried = match (app.convo.taint.private, app.convo.taint.untrusted) {
(true, true) => " · already holds private data and third-party content, so outbound calls will be refused",
(true, false) => " · already holds private data",
(false, true) => " · already holds third-party content",
(false, false) => "",
};
app.transcript.push(Entry::Notice(format!(
"resumed {} messages{carried}",
app.convo.len()
)));
}
let mailbox = prepared.mailbox.clone();
if let Some(mb) = &mailbox {
if !mb.delivers() {
if let Ok(pending) = mb.store.pending_for("chat") {
if !pending.is_empty() {
app.transcript.push(Entry::Notice(format!(
"{} message(s) waiting — `mecha msg list` to read them",
pending.len()
)));
}
}
}
}
let mut live = Live::new(prepared, global.clone());
let (mut terminal, kitty) = enter()?;
app.kitty_keyboard = kitty;
set_title(&format!("mecha · {}", workspace_name(&app)));
let result = run_loop(
&mut terminal,
&mut app,
&mut live,
&mut approvals,
&mut questions,
&mut shell_rx,
session.as_ref(),
&approver,
)
.await;
leave(&mut terminal)?;
if let Some(s) = &session {
println!(
"session {} · {}",
s.meta.id,
crate::render::format_usage(&app.usage)
);
if let Some(mb) = &mailbox {
mb.detach(&s.meta.id);
}
let cx = live.agent.context();
cx.hooks
.session_end(&s.meta.id, &s.path, &cx.tools.workspace)
.await;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn run_loop(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
app: &mut App,
live: &mut Live,
approvals: &mut mpsc::UnboundedReceiver<approve::Request>,
questions: &mut mpsc::UnboundedReceiver<ask::Question>,
shell_results: &mut mpsc::UnboundedReceiver<Entry>,
session: Option<&Session>,
approver: &Arc<dyn Approver>,
) -> Result<()> {
let mut keys = EventStream::new();
let (mut events_tx, mut events_rx) = mpsc::unbounded_channel::<AgentEvent>();
loop {
let (model, provider, tools) = (
live.model.clone(),
live.provider.clone(),
live.agent.registry().len(),
);
let todo_items = live.todo.as_ref().map(|t| t.items());
crossterm::queue!(
std::io::stdout(),
crossterm::terminal::BeginSynchronizedUpdate
)?;
terminal.draw(|frame| draw(frame, app, &model, &provider, tools, todo_items.as_deref()))?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EndSynchronizedUpdate
)?;
if let Some(switch) = app.pending_switch.take() {
apply_switch(switch, app, live, approver, session).await?;
continue;
}
if app.pending_editor {
app.pending_editor = false;
suspend_and_edit(terminal, app)?;
continue;
}
if let Some(name) = app.pending_trigger_edit.take() {
suspend_and_edit_trigger(terminal, app, &name)?;
continue;
}
if let Some(id) = app.pending_outbox_edit.take() {
suspend_and_edit_outbox(terminal, app, &id)?;
continue;
}
if let Some(remedy) = app.pending_doctor_remedy.take() {
suspend_and_run_remedy(terminal, app, &remedy)?;
continue;
}
if app.should_quit {
return Ok(());
}
let tick = tokio::time::sleep(std::time::Duration::from_millis(if app.running.is_some() {
200
} else if !app.watches.is_empty() {
1_000
} else {
60_000
}));
tokio::select! {
Some(Ok(event)) = keys.next() => on_terminal_event(app, event, &mut events_tx, &mut events_rx, &live.agent, session)?,
Some(event) = events_rx.recv() => {
match &event {
AgentEvent::TurnUsage(u) => {
app.usage.add(u);
app.prompt_tokens = u.total_input();
}
AgentEvent::Compacted { messages_before, messages_after, .. } => {
app.transcript.push(Entry::Notice(format!(
"compacted {messages_before} messages into {messages_after} to fit the context"
)));
}
_ => {}
}
app.transcript.absorb(&event);
}
Some(request) = approvals.recv() => app.pending = Some(request),
Some(question) = questions.recv() => app.asking = Some(question),
Some(entry) = shell_results.recv() => app.transcript.push(entry),
outcome = wait_for_run(&mut app.running), if app.running.is_some() => {
let persisted = app.running.as_mut().map(|r| std::mem::take(&mut r.persisted)).unwrap_or_default();
let baseline = app.running.as_mut().and_then(|r| r.outbox_before.take());
finish_run(app, outcome, persisted, baseline, session)?;
}
_ = tick => {
poll_watches(app);
if app.running.is_none() && app.watches.is_empty() {
app.outbox_pending = outbox_pending_count();
}
}
}
}
}
async fn wait_for_run(running: &mut Option<Running>) -> RunResult {
match running {
Some(run) => match (&mut run.handle).await {
Ok(result) => result,
Err(e) => (
Err(anyhow::anyhow!(
"the run task failed: {e}. The conversation in memory is lost; \
reopen it with --resume."
)),
Conversation::new(),
),
},
None => std::future::pending().await,
}
}
fn finish_run(
app: &mut App,
outcome: RunResult,
persisted: Vec<Message>,
baseline: Option<std::collections::HashSet<String>>,
session: Option<&Session>,
) -> Result<()> {
let (result, convo) = outcome;
app.convo = convo;
let mut finished_clean = false;
match result {
Ok(outcome) => {
app.usage = Usage::default();
app.usage.add(&outcome.usage);
finished_clean = !outcome.stop_cause.is_early();
if outcome.stop_cause.is_early() {
app.transcript.push(Entry::Notice(format!(
"{} after {}",
outcome.stop_cause.describe(),
mecha_core::agent::turns_phrase(outcome.turns)
)));
}
if let Some(s) = session {
s.record_run(&persisted, &app.convo)?;
s.record_outcome(&outcome)?;
s.append(&Record::Taint(app.convo.taint))?;
}
}
Err(e) => {
app.transcript.push(Entry::Error(format!("error: {e:#}")));
app.convo.messages = persisted;
app.convo.messages.pop();
}
}
app.running = None;
set_title(&format!("mecha · {}", workspace_name(app)));
settle_staged_drafts(app, baseline, finished_clean);
Ok(())
}
fn settle_staged_drafts(
app: &mut App,
baseline: Option<std::collections::HashSet<String>>,
finished_clean: bool,
) {
app.outbox_pending = outbox_pending_count();
let Some(baseline) = baseline else { return };
let Ok(store) = crate::commands::outbox::open_store() else {
return;
};
let Ok(items) = store.items() else { return };
let staged: Vec<mecha_core::outbox::OutboxItem> = items
.into_iter()
.filter(|i| i.status == "pending" && !baseline.contains(&i.id))
.collect();
if staged.is_empty() {
return;
}
use command::ReviewMode;
match app.review {
ReviewMode::Later => notice_staged(app, staged.len()),
ReviewMode::Now => open_scoped_review(app, staged.iter().map(|i| i.id.clone()).collect()),
ReviewMode::Auto => {
let (clean, tainted): (Vec<_>, Vec<_>) = staged.into_iter().partition(|i| {
crate::review_policy::auto_releases(
ReviewMode::Auto,
i.taint.trifecta_armed(),
finished_clean,
)
});
if !finished_clean {
app.transcript.push(Entry::Notice(
"the run stopped early — its drafts wait for review".into(),
));
open_scoped_review(app, tainted.iter().map(|i| i.id.clone()).collect());
return;
}
if !clean.is_empty() {
let mut args = vec!["outbox".to_string(), "send".to_string()];
args.extend(clean.iter().map(|i| i.id.clone()));
args.push("--yes".to_string());
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let spawned = spawn_detached(&argv);
app.transcript.push(Entry::Notice(match &spawned {
Ok(_) => format!(
"review auto: releasing {} draft(s) — results will be reported here",
clean.len()
),
Err(e) => format!(
"review auto: could not release {} draft(s): {e} — they stay pending",
clean.len()
),
}));
if spawned.is_ok() {
let now = std::time::Instant::now();
app.watches.extend(clean.iter().map(|i| Watch::Send {
id: i.id.clone(),
error_before: i.error.clone(),
since: now,
}));
}
}
if !tainted.is_empty() {
app.transcript.push(Entry::Notice(format!(
"⚠ {} draft(s) were written under the trifecta and are never \
auto-released — review them",
tainted.len()
)));
open_scoped_review(app, tainted.iter().map(|i| i.id.clone()).collect());
}
}
}
}
fn report_restart_probe(app: &mut App, line: String) {
match &mut app.health {
Some(modal) => modal.status = Some(line),
None => app.transcript.push(Entry::Notice(line)),
}
}
fn notice_staged(app: &mut App, n: usize) {
app.transcript.push(Entry::Notice(format!(
"{n} draft(s) staged — /outbox to review"
)));
}
fn poll_watches(app: &mut App) {
if app.watches.is_empty() {
return;
}
let watches = std::mem::take(&mut app.watches);
let (mut outbox_moved, mut requests_moved) = (false, false);
for watch in watches {
match watch {
Watch::Send {
id,
error_before,
since,
} => {
let item = crate::commands::outbox::open_store()
.and_then(|s| s.item(&id))
.ok();
match item {
Some(item) if item.status != "pending" => {
app.transcript
.push(Entry::Notice(match item.status.as_str() {
"sent" => format!("sent `{id}` via `{}`", item.tool),
other => format!("`{id}` is now {other}"),
}));
outbox_moved = true;
}
Some(item) if item.error != error_before && item.error.is_some() => {
app.transcript.push(Entry::Notice(format!(
"release of `{id}` failed: {} — it stays pending — /doctor for a full report",
item.error.as_deref().unwrap_or("unknown")
)));
outbox_moved = true;
}
Some(_) if since.elapsed() > std::time::Duration::from_secs(300) => {
app.transcript.push(Entry::Notice(format!(
"`{id}` is still releasing after 5m — /outbox has the record — /doctor for a full report"
)));
outbox_moved = true;
}
Some(_) => app.watches.push(Watch::Send {
id,
error_before,
since,
}),
None => {}
}
}
Watch::Request {
seq,
state_before,
since,
} => {
let record = mecha_core::frontdoor::Frontdoor::open_default()
.and_then(|s| s.record(seq))
.ok();
match record {
Some(record) if record.state != state_before => {
let drafts = if record.state == mecha_core::frontdoor::AWAITING_ME {
format!(" — {} draft(s) in /outbox", record.outbox.len())
} else {
String::new()
};
app.transcript.push(Entry::Notice(format!(
"request {seq}: {state_before} → {}{drafts}",
record.state
)));
requests_moved = true;
outbox_moved = outbox_moved || !record.outbox.is_empty();
}
Some(_) if since.elapsed() > std::time::Duration::from_secs(1800) => {
app.transcript.push(Entry::Notice(format!(
"request {seq} is still {state_before} after 30m — /frontdoor has the record — /doctor for a full report"
)));
requests_moved = true;
}
Some(_) => app.watches.push(Watch::Request {
seq,
state_before,
since,
}),
None => {}
}
}
Watch::Remedy {
mut child,
argv_line,
since,
notices,
} => {
match child.try_wait() {
Ok(Some(status)) => {
let exit = if status.success() {
"finished".to_string()
} else {
format!("exited with {status}")
};
app.transcript.push(Entry::Notice(format!(
"remedy `{argv_line}` {exit} — re-examining"
)));
start_examination(app);
}
Ok(None) => match doctor::remedy_poll(since.elapsed(), notices) {
doctor::RemedyPoll::Wait => app.watches.push(Watch::Remedy {
child,
argv_line,
since,
notices,
}),
doctor::RemedyPoll::Notice => {
app.transcript.push(Entry::Notice(format!(
"`{argv_line}` is still running after {}m — the outcome \
will be reported here",
since.elapsed().as_secs() / 60
)));
app.watches.push(Watch::Remedy {
child,
argv_line,
since,
notices: notices + 1,
});
}
doctor::RemedyPoll::Kill => {
let _ = child.kill();
let _ = child.wait();
app.transcript.push(Entry::Notice(format!(
"`{argv_line}` did not finish after {}m and was stopped — \
r in /doctor re-examines",
doctor::REMEDY_HARD_CAP.as_secs() / 60
)));
}
},
Err(e) => {
let _ = child.kill();
let _ = child.wait();
app.transcript.push(Entry::Notice(format!(
"`{argv_line}` could not be checked ({e}) and was stopped — \
r in /doctor re-examines"
)));
}
}
}
Watch::RestartProbe {
rx,
argv,
unit,
since,
} => match rx.try_recv() {
Ok(failed) => {
if let Some(line) =
crate::commands::doctor::recovered_before_restart(&unit, failed)
{
report_restart_probe(app, line);
} else {
let argv_line = argv.join(" ");
match spawn_remedy(&argv) {
Ok(child) => {
report_restart_probe(
app,
format!(
"running `{argv_line}` — the outcome will be \
reported here"
),
);
app.watches.push(Watch::Remedy {
child,
argv_line,
since: std::time::Instant::now(),
notices: 0,
});
}
Err(e) => report_restart_probe(
app,
format!("could not start `{argv_line}`: {e}"),
),
}
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
report_restart_probe(
app,
format!(
"the {unit} probe never answered — nothing was run; \
r in /doctor re-examines"
),
);
} else {
app.watches.push(Watch::RestartProbe {
rx,
argv,
unit,
since,
});
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
report_restart_probe(
app,
format!("the {unit} probe was lost — nothing was run"),
);
}
},
Watch::Examine { mut child, since } => match child.try_wait() {
Ok(Some(_)) => {
let verdict = match doctor::finish_examination(child) {
Ok(rows) => {
let broken = rows
.iter()
.filter(|r| r.severity == mecha_core::doctor::Severity::Broken)
.count();
let verdict = if rows.is_empty() {
"nothing wrong that this doctor can see".to_string()
} else {
format!("{} finding(s), {broken} broken", rows.len())
};
if app.health.is_some() {
install_doctor_rows(app, rows);
} else {
app.transcript.push(Entry::Notice(format!(
"doctor: {verdict} — /doctor has the report"
)));
}
verdict
}
Err(e) => {
let line = format!("{e:#}");
if app.health.is_none() {
app.transcript.push(Entry::Error(format!("doctor: {line}")));
}
line
}
};
if let Some(modal) = &mut app.health {
modal.examining = false;
modal.status = Some(verdict);
}
}
Ok(None) if since.elapsed() > doctor::EXAMINE_CAP => {
let _ = child.kill();
let _ = child.wait();
let line = format!(
"the examination did not answer within {}s and was stopped — \
r in /doctor retries",
doctor::EXAMINE_CAP.as_secs()
);
if let Some(modal) = &mut app.health {
modal.examining = false;
modal.status = Some(line);
} else {
app.transcript
.push(Entry::Notice(format!("doctor: {line}")));
}
}
Ok(None) => app.watches.push(Watch::Examine { child, since }),
Err(e) => {
let _ = child.kill();
let _ = child.wait();
let line = format!("the examination could not be checked: {e}");
if let Some(modal) = &mut app.health {
modal.examining = false;
modal.status = Some(line);
} else {
app.transcript.push(Entry::Error(format!("doctor: {line}")));
}
}
},
}
}
if outbox_moved {
app.outbox_pending = outbox_pending_count();
reload_outbox(app);
}
if requests_moved {
reload_frontdoor(app);
}
}
fn open_scoped_review(app: &mut App, ids: Vec<String>) {
let busy = app.pending.is_some()
|| app.asking.is_some()
|| app.picker.is_some()
|| app.tools.is_some()
|| app.scheduled.is_some()
|| app.staged.is_some()
|| app.requests.is_some()
|| app.health.is_some()
|| app.help;
if busy {
notice_staged(app, ids.len());
return;
}
match outbox::load() {
Ok(rows) => {
let rows: Vec<outbox::OutboxRow> =
rows.into_iter().filter(|r| ids.contains(&r.id)).collect();
if rows.is_empty() {
notice_staged(app, ids.len());
return;
}
app.staged = Some(outbox::OutboxModal {
scope: Some(ids),
..outbox::OutboxModal::new(rows)
});
}
Err(e) => app.transcript.push(Entry::Error(format!("outbox: {e:#}"))),
}
}
fn on_terminal_event(
app: &mut App,
event: Event,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
agent: &Arc<Agent>,
session: Option<&Session>,
) -> Result<()> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
on_key(app, key, events_tx, events_rx, agent, session)
}
Event::Paste(text) => {
app.quit_armed = false;
app.input.insert_str(app.cursor, &text);
app.cursor += text.len();
Ok(())
}
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollUp => app.transcript.scroll_up(3),
MouseEventKind::ScrollDown => app.transcript.scroll_down(3),
_ => {}
}
Ok(())
}
_ => Ok(()),
}
}
fn on_key(
app: &mut App,
key: KeyEvent,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
agent: &Arc<Agent>,
session: Option<&Session>,
) -> Result<()> {
if let Some(request) = app.pending.take() {
use approve::Answer;
let answer = match key.code {
KeyCode::Char('y') | KeyCode::Enter => Some(Answer::Allow),
KeyCode::Char('a') => Some(Answer::Always),
KeyCode::Char('n') | KeyCode::Esc => Some(Answer::Deny),
_ => None,
};
match answer {
Some(answer) => {
app.transcript.push(Entry::Notice(match answer {
Answer::Allow => format!("allowed {}", request.tool),
Answer::Always => format!("allowing {} for this session", request.tool),
Answer::Deny => format!("declined {}", request.tool),
}));
let _ = request.reply.send(answer);
}
None => app.pending = Some(request),
}
return Ok(());
}
if app.asking.is_some() {
let has_options = app.asking.as_ref().is_some_and(|q| !q.options.is_empty());
match key.code {
KeyCode::Esc => {
if let Some(q) = app.asking.take() {
let _ = q.reply.send(None);
app.transcript
.push(Entry::Notice("left it to the model".into()));
}
return Ok(());
}
KeyCode::Char(c) if has_options && c.is_ascii_digit() && app.input.is_empty() => {
let choice = c.to_digit(10).unwrap_or(0) as usize;
if choice >= 1 {
if let Some(q) = app.asking.take() {
match q.options.get(choice - 1) {
Some(answer) => {
app.transcript.push(Entry::User(answer.clone()));
let _ = q.reply.send(Some(answer.clone()));
}
None => app.asking = Some(q),
}
}
}
return Ok(());
}
KeyCode::Enter
if !app.input.trim().is_empty()
&& !key
.modifiers
.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) =>
{
let answer = app.input.trim().to_string();
app.input.clear();
app.cursor = 0;
if let Some(q) = app.asking.take() {
app.transcript.push(Entry::User(answer.clone()));
let _ = q.reply.send(Some(answer));
}
return Ok(());
}
_ => {}
}
}
if let Some(modal) = &mut app.tools {
match key.code {
KeyCode::Up if !modal.detail => modal.move_by(-1),
KeyCode::Down if !modal.detail => modal.move_by(1),
KeyCode::Enter => modal.detail = !modal.detail,
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.tools = None;
}
}
_ => {}
}
return Ok(());
}
if app.scheduled.is_some() {
return handle_triggers_key(app, key);
}
if app.staged.is_some() {
return handle_outbox_key(app, key);
}
if app.requests.is_some() {
return handle_frontdoor_key(app, key);
}
if app.mail.is_some() {
return handle_mail_key(app, key);
}
if app.poll_monitor.is_some() {
return handle_polls_key(app, key);
}
if app.health.is_some() {
return handle_doctor_key(app, key, agent, session);
}
if let Some(picker) = &mut app.picker {
match key.code {
KeyCode::Up => picker.move_by(-1),
KeyCode::Down => picker.move_by(1),
KeyCode::Esc | KeyCode::Char('q') => {
app.picker = None;
}
KeyCode::Enter => {
if let Some(picker) = app.picker.take() {
let chosen = picker.selected;
if let Some((_, cmd)) = picker.items.into_iter().nth(chosen) {
return run_command(app, cmd, agent, session);
}
}
}
_ => {}
}
return Ok(());
}
if app.help {
app.help = false;
match key.code {
KeyCode::Char(c) if c != '?' => {}
KeyCode::Backspace => {}
_ => return Ok(()),
}
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => match &mut app.running {
Some(run) => {
run.cancel.cancel();
run.cancelling = true;
}
None if app.quit_armed => app.should_quit = true,
None => {
app.quit_armed = true;
app.transcript
.push(Entry::Notice("^C again to quit".into()));
}
},
KeyCode::Char('d') if ctrl && app.input.is_empty() => app.should_quit = true,
KeyCode::Char('g') if ctrl => app.pending_editor = true,
KeyCode::Char('o') if ctrl => {
app.transcript.verbose = !app.transcript.verbose;
app.transcript
.push(Entry::Notice(if app.transcript.verbose {
"showing thinking and tool output — ^O to hide".into()
} else {
"hiding thinking and tool output — ^O to show".into()
}));
}
KeyCode::Tab => {
if let Some((start, partial)) = command::at_token(&app.input, app.cursor) {
let candidates = command::path_candidates(partial, &app.workspace);
let filled = command::common_prefix(&candidates);
if filled.len() > partial.len() {
app.input.replace_range(start..app.cursor, &filled);
app.cursor = start + filled.len();
}
} else {
let candidates = command::completions(&app.input);
let filled = command::common_prefix(&candidates);
if !filled.is_empty() {
app.input = format!("/{filled}");
app.cursor = app.input.len();
}
}
}
KeyCode::BackTab => {
app.phase = match app.phase {
Phase::Execute => Phase::Plan,
Phase::Plan => Phase::Execute,
};
app.transcript.push(Entry::Notice(match app.phase {
Phase::Plan => "planning — writing tools are not offered".into(),
Phase::Execute => "executing — every tool is available".into(),
}));
}
KeyCode::Enter
if key.modifiers.contains(KeyModifiers::SHIFT)
|| key.modifiers.contains(KeyModifiers::ALT) =>
{
app.quit_armed = false;
app.input.insert(app.cursor, '\n');
app.cursor += 1;
}
KeyCode::Enter => {
let text = app.input.trim().to_string();
if !text.is_empty() {
app.input.clear();
app.cursor = 0;
app.history.push(text.clone());
app.history_pos = None;
submit(app, text, events_tx, events_rx, agent, session)?;
}
}
KeyCode::Char('?') if app.input.is_empty() => app.help = true,
KeyCode::Char(c) => {
app.quit_armed = false;
app.input.insert(app.cursor, c);
app.cursor += c.len_utf8();
}
KeyCode::Backspace => {
if let Some(prev) = prev_boundary(&app.input, app.cursor) {
app.input.remove(prev);
app.cursor = prev;
}
}
KeyCode::Delete => {
if app.cursor < app.input.len() {
app.input.remove(app.cursor);
}
}
KeyCode::Left => app.cursor = prev_boundary(&app.input, app.cursor).unwrap_or(0),
KeyCode::Right => app.cursor = next_boundary(&app.input, app.cursor),
KeyCode::Home => app.cursor = 0,
KeyCode::End => app.cursor = app.input.len(),
KeyCode::Up => recall(app, -1),
KeyCode::Down => recall(app, 1),
KeyCode::PageUp => app.transcript.scroll_up(10),
KeyCode::PageDown => app.transcript.scroll_down(10),
KeyCode::Esc => app.transcript.jump_to_bottom(),
_ => {}
}
Ok(())
}
async fn apply_switch(
switch: Switch,
app: &mut App,
live: &mut Live,
approver: &Arc<dyn Approver>,
session: Option<&Session>,
) -> Result<()> {
if app.running.is_some() {
app.transcript
.push(Entry::Notice("busy — stop the run first (^C)".into()));
return Ok(());
}
if let Switch::Mode(mode) = switch {
let Some(agent) = Arc::get_mut(&mut live.agent) else {
app.transcript.push(Entry::Notice(
"cannot change mode while the agent is shared".into(),
));
return Ok(());
};
let next: Arc<dyn Approver> = match mode {
PermissionMode::Ask => Arc::clone(approver),
other => Arc::new(ModeApprover { mode: other }),
};
agent.set_approver(next);
app.mode = mode;
app.transcript
.push(Entry::Notice(format!("mode {}", mode_name(mode))));
record_config(session, live, app.mode)?;
return Ok(());
}
let mut opts = live.opts.clone();
let what = match &switch {
Switch::Model(id) => {
opts.model = Some(id.clone());
format!("model {id}")
}
Switch::Provider(name) => {
opts.provider = Some(name.clone());
opts.model = None;
format!("provider {name}")
}
Switch::Mcp(on) => {
opts.no_mcp = !on;
if *on {
opts.no_mcp_servers.clear();
}
if *on {
"MCP on".to_string()
} else {
"MCP off".to_string()
}
}
Switch::McpServer(name, on) => {
opts.no_mcp_servers.retain(|n| n != name);
if !on {
opts.no_mcp_servers.push(name.clone());
} else {
opts.no_mcp = false;
}
format!("{name} {}", if *on { "on" } else { "off" })
}
Switch::Mode(_) => unreachable!("handled above"),
};
app.transcript
.push(Entry::Notice(format!("switching to {what}…")));
let prepared = match setup::prepare_with_approver(&opts, Arc::clone(approver)).await {
Ok(p) => p,
Err(e) => {
app.transcript.push(Entry::Error(format!(
"could not switch: {e:#} — staying on {}",
live.model
)));
return Ok(());
}
};
let tools_changed = prepared.agent.registry().len() != live.agent.registry().len();
*live = Live::new(prepared, opts);
app.mcp_on = !live.opts.no_mcp;
for (name, on) in &mut app.mcp_servers {
*on = !live.opts.no_mcp && !live.opts.no_mcp_servers.iter().any(|n| n == name);
}
app.transcript.push(Entry::Notice(format!(
"now {} ({}) · {} tools{}",
live.model,
live.provider,
live.agent.registry().len(),
if tools_changed {
" · prompt cache reset"
} else {
""
}
)));
record_config(session, live, app.mode)?;
Ok(())
}
fn record_config(session: Option<&Session>, live: &Live, mode: PermissionMode) -> Result<()> {
let Some(s) = session else { return Ok(()) };
let cfg = mecha_core::config::Config::load(
live.opts
.workspace
.as_deref()
.unwrap_or(std::path::Path::new(".")),
)?;
let mut record = RunConfig::of(&live.agent, &cfg, &live.provider);
record.permission_mode = mode;
s.append(&Record::Config(record))
}
fn run_command(
app: &mut App,
cmd: command::Command,
agent: &Arc<Agent>,
session: Option<&Session>,
) -> Result<()> {
use command::Command;
let mut say = |text: String| app.transcript.push(Entry::Notice(text));
match cmd {
Command::Help => app.help = true,
Command::Tools => {
let outbox = agent.context().outbox.clone();
let rows = agent
.registry()
.iter()
.map(|t| tools::ToolRow {
name: t.name().to_string(),
read_only: t.read_only(),
outbox: outbox.as_ref().is_some_and(|o| o.routes(t.name())),
caps: t.capabilities(),
description: t.description().to_string(),
})
.collect();
app.tools = Some(tools::ToolsModal {
rows,
selected: 0,
detail: false,
sandbox_line: app.sandbox_line.clone(),
});
}
Command::Triggers => match triggers::load(5) {
Ok(rows) => app.scheduled = Some(triggers::TriggersModal::new(rows)),
Err(e) => say(format!("triggers: {e:#}")),
},
Command::Outbox => match outbox::load() {
Ok(rows) => {
app.outbox_pending = rows.iter().filter(|r| r.pending()).count();
app.staged = Some(outbox::OutboxModal::new(rows));
}
Err(e) => say(format!("outbox: {e:#}")),
},
Command::Review(None) => say(format!(
"review {} — {}. /review now|later|auto switches",
app.review.name(),
app.review.describe()
)),
Command::Review(Some(mode)) => {
app.review = mode;
say(format!("review {} — {}", mode.name(), mode.describe()));
}
Command::BadReview(word) => say(format!("`{word}`? review is one of: now, later, auto")),
Command::Frontdoor => match frontdoor::load() {
Ok(rows) => app.requests = Some(frontdoor::FrontdoorModal::new(rows)),
Err(e) => say(format!("frontdoor: {e:#}")),
},
Command::Mail => match mail::load() {
Ok(rows) if rows.is_empty() => {
say("nothing classified yet — `mecha mail classify` fills the queue".into())
}
Ok(rows) => app.mail = Some(mail::MailModal::new(rows)),
Err(e) => say(format!("mail: {e:#}")),
},
Command::Polls => match polls::load() {
Ok(rows) => app.poll_monitor = Some(polls::PollsModal::new(rows)),
Err(e) => say(format!("polls: {e:#}")),
},
Command::Doctor => {
app.health = Some(doctor::DoctorModal::examining());
start_examination(app);
}
Command::Usage => say(format!(
"{} · {} in the last prompt",
crate::render::format_usage(&app.usage),
app.prompt_tokens
)),
Command::Session => say(match session {
Some(s) => format!("{}", s.path.display()),
None => "not recording a transcript (--no-session)".to_string(),
}),
Command::Clear => {
app.convo = Conversation::new();
app.usage = Usage::default();
app.prompt_tokens = 0;
app.transcript.push(Entry::Notice(
"cleared — new conversation, and the taint went with it".into(),
));
}
Command::Todo => {
app.todo_visible = !app.todo_visible;
say(if app.todo_visible {
"todo pane shown — it appears whenever the list is non-empty".into()
} else {
"todo pane hidden".into()
});
}
Command::Quit => app.should_quit = true,
Command::Model(None) | Command::Provider(None) => {
let current = agent.provider_id();
let items: Vec<(String, Command)> = app
.providers
.iter()
.map(|(name, model)| {
let here = if name == current { " ← current" } else { "" };
(
format!("{name:<10} {model}{here}"),
Command::Provider(Some(name.clone())),
)
})
.collect();
if items.is_empty() {
say("no providers configured — see `mecha config path`".into());
} else {
let selected = app
.providers
.iter()
.position(|(n, _)| n == current)
.unwrap_or(0);
app.picker = Some(Picker {
title: " switch model · ↑↓ then enter, esc to cancel ".into(),
items,
selected,
});
}
}
Command::Mode(None) => {
let modes = [
PermissionMode::Ask,
PermissionMode::Allow,
PermissionMode::ReadOnly,
];
let describe = |m: PermissionMode| match m {
PermissionMode::Ask => "ask approve each write or command",
PermissionMode::Allow => "allow run everything without asking",
PermissionMode::ReadOnly => "read-only refuse anything that writes",
};
app.picker = Some(Picker {
title: " permission mode · ↑↓ then enter ".into(),
items: modes
.iter()
.map(|m| {
let here = if *m == app.mode { " ← current" } else { "" };
(format!("{}{here}", describe(*m)), Command::Mode(Some(*m)))
})
.collect(),
selected: modes.iter().position(|m| *m == app.mode).unwrap_or(0),
});
}
Command::Mcp(None) => {
if app.mcp_servers.is_empty() {
say("no MCP servers configured — see `mecha config path`".into());
} else {
let mut items = vec![
("all on".to_string(), Command::Mcp(Some(true))),
("all off".to_string(), Command::Mcp(Some(false))),
];
for (name, on) in &app.mcp_servers {
items.push((
format!("{:<14} {}", name, if *on { "on" } else { "off" }),
Command::McpServer(name.clone(), Some(!on)),
));
}
app.picker = Some(Picker {
title: " MCP servers · enter flips the one you pick ".into(),
items,
selected: 2,
});
}
}
Command::McpServer(name, want) => match app.mcp_servers.iter().find(|(n, _)| *n == name) {
Some((_, on)) => {
let target = want.unwrap_or(!on);
if target == *on {
say(format!(
"{name} is already {}",
if target { "on" } else { "off" }
));
} else {
app.pending_switch = Some(Switch::McpServer(name, target));
}
}
None => say(format!(
"no MCP server named {name:?} — configured: {}",
app.mcp_servers
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
)),
},
Command::Model(Some(id)) => app.pending_switch = Some(Switch::Model(id)),
Command::Provider(Some(name)) => app.pending_switch = Some(Switch::Provider(name)),
Command::Mode(Some(m)) => app.pending_switch = Some(Switch::Mode(m)),
Command::Mcp(Some(on)) => app.pending_switch = Some(Switch::Mcp(on)),
Command::BadToggle(word) => say(format!("say on or off, not {word:?}")),
Command::BadMode(word) => say(format!("no such mode {word:?} (ask | allow | read-only)")),
Command::Unknown(name) => say(format!("no such command /{name}\n{}", command::HELP)),
}
Ok(())
}
fn submit(
app: &mut App,
text: String,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
agent: &Arc<Agent>,
session: Option<&Session>,
) -> Result<()> {
if let Some(cmd) = command::shell_escape(&text) {
run_shell_escape(app, agent, cmd.to_string());
return Ok(());
}
if let Some(cmd) = command::parse(&text) {
return run_command(app, cmd, agent, session);
}
if let Some(run) = &app.running {
if let Ok(mut queue) = run.queue.lock() {
queue.push_back(text);
}
return Ok(());
}
let user = Message::user(&text);
app.convo.push(user.clone());
if let Some(s) = session {
s.append(&Record::Message(user))?;
}
app.transcript.push(Entry::User(text));
set_title(&format!(
"mecha ▶ {} · {}",
workspace_name(app),
agent.model()
));
let (tx, rx) = mpsc::unbounded_channel();
*events_tx = tx.clone();
*events_rx = rx;
let cancel = CancellationToken::new();
let queue = Arc::new(Mutex::new(VecDeque::new()));
let cx = agent
.context()
.as_ref()
.clone()
.with_cancel(cancel.clone())
.with_phase(app.phase)
.with_queued_input(Arc::clone(&queue));
let agent = Arc::clone(agent);
let persisted = app.convo.messages.clone();
let mut convo = std::mem::take(&mut app.convo);
let handle = tokio::spawn(async move {
let result = agent.run_in(&cx, &mut convo, Some(tx)).await;
(result, convo)
});
app.running = Some(Running {
handle,
cancel,
queue,
started: std::time::Instant::now(),
cancelling: false,
persisted,
outbox_before: outbox_ids(),
});
Ok(())
}
fn outbox_ids() -> Option<std::collections::HashSet<String>> {
let store = crate::commands::outbox::open_store().ok()?;
Some(store.items().ok()?.into_iter().map(|i| i.id).collect())
}
fn outbox_pending_count() -> usize {
crate::commands::outbox::open_store()
.and_then(|s| s.items())
.map(|items| items.iter().filter(|i| i.status == "pending").count())
.unwrap_or(0)
}
fn handle_triggers_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.scheduled else {
return Ok(());
};
if let Some(confirm) = modal.confirm.take() {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let outcome = trigger_cli(&["rm", &confirm.name]);
modal.status = Some(match outcome {
Ok(_) => format!("deleted `{}`", confirm.name),
Err(e) => format!("could not delete `{}`: {e}", confirm.name),
});
reload_triggers(app);
}
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up => {
if modal.detail {
modal.scroll_detail(-1)
} else {
modal.move_by(-1)
}
}
KeyCode::Down => {
if modal.detail {
modal.scroll_detail(1)
} else {
modal.move_by(1)
}
}
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Enter => {
modal.detail = !modal.detail;
modal.detail_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.scheduled = None;
}
}
KeyCode::Char('e') => {
if let Some(name) = modal.selected_name() {
app.pending_trigger_edit = Some(name.to_string());
}
}
KeyCode::Char(' ') => {
if let Some(row) = modal.selected_row() {
let (verb, name) = (
if row.enabled { "disable" } else { "enable" },
row.name.clone(),
);
let outcome = trigger_cli(&[verb, &name]);
modal.status = Some(match outcome {
Ok(_) => format!("{verb}d `{name}`"),
Err(e) => format!("could not {verb} `{name}`: {e}"),
});
reload_triggers(app);
}
}
KeyCode::Char('r') => {
if let Some(name) = modal.selected_name().map(str::to_string) {
modal.status = Some(match spawn_detached(&["trigger", "run", &name]) {
Ok(_) => format!("started `{name}` — reopen /triggers to see how it went"),
Err(e) => format!("could not start `{name}`: {e}"),
});
reload_triggers(app);
}
}
KeyCode::Char('c') => {
if let Some(name) = modal.selected_name().map(str::to_string) {
modal.status = Some(match trigger_cli(&["cancel", &name]) {
Ok(out) => out.trim().to_string(),
Err(e) => format!("could not cancel `{name}`: {e}"),
});
reload_triggers(app);
}
}
KeyCode::Char('x') => {
if let Some(row) = modal.selected_row() {
modal.confirm = Some(triggers::Confirm {
name: row.name.clone(),
prompt: format!(
"Delete trigger `{}`? Its file goes; its ledger rows stay as the record.",
row.name
),
});
}
}
_ => {}
}
Ok(())
}
fn reload_triggers(app: &mut App) {
let (selected, detail, status) = match &app.scheduled {
Some(m) => (m.selected, m.detail, m.status.clone()),
None => return,
};
match triggers::load(5) {
Ok(rows) => {
let selected = selected.min(rows.len().saturating_sub(1));
app.scheduled = Some(triggers::TriggersModal {
selected,
detail: detail && !rows.is_empty(),
status,
..triggers::TriggersModal::new(rows)
});
}
Err(e) => {
app.scheduled = None;
app.transcript
.push(Entry::Error(format!("triggers: {e:#}")));
}
}
}
fn handle_outbox_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.staged else {
return Ok(());
};
if let Some(confirm) = modal.confirm.take() {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let outcome = spawn_detached(&["outbox", "send", &confirm.id, "--yes"]);
let watch = outcome.is_ok();
modal.status = Some(match outcome {
Ok(_) => format!(
"releasing `{}` — the result will be reported here",
confirm.id
),
Err(e) => format!("could not start the send: {e}"),
});
if watch {
app.watches.push(Watch::Send {
id: confirm.id,
error_before: confirm.error_before,
since: std::time::Instant::now(),
});
}
reload_outbox(app);
}
return Ok(());
}
if modal.rejecting.is_some() {
match key.code {
KeyCode::Esc => modal.rejecting = None,
KeyCode::Enter => {
let input = modal.rejecting.take().expect("checked above");
let reason = input.buffer.trim().to_string();
let mut args = vec!["outbox", "reject", input.id.as_str()];
if !reason.is_empty() {
args.extend(["--reason", reason.as_str()]);
}
modal.status = Some(match self_cli(&args) {
Ok(_) => format!("rejected `{}`; nothing was sent", input.id),
Err(e) => format!("could not reject `{}`: {e}", input.id),
});
reload_outbox(app);
}
KeyCode::Backspace => {
if let Some(input) = &mut modal.rejecting {
input.buffer.pop();
}
}
KeyCode::Char(c) => {
if let Some(input) = &mut modal.rejecting {
input.buffer.push(c);
}
}
_ => {}
}
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up => {
if modal.detail {
modal.scroll_detail(-1)
} else {
modal.move_by(-1)
}
}
KeyCode::Down => {
if modal.detail {
modal.scroll_detail(1)
} else {
modal.move_by(1)
}
}
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Enter => {
modal.detail = !modal.detail;
modal.detail_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.staged = None;
}
}
KeyCode::Char('s') => {
if let Some(row) = modal.selected_row() {
if row.pending() {
modal.confirm = Some(outbox::SendConfirm {
id: row.id.clone(),
summary: row.summary.clone(),
tainted: row.tainted,
args_text: row.args_text.clone(),
error_before: row.error.clone(),
});
} else {
modal.status = Some(format!("`{}` is {}, not pending", row.id, row.status));
}
}
}
KeyCode::Char('e') => {
if let Some(row) = modal.selected_row() {
if !row.pending() {
modal.status = Some(format!("`{}` is {}, not pending", row.id, row.status));
} else if row.kind == mecha_core::outbox::OutboxKind::Publish {
modal.status = Some(
"a publish is not editable — edit the source, re-render, \
and publish again, which stages a new item"
.into(),
);
} else {
app.pending_outbox_edit = Some(row.id.clone());
}
}
}
KeyCode::Char('r') => {
if let Some(row) = modal.selected_row() {
if row.pending() {
modal.rejecting = Some(outbox::ReasonInput {
id: row.id.clone(),
buffer: String::new(),
});
} else {
modal.status = Some(format!("`{}` is {}, not pending", row.id, row.status));
}
}
}
_ => {}
}
Ok(())
}
fn reload_outbox(app: &mut App) {
let (selected, detail, status, scope) = match &app.staged {
Some(m) => (m.selected, m.detail, m.status.clone(), m.scope.clone()),
None => return,
};
match outbox::load() {
Ok(rows) => {
app.outbox_pending = rows.iter().filter(|r| r.pending()).count();
let rows: Vec<outbox::OutboxRow> = match &scope {
Some(ids) => rows.into_iter().filter(|r| ids.contains(&r.id)).collect(),
None => rows,
};
let selected = selected.min(rows.len().saturating_sub(1));
app.staged = Some(outbox::OutboxModal {
selected,
detail: detail && !rows.is_empty(),
status,
scope,
..outbox::OutboxModal::new(rows)
});
}
Err(e) => {
app.staged = None;
app.transcript.push(Entry::Error(format!("outbox: {e:#}")));
}
}
}
fn handle_frontdoor_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.requests else {
return Ok(());
};
if modal.input.is_some() {
match key.code {
KeyCode::Esc => modal.input = None,
KeyCode::Enter => {
let input = modal.input.take().expect("checked above");
let note = input.buffer.trim().to_string();
let seq = input.seq.to_string();
let outcome = match input.action {
frontdoor::NoteAction::Close if note.is_empty() => {
modal.status = Some(format!("a close needs a reason — {seq} is unchanged"));
return Ok(());
}
frontdoor::NoteAction::Close => {
self_cli(&["frontdoor", "close", &seq, "--reason", ¬e])
.map(|_| format!("closed {seq}"))
}
frontdoor::NoteAction::NeedsInfo => {
let mut args = vec!["frontdoor", "needs-info", seq.as_str()];
if !note.is_empty() {
args.extend(["--note", note.as_str()]);
}
self_cli(&args).map(|_| format!("{seq} parked until they answer"))
}
};
modal.status = Some(match outcome {
Ok(done) => done,
Err(e) => format!("could not update {seq}: {e}"),
});
reload_frontdoor(app);
}
KeyCode::Backspace => {
if let Some(input) = &mut modal.input {
input.buffer.pop();
}
}
KeyCode::Char(c) => {
if let Some(input) = &mut modal.input {
input.buffer.push(c);
}
}
_ => {}
}
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up => {
if modal.detail {
modal.scroll_detail(-1)
} else {
modal.move_by(-1)
}
}
KeyCode::Down => {
if modal.detail {
modal.scroll_detail(1)
} else {
modal.move_by(1)
}
}
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Enter => {
modal.detail = !modal.detail;
modal.detail_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.requests = None;
}
}
KeyCode::Char('x') => {
if let Some(row) = modal.selected_row() {
if !row.valid {
modal.status = Some(format!(
"{} is invalid — invalid records are never extracted",
row.seq
));
} else {
let (seq, state_before) = (row.seq, row.state.clone());
let spawned =
spawn_detached(&["frontdoor", "extract", "--seq", &seq.to_string()]);
let watch = spawned.is_ok();
modal.status = Some(match spawned {
Ok(_) => format!("extracting {seq} — the result will be reported here"),
Err(e) => format!("could not start the extraction: {e}"),
});
if watch {
app.watches.push(Watch::Request {
seq,
state_before,
since: std::time::Instant::now(),
});
}
reload_frontdoor(app);
}
}
}
KeyCode::Char('t') => {
if let Some(row) = modal.selected_row() {
if row.state != mecha_core::frontdoor::EXTRACTED {
modal.status = Some(format!(
"{} is `{}` — triage runs on `extracted`",
row.seq, row.state
));
} else {
let (seq, state_before) = (row.seq, row.state.clone());
let spawned =
spawn_detached(&["frontdoor", "triage", "--seq", &seq.to_string()]);
let watch = spawned.is_ok();
modal.status = Some(match spawned {
Ok(_) => {
format!("triaging {seq} — its drafts will be reported when it finishes")
}
Err(e) => format!("could not start the triage: {e}"),
});
if watch {
app.watches.push(Watch::Request {
seq,
state_before,
since: std::time::Instant::now(),
});
}
reload_frontdoor(app);
}
}
}
KeyCode::Char('n') => {
if let Some(row) = modal.selected_row() {
modal.input = Some(frontdoor::NoteInput {
seq: row.seq,
action: frontdoor::NoteAction::NeedsInfo,
buffer: String::new(),
});
}
}
KeyCode::Char('c') => {
if let Some(row) = modal.selected_row() {
modal.input = Some(frontdoor::NoteInput {
seq: row.seq,
action: frontdoor::NoteAction::Close,
buffer: String::new(),
});
}
}
_ => {}
}
Ok(())
}
fn handle_polls_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.poll_monitor else {
return Ok(());
};
if modal.input.is_some() {
match key.code {
KeyCode::Esc => modal.input = None,
KeyCode::Enter => {
let input = modal.input.take().expect("checked above");
let note = input.buffer.trim().to_string();
let Some(row) = modal.selected_row() else {
return Ok(());
};
let instrument = row.instrument.clone();
let poll_id = input.poll_id;
let mut args = vec!["polls", "close", instrument.as_str(), poll_id.as_str()];
if !note.is_empty() {
args.extend(["--resolution", note.as_str()]);
}
modal.status = Some(match factory_cli(&args) {
Ok(_) => format!("closed {poll_id}"),
Err(e) => format!("could not close {poll_id}: {e}"),
});
fetch_selected_poll(modal);
}
KeyCode::Backspace => {
if let Some(input) = &mut modal.input {
input.buffer.pop();
}
}
KeyCode::Char(c) => {
if let Some(input) = &mut modal.input {
input.buffer.push(c);
}
}
_ => {}
}
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up => {
if modal.detail {
modal.scroll_detail(-1)
} else {
modal.move_by(-1)
}
}
KeyCode::Down => {
if modal.detail {
modal.scroll_detail(1)
} else {
modal.move_by(1)
}
}
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Enter => {
if !modal.detail {
fetch_selected_poll(modal);
}
modal.detail = !modal.detail;
modal.detail_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.poll_monitor = None;
}
}
KeyCode::Char('r') => fetch_selected_poll(modal),
KeyCode::Char('c') => {
if let Some(row) = modal.selected_row() {
modal.input = Some(polls::ResolutionInput {
poll_id: row.poll_id.clone(),
buffer: String::new(),
});
}
}
KeyCode::Char('e') => {
if let Some(row) = modal.selected_row() {
let instrument = row.instrument.clone();
let poll_id = row.poll_id.clone();
let out = mecha_core::work::mecha_home().map(|home| {
home.join("factory")
.join("polls")
.join(format!("{poll_id}.csv"))
});
modal.status = Some(match out {
Ok(out) => {
let path = out.display().to_string();
match factory_cli(&[
"polls",
"export",
&instrument,
&poll_id,
"--out",
&path,
]) {
Ok(_) => format!("exported → {path}"),
Err(e) => format!("export failed: {e}"),
}
}
Err(e) => format!("export failed: {e}"),
});
}
}
KeyCode::Char('s') => {
if let Some(row) = modal.selected_row() {
modal.status = Some(match &row.screen_url {
Some(url) => format!("projector: {url}"),
None => "no projector url on record — older poll, or a times poll".into(),
});
}
}
_ => {}
}
Ok(())
}
fn fetch_selected_poll(modal: &mut polls::PollsModal) {
let selected = modal.selected;
let Some(row) = modal.rows.get_mut(selected) else {
return;
};
let as_of = chrono::Local::now().format("%H:%M:%S").to_string();
let instrument = row.instrument.clone();
let poll_id = row.poll_id.clone();
let result = factory_cli(&["polls", "status", &instrument, &poll_id]);
row.install_fetch(as_of, result);
}
fn factory_cli(args: &[&str]) -> Result<String> {
let out = std::process::Command::new("factory-publish")
.args(args)
.stdin(std::process::Stdio::null())
.output()
.context("running factory-publish — is it installed and on PATH?")?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("{}", err.trim().lines().next().unwrap_or("failed"))
}
}
fn reload_frontdoor(app: &mut App) {
let (selected, detail, status) = match &app.requests {
Some(m) => (m.selected, m.detail, m.status.clone()),
None => return,
};
match frontdoor::load() {
Ok(rows) => {
let selected = selected.min(rows.len().saturating_sub(1));
app.requests = Some(frontdoor::FrontdoorModal {
selected,
detail: detail && !rows.is_empty(),
status,
..frontdoor::FrontdoorModal::new(rows)
});
}
Err(e) => {
app.requests = None;
app.transcript
.push(Entry::Error(format!("frontdoor: {e:#}")));
}
}
}
fn handle_doctor_key(
app: &mut App,
key: KeyEvent,
agent: &Arc<Agent>,
session: Option<&Session>,
) -> Result<()> {
let Some(modal) = &mut app.health else {
return Ok(());
};
if let Some(confirm) = modal.confirm.take() {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let argv_line = confirm.argv.join(" ");
if let Some(unit) = crate::commands::doctor::restart_unit_of(&confirm.argv) {
let unit = unit.to_string();
let (tx, rx) = std::sync::mpsc::channel();
let probed = unit.clone();
std::thread::spawn(move || {
let _ = tx.send(crate::commands::doctor::unit_is_failed(&probed));
});
modal.status = Some(format!(
"checking whether {unit} is still failed — the outcome will be \
reported here"
));
app.watches.push(Watch::RestartProbe {
rx,
argv: confirm.argv,
unit,
since: std::time::Instant::now(),
});
return Ok(());
}
match spawn_remedy(&confirm.argv) {
Ok(child) => {
modal.status = Some(format!(
"running `{argv_line}` — the outcome will be reported here"
));
app.watches.push(Watch::Remedy {
child,
argv_line,
since: std::time::Instant::now(),
notices: 0,
});
}
Err(e) => modal.status = Some(format!("could not start `{argv_line}`: {e}")),
}
}
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up => {
if modal.detail {
modal.scroll_detail(-1)
} else {
modal.move_by(-1)
}
}
KeyCode::Down => {
if modal.detail {
modal.scroll_detail(1)
} else {
modal.move_by(1)
}
}
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Enter => {
modal.detail = !modal.detail;
modal.detail_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.health = None;
}
}
KeyCode::Char('r') => {
reload_doctor(app);
}
KeyCode::Char('a') => {
let remedy = modal.selected_row().and_then(|r| r.remedy.clone());
match remedy {
None => {
modal.status =
Some("this finding carries no remedy — it is the diagnosis".into())
}
Some(remedy) => match doctor::dispatch(&remedy) {
doctor::RemedyDispatch::DeepLink(cmd) => {
app.health = None;
return run_command(app, cmd, agent, session);
}
doctor::RemedyDispatch::Interactive => {
app.pending_doctor_remedy = Some(remedy);
}
doctor::RemedyDispatch::Spawn => {
modal.confirm = Some(doctor::RemedyConfirm {
description: remedy.description,
argv: remedy.argv,
});
}
},
}
}
_ => {}
}
Ok(())
}
fn reload_doctor(app: &mut App) {
if app.health.is_none() {
return;
}
start_examination(app);
}
fn start_examination(app: &mut App) {
if app
.watches
.iter()
.any(|w| matches!(w, Watch::Examine { .. }))
{
if let Some(modal) = &mut app.health {
modal.examining = true;
}
return;
}
match doctor::spawn_examination() {
Ok(child) => {
if let Some(modal) = &mut app.health {
modal.examining = true;
}
app.watches.push(Watch::Examine {
child,
since: std::time::Instant::now(),
});
}
Err(e) => {
if let Some(modal) = &mut app.health {
modal.examining = false;
modal.status = Some(format!("doctor could not run: {e:#}"));
} else {
app.transcript.push(Entry::Error(format!("doctor: {e:#}")));
}
}
}
}
fn install_doctor_rows(app: &mut App, rows: Vec<doctor::FindingRow>) {
let (selected, detail, status) = match &app.health {
Some(m) => (m.selected, m.detail, m.status.clone()),
None => return,
};
let selected = selected.min(rows.len().saturating_sub(1));
app.health = Some(doctor::DoctorModal {
selected,
detail: detail && !rows.is_empty(),
status,
..doctor::DoctorModal::new(rows)
});
}
fn spawn_remedy(argv: &[String]) -> Result<std::process::Child> {
let (program, rest) = argv.split_first().context("a remedy with an empty argv")?;
let program: std::path::PathBuf = if program.as_str() == "mecha" {
std::env::current_exe().context("cannot find my own binary")?
} else {
program.into()
};
std::process::Command::new(program)
.args(rest)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("starting it")
}
fn suspend_and_run_remedy(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
app: &mut App,
remedy: &mecha_core::doctor::Remedy,
) -> Result<()> {
let argv_line = remedy.argv.join(" ");
let result = with_terminal_suspended(terminal, || run_remedy_interactive(&remedy.argv))?;
if let Some(modal) = &mut app.health {
modal.status = Some(match &result {
Ok(_) => format!("`{argv_line}` finished"),
Err(e) => format!("`{argv_line}` failed: {e}"),
});
}
if let Err(e) = result {
app.transcript
.push(Entry::Error(format!("remedy `{argv_line}` failed: {e}")));
}
reload_doctor(app);
Ok(())
}
fn run_remedy_interactive(argv: &[String]) -> Result<()> {
let (program, rest) = argv.split_first().context("a remedy with an empty argv")?;
let program: std::path::PathBuf = if program.as_str() == "mecha" {
std::env::current_exe().context("cannot find my own binary")?
} else {
program.into()
};
let status = std::process::Command::new(program)
.args(rest)
.status()
.context("running it")?;
if status.success() {
Ok(())
} else {
anyhow::bail!("exited with {status}")
}
}
fn self_cli(args: &[&str]) -> Result<String> {
let exe = std::env::current_exe().context("cannot find my own binary")?;
let out = std::process::Command::new(exe)
.args(args)
.output()
.with_context(|| format!("running mecha {}", args.first().unwrap_or(&"")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("{}", err.trim().lines().next().unwrap_or("failed"))
}
}
fn trigger_cli(args: &[&str]) -> Result<String> {
let mut full = vec!["trigger"];
full.extend_from_slice(args);
self_cli(&full)
}
fn spawn_detached(args: &[&str]) -> Result<()> {
let exe = std::env::current_exe().context("cannot find my own binary")?;
std::process::Command::new(exe)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("starting it")?;
Ok(())
}
fn with_terminal_suspended<T>(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
f: impl FnOnce() -> T,
) -> Result<T> {
disable_raw_mode()?;
if kitty_pushed() {
crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags)?;
}
crossterm::execute!(
std::io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
let result = f();
enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
if kitty_pushed() {
crossterm::execute!(
std::io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
}
terminal.clear()?;
Ok(result)
}
fn self_cli_interactive(args: &[&str]) -> Result<()> {
let exe = std::env::current_exe().context("cannot find my own binary")?;
let child = std::process::Command::new(exe)
.args(args)
.stderr(std::process::Stdio::piped())
.spawn()
.context("starting it")?;
let out = child.wait_with_output().context("waiting for it")?;
if out.status.success() {
Ok(())
} else {
let err = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("{}", err.trim().lines().next().unwrap_or("failed"))
}
}
fn suspend_and_edit(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
app: &mut App,
) -> Result<()> {
let result = with_terminal_suspended(terminal, || {
crate::editor::edit_text(
&app.input,
&format!("mecha-compose-{}.txt", std::process::id()),
)
})?;
match result {
Ok(text) => {
app.input = text.trim_end().to_string();
app.cursor = app.input.len();
}
Err(e) => app.transcript.push(Entry::Error(format!(
"editor: {e:#} — the input is unchanged"
))),
}
Ok(())
}
fn suspend_and_edit_trigger(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
app: &mut App,
name: &str,
) -> Result<()> {
let result = with_terminal_suspended(terminal, || {
self_cli_interactive(&["trigger", "edit", name])
})?;
if let Some(modal) = &mut app.scheduled {
modal.status = Some(match &result {
Ok(_) => format!("saved `{name}`"),
Err(e) => format!("`{name}` not saved: {e}"),
});
}
if let Err(e) = result {
app.transcript
.push(Entry::Error(format!("trigger `{name}` was not saved: {e}")));
}
reload_triggers(app);
Ok(())
}
fn suspend_and_edit_outbox(
terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>,
app: &mut App,
id: &str,
) -> Result<()> {
let result =
with_terminal_suspended(terminal, || self_cli_interactive(&["outbox", "edit", id]))?;
if let Some(modal) = &mut app.staged {
modal.status = Some(match &result {
Ok(_) => format!("edited `{id}` — send releases the new arguments"),
Err(e) => format!("`{id}` unchanged: {e}"),
});
}
if let Err(e) = result {
app.transcript
.push(Entry::Error(format!("outbox `{id}` was not edited: {e}")));
}
reload_outbox(app);
Ok(())
}
fn set_title(title: &str) {
let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::SetTitle(title));
}
fn workspace_name(app: &App) -> String {
app.workspace
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| app.workspace.display().to_string())
}
fn run_shell_escape(app: &mut App, agent: &Arc<Agent>, cmd: String) {
let workspace = agent.context().tools.workspace.clone();
let tx = app.shell_tx.clone();
app.transcript
.push(Entry::Notice(format!("running !{cmd}")));
tokio::spawn(async move {
let result = tokio::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.current_dir(&workspace)
.stdin(std::process::Stdio::null())
.output()
.await;
let entry = match result {
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
if !out.stderr.is_empty() {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&String::from_utf8_lossy(&out.stderr));
}
Entry::Shell {
cmd,
output: clip_output(&text),
status: out.status.code(),
}
}
Err(e) => Entry::Error(format!("!{cmd}: {e}")),
};
let _ = tx.send(entry);
});
}
fn clip_output(s: &str) -> String {
const MAX_LINES: usize = 200;
const MAX_BYTES: usize = 16_000;
let total = s.lines().count();
let mut out: String = if total <= MAX_LINES {
s.trim_end().to_string()
} else {
let mut kept: String = s.lines().take(MAX_LINES).collect::<Vec<_>>().join("\n");
kept.push_str(&format!("\n… ({} more lines)", total - MAX_LINES));
kept
};
if out.len() > MAX_BYTES {
let cut = (0..=MAX_BYTES)
.rev()
.find(|&i| out.is_char_boundary(i))
.unwrap_or(0);
let dropped = out.len() - cut;
out.truncate(cut);
out.push_str(&format!("\n… ({dropped} more bytes)"));
}
out
}
fn recall(app: &mut App, direction: i32) {
if app.history.is_empty() {
return;
}
let next = match (app.history_pos, direction) {
(None, -1) => Some(app.history.len() - 1),
(Some(i), -1) => Some(i.saturating_sub(1)),
(Some(i), 1) if i + 1 < app.history.len() => Some(i + 1),
(Some(_), 1) => None,
(None, _) => None,
_ => app.history_pos,
};
app.history_pos = next;
app.input = next.map(|i| app.history[i].clone()).unwrap_or_default();
app.cursor = app.input.len();
}
fn prev_boundary(s: &str, at: usize) -> Option<usize> {
s[..at].char_indices().next_back().map(|(i, _)| i)
}
fn next_boundary(s: &str, at: usize) -> usize {
s[at..].chars().next().map_or(at, |c| at + c.len_utf8())
}
fn input_layout(text: &str, cursor: usize, width: u16) -> (u16, u16, u16) {
let width = width.max(1);
let (mut col, mut row) = (0u16, 0u16);
let (mut cursor_col, mut cursor_row) = (0u16, 0u16);
for (offset, ch) in text.char_indices() {
if offset == cursor {
(cursor_col, cursor_row) = (col, row);
}
if ch == '\n' {
col = 0;
row += 1;
} else {
col += 1;
if col >= width {
col = 0;
row += 1;
}
}
}
if cursor >= text.len() {
(cursor_col, cursor_row) = (col, row);
}
(cursor_col, cursor_row, row + 1)
}
fn draw(
frame: &mut Frame,
app: &mut App,
model: &str,
provider: &str,
tools: usize,
todo: Option<&[mecha_core::tool::todo::TodoItem]>,
) {
let inner_width = frame.area().width.saturating_sub(2);
let (cursor_col, cursor_row, rows) = input_layout(&app.input, app.cursor, inner_width);
let input_height = rows.clamp(1, 6) + 2;
let todo = todo.filter(|items| app.todo_visible && !items.is_empty());
let todo_height = todo.map_or(0, |items| (items.len() as u16).min(8) + 2);
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(todo_height),
Constraint::Length(1),
Constraint::Length(input_height),
])
.split(frame.area());
app.transcript.draw(frame, chunks[0]);
if let Some(items) = todo {
draw_todo(frame, chunks[1], items);
}
frame.render_widget(
Paragraph::new(app.status(model, provider, tools)),
chunks[2],
);
let (border, hint) = match &app.running {
Some(run) if run.cancelling => (Color::Red, " stopping "),
Some(_) => (Color::Yellow, " steer "),
None => (Color::Cyan, " message "),
};
let (candidates, typed) = match command::at_token(&app.input, app.cursor) {
Some((_, partial)) => (
command::path_candidates(partial, &app.workspace),
partial.to_string(),
),
None => (
command::completions(&app.input)
.into_iter()
.map(str::to_string)
.collect(),
app.input.trim_start_matches('/').to_string(),
),
};
let ghost = command::common_prefix(&candidates)
.strip_prefix(&typed)
.unwrap_or_default()
.to_string();
let body = if ghost.is_empty() {
Line::from(app.input.as_str())
} else {
Line::from(vec![
Span::raw(app.input.as_str()),
Span::styled(ghost.clone(), Style::new().fg(Color::DarkGray)),
Span::styled(" tab", Style::new().fg(Color::DarkGray)),
])
};
let input = Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border))
.title(hint),
);
frame.render_widget(input, chunks[3]);
frame.set_cursor_position((
chunks[3].x + 1 + cursor_col,
chunks[3].y + 1 + cursor_row.min(rows.clamp(1, 6).saturating_sub(1)),
));
if !candidates.is_empty() && candidates.len() > 1 {
let shown = candidates.len().min(12);
let mut hint = format!(" {}", candidates[..shown].join(" "));
if candidates.len() > shown {
hint.push_str(&format!(" … +{}", candidates.len() - shown));
}
let area = Rect {
x: chunks[3].x,
y: chunks[3].y.saturating_sub(1),
width: chunks[3].width,
height: 1,
};
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(Line::styled(hint, Style::new().fg(Color::DarkGray))),
area,
);
}
if app.help {
draw_help(frame, app.kitty_keyboard);
}
if let Some(modal) = &app.tools {
modal.draw(frame);
}
if let Some(modal) = &app.scheduled {
modal.draw(frame);
}
if let Some(modal) = &app.staged {
modal.draw(frame);
}
if let Some(modal) = &app.requests {
modal.draw(frame);
}
if let Some(modal) = &app.mail {
modal.draw(frame);
}
if let Some(modal) = &app.poll_monitor {
modal.draw(frame);
}
if let Some(modal) = &app.health {
modal.draw(frame);
}
if let Some(question) = &app.asking {
draw_question(frame, question);
}
if let Some(picker) = &app.picker {
draw_picker(frame, picker);
}
if let Some(request) = &app.pending {
draw_approval(frame, request);
}
}
fn draw_todo(frame: &mut Frame, area: Rect, items: &[mecha_core::tool::todo::TodoItem]) {
use mecha_core::tool::todo::Status;
let done = items
.iter()
.filter(|i| i.status == Status::Completed)
.count();
let body: Vec<Line> = items
.iter()
.map(|item| {
let (marker, style) = match item.status {
Status::Completed => ("[x]", Style::new().fg(Color::DarkGray)),
Status::InProgress => ("[~]", Style::new().fg(Color::Yellow)),
Status::Pending => ("[ ]", Style::new().fg(Color::White)),
};
Line::styled(format!(" {marker} {}", item.content), style)
})
.collect();
let visible = area.height.saturating_sub(2).max(1) as usize;
let first_active = items
.iter()
.position(|i| i.status != Status::Completed)
.unwrap_or(0);
let scroll = (first_active + 1).saturating_sub(visible) as u16;
frame.render_widget(
Paragraph::new(body).scroll((scroll, 0)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray))
.title(format!(" todo {done}/{} · /todo hides ", items.len())),
),
area,
);
}
fn draw_help(frame: &mut Frame, kitty: bool) {
let newline_keys = if kitty {
"shift+enter · alt+enter"
} else {
"alt+enter"
};
let keys: Vec<(&str, String)> = vec![
("enter", "send · while running, steer the run".into()),
(newline_keys, "insert a newline".into()),
("tab", "complete a /command or an @path".into()),
("shift+tab", "toggle planning (writing tools hidden)".into()),
("^o", "show or hide thinking and tool output".into()),
("^c", "stop the run · twice at idle to quit".into()),
("^d", "quit, when the input is empty".into()),
("esc", "jump back to the newest output".into()),
("pgup pgdn wheel", "scroll the transcript".into()),
("↑ ↓", "input history".into()),
("?", "this overlay, on an empty line".into()),
(
"!command",
"run it locally — the model never sees it".into(),
),
("^g", "compose the input in $EDITOR".into()),
];
let mut body: Vec<Line> = keys
.iter()
.map(|(key, what)| {
Line::from(vec![
Span::styled(format!(" {key:<18}"), Style::new().fg(Color::Cyan)),
Span::styled(what.clone(), Style::new().fg(Color::White)),
])
})
.collect();
body.push(Line::raw(""));
for line in command::HELP.lines() {
body.push(Line::styled(
line.to_string(),
Style::new().fg(Color::DarkGray),
));
}
let area = centered(
frame.area(),
70,
(body.len() as u16)
.saturating_add(2)
.min(frame.area().height),
);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(" help · any key to close "),
),
area,
);
}
fn draw_question(frame: &mut Frame, q: &ask::Question) {
const WIDTH: u16 = 74;
let question_rows = (q.question.len() as u16 / (WIDTH - 2).max(1)) + 1;
let height = (q.options.len() as u16).clamp(0, 8) + question_rows + 5;
let area = centered(frame.area(), WIDTH, height);
frame.render_widget(Clear, area);
let mut body = vec![
Line::styled(q.question.as_str(), Style::new().fg(Color::White).bold()),
Line::raw(""),
];
for (i, option) in q.options.iter().enumerate() {
body.push(Line::from(vec![
Span::styled(
format!(" {} ", i + 1),
Style::new().fg(Color::Black).bg(Color::Green),
),
Span::raw(" "),
Span::styled(option.clone(), Style::new().fg(Color::White)),
]));
}
body.push(Line::raw(""));
body.push(Line::styled(
if q.options.is_empty() {
"type an answer and press enter · esc to let it decide"
} else {
"press a number, or type an answer · esc to let it decide"
},
Style::new().fg(Color::DarkGray),
));
frame.render_widget(
Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Green))
.title(" the agent is asking "),
),
area,
);
}
fn draw_picker(frame: &mut Frame, picker: &Picker) {
let height = (picker.items.len() as u16).clamp(1, 12) + 2;
let area = centered(frame.area(), 64, height);
frame.render_widget(Clear, area);
let body: Vec<Line> = picker
.items
.iter()
.enumerate()
.map(|(i, (label, _))| {
if i == picker.selected {
Line::styled(
format!("› {label}"),
Style::new().fg(Color::Black).bg(Color::Cyan),
)
} else {
Line::styled(format!(" {label}"), Style::new().fg(Color::White))
}
})
.collect();
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(picker.title.as_str()),
),
area,
);
}
fn draw_approval(frame: &mut Frame, request: &approve::Request) {
let area = centered(frame.area(), 70, 9);
frame.render_widget(Clear, area);
let body = vec![
Line::from(vec![Span::styled(
request.tool.as_str(),
Style::new().fg(Color::Magenta).bold(),
)]),
Line::raw(""),
Line::styled(request.summary.as_str(), Style::new().fg(Color::White)),
Line::raw(""),
Line::from(vec![
Span::styled("[y]", Style::new().fg(Color::Green).bold()),
Span::raw("es "),
Span::styled("[a]", Style::new().fg(Color::Green).bold()),
Span::raw("lways "),
Span::styled("[n]", Style::new().fg(Color::Red).bold()),
Span::raw("o"),
]),
];
frame.render_widget(
Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Yellow))
.title(" allow this? "),
),
area,
);
}
fn human_tokens(n: u64) -> String {
if n < 1000 {
n.to_string()
} else {
format!("{:.1}k", n as f64 / 1000.0)
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
}
}
static KITTY_PUSHED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn kitty_pushed() -> bool {
KITTY_PUSHED.load(std::sync::atomic::Ordering::SeqCst)
}
fn enter() -> Result<(Terminal<CrosstermBackend<std::io::Stdout>>, bool)> {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EndSynchronizedUpdate
);
if kitty_pushed() {
let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
}
let _ = crossterm::execute!(
std::io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
);
previous(info);
}));
enable_raw_mode().context("this needs a terminal")?;
let mut stdout = std::io::stdout();
crossterm::execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let kitty = matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
);
if kitty {
crossterm::execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
KITTY_PUSHED.store(true, std::sync::atomic::Ordering::SeqCst);
}
Ok((Terminal::new(CrosstermBackend::new(stdout))?, kitty))
}
fn leave(terminal: &mut Terminal<impl Backend<Error: Send + Sync + 'static>>) -> Result<()> {
set_title("");
disable_raw_mode()?;
if kitty_pushed() {
crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags)?;
KITTY_PUSHED.store(false, std::sync::atomic::Ordering::SeqCst);
}
crossterm::execute!(
std::io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
println!();
Ok(())
}
fn handle_mail_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.mail else {
return Ok(());
};
if let Some(input) = &mut modal.input {
match key.code {
KeyCode::Esc => {
modal.input = None;
}
KeyCode::Enter => {
let Some(input) = modal.input.take() else {
return Ok(());
};
let Some(row) = modal.rows.get(modal.selected) else {
return Ok(());
};
if input.buffer.trim().is_empty() {
modal.status = Some("nothing typed — cancelled".into());
return Ok(());
}
let (thread, account) = (row.thread_id.clone(), row.account.clone());
if input.verb == "forward" {
let to = input.buffer.trim().trim_end_matches(',').to_string();
if to.is_empty() {
modal.status = Some("no recipient — cancelled".into());
return Ok(());
}
spawn_draft(app, "forward", &thread, &account, Some(&to));
return Ok(());
}
let result = match input.verb {
"needs-info" => self_cli(&[
"mail",
"needs-info",
&thread,
"--account",
&account,
"--missing",
input.buffer.trim(),
]),
_ => self_cli(&[
"mail",
"correct",
&thread,
"--account",
&account,
"--bucket",
input.buffer.trim(),
]),
};
modal.status = Some(match result {
Ok(out) => out.lines().next().unwrap_or("done").to_string(),
Err(e) => format!("{e:#}"),
});
refresh_mail(app);
}
KeyCode::Backspace => input.backspace(),
KeyCode::Left => {
input.cursor = input.buffer[..input.cursor]
.chars()
.next_back()
.map(|c| input.cursor - c.len_utf8())
.unwrap_or(0);
}
KeyCode::Right => {
input.cursor = input.buffer[input.cursor..]
.chars()
.next()
.map(|c| input.cursor + c.len_utf8())
.unwrap_or(input.cursor);
}
KeyCode::Up if !input.contacts.is_empty() => {
input.pick = input.pick.saturating_sub(1);
}
KeyCode::Down if !input.contacts.is_empty() => {
let n = input.candidates().len();
input.pick = (input.pick + 1).min(n.saturating_sub(1));
}
KeyCode::Tab => {
let chosen = input
.candidates()
.get(input.pick)
.map(|c| c.address.clone());
if let Some(a) = chosen {
input.accept(&a);
}
}
KeyCode::Char(c) => input.insert(c),
_ => {}
}
return Ok(());
}
if modal.confirm.is_some() {
let yes = matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'));
modal.confirm = None;
if !yes {
modal.status = Some("cancelled".into());
return Ok(());
}
let Some(row) = modal.rows.get(modal.selected) else {
return Ok(());
};
let (thread, account) = (row.thread_id.clone(), row.account.clone());
let out = self_cli(&["mail", "spam", &thread, "--account", &account]);
modal.status = Some(match out {
Ok(o) => o.lines().next().unwrap_or("marked spam").to_string(),
Err(e) => format!("{e:#}"),
});
refresh_mail(app);
return Ok(());
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.mail = None;
}
KeyCode::Up | KeyCode::Char('k') => modal.move_by(-1),
KeyCode::Down | KeyCode::Char('j') => modal.move_by(1),
KeyCode::Enter => {
let Some(row) = modal.rows.get(modal.selected) else {
return Ok(());
};
let (thread, account) = (row.thread_id.clone(), row.account.clone());
modal.status = Some(
match self_cli(&["mail", "show", &thread, "--account", &account]) {
Ok(o) => o
.lines()
.find(|l| l.starts_with("subject:"))
.unwrap_or("read")
.to_string(),
Err(e) => format!("{e:#}"),
},
);
}
KeyCode::Char(c) => {
let Some(action) = mail::action_for(c) else {
return Ok(());
};
let Some(row) = modal.rows.get(modal.selected) else {
return Ok(());
};
let (thread, account) = (row.thread_id.clone(), row.account.clone());
match action {
mail::Action::Close => app.mail = None,
mail::Action::Confirm(verb) => {
modal.confirm = Some(format!("mark as {verb}? trains the filter — y/N"));
}
mail::Action::Prompt(verb, label) => {
modal.input = Some(mail::MailInput::text(label, verb));
}
mail::Action::Now(verb) => {
modal.status = Some(format!("{verb}…"));
let out = self_cli(&["mail", verb, &thread, "--account", &account]);
modal.status = Some(match out {
Ok(o) => o.lines().next().unwrap_or("done").to_string(),
Err(e) => format!("{e:#}"),
});
refresh_mail(app);
}
mail::Action::Recipients(verb) => {
let mine = mecha_core::mail_triage::TriageStore::open_existing_default()
.and_then(|s| s.list().ok())
.map(|rows| mecha_core::mail_triage::contacts(&rows, &[]))
.unwrap_or_default();
modal.input = Some(mail::MailInput::recipients("forward to", verb, mine));
}
mail::Action::Detached(verb) => {
spawn_draft(app, verb, &thread, &account, None);
}
}
}
_ => {}
}
Ok(())
}
fn refresh_mail(app: &mut App) {
let (selected, status) = match &app.mail {
Some(m) => (m.selected, m.status.clone()),
None => return,
};
if let Ok(rows) = mail::load() {
let mut modal = mail::MailModal::new(rows);
modal.selected = selected.min(modal.rows.len().saturating_sub(1));
modal.status = status;
app.mail = Some(modal);
}
}
fn spawn_draft(app: &mut App, verb: &str, thread: &str, account: &str, to: Option<&str>) {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => {
if let Some(m) = &mut app.mail {
m.status = Some(format!("cannot find my own binary: {e}"));
}
return;
}
};
let mut args: Vec<String> = vec![
"mail".into(),
verb.into(),
thread.into(),
"--account".into(),
account.into(),
];
if let Some(to) = to {
args.push("--to".into());
args.push(to.into());
}
let spawned = std::process::Command::new(exe)
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if let Some(m) = &mut app.mail {
m.input = None;
m.status = Some(match spawned {
Ok(_) => format!("{verb} drafting in the background — watch /outbox"),
Err(e) => format!("could not start {verb}: {e}"),
});
}
}
#[cfg(test)]
mod tests {
use super::input_layout;
use super::*;
use ratatui::backend::TestBackend;
use super::Picker;
fn test_app() -> App {
let (shell_tx, _shell_rx) = mpsc::unbounded_channel();
std::mem::forget(_shell_rx);
App {
transcript: Transcript::new(false),
input: String::new(),
cursor: 0,
history: Vec::new(),
history_pos: None,
convo: Conversation::new(),
running: None,
pending: None,
usage: Usage::default(),
prompt_tokens: 0,
context_window: None,
should_quit: false,
quit_armed: false,
pending_switch: None,
mode: PermissionMode::Ask,
mcp_on: false,
mcp_servers: Vec::new(),
phase: Phase::default(),
asking: None,
picker: None,
help: false,
tools: None,
sandbox_line: "sandbox: none — commands run as you, with your credentials".into(),
workspace: std::env::temp_dir(),
todo_visible: true,
pending_editor: false,
scheduled: None,
staged: None,
requests: None,
mail: None,
poll_monitor: None,
health: None,
pending_doctor_remedy: None,
pending_trigger_edit: None,
pending_outbox_edit: None,
outbox_pending: 0,
review: command::ReviewMode::default(),
watches: Vec::new(),
shell_tx,
providers: Vec::new(),
kitty_keyboard: false,
}
}
fn frame_text(app: &mut App, width: u16, height: u16, todo: Option<&[TodoItem]>) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw(frame, app, "test-model", "test-provider", 3, todo))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
use mecha_core::tool::todo::{Status, TodoItem};
#[test]
fn the_status_line_reads_idle_context_and_scrolled() {
let mut app = test_app();
let idle = frame_text(&mut app, 80, 12, None);
assert!(idle.contains("test-model"), "{idle}");
assert!(idle.contains("0 in / 0 out"), "{idle}");
app.prompt_tokens = 29_300;
app.context_window = Some(32_800);
let gauged = frame_text(&mut app, 80, 12, None);
assert!(gauged.contains("context 29.3k/32.8k (89%)"), "{gauged}");
for i in 0..40 {
app.transcript.push(Entry::Notice(format!("line {i}")));
}
app.transcript.scroll_up(5);
let scrolled = frame_text(&mut app, 110, 12, None);
assert!(scrolled.contains("scrolled"), "{scrolled}");
app.transcript.jump_to_bottom();
let followed = frame_text(&mut app, 110, 12, None);
assert!(!followed.contains("scrolled"), "{followed}");
}
#[tokio::test]
async fn a_running_frame_shows_the_timer_and_the_steering_hint() {
let mut app = test_app();
app.running = Some(Running {
handle: tokio::spawn(async { std::future::pending::<RunResult>().await }),
cancel: CancellationToken::new(),
queue: Arc::new(Mutex::new(VecDeque::new())),
started: std::time::Instant::now(),
cancelling: false,
persisted: Vec::new(),
outbox_before: None,
});
let text = frame_text(&mut app, 80, 12, None);
assert!(text.contains("working"), "{text}");
assert!(text.contains("type to steer"), "{text}");
}
#[test]
fn the_help_overlay_advertises_the_newline_key_only_where_it_exists() {
let mut app = test_app();
app.help = true;
let plain = frame_text(&mut app, 100, 36, None);
assert!(plain.contains("alt+enter"), "{plain}");
assert!(!plain.contains("shift+enter"), "{plain}");
assert!(
plain.contains("/clear"),
"commands render from HELP: {plain}"
);
app.kitty_keyboard = true;
let kitty = frame_text(&mut app, 100, 36, None);
assert!(kitty.contains("shift+enter"), "{kitty}");
}
#[test]
fn the_outbox_badge_appears_only_when_something_is_pending() {
let mut app = test_app();
let clear = frame_text(&mut app, 110, 12, None);
assert!(!clear.contains("outbox"), "{clear}");
app.outbox_pending = 3;
let badged = frame_text(&mut app, 110, 12, None);
assert!(badged.contains("outbox 3"), "{badged}");
}
#[test]
fn the_outbox_confirm_puts_a_tainted_drafts_arguments_on_screen() {
let mut app = test_app();
app.staged = Some(outbox::OutboxModal {
confirm: Some(outbox::SendConfirm {
id: "abc123".into(),
summary: "mail to a@example.com".into(),
tainted: true,
args_text: "{\n \"to\": \"a@example.com\"\n}".into(),
error_before: None,
}),
..outbox::OutboxModal::new(Vec::new())
});
let frame = frame_text(&mut app, 110, 35, None);
assert!(frame.contains("attacker"), "{frame}");
assert!(frame.contains("a@example.com"), "{frame}");
assert!(frame.contains("y sends it for real"), "{frame}");
app.staged.as_mut().unwrap().confirm = Some(outbox::SendConfirm {
id: "abc123".into(),
summary: "mail to a@example.com".into(),
tainted: false,
args_text: String::new(),
error_before: None,
});
let frame = frame_text(&mut app, 110, 35, None);
assert!(!frame.contains("attacker"), "{frame}");
assert!(frame.contains("send abc123"), "{frame}");
}
#[test]
fn the_tools_modal_detail_spells_the_declared_surface_out() {
let mut app = test_app();
app.tools = Some(tools::ToolsModal {
rows: vec![tools::ToolRow {
name: "shell".into(),
read_only: false,
outbox: false,
caps: mecha_core::tool::Capabilities {
private_data: true,
..Default::default()
},
description: "Run a command.".into(),
}],
selected: 0,
detail: true,
sandbox_line: app.sandbox_line.clone(),
});
let text = frame_text(&mut app, 100, 30, None);
assert!(
text.contains("reads data the user considers private"),
"{text}"
);
assert!(
text.contains("sandbox: none"),
"shell's detail names the sandbox: {text}"
);
}
#[test]
fn the_todo_pane_appears_with_content_clamps_and_can_be_vetoed() {
let mut app = test_app();
let items: Vec<TodoItem> = (0..12)
.map(|i| TodoItem {
content: format!("step {i}"),
status: if i < 2 {
Status::Completed
} else {
Status::Pending
},
})
.collect();
let text = frame_text(&mut app, 80, 24, Some(&items));
assert!(text.contains("todo 2/12"), "{text}");
let shown = (0..12)
.filter(|i| text.contains(&format!("step {i}")))
.count();
assert!(
shown <= 8,
"expected at most 8 items on screen, saw {shown}:\n{text}"
);
let empty = frame_text(&mut app, 80, 24, Some(&[]));
assert!(!empty.contains("todo"), "{empty}");
app.todo_visible = false;
let vetoed = frame_text(&mut app, 80, 24, Some(&items));
assert!(!vetoed.contains("todo 2/12"), "{vetoed}");
}
#[test]
fn shell_output_is_clipped_on_both_axes() {
let many = (0..500)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let clipped = super::clip_output(&many);
assert!(
clipped.lines().count() <= 201,
"kept {} lines",
clipped.lines().count()
);
assert!(clipped.contains("more lines"), "{clipped}");
let huge = "x".repeat(100_000);
let clipped = super::clip_output(&huge);
assert!(clipped.len() < 17_000, "kept {} bytes", clipped.len());
assert!(clipped.contains("more bytes"), "says what was dropped");
let unicode = "é".repeat(20_000);
let clipped = super::clip_output(&unicode);
assert!(clipped.len() < 17_000);
}
#[test]
fn nested_subagent_calls_indent_under_their_parent() {
let mut app = test_app();
app.transcript.absorb(&AgentEvent::ToolCall {
id: "p".into(),
name: "helper".into(),
input: serde_json::json!({}),
});
app.transcript.absorb(&AgentEvent::Nested {
tool: "helper".into(),
id: Some("p".into()),
event: Box::new(AgentEvent::ToolCall {
id: "c".into(),
name: "echo".into(),
input: serde_json::json!({}),
}),
});
let text = frame_text(&mut app, 80, 12, None);
let parent = text.lines().find(|l| l.contains("helper")).unwrap();
let child = text.lines().find(|l| l.contains("echo")).unwrap();
assert!(parent.starts_with("● "), "parent at the margin: {parent:?}");
assert!(child.starts_with(" ● "), "child one level in: {child:?}");
}
fn picker(n: usize) -> Picker {
Picker {
title: String::new(),
items: (0..n)
.map(|i| (i.to_string(), super::command::Command::Usage))
.collect(),
selected: 0,
}
}
#[test]
fn the_selection_wraps_at_both_ends() {
let mut p = picker(3);
p.move_by(1);
assert_eq!(p.selected, 1);
p.move_by(1);
p.move_by(1);
assert_eq!(p.selected, 0, "did not wrap forwards");
p.move_by(-1);
assert_eq!(p.selected, 2, "did not wrap backwards");
}
#[test]
fn an_empty_list_does_not_panic_or_move() {
let mut p = picker(0);
p.move_by(1);
p.move_by(-1);
assert_eq!(p.selected, 0);
}
#[test]
fn the_cursor_tracks_plain_wrapping() {
assert_eq!(input_layout("abcdefghijk", 11, 10), (1, 1, 2));
assert_eq!(input_layout("abc", 3, 10), (3, 0, 1));
assert_eq!(input_layout("", 0, 10), (0, 0, 1));
}
#[test]
fn a_pasted_newline_breaks_the_line_instead_of_being_counted_as_a_character() {
let text = "one\ntwo";
assert_eq!(input_layout(text, text.len(), 40), (3, 1, 2));
let three = "a\nb\nc";
assert_eq!(input_layout(three, three.len(), 40), (1, 2, 3));
}
#[test]
fn a_cursor_in_the_middle_of_pasted_text_lands_on_the_right_row() {
let text = "one\ntwo\nthree";
assert_eq!(input_layout(text, 8, 40), (0, 2, 3));
}
#[test]
fn a_zero_width_terminal_does_not_divide_by_zero() {
let (_, _, rows) = input_layout("abc", 3, 0);
assert!(rows >= 1);
}
}