mod approve;
mod ask;
mod command;
mod docs;
mod doctor;
mod entity;
mod find;
mod frontdoor;
mod mail;
mod outbox;
mod polls;
pub(crate) mod queues;
mod skills;
mod tasks;
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::{Block as MsgBlock, 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::path::PathBuf;
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 AttachOutcome {
Attached(Box<crate::slack::remote::Attached>, String),
Detached(String),
Failed {
name: Option<String>,
error: 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,
},
MailRead {
rx: std::sync::mpsc::Receiver<Result<String>>,
handle: String,
since: std::time::Instant,
},
MailAction {
rx: std::sync::mpsc::Receiver<Result<String>>,
verb: String,
handle: String,
since: std::time::Instant,
},
KgNote {
rx: std::sync::mpsc::Receiver<Result<String>>,
since: std::time::Instant,
},
KgSearch {
rx: std::sync::mpsc::Receiver<Result<String>>,
query: String,
since: std::time::Instant,
},
KgEntity {
rx: std::sync::mpsc::Receiver<Result<String>>,
name: String,
since: std::time::Instant,
},
QueuesGroups {
rx: std::sync::mpsc::Receiver<Result<String>>,
class: Option<(String, String)>,
since: std::time::Instant,
},
Docs {
rx: std::sync::mpsc::Receiver<Result<String>>,
job: DocsJob,
since: std::time::Instant,
},
RestartProbe {
rx: std::sync::mpsc::Receiver<bool>,
argv: Vec<String>,
unit: String,
since: std::time::Instant,
},
}
#[derive(Clone, Copy, PartialEq)]
enum DocsJob {
List,
PickUrl,
PickDone,
}
struct Live {
agent: Arc<Agent>,
model: String,
provider: String,
opts: GlobalOpts,
todo: Option<Arc<mecha_core::tool::todo::TodoTool>>,
skill: Option<Arc<mecha_core::tool::skill::SkillTool>>,
_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,
skill: p.skill,
_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>,
selecting: bool,
help: bool,
help_scroll: u16,
tools: Option<tools::ToolsModal>,
skills: Option<skills::SkillsModal>,
skills_dir: PathBuf,
scheduled: Option<triggers::TriggersModal>,
staged: Option<outbox::OutboxModal>,
requests: Option<frontdoor::FrontdoorModal>,
mail: Option<mail::MailModal>,
find: Option<find::FindModal>,
documents: Option<docs::DocsModal>,
entities: Option<entity::EntityModal>,
tasks: Option<tasks::TasksModal>,
queues: Option<queues::QueuesModal>,
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>,
attach_tx: mpsc::UnboundedSender<AttachOutcome>,
attached: Option<crate::slack::remote::Attached>,
dropped: Vec<(String, MsgBlock)>,
last_waiting_note: Option<std::time::Instant>,
attaching: Option<String>,
sandbox_line: String,
workspace: std::path::PathBuf,
todo_visible: bool,
pending_editor: bool,
providers: Vec<(String, String)>,
kitty_keyboard: bool,
}
impl App {
fn wants_the_mouse_back(&self) -> bool {
self.selecting || self.a_modal_is_up()
}
fn a_modal_is_up(&self) -> bool {
self.help
|| self.pending.is_some()
|| self.asking.is_some()
|| self.picker.is_some()
|| self.tools.is_some()
|| self.skills.is_some()
|| self.scheduled.is_some()
|| self.staged.is_some()
|| self.requests.is_some()
|| self.mail.is_some()
|| self.documents.is_some()
|| self.entities.is_some()
|| self.tasks.is_some()
|| self.queues.is_some()
|| self.poll_monitor.is_some()
|| self.health.is_some()
}
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),
));
}
if self.selecting {
spans.push(Span::styled(
" select ^S ",
Style::new().fg(Color::Black).bg(Color::Blue),
));
}
if let Some(a) = &self.attached {
spans.push(Span::styled(
format!(" ⇄ {} ", a.name),
Style::new().fg(Color::Black).bg(Color::Green),
));
}
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?;
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,
},
)?);
}
install_frontend_tools(&mut prepared.agent, &asker, session.as_ref());
if let Some(s) = &session {
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 (attach_tx, mut attach_rx) = mpsc::unbounded_channel::<AttachOutcome>();
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,
selecting: false,
help: false,
help_scroll: 0,
tools: None,
skills: None,
skills_dir: prepared
.config
.skills
.dir
.clone()
.or_else(|| mecha_core::skill::SkillStore::default_dir().ok())
.unwrap_or_default(),
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,
find: None,
documents: None,
entities: None,
tasks: None,
queues: 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,
attach_tx,
attached: None,
dropped: Vec::new(),
attaching: None,
last_waiting_note: None,
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()?;
crate::logs::capture();
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,
&mut attach_rx,
session.as_ref(),
&approver,
&asker,
)
.await;
leave(&mut terminal)?;
for line in crate::logs::release() {
eprintln!("{line}");
}
if let Some(a) = app.attached.take() {
if let Err(e) = crate::slack::remote::detach(&a, "the terminal session ended").await {
eprintln!("could not close the Slack thread for `{}`: {e:#}", a.name);
}
}
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
}
fn approver_for(mode: PermissionMode, retained: &Arc<dyn Approver>) -> Arc<dyn Approver> {
match mode {
PermissionMode::Ask => Arc::clone(retained),
other => Arc::new(ModeApprover { mode: other }),
}
}
fn install_frontend_tools(
agent: &mut mecha_core::agent::Agent,
asker: &Arc<dyn mecha_core::tool::ask::Asker>,
session: Option<&Session>,
) {
agent
.registry_mut()
.insert(Arc::new(mecha_core::tool::ask::AskUserTool::new(
Arc::clone(asker),
)));
agent
.registry_mut()
.insert(Arc::new(crate::slack::show::ShowFileTool));
if let Some(s) = session {
setup::register_recall(agent, s);
}
}
#[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>,
attach_results: &mut mpsc::UnboundedReceiver<AttachOutcome>,
session: Option<&Session>,
approver: &Arc<dyn Approver>,
asker: &Arc<dyn mecha_core::tool::ask::Asker>,
) -> Result<()> {
let mut keys = EventStream::new();
let (mut events_tx, mut events_rx) = mpsc::unbounded_channel::<AgentEvent>();
const INBOUND_EVERY: std::time::Duration = std::time::Duration::from_millis(1_000);
let mut last_inbound = std::time::Instant::now();
loop {
for line in crate::logs::drain() {
app.transcript.push(if crate::logs::is_alarming(&line) {
Entry::Error(line)
} else {
Entry::Notice(line)
});
}
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());
sync_mouse_capture(!app.wants_the_mouse_back());
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, asker, 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(());
}
if app.attached.is_some() && last_inbound.elapsed() >= INBOUND_EVERY {
last_inbound = std::time::Instant::now();
deliver_inbound(app, live, session, &mut events_tx, &mut events_rx);
}
let tick = tokio::time::sleep(std::time::Duration::from_millis(if app.running.is_some() {
200
} else if !app.watches.is_empty() || app.attached.is_some() {
1_000
} else {
60_000
}));
tokio::select! {
Some(Ok(event)) = keys.next() => on_terminal_event(app, event, &mut events_tx, &mut events_rx, live, 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() => {
let quiet_for = app
.last_waiting_note
.map(|t| t.elapsed() >= std::time::Duration::from_secs(60))
.unwrap_or(true);
if let (Some(a), true) = (&app.attached, quiet_for) {
app.last_waiting_note = Some(std::time::Instant::now());
spawn_note(
a,
&format!(
"Waiting for you at the terminal: `{}` needs approval. \
`/mode allow` there, or answer the prompt.",
request.tool
),
);
}
app.pending = Some(request);
}
Some(question) = questions.recv() => app.asking = Some(question),
Some(entry) = shell_results.recv() => app.transcript.push(entry),
Some(outcome) = attach_results.recv() => match outcome {
AttachOutcome::Attached(a, notice) => {
if app.attaching.as_deref() == Some(a.name.as_str()) {
app.attaching = None;
}
app.transcript.push(Entry::Notice(notice));
app.attached = Some(*a);
}
AttachOutcome::Detached(notice) => {
app.transcript.push(Entry::Notice(notice));
app.attached = None;
}
AttachOutcome::Failed { name, error } => {
if name.is_some() && app.attaching == name {
app.attaching = None;
}
app.transcript.push(Entry::Error(error));
}
},
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());
let leftover: Vec<String> = app
.running
.as_mut()
.and_then(|r| r.queue.lock().ok().map(|mut q| q.drain(..).collect()))
.unwrap_or_default();
let cancelled = app.running.as_ref().is_some_and(|r| r.cancelling);
finish_run(app, outcome, persisted, baseline, session)?;
if cancelled && !leftover.is_empty() {
app.transcript.push(Entry::Notice(format!(
"dropped {} queued line(s) — the run was stopped",
leftover.len()
)));
} else if !leftover.is_empty() {
app.transcript.push(Entry::Notice(
"the run ended before folding these in — sending them now".into(),
));
let carried = leftover.join("\n");
if let Err(e) =
submit(app, carried, Vec::new(), &mut events_tx, &mut events_rx, live, session, true)
{
app.transcript
.push(Entry::Error(format!("could not carry steering over: {e:#}")));
}
}
}
_ = 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::MailRead { rx, handle, since } => match rx.try_recv() {
Ok(Ok(text)) => match &mut app.mail {
Some(modal) => {
modal.loading = None;
modal.status = None;
modal.reading = Some(mail::Reader::new(handle, &text));
}
None => app.transcript.push(Entry::Notice(format!(
"{handle} finished loading after /mail closed"
))),
},
Ok(Err(e)) => {
let line = format!("could not read {handle}: {e:#}");
match &mut app.mail {
Some(modal) => {
modal.loading = None;
modal.status = Some(line);
}
None => app.transcript.push(Entry::Error(line)),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
if let Some(modal) = &mut app.mail {
modal.loading = None;
modal.status =
Some(format!("{handle} never answered — enter tries again"));
}
} else {
app.watches.push(Watch::MailRead { rx, handle, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(modal) = &mut app.mail {
modal.loading = None;
modal.status = Some(format!("the read of {handle} was lost"));
}
}
},
Watch::MailAction {
rx,
verb,
handle,
since,
} => match rx.try_recv() {
Ok(out) => {
let line = match out {
Ok(o) => o.lines().next().unwrap_or("done").to_string(),
Err(e) => format!("{verb} {handle}: {e:#}"),
};
match &mut app.mail {
Some(modal) => modal.status = Some(line),
None => app.transcript.push(Entry::Notice(line)),
}
refresh_mail(app);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
if let Some(modal) = &mut app.mail {
modal.status =
Some(format!("{verb} {handle} never answered — check /doctor"));
}
} else {
app.watches.push(Watch::MailAction {
rx,
verb,
handle,
since,
});
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(modal) = &mut app.mail {
modal.status = Some(format!("{verb} {handle} was lost"));
}
}
},
Watch::KgNote { rx, since } => match rx.try_recv() {
Ok(out) => app.transcript.push(match out {
Ok(o) => Entry::Notice(o.lines().next().unwrap_or("noted").to_string()),
Err(e) => Entry::Error(format!("note failed: {e:#}")),
}),
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
app.transcript.push(Entry::Error(
"the note never answered — check /doctor".into(),
));
} else {
app.watches.push(Watch::KgNote { rx, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.transcript
.push(Entry::Error("the note was lost".into()));
}
},
Watch::KgSearch { rx, query, since } => match rx.try_recv() {
Ok(out) => {
let Some(m) = &mut app.find else { continue };
m.loading = false;
match out.and_then(|t| find::rows_from_pack(&t)) {
Ok(rows) => {
m.status = Some(match rows.len() {
0 => format!("nothing found for `{query}`"),
n => format!("{n} result(s) for `{query}`"),
});
m.rows = rows;
m.selected = 0;
m.typing = false;
}
Err(e) => m.status = Some(format!("search failed: {e:#}")),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
if let Some(m) = &mut app.find {
m.loading = false;
m.status = Some("the search never answered — check /doctor".into());
}
} else {
app.watches.push(Watch::KgSearch { rx, query, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(m) = &mut app.find {
m.loading = false;
m.status = Some("the search was lost".into());
}
}
},
Watch::KgEntity { rx, name, since } => match rx.try_recv() {
Ok(out) => {
let Some(m) = &mut app.find else { continue };
m.loading = false;
match out.and_then(|t| find::entity_detail(&t)) {
Ok((title, lines)) => {
m.detail = Some((title, lines));
m.scroll = 0;
}
Err(e) => m.status = Some(format!("entity {name}: {e:#}")),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
if let Some(m) = &mut app.find {
m.loading = false;
m.status = Some(format!("{name} never answered — check /doctor"));
}
} else {
app.watches.push(Watch::KgEntity { rx, name, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(m) = &mut app.find {
m.loading = false;
m.status = Some("the fetch was lost".into());
}
}
},
Watch::QueuesGroups { rx, class, since } => match rx.try_recv() {
Ok(Ok(text)) => {
let what = match &class {
Some((p, pr)) => format!("{p} · {pr}"),
None => "the whole queue".into(),
};
let entry_level = match &class {
Some(_) => queues::Level::Candidates,
None => queues::Level::Proposers,
};
match &mut app.queues {
Some(m)
if m.level == entry_level
|| (m.level == queues::Level::Groups && m.item_class == class) =>
{
match queues::groups_from_json(&text) {
Ok((threshold, rows)) => {
let n = rows.len();
m.level = queues::Level::Groups;
m.groups = rows;
m.group_threshold = threshold;
m.item_class = class;
m.selected = 0;
m.status = Some(match n {
0 => "nothing repeats above the threshold".into(),
n => format!(
"{n} group(s) at cosine ≥ {threshold:.2} — \
a/r verdicts a whole group, [/] adjusts"
),
});
}
Err(e) => m.status = Some(format!("groups: {e:#}")),
}
}
Some(m) => {
m.status = Some(format!("groups of {what} ready — s re-opens them"));
}
None => app.transcript.push(Entry::Notice(format!(
"grouping {what} finished after /queues closed"
))),
}
}
Ok(Err(e)) => {
let what = match &class {
Some((p, pr)) => format!("{p} · {pr}"),
None => "the whole queue".into(),
};
let line = format!("grouping {what} failed: {e:#}");
match &mut app.queues {
Some(m) => m.status = Some(line),
None => app.transcript.push(Entry::Error(line)),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
let cap = match &class {
Some(_) => doctor::EXAMINE_CAP,
None => std::time::Duration::from_secs(360),
};
if since.elapsed() > cap {
if let Some(m) = &mut app.queues {
m.status = Some("grouping never answered — is :8081 up?".into());
}
} else {
app.watches.push(Watch::QueuesGroups { rx, class, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(m) = &mut app.queues {
m.status = Some("the grouping was lost".into());
}
}
},
Watch::Docs { rx, job, since } => match rx.try_recv() {
Ok(answer) => install_docs_answer(app, job, answer),
Err(std::sync::mpsc::TryRecvError::Empty) => {
if since.elapsed() > doctor::EXAMINE_CAP {
if let Some(modal) = &mut app.documents {
modal.loading = false;
if let Some(pick) = &mut modal.pick {
pick.working = false;
}
modal.status = Some("mecha-docs never answered — r tries again".into());
}
} else {
app.watches.push(Watch::Docs { rx, job, since });
}
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(modal) = &mut app.documents {
modal.loading = false;
if let Some(pick) = &mut modal.pick {
pick.working = false;
}
modal.status = Some("the call to mecha-docs was lost".into());
}
}
},
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.skills.is_some()
|| app.scheduled.is_some()
|| app.staged.is_some()
|| app.requests.is_some()
|| app.mail.is_some()
|| app.find.is_some()
|| app.documents.is_some()
|| app.entities.is_some()
|| app.tasks.is_some()
|| app.queues.is_some()
|| app.poll_monitor.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>,
live: &Live,
session: Option<&Session>,
) -> Result<()> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
on_key(app, key, events_tx, events_rx, live, session)
}
Event::Paste(text) => {
app.quit_armed = false;
if paste_into_pick(app, &text) {
return Ok(());
}
let insert = match dropped_images(app, &text, live) {
Some(chips) => chips,
None => text,
};
app.input.insert_str(app.cursor, &insert);
app.cursor += insert.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 dropped_images(app: &mut App, paste: &str, live: &Live) -> Option<String> {
let paths = drop_paths(paste)?;
if !live.agent.vision() {
app.transcript.push(Entry::Notice(format!(
"{} cannot see images — dropped the path instead. See `[providers.*] vision`.",
live.agent.model()
)));
return None;
}
let mut chips = String::new();
let mut attached = 0usize;
let mut bytes = 0usize;
for path in &paths {
match mecha_core::image::block_from_path(path) {
Ok(Some(block)) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
if let MsgBlock::Image { data, .. } = &block {
bytes += data.len() / 4 * 3;
}
let chip = format!("[image: {name}]");
if !chips.is_empty() {
chips.push(' ');
}
chips.push_str(&chip);
app.dropped.push((chip, block));
attached += 1;
}
Ok(None) => return None,
Err(e) => {
app.transcript.push(Entry::Error(format!(
"could not take {}: {e:#}",
path.display()
)));
return None;
}
}
}
if attached == 0 {
return None;
}
app.transcript.push(Entry::Notice(format!(
"⇄ {attached} image{} attached · {}",
if attached == 1 { "" } else { "s" },
human_bytes(bytes)
)));
Some(chips)
}
fn drop_paths(paste: &str) -> Option<Vec<std::path::PathBuf>> {
let trimmed = paste.trim();
if trimmed.is_empty() || trimmed.contains('\n') {
return None;
}
let whole = std::path::PathBuf::from(trimmed);
if whole.is_file() {
return Some(vec![whole]);
}
let mut out = Vec::new();
for token in split_drop_tokens(trimmed) {
let path = std::path::PathBuf::from(&token);
if !path.is_file() {
return None;
}
out.push(path);
}
(!out.is_empty()).then_some(out)
}
fn split_drop_tokens(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for c in s.chars() {
if escaped {
cur.push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
} else if let Some(q) = quote {
if c == q {
quote = None;
} else {
cur.push(c);
}
} else if c == '\'' || c == '"' {
quote = Some(c);
} else if c.is_whitespace() {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn human_bytes(b: usize) -> String {
if b >= 1024 * 1024 {
format!("{:.1} MB", b as f64 / (1024.0 * 1024.0))
} else {
format!("{:.0} KB", b as f64 / 1024.0)
}
}
fn take_dropped(app: &mut App, text: &str) -> Vec<MsgBlock> {
std::mem::take(&mut app.dropped)
.into_iter()
.filter(|(chip, _)| text.contains(chip.as_str()))
.map(|(_, block)| block)
.collect()
}
fn on_key(
app: &mut App,
key: KeyEvent,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
live: &Live,
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::Up => modal.scroll_detail(-1),
KeyCode::Down => modal.scroll_detail(1),
KeyCode::PageUp => modal.scroll_detail(-10),
KeyCode::PageDown => modal.scroll_detail(10),
KeyCode::Enter => modal.detail = !modal.detail,
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.tools = None;
}
}
_ => {}
}
return Ok(());
}
if let Some(modal) = &mut app.skills {
match key.code {
KeyCode::Up if !modal.detail => modal.move_by(-1),
KeyCode::Down if !modal.detail => modal.move_by(1),
KeyCode::Up => modal.scroll_detail(-1),
KeyCode::Down => modal.scroll_detail(1),
KeyCode::Enter => modal.toggle_detail(),
KeyCode::Esc | KeyCode::Char('q') => {
if modal.detail {
modal.detail = false;
} else {
app.skills = 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.find.is_some() {
return handle_find_key(app, key);
}
if app.documents.is_some() {
return handle_docs_key(app, key);
}
if app.entities.is_some() {
return handle_entity_key(app, key);
}
if app.queues.is_some() {
return handle_queues_key(app, key);
}
if app.tasks.is_some() {
return handle_tasks_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, live, 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, live, session);
}
}
}
_ => {}
}
return Ok(());
}
if app.help {
match key.code {
KeyCode::Up | KeyCode::PageUp => {
app.help_scroll = app.help_scroll.saturating_sub(1);
return Ok(());
}
KeyCode::Down | KeyCode::PageDown => {
app.help_scroll = app.help_scroll.saturating_add(1);
return Ok(());
}
_ => {}
}
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('s') if ctrl => {
app.selecting = !app.selecting;
app.transcript.push(Entry::Notice(if app.selecting {
"selecting — drag to select, your terminal's own copy · ^S gives the wheel back"
.into()
} else {
"selecting off — the wheel scrolls again".into()
}));
}
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;
let images = take_dropped(app, &text);
submit(
app, text, images, events_tx, events_rx, live, session, false,
)?;
}
}
KeyCode::Char('?') if app.input.is_empty() => {
app.help = true;
app.help_scroll = 0;
}
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(())
}
#[allow(clippy::too_many_arguments)]
async fn apply_switch(
switch: Switch,
app: &mut App,
live: &mut Live,
approver: &Arc<dyn Approver>,
asker: &Arc<dyn mecha_core::tool::ask::Asker>,
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(());
};
agent.set_approver(approver_for(mode, approver));
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, approver_for(app.mode, approver)).await
{
Ok(p) => p,
Err(e) => {
app.transcript.push(Entry::Error(format!(
"could not switch: {e:#} — staying on {}",
live.model
)));
return Ok(());
}
};
let mut prepared = prepared;
install_frontend_tools(&mut prepared.agent, asker, session);
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,
live: &Live,
session: Option<&Session>,
) -> Result<()> {
use command::Command;
let agent = &live.agent;
let mut say = |text: String| app.transcript.push(Entry::Notice(text));
match cmd {
Command::Help => {
app.help = true;
app.help_scroll = 0;
}
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,
detail_scroll: 0,
sandbox_line: app.sandbox_line.clone(),
});
}
Command::Skills => {
let (store, errors) = mecha_core::skill::SkillStore::load(&app.skills_dir);
let carried: Vec<&mecha_core::skill::Skill> =
live.skill.iter().flat_map(|h| h.available()).collect();
let loaded = live.skill.as_ref().map(|h| h.loaded()).unwrap_or_default();
let mut rows: Vec<skills::SkillRow> = store
.all()
.iter()
.map(|on_disk| {
let mine = carried.iter().find(|s| s.name == on_disk.name);
let s = mine.copied().unwrap_or(on_disk);
skills::SkillRow {
name: s.name.clone(),
description: s.description.clone(),
triggers: s.triggers.clone(),
narrows: s.tools.clone(),
body: s.body.clone(),
dir: s.dir.clone(),
carried: mine.is_some(),
loaded: loaded.iter().any(|n| n == &s.name),
error: None,
}
})
.collect();
for skill in live.skill.iter().flat_map(|h| h.available()) {
if rows.iter().any(|r| r.name == skill.name) {
continue;
}
rows.push(skills::SkillRow {
name: skill.name.clone(),
description: format!(
"{} (no longer in the store — carried from startup)",
skill.description
),
triggers: skill.triggers.clone(),
narrows: skill.tools.clone(),
body: skill.body.clone(),
dir: skill.dir.clone(),
carried: true,
loaded: loaded.iter().any(|n| n == &skill.name),
error: None,
});
}
rows.extend(errors.into_iter().map(|e| {
skills::SkillRow {
name: e
.dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string(),
description: String::new(),
triggers: Vec::new(),
narrows: None,
body: String::new(),
dir: e.dir,
carried: false,
loaded: false,
error: Some(e.why),
}
}));
app.skills = Some(skills::SkillsModal {
rows,
selected: 0,
detail: false,
detail_scroll: 0,
dir: app.skills_dir.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; /queues is the backlog",
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::Note(text) => match text {
None => say("usage: /note <what happened> — it lands as a graph episode".into()),
Some(text) => {
app.transcript
.push(Entry::Notice(format!("noting: {text}")));
spawn_kg_note(app, &text);
}
},
Command::Find(query) => {
let mut modal = find::FindModal::new(query.clone());
if let Some(q) = query.filter(|q| !q.trim().is_empty()) {
modal.typing = false;
modal.loading = true;
modal.status = Some(format!("searching `{q}`…"));
app.find = Some(modal);
spawn_kg_search(app, &q);
} else {
app.find = Some(modal);
}
}
Command::Entity => {
app.entities = Some(entity::EntityModal::new());
}
Command::Docs => match docs_accounts() {
accounts if accounts.is_empty() => {
say("no documents grant yet — run `mecha-docs auth` once, then \
`mecha-docs pick` or /docs to put a document in scope"
.into())
}
accounts => {
let account = accounts[0].clone();
app.documents = Some(docs::DocsModal::new(account.clone(), accounts));
spawn_docs(
app,
DocsJob::List,
&["--account", &account, "list", "--json"],
);
}
},
Command::Tasks => match load_tasks(false) {
Ok(modal) => app.tasks = Some(modal),
Err(e) => say(format!("tasks: {e:#}")),
},
Command::Queues => match load_queues() {
Ok(modal) => app.queues = Some(modal),
Err(e) => say(format!("queues: {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;
agent.registry().forget_conversation_state();
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::Send(None) => app.transcript.push(Entry::Error(
"/send needs a path — try `/send report.png`".into(),
)),
Command::Send(Some(raw)) => match agent.context().tools.resolve(&raw) {
Ok(path) => {
app.transcript
.push(Entry::Notice(format!("sending {raw} to Slack…")));
spawn_send(path, app.shell_tx.clone());
}
Err(e) => app
.transcript
.push(Entry::Error(format!("/send {raw}: {e:#}"))),
},
Command::RemoteControl(command::Remote::Show) => match &app.attached {
Some(a) => say(format!(
"attached as `{}` — this session is mirrored into your Slack DM",
a.name
)),
None => say(
"not attached — `/remote-control <name>` mirrors this session into a named \
Slack thread"
.into(),
),
},
Command::RemoteControl(command::Remote::Off) => match app.attached.take() {
Some(a) => spawn_detach(a, "detached from the terminal", app.attach_tx.clone()),
None if app.attaching.is_some() => say(
"an attach is still in flight — run `/remote-control off` again once it lands"
.into(),
),
None => say("not attached".into()),
},
Command::RemoteControl(command::Remote::Attach(name)) => match (&app.attached, session) {
_ if app.attaching.is_some() => app.transcript.push(Entry::Error(format!(
"already attaching as `{}` — wait for it to land",
app.attaching.clone().unwrap_or_default()
))),
(Some(current), _) => app.transcript.push(Entry::Error(format!(
"already attached as `{}` — `/remote-control off` first",
current.name
))),
(None, None) => app.transcript.push(Entry::Error(
"this session is not being recorded (--no-session), so there is nothing to \
attach"
.into(),
)),
(None, Some(s)) => {
app.transcript
.push(Entry::Notice(format!("attaching as `{name}`…")));
app.attaching = Some(name.clone());
spawn_attach(
name,
s.meta.id.clone(),
agent.context().tools.workspace.clone(),
live.model.clone(),
(app.convo.taint.private, app.convo.taint.untrusted),
app.convo.len(),
app.attach_tx.clone(),
);
}
},
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(())
}
#[allow(clippy::too_many_arguments)]
fn submit(
app: &mut App,
text: String,
images: Vec<MsgBlock>,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
live: &Live,
session: Option<&Session>,
from_remote: bool,
) -> Result<()> {
let agent = &live.agent;
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, live, session);
}
if let Some(run) = &app.running {
if let Some(a) = &app.attached.as_ref().filter(|_| !from_remote) {
drop(spawn_echo(a, &text, true));
}
if let Ok(mut queue) = run.queue.lock() {
queue.push_back(text);
}
return Ok(());
}
let user = if images.is_empty() {
Message::user(&text)
} else {
let mut content = vec![MsgBlock::text(&text)];
content.extend(images);
Message {
role: mecha_core::message::Role::User,
content,
}
};
app.convo.push(user.clone());
if let Some(s) = session {
s.append(&Record::Message(user))?;
}
let echoed = app
.attached
.as_ref()
.filter(|_| !from_remote)
.map(|a| spawn_echo(a, &text, false));
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 run_tx = match &app.attached {
None => tx,
Some(a) => {
let (from_agent, mut split_rx) = mpsc::unbounded_channel::<AgentEvent>();
let (to_slack, slack_rx) = mpsc::unbounded_channel::<AgentEvent>();
let (slack, channel, thread_ts) =
(a.slack.clone(), a.channel_id.clone(), a.thread_ts.clone());
let cfg = crate::slack::pump::PumpConfig {
flush_chars: a.flush_chars,
flush_ms: a.flush_ms,
};
tokio::spawn(async move {
if let Some(echoed) = echoed {
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), echoed).await;
}
crate::slack::pump::pump(&slack, &channel, &thread_ts, slack_rx, &cfg).await;
});
tokio::spawn(async move {
while let Some(event) = split_rx.recv().await {
let _ = tx.send(event.clone());
let _ = to_slack.send(event);
}
});
from_agent
}
};
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(run_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.as_mut() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
confirm.scroll = confirm.scroll.saturating_sub(1);
return Ok(());
}
KeyCode::Down | KeyCode::Char('j') => {
confirm.scroll = confirm.scroll.saturating_add(1);
return Ok(());
}
KeyCode::PageUp => {
confirm.scroll = confirm.scroll.saturating_sub(10);
return Ok(());
}
KeyCode::PageDown => {
confirm.scroll = confirm.scroll.saturating_add(10);
return Ok(());
}
KeyCode::Home => {
confirm.scroll = 0;
return Ok(());
}
_ => {}
}
let confirm = modal.confirm.take().expect("checked above");
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
let outcome = spawn_detached(&["outbox", "approve", &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 release: {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();
}
}
_ => {
if let (Some(c), Some(input)) = (typed_char(&key), &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('J') if modal.detail => {
modal.show_raw = !modal.show_raw;
modal.detail_scroll = 0;
}
KeyCode::Char('h') if !modal.detail => modal.toggle_history(),
KeyCode::Char('a') | KeyCode::Char('s') => {
if let Some(row) = modal.selected_row() {
if row.pending() {
modal.confirm = Some(outbox::SendConfirm {
scroll: 0,
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, show_raw, history) = match &app.staged {
Some(m) => (
m.selected,
m.detail,
m.status.clone(),
m.scope.clone(),
m.show_raw,
m.history,
),
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 mut modal = outbox::OutboxModal {
status,
scope,
show_raw,
history,
..outbox::OutboxModal::new(rows)
};
let visible = modal.shown().len();
modal.selected = selected.min(visible.saturating_sub(1));
modal.detail = detail && visible > 0;
app.staged = Some(modal);
}
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();
}
}
_ => {
if let (Some(c), Some(input)) = (typed_char(&key), &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();
}
}
_ => {
if let (Some(c), Some(input)) = (typed_char(&key), &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,
live: &Live,
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, live, 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" {
crate::exe::self_exe()
} 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" {
crate::exe::self_exe()
} 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 load_tasks(show_closed: bool) -> Result<tasks::TasksModal> {
let mut args = vec!["tasks", "list", "--json"];
if show_closed {
args.push("--closed");
}
let (rows, today) = tasks::rows_from_json(&self_cli(&args)?)?;
let mut modal = tasks::TasksModal::new(rows, today);
modal.show_closed = show_closed;
Ok(modal)
}
fn reload_tasks(app: &mut App, status: Option<String>) {
let Some(old) = &app.tasks else {
return;
};
let (detail, show_closed, help) = (old.detail, old.show_closed, old.help);
let id = old.selected_row().map(|r| r.id.clone());
let fallback = old.selected;
match load_tasks(show_closed) {
Ok(mut modal) => {
let found = id.and_then(|id| modal.rows.iter().position(|r| r.id == id));
modal.selected =
found.unwrap_or_else(|| fallback.min(modal.rows.len().saturating_sub(1)));
modal.detail = detail && found.is_some();
modal.help = help;
modal.status = status;
app.tasks = Some(modal);
}
Err(e) => {
app.tasks = None;
app.transcript
.push(transcript::Entry::Error(format!("tasks: {e:#}")));
}
}
}
fn tasks_cli(args: &[&str]) -> Result<String> {
let mut full = vec!["tasks"];
full.extend_from_slice(args);
self_cli(&full)
}
fn review_cli(args: &[&str]) -> Result<String> {
let mut full = vec!["review"];
full.extend_from_slice(args);
self_cli(&full)
}
fn fresh_seed() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
}
fn load_queues() -> Result<queues::QueuesModal> {
let rows = queues::queues_from_json(&review_cli(&["queues", "--json"])?)?;
Ok(queues::QueuesModal::new(rows))
}
fn reload_queues(app: &mut App, status: Option<String>) {
let Some(old) = &app.queues else { return };
let (level, filter, selected) = (old.level, old.filter.clone(), old.selected);
let (item_class, item_seed, tier) = (old.item_class.clone(), old.item_seed, old.tier);
let from_group = old.from_group.clone();
let loaded = match level {
queues::Level::Groups => {
let Some((pb, pred)) = old.item_class.clone() else {
return;
};
match review_cli(&["groups", "--proposer", &pb, "--predicate", &pred, "--json"]) {
Ok(t) => queues::groups_from_json(&t).map(|(threshold, rows)| {
let mut m = queues::QueuesModal::new(vec![]);
m.level = queues::Level::Groups;
m.groups = rows;
m.group_threshold = threshold;
m.item_class = Some((pb.clone(), pred.clone()));
m
}),
Err(e) => return fail_queues(app, e),
}
}
queues::Level::Items if from_group.is_some() => {
let csv = from_group.clone().unwrap_or_default();
match review_cli(&["items", "--ids", &csv, "--json"]) {
Ok(t) => queues::items_from_json(&t).map(|rows| {
let mut m = queues::QueuesModal::new(vec![]);
m.level = queues::Level::Items;
m.items = rows;
m.from_group = Some(csv.clone());
m
}),
Err(e) => return fail_queues(app, e),
}
}
queues::Level::Queues => {
queues::queues_from_json(&match review_cli(&["queues", "--json"]) {
Ok(t) => t,
Err(e) => return fail_queues(app, e),
})
.map(|rows| {
let mut m = queues::QueuesModal::new(rows);
m.level = queues::Level::Queues;
m
})
}
queues::Level::Proposers => match review_cli(&["proposers", "--json"]) {
Ok(t) => queues::proposers_from_json(&t).map(|rows| {
let mut m = queues::QueuesModal::new(vec![]);
m.level = queues::Level::Proposers;
m.proposers = rows;
m
}),
Err(e) => return fail_queues(app, e),
},
queues::Level::Items => {
let Some((pb, pred)) = old.item_class.clone() else {
return;
};
let seed = old.item_seed;
let seed_s = seed.map(|x| x.to_string());
let mut args = vec!["sample", "--proposer", &pb, "--predicate", &pred, "--json"];
if let Some(sd) = &seed_s {
args.push("--seed");
args.push(sd);
}
match review_cli(&args) {
Ok(t) => queues::items_from_json(&t).map(|rows| {
let mut m = queues::QueuesModal::new(vec![]);
m.level = queues::Level::Items;
m.items = rows;
m.item_class = Some((pb.clone(), pred.clone()));
m.item_seed = seed;
m
}),
Err(e) => return fail_queues(app, e),
}
}
queues::Level::Candidates => {
let mut args = vec!["list", "--json"];
if let Some(f) = &filter {
args.push("--proposer");
args.push(f);
}
match review_cli(&args) {
Ok(t) => queues::candidates_from_json(&t).map(|rows| {
let mut m = queues::QueuesModal::new(vec![]);
m.level = queues::Level::Candidates;
m.candidates = rows;
m
}),
Err(e) => return fail_queues(app, e),
}
}
};
match loaded {
Ok(mut m) => {
m.filter = filter;
if m.item_class.is_none() {
m.item_class = item_class;
m.item_seed = item_seed;
}
m.tier = tier;
m.selected = selected.min(m.len().saturating_sub(1));
m.status = status;
app.queues = Some(m);
}
Err(e) => fail_queues(app, e),
}
}
fn fail_queues(app: &mut App, e: impl std::fmt::Display) {
app.queues = None;
app.transcript
.push(transcript::Entry::Error(format!("queues: {e:#}")));
}
fn handle_queues_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.queues else {
return Ok(());
};
if modal.help {
modal.help = false;
return Ok(());
}
match key.code {
KeyCode::Char('?') => modal.help = true,
KeyCode::Char('t') if modal.tier_applies() => {
modal.cycle_tier();
let n = modal.len();
modal.status = Some(match modal.tier {
Some(t) => format!("{n} at `{}`", t.as_str()),
None => format!("{n}, all tiers"),
});
}
KeyCode::Char('j') | KeyCode::Down => {
modal.move_sel(1);
modal.detail_scroll = 0;
}
KeyCode::Char('k') | KeyCode::Up => {
modal.move_sel(-1);
modal.detail_scroll = 0;
}
KeyCode::PageDown if modal.level == queues::Level::Items && modal.item_detail => {
modal.detail_scroll = modal.detail_scroll.saturating_add(5);
}
KeyCode::PageUp if modal.level == queues::Level::Items && modal.item_detail => {
modal.detail_scroll = modal.detail_scroll.saturating_sub(5);
}
KeyCode::Char('g') | KeyCode::Home => modal.selected = 0,
KeyCode::Char('G') | KeyCode::End => modal.selected = modal.len().saturating_sub(1),
KeyCode::Esc | KeyCode::Char('q') => match modal.level {
queues::Level::Items if modal.item_detail => {
modal.item_detail = false;
modal.detail_scroll = 0;
}
queues::Level::Items if modal.from_group.is_some() => {
let remaining: Vec<i64> = modal.items.iter().map(|r| r.id).collect();
let csv = modal.from_group.take().unwrap_or_default();
let ids: Vec<i64> = csv
.split(',')
.filter_map(|t| t.trim().parse().ok())
.collect();
if let Some(pos) = modal
.groups
.iter()
.position(|g| ids.first() == Some(&g.leader_id))
{
let survivors: Vec<i64> = ids
.iter()
.copied()
.filter(|i| remaining.contains(i))
.collect();
match survivors.split_first() {
Some((lead, rest)) => {
let g = &mut modal.groups[pos];
if *lead != g.leader_id {
g.statement = modal
.items
.iter()
.find(|r| r.id == *lead)
.map(|r| r.statement.clone())
.unwrap_or_else(|| g.statement.clone());
}
g.leader_id = *lead;
g.member_ids = rest.to_vec();
g.sample.retain(|_| false);
}
None => {
modal.groups.remove(pos);
}
}
}
modal.level = queues::Level::Groups;
modal.items.clear();
modal.item_detail = false;
modal.detail_scroll = 0;
modal.selected = modal.selected.min(modal.groups.len().saturating_sub(1));
modal.status = None;
}
queues::Level::Items => {
modal.level = queues::Level::Candidates;
modal.item_class = None;
modal.item_seed = None;
modal.selected = 0;
modal.status = None;
reload_queues(app, None);
}
queues::Level::Groups => {
modal.level = match modal.item_class {
Some(_) => queues::Level::Candidates,
None => queues::Level::Proposers,
};
modal.groups.clear();
modal.item_class = None;
modal.selected = 0;
modal.status = None;
reload_queues(app, None);
}
queues::Level::Candidates => {
modal.level = queues::Level::Proposers;
modal.filter = None;
modal.selected = 0;
modal.status = None;
reload_queues(app, None);
}
queues::Level::Proposers => {
modal.level = queues::Level::Queues;
modal.selected = 0;
modal.status = None;
reload_queues(app, None);
}
queues::Level::Queues => app.queues = None,
},
KeyCode::Enter => match modal.level {
queues::Level::Queues => {
let Some(q) = modal.selected_queue() else {
return Ok(());
};
if q.is_graph() {
modal.level = queues::Level::Proposers;
modal.selected = 0;
reload_queues(app, None);
} else {
let opens = q.opens.clone();
app.queues = None;
match opens.as_str() {
"mecha outbox" => match outbox::load() {
Ok(rows) => app.staged = Some(outbox::OutboxModal::new(rows)),
Err(e) => app
.transcript
.push(transcript::Entry::Error(format!("outbox: {e:#}"))),
},
"mecha frontdoor list" => match frontdoor::load() {
Ok(rows) => app.requests = Some(frontdoor::FrontdoorModal::new(rows)),
Err(e) => app
.transcript
.push(transcript::Entry::Error(format!("frontdoor: {e:#}"))),
},
other => app.transcript.push(transcript::Entry::Notice(format!(
"no modal for that one yet — run `{other}`"
))),
}
}
}
queues::Level::Proposers => {
let Some(p) = modal.selected_proposer() else {
return Ok(());
};
let name = p.proposer.clone();
modal.level = queues::Level::Candidates;
modal.filter = Some(name.clone());
modal.selected = 0;
reload_queues(app, Some(format!("classes proposed by {name}")));
}
queues::Level::Candidates => {
let Some(c) = modal.selected_candidate() else {
return Ok(());
};
let (pb, pred) = (c.proposer.clone(), c.predicate.clone());
let seed = fresh_seed();
let seed_s = seed.to_string();
match review_cli(&[
"sample",
"--proposer",
&pb,
"--predicate",
&pred,
"--seed",
&seed_s,
"--json",
]) {
Ok(t) => match queues::items_from_json(&t) {
Ok(rows) => {
modal.level = queues::Level::Items;
modal.items = rows;
modal.item_class = Some((pb.clone(), pred.clone()));
modal.item_seed = Some(seed);
modal.selected = 0;
modal.status = Some(format!(
"random sample of {} from {pb} · {pred}",
modal.items.len()
));
}
Err(e) => modal.status = Some(format!("sample: {e:#}")),
},
Err(e) => modal.status = Some(format!("sample failed: {e:#}")),
}
}
queues::Level::Groups => {
let Some(g) = modal.selected_group() else {
return Ok(());
};
let csv = g.all_ids_csv();
match review_cli(&["items", "--ids", &csv, "--json"]) {
Ok(t) => match queues::items_from_json(&t) {
Ok(rows) => {
modal.level = queues::Level::Items;
modal.items = rows;
modal.from_group = Some(csv);
modal.item_seed = None;
modal.selected = 0;
modal.status =
Some("this group, one at a time — Esc returns to groups".into());
}
Err(e) => modal.status = Some(format!("items: {e:#}")),
},
Err(e) => modal.status = Some(format!("items failed: {e:#}")),
}
}
queues::Level::Items => {
if modal.selected_item().is_some() {
modal.item_detail = !modal.item_detail;
modal.detail_scroll = 0;
}
}
},
KeyCode::Char('b') if modal.level == queues::Level::Groups => {
let Some(g) = modal.selected_group() else {
return Ok(());
};
let id_s = g.leader_id.to_string();
match review_cli(&["bind", &id_s]) {
Ok(report) => {
if let Some(m) = &mut app.queues {
m.status = Some(format!("{} — a cascades the group", report.trim()));
}
}
Err(e) => {
if let Some(m) = &mut app.queues {
m.status = Some(format!("bind #{id_s} failed: {e:#}"));
}
}
}
}
KeyCode::Char('a') | KeyCode::Char('r') | KeyCode::Char('A')
if modal.level == queues::Level::Groups =>
{
let Some(g) = modal.selected_group() else {
return Ok(());
};
let (leader, stmt) = (g.leader_id, g.statement.clone());
let member_csv = g
.member_ids
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let create = key.code == KeyCode::Char('A');
let accept = create || key.code == KeyCode::Char('a');
let verb = if accept { "accept" } else { "reject" };
let fan = if member_csv.is_empty() {
crate::commands::review::Fan::None
} else if modal.item_class.is_none() {
crate::commands::review::Fan::IdsAcross(&member_csv)
} else {
crate::commands::review::Fan::Ids(&member_csv)
};
let outcome = crate::commands::review::decide_report(
verb,
&[leader],
None,
None,
None,
None,
create,
false,
fan,
);
match outcome {
Ok(report) => {
let (cascaded, left) =
crate::commands::review::cascade_tally(&report).unwrap_or((0, 0));
if let Some(m) = &mut app.queues {
m.groups.retain(|x| x.leader_id != leader);
m.selected = m.selected.min(m.groups.len().saturating_sub(1));
let mut said = format!(
"{verb}ed ×{} — {}",
1 + cascaded,
stmt.chars().take(48).collect::<String>()
);
if left > 0 {
said.push_str(&format!(" ({left} similar left pending)"));
}
m.status = Some(said);
}
}
Err(e) => {
if let Some(m) = &mut app.queues {
m.status = Some(if create {
format!("{verb} failed, nothing cascaded: {e:#}")
} else {
format!(
"{verb} failed, nothing cascaded: {e:#} — b binds the subject \
here; A accepts it as a new topic"
)
});
}
}
}
}
KeyCode::Char('s') if modal.level == queues::Level::Candidates => {
let Some(c) = modal.selected_candidate() else {
return Ok(());
};
let (pb, pred) = (c.proposer.clone(), c.predicate.clone());
let pending = c.pending;
if pred.starts_with('(') {
modal.status =
Some("commitments do not group — they are reviewed one at a time".into());
return Ok(());
}
modal.status = Some(format!(
"grouping {pending} pending in {pb} · {pred} — embedding…"
));
spawn_group_load(app, Some((pb, pred)), None);
}
KeyCode::Char('s') if modal.level == queues::Level::Proposers => {
let pending: usize = modal.proposers.iter().map(|p| p.pending).sum();
modal.status = Some(format!(
"grouping {pending} pending across every class — embedding the whole queue, \
this runs a minute or two…"
));
spawn_group_load(app, None, None);
}
KeyCode::Char('[') | KeyCode::Char(']') if modal.level == queues::Level::Groups => {
let class = modal.item_class.clone();
let step = if key.code == KeyCode::Char(']') {
0.02
} else {
-0.02
};
let next = (modal.group_threshold + step).clamp(0.60, 0.97);
modal.status = Some(format!("re-grouping at cosine ≥ {next:.2}…"));
spawn_group_load(app, class, Some(next));
}
KeyCode::Char('a') | KeyCode::Char('r') | KeyCode::Char('A')
if modal.level == queues::Level::Items =>
{
let Some(it) = modal.selected_item() else {
return Ok(());
};
let (id, stmt) = (it.id, it.statement.clone());
let create = key.code == KeyCode::Char('A');
let accept = create || key.code == KeyCode::Char('a');
let verb = if accept { "accept" } else { "reject" };
let id_s = id.to_string();
let mut cli_args = vec![verb, id_s.as_str()];
if create {
cli_args.push("--create-subjects");
}
match review_cli(&cli_args) {
Ok(report) => {
let (done, _failed) = crate::commands::review::tally_report(&report);
if let Some(m) = &mut app.queues {
if done == 0 {
let why = report
.lines()
.find(|l| l.contains("FAILED"))
.unwrap_or("failed with no report")
.trim()
.to_string();
m.status = Some(if create {
why
} else {
format!(
"{why} — b binds the subject here; A accepts it as a new topic"
)
});
return Ok(());
}
m.items.retain(|r| r.id != id);
m.selected = m.selected.min(m.items.len().saturating_sub(1));
m.item_detail = false;
m.detail_scroll = 0;
let did = if create { "accepted (new topic)" } else { verb };
m.status = Some(format!(
"{did}{} #{id} — {}",
if create { "" } else { "ed" },
stmt.chars().take(48).collect::<String>()
));
}
}
Err(e) => {
if let Some(m) = &mut app.queues {
m.status = Some(format!("{verb} #{id} failed: {e:#}"));
}
}
}
}
KeyCode::Char('b') if modal.level == queues::Level::Items => {
let Some(it) = modal.selected_item() else {
return Ok(());
};
let id = it.id;
let id_s = id.to_string();
match review_cli(&["bind", &id_s]) {
Ok(report) => {
if let Some(m) = &mut app.queues {
m.status = Some(report.trim().to_string());
}
}
Err(e) => {
if let Some(m) = &mut app.queues {
m.status = Some(format!("bind #{id} failed: {e:#}"));
}
}
}
}
KeyCode::Char('n') if modal.level == queues::Level::Items => {
if modal.from_group.is_some() {
return Ok(());
}
let Some((pb, pred)) = modal.item_class.clone() else {
return Ok(());
};
let seed = fresh_seed();
let seed_s = seed.to_string();
match review_cli(&[
"sample",
"--proposer",
&pb,
"--predicate",
&pred,
"--seed",
&seed_s,
"--json",
]) {
Ok(t) => match queues::items_from_json(&t) {
Ok(rows) => {
modal.items = rows;
modal.item_seed = Some(seed);
modal.selected = 0;
modal.detail_scroll = 0;
modal.status = Some(format!("new sample of {}", modal.items.len()));
}
Err(e) => modal.status = Some(format!("sample: {e:#}")),
},
Err(e) => modal.status = Some(format!("sample failed: {e:#}")),
}
}
KeyCode::Char('a') | KeyCode::Char('r') if modal.level == queues::Level::Candidates => {
let Some(c) = modal.selected_candidate() else {
return Ok(());
};
let (proposer, predicate, n) = (c.proposer.clone(), c.predicate.clone(), c.pending);
let accept = key.code == KeyCode::Char('a');
let _ = n;
let verb = if accept { "accept" } else { "reject" };
let outcome = crate::commands::review::decide_report(
verb,
&[],
None,
Some(&proposer),
Some(&predicate),
None,
false,
false,
crate::commands::review::Fan::None,
);
match outcome {
Ok(report) => {
let (done, failed) = crate::commands::review::tally_report(&report);
let mut said = format!("{verb}ed {done} × {proposer} · {predicate}");
if failed > 0 {
said.push_str(&format!(" ({failed} failed)"));
}
reload_queues(app, Some(said))
}
Err(e) => {
if let Some(m) = &mut app.queues {
m.status = Some(format!("{verb} failed: {e:#}"));
}
}
}
}
_ => {}
}
Ok(())
}
fn handle_tasks_key(app: &mut App, key: KeyEvent) -> Result<()> {
if app.tasks.as_ref().is_some_and(|m| m.form.is_some()) {
return handle_tasks_form_key(app, key);
}
let Some(modal) = &mut app.tasks else {
return Ok(());
};
if modal.help {
modal.help = false;
return Ok(());
}
modal.status = None;
match key.code {
KeyCode::Up if modal.detail => modal.scroll_detail(-1),
KeyCode::Down if modal.detail => modal.scroll_detail(1),
KeyCode::PageUp if modal.detail => modal.scroll_detail(-10),
KeyCode::PageDown if modal.detail => modal.scroll_detail(10),
KeyCode::Up | KeyCode::Char('k') => modal.move_by(-1),
KeyCode::Down | KeyCode::Char('j') => modal.move_by(1),
KeyCode::Enter => {
if !modal.rows.is_empty() {
modal.detail = !modal.detail;
}
}
KeyCode::Char('?') => modal.help = true,
KeyCode::Esc => {
if modal.detail {
modal.detail = false;
} else {
app.tasks = None;
}
}
KeyCode::Char(c) => return run_task_action(app, c),
_ => {}
}
Ok(())
}
fn run_task_action(app: &mut App, key: char) -> Result<()> {
let Some(modal) = &mut app.tasks else {
return Ok(());
};
let Some(action) = tasks::action_for(key) else {
return Ok(());
};
let selected = modal
.selected_row()
.map(|r| (r.id.clone(), r.status.clone()));
let status = match action {
tasks::Action::Close => {
if modal.detail {
modal.detail = false;
} else {
app.tasks = None;
}
return Ok(());
}
tasks::Action::Add => {
modal.form = Some(tasks::Form::capture());
return Ok(());
}
tasks::Action::Edit => {
if let Some(row) = modal.selected_row() {
modal.form = Some(tasks::Form::edit(row));
}
return Ok(());
}
tasks::Action::Closed => {
modal.show_closed = !modal.show_closed;
let shown = modal.show_closed;
reload_tasks(
app,
Some(if shown {
"showing done and dropped".into()
} else {
"open tasks only".into()
}),
);
return Ok(());
}
tasks::Action::Refresh => None,
tasks::Action::Status(status) => Some(status),
tasks::Action::Cycle => modal.next_in_cycle(),
};
let Some(status) = status else {
reload_tasks(app, None);
return Ok(());
};
let Some((id, was)) = selected else {
return Ok(());
};
let note = match tasks_cli(&["set", &id, "--status", status]) {
Ok(_) => format!("{was} → {status}"),
Err(e) => format!("could not set {status}: {e}"),
};
reload_tasks(app, Some(note));
Ok(())
}
fn handle_tasks_form_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.tasks else {
return Ok(());
};
let Some(form) = &mut modal.form else {
return Ok(());
};
match key.code {
KeyCode::Esc => modal.form = None,
KeyCode::Tab | KeyCode::Down => form.move_by(1),
KeyCode::BackTab | KeyCode::Up => form.move_by(-1),
KeyCode::Backspace => form.backspace(),
KeyCode::Enter => return submit_task_form(app),
_ => {
if let Some(c) = typed_char(&key) {
form.push(c);
}
}
}
Ok(())
}
fn submit_task_form(app: &mut App) -> Result<()> {
let Some(modal) = &mut app.tasks else {
return Ok(());
};
let Some(form) = &modal.form else {
return Ok(());
};
let editing = form.editing.clone();
let (due, defer, context, project, name) = (
form.value("due").trim().to_string(),
form.value("defer").trim().to_string(),
form.value("context").trim().to_string(),
form.value("project").trim().to_string(),
form.value("name").trim().to_string(),
);
let result = match &editing {
Some(id) => tasks_cli(&[
"set",
id,
"--due",
&due,
"--defer",
&defer,
"--context",
&context,
])
.map(|_| "schedule saved".to_string()),
None if name.is_empty() => Err(anyhow::anyhow!("a task needs a name")),
None => {
let mut args = vec!["add"];
for (flag, value) in [
("--due", &due),
("--project", &project),
("--context", &context),
] {
if !value.is_empty() {
args.extend([flag, value.as_str()]);
}
}
args.push("--");
args.push(&name);
tasks_cli(&args).map(|_| "captured".to_string())
}
};
match result {
Ok(note) => {
modal.form = None;
reload_tasks(app, Some(note));
}
Err(e) => {
if let Some(form) = &mut modal.form {
form.error = Some(format!("{e:#}"));
}
}
}
Ok(())
}
fn self_cli(args: &[&str]) -> Result<String> {
let exe = crate::exe::self_exe();
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 = crate::exe::self_exe();
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
)?;
MOUSE_CAPTURED.store(false, std::sync::atomic::Ordering::SeqCst);
let result = f();
enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
MOUSE_CAPTURED.store(true, std::sync::atomic::Ordering::SeqCst);
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 = crate::exe::self_exe();
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 deliver_inbound(
app: &mut App,
live: &Live,
session: Option<&Session>,
events_tx: &mut mpsc::UnboundedSender<AgentEvent>,
events_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
) {
let Some(attached) = app.attached.clone() else {
return;
};
let Ok(store) = crate::slack::remote::RemoteStore::open_default() else {
return;
};
let lines = match store.claim_inbound(&attached.name) {
Ok(lines) => lines,
Err(e) => {
tracing::warn!("could not read the remote inbox: {e:#}");
return;
}
};
let workspace = live.agent.context().tools.workspace.clone();
for line in lines {
let landed = match store.take_files(&attached.name, &line.files, &workspace) {
Ok(landed) => landed,
Err(e) => {
app.transcript
.push(Entry::Error(format!("could not save an attachment: {e:#}")));
spawn_note(
&attached,
&format!("Could not save your attachment into the workspace: {e:#}"),
);
Vec::new()
}
};
let mut images = Vec::new();
if live.agent.vision() {
for rel in &landed {
let path = workspace.join(rel.trim_start_matches("./"));
match mecha_core::image::block_from_path(&path) {
Ok(Some(block)) => images.push(block),
Ok(None) => {}
Err(e) => {
app.transcript
.push(Entry::Error(format!("could not look at {rel}: {e:#}")));
spawn_note(
&attached,
&format!("Saved {rel}, but could not look at it: {e:#}"),
);
}
}
}
}
if !landed.is_empty() {
app.transcript
.push(Entry::Notice(format!("⇄ saved {}", landed.join(", "))));
spawn_note(
&attached,
&format!("Saved to the workspace: {}", landed.join(", ")),
);
}
if command::parse(&line.text).is_some() || command::shell_escape(&line.text).is_some() {
let refusal = if landed.is_empty() {
"Commands and `!` shell escapes only work at the terminal. Send a prompt \
instead."
.to_string()
} else {
format!(
"The attachment was saved. Commands and `!` shell escapes only work at \
the terminal, so `{}` was not run.",
line.text.trim()
)
};
app.transcript.push(Entry::Notice(format!(
"refused a command from Slack: {}",
line.text
)));
spawn_note(&attached, &refusal);
continue;
}
let mut prompt = line.text.trim().to_string();
if !landed.is_empty() {
if !prompt.is_empty() {
prompt.push_str("\n\n");
}
prompt.push_str("The user attached:\n");
for path in &landed {
prompt.push_str(&format!("- {path}\n"));
}
}
if prompt.trim().is_empty() {
continue;
}
app.transcript
.push(Entry::Notice(format!("⇄ from Slack · {}", attached.name)));
if let Err(e) = submit(
app, prompt, images, events_tx, events_rx, live, session, true,
) {
app.transcript.push(Entry::Error(format!(
"could not deliver a Slack line: {e:#}"
)));
}
}
}
fn spawn_note(attached: &crate::slack::remote::Attached, text: &str) {
let (slack, channel, thread_ts) = (
attached.slack.clone(),
attached.channel_id.clone(),
attached.thread_ts.clone(),
);
let body = text.to_string();
tokio::spawn(async move {
let _ =
mecha_slack::chat::post_message(&slack, &channel, Some(&thread_ts), &body, None).await;
});
}
fn spawn_echo(
attached: &crate::slack::remote::Attached,
text: &str,
steering: bool,
) -> JoinHandle<()> {
let (slack, channel, thread_ts) = (
attached.slack.clone(),
attached.channel_id.clone(),
attached.thread_ts.clone(),
);
let body = crate::slack::remote::echo_text(text, steering);
tokio::spawn(async move {
let _ =
mecha_slack::chat::post_message(&slack, &channel, Some(&thread_ts), &body, None).await;
})
}
#[allow(clippy::too_many_arguments)]
fn spawn_attach(
name: String,
session_id: String,
workspace: PathBuf,
model: String,
taint: (bool, bool),
prior_messages: usize,
tx: mpsc::UnboundedSender<AttachOutcome>,
) {
tokio::spawn(async move {
let outcome = match crate::slack::remote::attach(
&name,
&session_id,
&workspace,
&model,
taint,
prior_messages,
)
.await
{
Ok((attached, notice)) => AttachOutcome::Attached(Box::new(attached), notice),
Err(e) => AttachOutcome::Failed {
name: Some(name.clone()),
error: format!("/remote-control {name}: {e:#}"),
},
};
let _ = tx.send(outcome);
});
}
fn spawn_detach(
attached: crate::slack::remote::Attached,
reason: &'static str,
tx: mpsc::UnboundedSender<AttachOutcome>,
) {
tokio::spawn(async move {
let name = attached.name.clone();
let outcome = match crate::slack::remote::detach(&attached, reason).await {
Ok(()) => AttachOutcome::Detached(format!(
"detached `{name}` — the thread and everything in it stay"
)),
Err(e) => AttachOutcome::Failed {
name: None,
error: format!("/remote-control off: {e:#}"),
},
};
let _ = tx.send(outcome);
});
}
fn spawn_send(path: PathBuf, tx: mpsc::UnboundedSender<Entry>) {
tokio::spawn(async move {
let entry = match crate::slack::send::send_file(&path, None).await {
Ok(sent) => Entry::Notice(format!(
"sent {} ({}) to your Slack DM",
sent.filename,
crate::slack::send::human(sent.bytes)
)),
Err(e) => Entry::Error(format!("/send {}: {e:#}", path.display())),
};
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 typed_char(key: &KeyEvent) -> Option<char> {
let chord = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
match key.code {
KeyCode::Char(c) if !key.modifiers.intersects(chord) => Some(c),
_ => None,
}
}
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())
}
struct InputLayout {
rows: Vec<std::ops::Range<usize>>,
cursor_col: u16,
cursor_row: u16,
}
fn cell_width(c: char) -> usize {
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
}
fn input_layout(text: &str, cursor: usize, width: u16) -> InputLayout {
let width = (width.max(1)) as usize;
let mut rows: Vec<std::ops::Range<usize>> = Vec::new();
let (mut start, mut col) = (0usize, 0usize);
let mut chars = text.char_indices().peekable();
while let Some(&(i, ch)) = chars.peek() {
if ch == '\n' {
chars.next();
rows.push(start..i + 1);
(start, col) = (i + 1, 0);
continue;
}
if ch.is_whitespace() {
chars.next();
col += cell_width(ch);
continue;
}
let (mut end, mut w) = (i, 0usize);
while let Some(&(j, c)) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
end = j + c.len_utf8();
w += cell_width(c);
}
if col > 0 && col + w > width {
rows.push(start..i);
(start, col) = (i, 0);
}
if col + w <= width {
col += w;
continue;
}
for (o, c) in text[i..end].char_indices() {
let (j, cw) = (i + o, cell_width(c));
if col > 0 && col + cw > width {
rows.push(start..j);
(start, col) = (j, 0);
}
col += cw;
}
}
rows.push(start..text.len());
let cursor = cursor.min(text.len());
let (mut cursor_row, mut cursor_col) = (0u16, 0u16);
for (n, r) in rows.iter().enumerate() {
if cursor < r.end || n + 1 == rows.len() {
let upto = cursor.clamp(r.start, r.end);
cursor_row = n as u16;
cursor_col = text[r.start..upto].chars().map(cell_width).sum::<usize>() as u16;
break;
}
}
if cursor_col as usize >= width {
if cursor_row as usize + 1 == rows.len() {
rows.push(text.len()..text.len());
}
cursor_row += 1;
cursor_col = 0;
}
InputLayout {
rows,
cursor_col,
cursor_row,
}
}
const INPUT_ROWS: u16 = 6;
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 (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 tail = if ghost.is_empty() { "" } else { " tab" };
let display = format!("{}{ghost}{tail}", app.input);
let layout = input_layout(&display, app.cursor, inner_width);
let visible = (layout.rows.len() as u16).clamp(1, INPUT_ROWS);
let input_height = visible + 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 scroll = layout
.cursor_row
.saturating_sub(visible - 1)
.min((layout.rows.len() as u16).saturating_sub(visible));
let (typed_end, ghost_end) = (app.input.len(), app.input.len() + ghost.len());
let body: Vec<Line> = layout.rows[scroll as usize..]
.iter()
.take(visible as usize)
.map(|r| {
let clip = |lo: usize, hi: usize| {
let a = r.start.max(lo).min(r.end);
let b = r.end.min(hi).max(a);
display[a..b].trim_end_matches('\n')
};
let dim = Style::new().fg(Color::DarkGray);
Line::from(vec![
Span::raw(clip(0, typed_end)),
Span::styled(clip(typed_end, ghost_end), dim),
Span::styled(clip(ghost_end, display.len()), dim),
])
})
.collect();
let title = if layout.rows.len() as u16 > visible {
format!(
" {hint} · line {}/{} ",
layout.cursor_row + 1,
layout.rows.len()
)
} else {
format!(" {hint} ")
};
let input = Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border))
.title(title),
);
frame.render_widget(input, chunks[3]);
if chunks[3].height >= 3 {
frame.set_cursor_position((
chunks[3].x + 1 + layout.cursor_col.min(inner_width.saturating_sub(1)),
chunks[3].y + 1 + layout.cursor_row.saturating_sub(scroll),
));
}
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 {
app.help_scroll = draw_help(frame, app.kitty_keyboard, app.help_scroll);
}
if let Some(modal) = &app.tools {
modal.draw(frame);
}
if let Some(modal) = &app.skills {
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.find {
modal.draw(frame);
}
if let Some(modal) = &app.documents {
modal.draw(frame);
}
if let Some(modal) = &app.entities {
modal.draw(frame);
}
if let Some(modal) = &app.tasks {
modal.draw(frame);
}
if let Some(modal) = &app.queues {
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, scroll: u16) -> u16 {
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()),
(
"^s",
"select text with the mouse (the wheel stops until you press it again)".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 widest = body.iter().map(Line::width).max().unwrap_or(0) as u16;
let area = centered(
frame.area(),
widest.saturating_add(4).max(40),
(body.len() as u16)
.saturating_add(2)
.min(frame.area().height),
);
let visible = area.height.saturating_sub(2) as usize;
let max_scroll = (body.len().saturating_sub(visible)) as u16;
let scroll = scroll.min(max_scroll);
let title = if max_scroll == 0 {
" help · any key to close ".to_string()
} else {
format!(
" help · {}–{} of {} · ↑↓ scrolls · any other key closes ",
scroll as usize + 1,
(scroll as usize + visible).min(body.len()),
body.len()
)
};
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).scroll((scroll, 0)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(title),
),
area,
);
scroll
}
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 list_height(rows: u16, terminal_height: u16) -> u16 {
list_height_reserving(rows, terminal_height, 0)
}
fn list_height_reserving(rows: u16, terminal_height: u16, reserved: u16) -> u16 {
let max = terminal_height.saturating_sub(4).max(1);
let min = reserved.saturating_add(1).min(max);
rows.saturating_add(reserved).clamp(min, max) + 2
}
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)
}
static MOUSE_CAPTURED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn sync_mouse_capture(want: bool) {
use std::sync::atomic::Ordering::SeqCst;
if MOUSE_CAPTURED.swap(want, SeqCst) == want {
return;
}
let _ = if want {
crossterm::execute!(std::io::stdout(), EnableMouseCapture)
} else {
crossterm::execute!(std::io::stdout(), DisableMouseCapture)
};
}
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
)?;
MOUSE_CAPTURED.store(true, std::sync::atomic::Ordering::SeqCst);
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
)?;
MOUSE_CAPTURED.store(false, std::sync::atomic::Ordering::SeqCst);
terminal.show_cursor()?;
println!();
Ok(())
}
fn handle_find_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.find else {
return Ok(());
};
if modal.detail.is_some() {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
modal.detail = None;
modal.scroll = 0;
}
KeyCode::Up | KeyCode::Char('k') => modal.scroll = modal.scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => modal.scroll = modal.scroll.saturating_add(1),
KeyCode::PageUp => modal.scroll = modal.scroll.saturating_sub(10),
KeyCode::PageDown => modal.scroll = modal.scroll.saturating_add(10),
_ => {}
}
return Ok(());
}
if modal.typing {
match key.code {
KeyCode::Esc => app.find = None,
KeyCode::Backspace => {
modal.query.pop();
}
KeyCode::Enter => {
let q = modal.query.trim().to_string();
if q.is_empty() || modal.loading {
return Ok(());
}
modal.typing = false;
modal.loading = true;
modal.status = Some(format!("searching `{q}`…"));
spawn_kg_search(app, &q);
}
_ => {
if let Some(c) = typed_char(&key) {
modal.query.push(c);
}
}
}
return Ok(());
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.find = None,
KeyCode::Char('/') => {
modal.typing = true;
}
KeyCode::Up | KeyCode::Char('k') => modal.move_sel(-1),
KeyCode::Down | KeyCode::Char('j') => modal.move_sel(1),
KeyCode::Enter => {
if modal.loading {
return Ok(());
}
let Some(row) = modal.selected_row() else {
return Ok(());
};
match &row.entity {
Some(name) => {
let name = name.clone();
modal.loading = true;
modal.status = Some(format!("fetching {name}…"));
spawn_kg_entity(app, &name);
}
None => {
let title = format!("{} · {}", row.kind, row.when);
let lines: Vec<String> = row.full.lines().map(|l| l.to_string()).collect();
modal.detail = Some((title, lines));
modal.scroll = 0;
}
}
}
_ => {}
}
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);
}
}
_ => {
if let Some(c) = typed_char(&key) {
input.insert(c);
}
}
}
return Ok(());
}
if modal.help {
modal.help = false;
return Ok(());
}
if let Some(reader) = &mut modal.reading {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
modal.reading = None;
return Ok(());
}
KeyCode::Up | KeyCode::Char('k') => {
reader.scroll_by(-1);
return Ok(());
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Enter => {
reader.scroll_by(1);
return Ok(());
}
KeyCode::PageUp => {
reader.scroll_by(-15);
return Ok(());
}
KeyCode::PageDown | KeyCode::Char(' ') => {
reader.scroll_by(15);
return Ok(());
}
KeyCode::Char('?') => {
modal.help = true;
return Ok(());
}
KeyCode::Char(c) if mail::action_for(c).is_some() => modal.reading = None,
_ => 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, handle) = (
row.thread_id.clone(),
row.account.clone(),
row.handle.clone(),
);
spawn_mail_action(app, "spam", &thread, &account, &handle);
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::Char('?') => modal.help = true,
KeyCode::Enter => {
if modal.loading.is_some() {
return Ok(());
}
let Some(row) = modal.rows.get(modal.selected) else {
return Ok(());
};
let (thread, account, handle) = (
row.thread_id.clone(),
row.account.clone(),
row.handle.clone(),
);
modal.loading = Some(handle.clone());
spawn_mail_read(app, &thread, &account, &handle);
}
KeyCode::Char(_) => {
let Some(action) = typed_char(&key).and_then(mail::action_for) 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) => {
let handle = row.handle.clone();
spawn_mail_action(app, verb, &thread, &account, &handle);
}
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 Some(current) = &mut app.mail else {
return;
};
let Ok(rows) = mail::load() else {
return;
};
current.rows = rows;
current.selected = current.selected.min(current.rows.len().saturating_sub(1));
}
fn spawn_group_load(app: &mut App, class: Option<(String, String)>, threshold: Option<f64>) {
let mut args: Vec<String> = vec!["review".into(), "groups".into()];
match &class {
Some((pb, pred)) => {
args.push("--proposer".into());
args.push(pb.clone());
args.push("--predicate".into());
args.push(pred.clone());
}
None => args.push("--all".into()),
}
args.push("--json".into());
if let Some(t) = threshold {
args.push("--threshold".into());
args.push(format!("{t:.2}"));
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
app.watches.push(Watch::QueuesGroups {
rx,
class,
since: std::time::Instant::now(),
});
}
fn spawn_kg_note(app: &mut App, text: &str) {
let args: Vec<String> = vec!["kg".into(), "note".into(), text.into()];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
app.watches.push(Watch::KgNote {
rx,
since: std::time::Instant::now(),
});
}
fn spawn_kg_search(app: &mut App, query: &str) {
let args: Vec<String> = vec!["kg".into(), "search".into(), query.into(), "--json".into()];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
app.watches.push(Watch::KgSearch {
rx,
query: query.to_string(),
since: std::time::Instant::now(),
});
}
fn spawn_kg_entity(app: &mut App, name: &str) {
let args: Vec<String> = vec!["kg".into(), "entity".into(), name.into(), "--json".into()];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
app.watches.push(Watch::KgEntity {
rx,
name: name.to_string(),
since: std::time::Instant::now(),
});
}
fn spawn_mail_action(app: &mut App, verb: &str, thread: &str, account: &str, handle: &str) {
let args: Vec<String> = vec![
"mail".into(),
verb.into(),
thread.into(),
"--account".into(),
account.into(),
];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
if let Some(modal) = &mut app.mail {
modal.status = Some(format!("{verb} {handle}…"));
}
app.watches.push(Watch::MailAction {
rx,
verb: verb.to_string(),
handle: handle.to_string(),
since: std::time::Instant::now(),
});
}
fn spawn_mail_read(app: &mut App, thread: &str, account: &str, handle: &str) {
let args: Vec<String> = vec![
"mail".into(),
"show".into(),
thread.into(),
"--account".into(),
account.into(),
];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let _ = tx.send(self_cli(&borrowed));
});
app.watches.push(Watch::MailRead {
rx,
handle: handle.to_string(),
since: std::time::Instant::now(),
});
}
fn docs_bin() -> std::path::PathBuf {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|d| d.join("mecha-docs")))
.filter(|p| p.is_file())
.unwrap_or_else(|| "mecha-docs".into())
}
fn spawn_docs(app: &mut App, job: DocsJob, args: &[&str]) {
let args: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let out = std::process::Command::new(docs_bin())
.args(&args)
.stdin(std::process::Stdio::null())
.output();
let _ = tx.send(match out {
Ok(out) if out.status.success() => {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
Err(anyhow::anyhow!(
"{}",
err.trim()
.lines()
.next_back()
.unwrap_or("mecha-docs failed")
))
}
Err(e) => Err(anyhow::anyhow!(
"cannot run {}: {e} — is mecha-docs installed?",
docs_bin().display()
)),
});
});
if let Some(m) = &mut app.documents {
m.loading = true;
}
app.watches.push(Watch::Docs {
rx,
job,
since: std::time::Instant::now(),
});
}
fn docs_accounts() -> Vec<String> {
let Some(home) = dirs_home() else {
return Vec::new();
};
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(home.join(".mecha").join("docs")) else {
return out;
};
for entry in entries.flatten() {
if entry.path().join("oauth.json").is_file() {
if let Some(name) = entry.file_name().to_str() {
out.push(name.to_string());
}
}
}
out.sort();
out
}
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
fn write_escape(seq: &str) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(seq.as_bytes());
let _ = out.flush();
}
fn paste_into_pick(app: &mut App, text: &str) -> bool {
let Some(pick) = app.documents.as_mut().and_then(|d| d.pick.as_mut()) else {
return false;
};
if !pick.working {
let clean: String = text.split_whitespace().collect();
pick.buffer.insert_str(pick.cursor, &clean);
pick.cursor += clean.len();
pick.bare = false;
}
true
}
fn open_locally(url: &str) -> Result<()> {
let opener = std::env::var("BROWSER")
.ok()
.filter(|b| !b.trim().is_empty())
.unwrap_or_else(|| {
if cfg!(target_os = "macos") {
"open".into()
} else {
"xdg-open".into()
}
});
std::process::Command::new(&opener)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("cannot run {opener}"))?;
Ok(())
}
fn graph_bin() -> String {
std::env::var("MECHA_GRAPH_BIN").unwrap_or_else(|_| "mecha-graph".into())
}
fn graph_cli(args: &[&str]) -> std::result::Result<String, String> {
let bin = graph_bin();
let out = std::process::Command::new(&bin).args(args).output();
let out = match out {
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!(
"`{bin}` not found — install mecha-graph, or set MECHA_GRAPH_BIN to its path"
));
}
Err(e) => return Err(format!("running {bin}: {e}")),
};
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).to_string());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let reason = stderr
.trim()
.lines()
.next()
.filter(|l| !l.trim().is_empty())
.or_else(|| stdout.trim().lines().next())
.unwrap_or("failed")
.trim_start_matches("error: ")
.to_string();
Err(reason)
}
fn entity_lookup(app: &mut App) {
let Some(modal) = &mut app.entities else {
return;
};
let q = modal.query.trim().to_string();
if q.is_empty() {
return;
}
match graph_cli(&["entity", &q, "--json"]) {
Ok(json) => modal.install(&json),
Err(e) => {
modal.rows.clear();
modal.fresh = false;
modal.status = Some(e);
}
}
}
fn handle_entity_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.entities else {
return Ok(());
};
if let Some((kind, buf)) = &mut modal.edit {
let kind = *kind;
match key.code {
KeyCode::Esc => modal.edit = None,
KeyCode::Backspace => {
buf.pop();
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => buf.push(c),
KeyCode::Enter => {
let text = buf.trim().to_string();
modal.edit = None;
if text.is_empty() {
return Ok(());
}
let target = modal.selected_row().map(|r| r.id.clone());
let args: Vec<String> = match kind {
entity::EditKind::NewPerson => vec!["new-person".into(), text.clone()],
_ => {
let Some(id) = target else {
modal.status = Some("no node selected".into());
return Ok(());
};
vec![kind.verb().into(), id, text.clone()]
}
};
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
match graph_cli(&argv) {
Ok(out) => {
modal.status =
Some(out.lines().next().unwrap_or("done").trim().to_string());
if kind == entity::EditKind::NewPerson {
modal.query = text;
}
entity_lookup(app);
}
Err(e) => modal.status = Some(e),
}
}
_ => {}
}
return Ok(());
}
if modal.help {
modal.help = false;
return Ok(());
}
match key.code {
KeyCode::Esc => app.entities = None,
KeyCode::Char('?') => modal.help = true,
KeyCode::Up => modal.move_sel(-1),
KeyCode::Down => modal.move_sel(1),
KeyCode::Enter => entity_lookup(app),
KeyCode::Backspace => {
modal.query.pop();
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let seed = modal.query.trim().to_string();
modal.edit = Some((entity::EditKind::NewPerson, seed));
}
KeyCode::Char('r') if modal.selected_row().is_some() => {
modal.edit = Some((entity::EditKind::Rename, String::new()));
}
KeyCode::Char('a') if modal.selected_row().is_some() => {
modal.edit = Some((entity::EditKind::Alias, String::new()));
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
modal.query.push(c);
}
_ => {}
}
Ok(())
}
fn handle_docs_key(app: &mut App, key: KeyEvent) -> Result<()> {
let Some(modal) = &mut app.documents else {
return Ok(());
};
if modal.help {
modal.help = false;
return Ok(());
}
if let Some(pick) = &mut modal.pick {
match key.code {
KeyCode::Esc => {
modal.pick = None;
modal.status = Some("pick cancelled — the grant is unchanged".into());
}
KeyCode::Enter if !pick.working => {
let pasted = pick.buffer.trim().to_string();
if pasted.is_empty() {
modal.status = Some("paste the address the browser landed on first".into());
return Ok(());
}
pick.working = true;
let account = modal.account.clone();
spawn_docs(
app,
DocsJob::PickDone,
&[
"--account",
&account,
"pick",
"--redirect",
&pasted,
"--json",
],
);
}
KeyCode::Backspace => {
if let Some(at) = prev_boundary(&pick.buffer, pick.cursor) {
pick.buffer.remove(at);
pick.cursor = at;
}
}
KeyCode::Left => pick.cursor = prev_boundary(&pick.buffer, pick.cursor).unwrap_or(0),
KeyCode::Right => pick.cursor = next_boundary(&pick.buffer, pick.cursor),
KeyCode::Home => pick.cursor = 0,
KeyCode::End => pick.cursor = pick.buffer.len(),
_ if typed_char(&key).is_some() => {
let c = typed_char(&key).unwrap_or_default();
if c == 'y' && pick.buffer.is_empty() {
write_escape(&docs::clipboard_escape(&pick.url));
modal.status = Some(
"link sent to your clipboard — if your terminal allows OSC 52 (tmux needs set-clipboard on)"
.into(),
);
return Ok(());
}
if c == 's' && pick.buffer.is_empty() {
pick.bare = !pick.bare;
modal.status = None;
return Ok(());
}
if c == 'o' && pick.buffer.is_empty() {
let url = pick.url.clone();
modal.status = Some(match open_locally(&url) {
Ok(()) => "asked this machine to open it — nothing happens here if the \
browser is on the machine you are sitting at"
.into(),
Err(e) => format!("cannot open it here: {e} — use s or y instead"),
});
return Ok(());
}
pick.buffer.insert(pick.cursor, c);
pick.cursor += c.len_utf8();
}
_ => {}
}
return Ok(());
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.documents = None,
KeyCode::Up | KeyCode::Char('k') => modal.move_by(-1),
KeyCode::Down | KeyCode::Char('j') => modal.move_by(1),
KeyCode::Char('?') => modal.help = true,
KeyCode::Char('r') => {
let account = modal.account.clone();
modal.status = None;
spawn_docs(
app,
DocsJob::List,
&["--account", &account, "list", "--json"],
);
}
KeyCode::Char('a') => {
if let Some(next) = modal.next_account() {
modal.account = next.clone();
modal.rows.clear();
modal.selected = 0;
modal.status = None;
spawn_docs(app, DocsJob::List, &["--account", &next, "list", "--json"]);
}
}
KeyCode::Char('p') => {
let account = modal.account.clone();
modal.status = Some("asking Google for a chooser link…".into());
spawn_docs(
app,
DocsJob::PickUrl,
&["--account", &account, "pick", "--url", "--json"],
);
}
KeyCode::Char('y') => {
let Some(row) = modal.current() else {
return Ok(());
};
let url = row.url();
write_escape(&docs::clipboard_escape(&url));
modal.status = Some(format!(
"{url} — sent to your clipboard if your terminal allows OSC 52"
));
}
KeyCode::Enter => {
let Some(row) = modal.current() else {
return Ok(());
};
let reference = row.reference();
app.documents = None;
if !app.input.is_empty() && !app.input.ends_with(' ') {
app.input.push(' ');
}
app.input.push_str(&reference);
app.cursor = app.input.len();
}
_ => {}
}
Ok(())
}
fn install_docs_answer(app: &mut App, job: DocsJob, answer: Result<String>) {
let Some(modal) = &mut app.documents else {
if let Err(e) = answer {
app.transcript.push(Entry::Error(format!("docs: {e:#}")));
}
return;
};
modal.loading = false;
let text = match answer {
Ok(text) => text,
Err(e) => {
if let Some(pick) = &mut modal.pick {
pick.working = false;
}
modal.status = Some(format!("{e:#}"));
return;
}
};
match job {
DocsJob::List => modal.install(&text),
DocsJob::PickUrl => {
let url = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v["url"].as_str().map(str::to_string));
match url {
Some(url) => {
modal.status = None;
modal.pick = Some(docs::Pick {
url,
buffer: String::new(),
cursor: 0,
working: false,
bare: false,
});
}
None => modal.status = Some("mecha-docs did not return a link".into()),
}
}
DocsJob::PickDone => {
let picked: Vec<String> = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v["picked"].as_array().cloned())
.unwrap_or_default()
.iter()
.map(|f| {
format!(
"{} {}",
f["kind"].as_str().unwrap_or("file"),
f["name"].as_str().unwrap_or("")
)
})
.collect();
modal.pick = None;
modal.status = Some(if picked.is_empty() {
"nothing was picked; the grant was renewed and scope is unchanged".into()
} else {
format!("in scope now: {}", picked.join(", "))
});
let account = modal.account.clone();
spawn_docs(
app,
DocsJob::List,
&["--account", &account, "list", "--json"],
);
}
}
}
fn spawn_draft(app: &mut App, verb: &str, thread: &str, account: &str, to: Option<&str>) {
let exe = crate::exe::self_exe();
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 drop_tests {
use super::*;
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"mecha-drop-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
fn png_at(p: &std::path::Path) {
let img = image::RgbImage::from_fn(8, 8, |_, _| image::Rgb([1, 2, 3]));
image::DynamicImage::ImageRgb8(img)
.save_with_format(p, image::ImageFormat::Png)
.unwrap();
}
#[test]
fn a_dropped_path_is_recognised_however_the_terminal_escaped_it() {
let p = tmp("a shot.png");
png_at(&p);
let d = p.display().to_string();
for paste in [
d.to_string(),
format!("'{d}'"),
format!("\"{d}\""),
d.replace(' ', "\\ "),
] {
let got = drop_paths(&paste).unwrap_or_else(|| panic!("not parsed: {paste}"));
assert_eq!(got, vec![p.clone()], "from {paste}");
}
std::fs::remove_file(&p).ok();
}
#[test]
fn pasted_prose_that_merely_mentions_a_real_image_is_not_a_drop() {
let p = tmp("mentioned.png");
png_at(&p);
let prose = format!("the bug is visible in {} near the top", p.display());
assert!(
drop_paths(&prose).is_none(),
"a sentence is not a drop, even when a path in it resolves"
);
std::fs::remove_file(&p).ok();
}
#[test]
fn a_path_that_does_not_exist_is_not_a_drop() {
assert!(drop_paths("/no/such/shot.png").is_none());
assert!(drop_paths("look at shot.png").is_none());
}
#[test]
fn two_files_dropped_together_are_both_taken() {
let (a, b) = (tmp("one.png"), tmp("two.png"));
png_at(&a);
png_at(&b);
let paste = format!("{} {}", a.display(), b.display());
assert_eq!(drop_paths(&paste).unwrap().len(), 2);
std::fs::remove_file(&a).ok();
std::fs::remove_file(&b).ok();
}
#[test]
fn deleting_the_chip_detaches_the_image_and_keeping_it_does_not() {
let block = MsgBlock::image("image/png", b"xx", Some("shot.png".into()));
let mut app = crate::tui::tests::test_app();
app.dropped = vec![("[image: shot.png]".into(), block.clone())];
assert_eq!(
take_dropped(&mut app, "what is this? [image: shot.png]").len(),
1,
"chip still in the text: the image is sent"
);
app.dropped = vec![("[image: shot.png]".into(), block)];
assert!(
take_dropped(&mut app, "never mind").is_empty(),
"chip deleted: the image is not sent"
);
assert!(
app.dropped.is_empty(),
"and it does not linger to surprise the next turn"
);
}
}
#[cfg(test)]
mod tests {
use super::input_layout;
use super::*;
use ratatui::backend::TestBackend;
use super::Picker;
fn picking_app() -> App {
let mut app = test_app();
let mut modal = docs::DocsModal::new("personal".into(), vec!["personal".into()]);
modal.pick = Some(docs::Pick {
url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=949".into(),
buffer: String::new(),
cursor: 0,
working: false,
bare: false,
});
app.documents = Some(modal);
app
}
fn entity_app() -> App {
let mut app = test_app();
let mut modal = entity::EntityModal::new();
modal.install(
r#"[{"id":"person-53667537","name":"Josephine B. Conley","node_type":"person",
"aliases":["josephine chang"],"interactions":1035,
"facts":[{"statement":"Josephine is one of Luke's twin daughters."}]}]"#,
);
app.entities = Some(modal);
app
}
fn ent_press(app: &mut App, code: KeyCode) {
handle_entity_key(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap();
}
fn ent_press_ctrl(app: &mut App, code: KeyCode) {
handle_entity_key(app, KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap();
}
#[test]
fn typing_reaches_the_query_and_r_opens_a_rename() {
let mut app = entity_app();
ent_press(&mut app, KeyCode::Char('r'));
let modal = app.entities.as_ref().unwrap();
assert_eq!(
modal.edit.as_ref().map(|(k, _)| *k),
Some(entity::EditKind::Rename),
"r on a selected row starts a rename"
);
assert!(modal.query.is_empty(), "r must not also land in the query");
}
#[test]
fn action_letters_are_letters_when_there_is_no_row() {
let mut app = test_app();
app.entities = Some(entity::EntityModal::new());
for c in ['r', 'a', 'n'] {
ent_press(&mut app, KeyCode::Char(c));
}
let modal = app.entities.as_ref().unwrap();
assert_eq!(modal.query, "ran");
assert!(modal.edit.is_none());
}
#[test]
fn an_edit_in_flight_swallows_the_action_letters() {
let mut app = entity_app();
ent_press(&mut app, KeyCode::Char('r'));
for c in ['S', 'a', 'r', 'a'] {
ent_press(&mut app, KeyCode::Char(c));
}
let modal = app.entities.as_ref().unwrap();
assert_eq!(modal.edit.as_ref().unwrap().1, "Sara");
assert!(modal.query.is_empty());
}
#[test]
fn esc_closes_the_edit_before_it_closes_the_modal() {
let mut app = entity_app();
ent_press(&mut app, KeyCode::Char('a'));
assert!(app.entities.as_ref().unwrap().edit.is_some());
ent_press(&mut app, KeyCode::Esc);
assert!(
app.entities.is_some(),
"the modal survives cancelling an edit"
);
assert!(app.entities.as_ref().unwrap().edit.is_none());
ent_press(&mut app, KeyCode::Esc);
assert!(app.entities.is_none());
}
#[test]
fn ctrl_n_prefills_the_new_person_with_the_query() {
let mut app = test_app();
app.entities = Some(entity::EntityModal::new());
for c in ['E', 'd', 'i', 'e'] {
ent_press(&mut app, KeyCode::Char(c));
}
ent_press_ctrl(&mut app, KeyCode::Char('n'));
let (kind, buf) = app.entities.as_ref().unwrap().edit.as_ref().unwrap();
assert_eq!(*kind, entity::EditKind::NewPerson);
assert_eq!(buf, "Edie");
}
#[test]
fn a_pane_of_text_to_copy_hands_the_mouse_back_to_the_terminal() {
assert!(picking_app().wants_the_mouse_back());
let mut listing = picking_app();
listing.documents.as_mut().unwrap().pick = None;
assert!(listing.wants_the_mouse_back());
let mut helping = test_app();
helping.help = true;
assert!(helping.wants_the_mouse_back());
assert!(!test_app().wants_the_mouse_back());
}
#[test]
fn selection_mode_takes_the_mouse_and_says_so_on_the_strip() {
let mut app = test_app();
app.selecting = true;
assert!(app.wants_the_mouse_back());
let strip: String = app
.status("claude-opus-5", "anthropic", 10)
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert!(strip.contains("select ^S"), "{strip}");
app.selecting = false;
let off: String = app
.status("claude-opus-5", "anthropic", 10)
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert!(!off.contains("select"), "{off}");
}
#[test]
fn a_paste_while_picking_lands_in_the_field_and_not_the_message_box() {
let mut app = picking_app();
assert!(paste_into_pick(
&mut app,
"http://127.0.0.1:8765/callback?state=pEykz\n6Jtni&code=4/0AX"
));
let pick = app.documents.as_ref().unwrap().pick.as_ref().unwrap();
assert_eq!(
pick.buffer,
"http://127.0.0.1:8765/callback?state=pEykz6Jtni&code=4/0AX"
);
assert_eq!(pick.cursor, pick.buffer.len());
assert!(
app.input.is_empty(),
"it went to the prompt: {:?}",
app.input
);
}
#[test]
fn a_paste_with_no_pick_up_is_still_the_message_box_s() {
let mut app = test_app();
assert!(!paste_into_pick(&mut app, "some prose"));
}
pub(super) 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,
selecting: false,
help: false,
help_scroll: 0,
tools: None,
skills: None,
skills_dir: std::path::PathBuf::from("/nonexistent-skills"),
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,
find: None,
documents: None,
entities: None,
tasks: None,
queues: 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,
attach_tx: mpsc::unbounded_channel().0,
attached: None,
dropped: Vec::new(),
attaching: None,
last_waiting_note: None,
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 remote_text_that_would_be_a_command_is_recognised_as_one() {
for dangerous in ["/clear", "/model claude-opus-5", "/mode allow", "!rm -rf ."] {
assert!(
command::parse(dangerous).is_some() || command::shell_escape(dangerous).is_some(),
"{dangerous} would have been sent to the model as a prompt"
);
}
for ordinary in [
"summarise the inbox",
"what did the 7am briefing say?",
"why / how did that fail",
] {
assert!(
command::parse(ordinary).is_none() && command::shell_escape(ordinary).is_none(),
"{ordinary} would have been refused"
);
}
}
#[test]
fn a_rebuild_keeps_the_permission_mode_it_was_in() {
let (tui, _rx) = approve::TuiApprover::new();
let retained: Arc<dyn Approver> = Arc::new(tui);
let ask = approver_for(PermissionMode::Ask, &retained);
assert!(
Arc::ptr_eq(&ask, &retained),
"ask must reinstate the approver wired to the event loop"
);
for other in [PermissionMode::ReadOnly, PermissionMode::Allow] {
let got = approver_for(other, &retained);
assert!(
!Arc::ptr_eq(&got, &retained),
"{other:?} silently fell back to asking"
);
}
}
#[test]
fn an_attached_session_says_so_on_the_always_visible_strip() {
let mut app = test_app();
assert!(
!frame_text(&mut app, 110, 12, None).contains("⇄"),
"an unattached session must claim nothing"
);
app.attached = Some(crate::slack::remote::Attached {
name: "lab".into(),
channel_id: "D1".into(),
thread_ts: "1755.0001".into(),
slack: mecha_slack::Slack::new("xoxb-not-a-real-token"),
flush_chars: 400,
flush_ms: 700,
});
let attached = frame_text(&mut app, 110, 12, None);
assert!(attached.contains("⇄ lab"), "{attached}");
}
#[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, 40, 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, 40, None);
assert!(kitty.contains("shift+enter"), "{kitty}");
}
#[test]
fn the_help_overlay_shows_its_longest_line_in_full() {
let mut app = test_app();
app.help = true;
let text = frame_text(&mut app, 120, 60, None);
for line in command::HELP.lines() {
assert!(
text.contains(line.trim_end()),
"truncated: {:?}\n{text}",
line
);
}
}
#[test]
fn a_short_terminal_scrolls_the_help_instead_of_swallowing_half_of_it() {
let mut app = test_app();
app.help = true;
let top = frame_text(&mut app, 120, 26, None);
assert!(top.contains("↑↓ scrolls"), "{top}");
assert!(top.contains("enter"), "{top}");
assert!(!top.contains("/exit"), "the tail is below the fold: {top}");
app.help_scroll = 99;
let bottom = frame_text(&mut app, 120, 26, None);
assert!(bottom.contains("/exit"), "{bottom}");
assert!(app.help_scroll < 99, "scroll was not clamped");
}
#[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}");
}
fn pending_row(id: &str) -> outbox::OutboxRow {
outbox::OutboxRow {
id: id.into(),
status: "pending".into(),
kind: mecha_core::outbox::OutboxKind::Message,
summary: "docs__docs_replace".into(),
tainted: true,
edited: false,
args_text: "find Spring 2024".into(),
error: None,
detail: Vec::new(),
raw: Vec::new(),
}
}
fn press(app: &mut App, c: char) {
handle_outbox_key(
app,
KeyEvent {
code: KeyCode::Char(c),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
},
)
.unwrap();
}
#[test]
fn approve_answers_to_a_and_still_to_s() {
for key in ['a', 's'] {
let mut app = test_app();
app.staged = Some(outbox::OutboxModal::new(vec![pending_row("abc123")]));
press(&mut app, key);
let confirm = &app.staged.as_ref().unwrap().confirm;
assert!(
confirm.as_ref().is_some_and(|c| c.id == "abc123"),
"`{key}` did not open the approval"
);
}
}
#[test]
fn scroll_keys_do_not_dismiss_the_approval() {
let mut app = test_app();
app.staged = Some(outbox::OutboxModal::new(vec![pending_row("abc123")]));
press(&mut app, 'a');
let scroll = |app: &App| {
app.staged
.as_ref()
.unwrap()
.confirm
.as_ref()
.map(|c| c.scroll)
};
press(&mut app, 'j');
assert_eq!(scroll(&app), Some(1), "`j` did not scroll down");
press(&mut app, 'j');
assert_eq!(scroll(&app), Some(2), "`j` did not keep scrolling");
press(&mut app, 'k');
assert_eq!(scroll(&app), Some(1), "`k` did not scroll back up");
for _ in 0..5 {
press(&mut app, 'k');
}
assert_eq!(scroll(&app), Some(0), "scrolling up past the top wrapped");
press(&mut app, 'z');
assert!(
app.staged.as_ref().unwrap().confirm.is_none(),
"an unrelated key must still keep the draft pending"
);
}
#[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 {
scroll: 0,
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 approve"), "{frame}");
app.staged.as_mut().unwrap().confirm = Some(outbox::SendConfirm {
scroll: 0,
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("approve 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,
detail_scroll: 0,
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}"
);
}
fn skill_row(name: &str) -> skills::SkillRow {
skills::SkillRow {
name: name.into(),
description: "how to answer a rec-letter request".into(),
triggers: Vec::new(),
narrows: None,
body: "1. read the request\n2. draft it".into(),
dir: std::path::PathBuf::from("/skills").join(name),
carried: true,
loaded: false,
error: None,
}
}
#[test]
fn the_skills_list_separates_loaded_withheld_and_failed() {
let mut app = test_app();
app.skills = Some(skills::SkillsModal {
rows: vec![
skills::SkillRow {
loaded: true,
..skill_row("rec-letter")
},
skills::SkillRow {
carried: false,
..skill_row("expenses")
},
skills::SkillRow {
error: Some("missing `description`".into()),
carried: false,
..skill_row("halfwritten")
},
],
selected: 0,
detail: false,
detail_scroll: 0,
dir: std::path::PathBuf::from("/skills"),
});
let text = frame_text(&mut app, 110, 30, None);
assert!(text.contains("rec-letter"), "{text}");
assert!(text.contains("loaded"), "{text}");
assert!(text.contains("withheld"), "{text}");
assert!(
text.contains("failed") && text.contains("missing `description`"),
"a broken SKILL.md is only ever visible here — the startup warning \
goes to a stderr the alternate screen ate: {text}"
);
assert!(
text.contains("1 of 2 skills carried"),
"the ratio excludes what could not load: {text}"
);
}
#[test]
fn the_skills_detail_names_the_narrowing_and_that_it_is_in_force() {
let mut app = test_app();
app.skills = Some(skills::SkillsModal {
rows: vec![skills::SkillRow {
narrows: Some(vec!["fs_read".into(), "mail_send".into()]),
loaded: true,
..skill_row("rec-letter")
}],
selected: 0,
detail: true,
detail_scroll: 0,
dir: std::path::PathBuf::from("/skills"),
});
let text = frame_text(&mut app, 100, 30, None);
assert!(text.contains("narrows the tool surface to"), "{text}");
assert!(text.contains("fs_read"), "{text}");
assert!(text.contains("in force until /clear"), "{text}");
assert!(
text.contains("read the request"),
"the procedure itself is the point of the detail view: {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);
}
fn at(text: &str, cursor: usize, width: u16) -> (u16, u16, usize) {
let l = input_layout(text, cursor, width);
(l.cursor_col, l.cursor_row, l.rows.len())
}
fn painted(text: &str, width: u16) -> Vec<String> {
input_layout(text, 0, width)
.rows
.iter()
.map(|r| text[r.clone()].trim_end_matches('\n').to_string())
.collect()
}
#[test]
fn a_chord_is_not_the_letter_it_is_spelled_with() {
use crossterm::event::KeyEventKind;
let key = |c, m| KeyEvent {
code: KeyCode::Char(c),
modifiers: m,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
};
assert_eq!(typed_char(&key('c', KeyModifiers::NONE)), Some('c'));
assert_eq!(typed_char(&key('C', KeyModifiers::SHIFT)), Some('C'));
for m in [
KeyModifiers::CONTROL,
KeyModifiers::ALT,
KeyModifiers::SUPER,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
] {
assert_eq!(typed_char(&key('a', m)), None, "{m:?}");
}
assert_eq!(
typed_char(&KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}),
None
);
}
#[test]
fn no_mail_action_can_be_reached_by_a_chord() {
for c in ['a', 's', 't', 'd', 'n', 'r', 'f', 'e', 'q'] {
assert!(mail::action_for(c).is_some(), "{c} should be an action");
let chord = KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
};
assert!(
typed_char(&chord).and_then(mail::action_for).is_none(),
"ctrl+{c} must not reach an action"
);
}
}
#[test]
fn the_rows_partition_the_text_so_nothing_is_shown_twice_or_lost() {
for text in [
"",
"\n",
"a\n\nb",
"short",
"a much longer line that has to wrap somewhere",
"https://example.com/a/very/long/path/that/cannot/fit/in/one/row",
" leading and trailing ",
"naïve 日本語 mixed",
] {
for width in [1u16, 3, 7, 12, 40] {
let rows = input_layout(text, 0, width).rows;
assert_eq!(rows[0].start, 0, "{text:?} @ {width}");
assert_eq!(rows.last().unwrap().end, text.len(), "{text:?} @ {width}");
for pair in rows.windows(2) {
assert_eq!(pair[0].end, pair[1].start, "{text:?} @ {width}");
}
}
}
}
#[test]
fn a_word_moves_down_whole_and_the_caret_goes_with_it() {
let text = "can you help me prepare my schedule for my undergrad fMRI class";
assert_eq!(
painted(text, 30),
vec![
"can you help me prepare my ",
"schedule for my undergrad fMRI ",
"class",
]
);
assert_eq!(at(text, text.len(), 30), (5, 2, 3));
}
#[test]
fn a_wide_character_costs_two_cells() {
assert_eq!(at("日本語", 9, 10), (6, 0, 1));
assert_eq!(painted("日本語だ", 7), vec!["日本語", "だ"]);
}
#[test]
fn the_cursor_tracks_plain_wrapping() {
assert_eq!(at("abcdefghijk", 11, 10), (1, 1, 2));
assert_eq!(at("abc", 3, 10), (3, 0, 1));
assert_eq!(at("", 0, 10), (0, 0, 1));
}
#[test]
fn a_caret_at_the_end_of_a_full_row_starts_the_next_one() {
assert_eq!(at("abcdefghij", 10, 10), (0, 1, 2));
}
#[test]
fn a_pasted_newline_breaks_the_line_instead_of_being_counted_as_a_character() {
let text = "one\ntwo";
assert_eq!(at(text, text.len(), 40), (3, 1, 2));
let three = "a\nb\nc";
assert_eq!(at(three, three.len(), 40), (1, 2, 3));
assert_eq!(at(text, 3, 40), (3, 0, 2));
assert_eq!(at(text, 4, 40), (0, 1, 2));
}
#[test]
fn a_trailing_newline_leaves_an_empty_row_to_type_on() {
assert_eq!(at("hi\n", 3, 40), (0, 1, 2));
assert_eq!(painted("hi\n", 40), vec!["hi", ""]);
}
#[test]
fn a_cursor_in_the_middle_of_pasted_text_lands_on_the_right_row() {
let text = "one\ntwo\nthree";
assert_eq!(at(text, 8, 40), (0, 2, 3));
}
#[test]
fn a_zero_width_terminal_does_not_divide_by_zero() {
assert!(!input_layout("abc", 3, 0).rows.is_empty());
}
#[test]
fn a_long_message_scrolls_instead_of_eating_the_transcript() {
let mut app = test_app();
app.input = (0..12)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
app.cursor = app.input.len();
let text = frame_text(&mut app, 40, 30, None);
assert!(text.contains("line 12/12"), "{text}");
assert!(text.contains("line11"), "{text}");
assert!(text.contains("line6"), "{text}");
assert!(!text.contains("line5"), "{text}");
}
#[test]
fn the_input_box_draws_what_the_caret_was_measured_against() {
let mut app = test_app();
app.input = "can you help me prepare my schedule for my undergrad fMRI class".into();
app.cursor = app.input.len();
let (width, height) = (32u16, 20u16);
let text = frame_text(&mut app, width, height, None);
let layout = input_layout(&app.input, app.cursor, width - 2);
let last = text
.lines()
.rev()
.find(|l| l.contains("class"))
.expect("the last row of the input is on screen");
let painted = last.trim_end().trim_start_matches('\u{2502}');
let painted = painted.trim_end_matches('\u{2502}');
assert_eq!(painted.trim_end(), "class");
assert_eq!(layout.cursor_col as usize, "class".len());
}
}