use mecha_core::outbox::{DraftView, OutboxItem, OutboxKind};
use mecha_core::outbox_source::SourceRead;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
pub struct OutboxRow {
pub id: String,
pub status: String,
pub kind: OutboxKind,
pub summary: String,
pub tainted: bool,
pub edited: bool,
pub args_text: String,
pub error: Option<String>,
pub detail: Vec<Line<'static>>,
pub raw: Vec<Line<'static>>,
}
impl OutboxRow {
pub fn pending(&self) -> bool {
self.status == "pending"
}
fn status_style(&self) -> Style {
match self.status.as_str() {
"pending" if self.tainted => Style::new().fg(Color::Red),
"pending" => Style::new().fg(Color::Yellow),
"sent" => Style::new().fg(Color::Green),
_ => Style::new().fg(Color::DarkGray),
}
}
}
pub struct SendConfirm {
pub id: String,
pub summary: String,
pub tainted: bool,
pub args_text: String,
pub error_before: Option<String>,
pub scroll: u16,
}
pub struct ReasonInput {
pub id: String,
pub buffer: String,
}
pub struct OutboxModal {
pub rows: Vec<OutboxRow>,
pub selected: usize,
pub detail: bool,
pub detail_scroll: u16,
pub show_raw: bool,
pub history: bool,
pub confirm: Option<SendConfirm>,
pub rejecting: Option<ReasonInput>,
pub status: Option<String>,
pub scope: Option<Vec<String>>,
}
impl OutboxModal {
pub fn new(rows: Vec<OutboxRow>) -> Self {
OutboxModal {
rows,
selected: 0,
detail: false,
detail_scroll: 0,
show_raw: false,
history: false,
confirm: None,
rejecting: None,
status: None,
scope: None,
}
}
pub fn shown(&self) -> Vec<&OutboxRow> {
self.rows
.iter()
.filter(|r| self.history || r.pending())
.collect()
}
pub fn resolved_count(&self) -> usize {
self.rows.iter().filter(|r| !r.pending()).count()
}
pub fn selected_row(&self) -> Option<&OutboxRow> {
self.shown().get(self.selected).copied()
}
pub fn toggle_history(&mut self) {
let under_cursor = self.selected_row().map(|r| r.id.clone());
self.history = !self.history;
self.selected = under_cursor
.and_then(|id| self.shown().iter().position(|r| r.id == id))
.unwrap_or(0);
self.detail_scroll = 0;
}
pub fn move_by(&mut self, delta: isize) {
let len = self.shown().len() as isize;
if len == 0 {
return;
}
self.selected = (((self.selected as isize + delta) % len + len) % len) as usize;
self.detail_scroll = 0;
}
pub fn scroll_detail(&mut self, delta: i16) {
self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
}
fn list_scroll(&self, visible: u16) -> u16 {
let visible = visible.max(1) as usize;
(self.selected + 1).saturating_sub(visible) as u16
}
pub fn title(&self) -> String {
let scope = if self.scope.is_some() {
"this run's drafts · "
} else {
""
};
match &self.status {
Some(s) => format!(" outbox · {scope}{s} "),
None => {
let pending = self.rows.iter().filter(|r| r.pending()).count();
let resolved = self.resolved_count();
let history = match (self.history, resolved) {
(_, 0) => String::new(),
(true, n) => format!("with {n} resolved · h hides · "),
(false, n) => format!("h shows {n} resolved · "),
};
format!(
" {scope}{pending} pending · {history}enter detail · a approve · e edit · r reject · esc "
)
}
}
}
pub fn draw(&self, frame: &mut Frame) {
if let Some(confirm) = &self.confirm {
self.draw_confirm(frame, confirm);
} else if let Some(input) = &self.rejecting {
draw_reason_input(
frame,
&format!(
" reject {} — reason (optional) · enter rejects · esc keeps ",
input.id
),
&input.buffer,
);
} else if self.detail {
self.draw_detail(frame);
} else {
self.draw_list(frame);
}
}
fn draw_list(&self, frame: &mut Frame) {
let shown = self.shown();
let body: Vec<Line> = if shown.is_empty() {
let grey = Style::new().fg(Color::DarkGray);
match self.resolved_count() {
0 => vec![Line::styled(
" outbox empty — calls to [outbox]-routed tools are staged here",
grey,
)],
n => vec![Line::styled(
format!(" nothing pending — h shows the {n} already decided"),
grey,
)],
}
} else {
shown
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == self.selected;
let marker = if selected { "›" } else { " " };
let text = format!(
"{marker} {:<14} {:<9} {:<8} {}{}{}",
row.id,
row.status,
row.kind.as_str(),
row.summary,
if row.tainted { " ⚠ tainted" } else { "" },
if row.edited { " (edited)" } else { "" },
);
if selected {
Line::styled(text, Style::new().fg(Color::Black).bg(Color::Cyan))
} else {
Line::styled(text, row.status_style())
}
})
.collect()
};
let height = super::list_height(body.len() as u16, frame.area().height);
let area = super::centered(frame.area(), 110, height);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body)
.scroll((self.list_scroll(area.height.saturating_sub(2)), 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(self.title()),
),
area,
);
}
fn draw_detail(&self, frame: &mut Frame) {
let Some(row) = self.selected_row() else {
return;
};
let (body, what) = if self.show_raw {
(row.raw.clone(), "J readable")
} else {
(row.detail.clone(), "J raw")
};
let area = super::centered(frame.area(), 100, frame.area().height.saturating_sub(4));
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(format!(
" {} · ↑↓ scroll · a approve · e edit · r reject · {what} · esc back ",
row.id
)),
),
area,
);
}
fn draw_confirm(&self, frame: &mut Frame, confirm: &SendConfirm) {
let mut body: Vec<Line> = Vec::new();
if confirm.tainted {
body.push(Line::styled(
"⚠ drafted in a conversation holding private data AND third-party",
Style::new().fg(Color::Red),
));
body.push(Line::styled(
"content — review these arguments as possibly an attacker's words:",
Style::new().fg(Color::Red),
));
body.push(Line::raw(""));
for line in confirm.args_text.lines() {
body.push(Line::styled(
line.to_string(),
Style::new().fg(Color::White),
));
}
}
let area = super::centered(frame.area(), 90, frame.area().height.saturating_sub(4));
let inner_width = area.width.saturating_sub(2);
let inner_height = area.height.saturating_sub(2);
let paragraph = Paragraph::new(body).wrap(Wrap { trim: false });
let drawn = paragraph.line_count(inner_width) as u16;
let max_scroll = drawn.saturating_sub(inner_height);
let scroll = confirm.scroll.min(max_scroll);
let hint = if max_scroll > 0 {
format!(
" y approve · ↑↓ scroll ({} more line(s) below) · any other key keeps it pending ",
max_scroll.saturating_sub(scroll)
)
} else {
" y approve · any other key keeps it pending ".to_string()
};
frame.render_widget(Clear, area);
frame.render_widget(
paragraph.scroll((scroll, 0)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(if confirm.tainted {
Color::Red
} else {
Color::Yellow
}))
.title(format!(" approve {} — {}? ", confirm.id, confirm.summary))
.title_bottom(hint),
),
area,
);
}
}
pub fn draw_reason_input(frame: &mut Frame, title: &str, buffer: &str) {
let body = vec![Line::styled(
format!("{buffer}▏"),
Style::new().fg(Color::White),
)];
let area = super::centered(frame.area(), 90, 3);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Yellow))
.title(title.to_string()),
),
area,
);
}
pub fn load() -> anyhow::Result<Vec<OutboxRow>> {
let store = crate::commands::outbox::open_store()?;
let mut items = store.items()?;
items.sort_by_key(|i| i.status != "pending");
Ok(items.iter().map(row).collect())
}
fn row(item: &OutboxItem) -> OutboxRow {
OutboxRow {
id: item.id.clone(),
status: item.status.clone(),
kind: item.kind,
summary: item.summary.clone(),
tainted: item.taint.trifecta_armed(),
edited: item.edited(),
args_text: confirm_text(item),
error: item.error.clone(),
detail: detail_lines(item, &crate::commands::outbox::source_reads(item)),
raw: raw_lines(item),
}
}
fn detail_lines(item: &OutboxItem, reads: &[SourceRead]) -> Vec<Line<'static>> {
let mut body: Vec<Line<'static>> = Vec::new();
let white = Style::new().fg(Color::White);
let grey = Style::new().fg(Color::DarkGray);
let header = Style::new().fg(Color::Yellow);
let red = Style::new().fg(Color::Red);
if item.taint.trifecta_armed() {
for line in [
"⚠ drafted in a conversation holding private data AND third-party",
"content — read this as possibly an attacker's words, not the",
"assistant's.",
] {
body.push(Line::styled(line, red));
}
body.push(Line::raw(""));
}
if let Some(error) = &item.error {
body.push(Line::styled(
format!("last send attempt failed: {error}"),
red,
));
body.push(Line::raw(""));
}
match item.kind {
OutboxKind::Message => {
let view = DraftView::of(&item.args);
for (name, value) in &view.headers {
body.push(Line::from(vec![
Span::styled(format!("{name:<9} "), grey),
Span::styled(value.clone(), white),
]));
}
if let Some(text) = &view.body {
body.push(Line::raw(""));
for line in text.lines() {
body.push(Line::styled(line.to_string(), white));
}
}
if !view.other.is_empty() {
body.push(Line::raw(""));
body.push(Line::styled("other arguments", header));
for (name, value) in &view.other {
body.push(Line::from(vec![
Span::styled(format!("{name:<9} "), grey),
Span::styled(value.clone(), grey),
]));
}
}
if item.edited() {
body.push(Line::raw(""));
body.push(Line::styled("edited since drafting", header));
for line in mecha_core::outbox::diff_args(&item.args_before, &item.args).lines() {
let style = match line.trim_start().chars().next() {
Some('+') => Style::new().fg(Color::Green),
Some('-') => red,
_ => grey,
};
body.push(Line::styled(line.to_string(), style));
}
}
}
OutboxKind::Publish => {
for (label, path) in
crate::commands::outbox::local_paths(&item.args, item.workspace.as_deref())
{
body.push(Line::styled(format!("{label}: {}", path.display()), white));
if let Some(entry) = crate::commands::outbox::entry_point(&path) {
body.push(Line::styled(format!("open {}", entry.display()), grey));
}
if !path.exists() {
body.push(Line::styled(
"⚠ gone — rendered into a work directory retention may have swept; \
re-render before releasing",
red,
));
}
}
body.push(Line::raw(""));
body.push(Line::styled("what a release would publish", header));
for line in pretty(&item.args).lines() {
body.push(Line::styled(line.to_string(), white));
}
}
}
for read in reads {
body.push(Line::raw(""));
body.push(Line::styled(read.heading(), header));
for line in read.text.lines() {
body.push(Line::styled(format!("│ {line}"), grey));
}
}
body.push(Line::raw(""));
for line in provenance(item) {
body.push(Line::styled(line, grey));
}
body
}
fn provenance(item: &OutboxItem) -> Vec<String> {
let mut out = vec![
format!("{} · {} · {}", item.kind.as_str(), item.tool, item.status),
format!("created {}", item.created_at),
];
if let Some(session) = &item.session_id {
out.push(format!("drafted by session {session}"));
}
if let Some(workspace) = &item.workspace {
out.push(format!("jailed to {}", workspace.display()));
}
if let Some(resolved) = &item.resolved_at {
out.push(format!(
"resolved {resolved}{}",
item.reason
.as_deref()
.map(|r| format!(" — {r}"))
.unwrap_or_default()
));
}
out
}
fn confirm_text(item: &OutboxItem) -> String {
if item.kind != OutboxKind::Message {
return pretty(&item.args);
}
let view = DraftView::of(&item.args);
let mut out: Vec<String> = view
.headers
.iter()
.map(|(k, v)| format!("{k:<9} {v}"))
.collect();
if let Some(body) = &view.body {
out.push(String::new());
out.extend(body.lines().map(String::from));
}
if !view.other.is_empty() {
out.push(String::new());
out.extend(view.other.iter().map(|(k, v)| format!("{k:<9} {v}")));
}
out.join("\n")
}
fn raw_lines(item: &OutboxItem) -> Vec<Line<'static>> {
let mut body = vec![Line::styled(
"arguments a release would execute",
Style::new().fg(Color::Yellow),
)];
for line in pretty(&item.args).lines() {
body.push(Line::styled(
line.to_string(),
Style::new().fg(Color::White),
));
}
body
}
fn pretty(v: &serde_json::Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
pub(crate) fn text(lines: &[Line]) -> String {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn item(id: &str, status: &str, kind: OutboxKind) -> OutboxItem {
OutboxItem {
id: id.into(),
status: status.into(),
tool: "mail__mail_send".into(),
kind,
args_before: json!({"to": "a@example.com", "body": "hi"}),
args: json!({"to": "a@example.com", "body": "hi"}),
summary: "mail to a@example.com".into(),
session_id: None,
workspace: None,
taint: Default::default(),
created_at: "2026-08-08T07:00:00Z".into(),
resolved_at: None,
reason: None,
error: None,
}
}
fn rows() -> Vec<OutboxRow> {
vec![
row(&item("aaa1", "pending", OutboxKind::Message)),
row(&item("bbb1", "sent", OutboxKind::Message)),
]
}
#[test]
fn the_title_counts_pending_and_names_the_keys() {
let modal = OutboxModal::new(rows());
let title = modal.title();
assert!(title.contains("1 pending"), "{title}");
assert!(title.contains("h shows 1 resolved"), "{title}");
for key in ["enter", "a approve", "e edit", "r reject", "esc"] {
assert!(title.contains(key), "{key} missing from {title}");
}
let done = OutboxModal {
status: Some("rejected `aaa1`".into()),
..modal
};
assert!(done.title().contains("rejected `aaa1`"));
}
#[test]
fn a_scoped_modal_says_whose_drafts_it_is_showing() {
let scoped = OutboxModal {
scope: Some(vec!["aaa1".into()]),
..OutboxModal::new(rows())
};
assert!(
scoped.title().contains("this run's drafts"),
"{}",
scoped.title()
);
let with_status = OutboxModal {
status: Some("sent `aaa1`".into()),
..scoped
};
assert!(
with_status.title().contains("this run's drafts"),
"{}",
with_status.title()
);
assert!(
!OutboxModal::new(rows()).title().contains("this run"),
"the full queue does not claim a scope"
);
}
#[test]
fn the_selection_wraps_and_an_empty_list_does_not_panic() {
let mut modal = OutboxModal {
history: true,
..OutboxModal::new(rows())
};
modal.move_by(-1);
assert_eq!(modal.selected, 1);
modal.move_by(1);
assert_eq!(modal.selected, 0);
let mut empty = OutboxModal::new(Vec::new());
empty.move_by(1);
assert_eq!(empty.selected, 0);
assert!(empty.selected_row().is_none());
}
#[test]
fn resolved_items_are_hidden_until_asked_for() {
let mut modal = OutboxModal::new(rows());
assert_eq!(modal.shown().len(), 1);
assert_eq!(modal.selected_row().map(|r| r.id.as_str()), Some("aaa1"));
modal.toggle_history();
assert_eq!(modal.shown().len(), 2);
assert_eq!(modal.selected_row().map(|r| r.id.as_str()), Some("aaa1"));
modal.toggle_history();
assert_eq!(modal.selected_row().map(|r| r.id.as_str()), Some("aaa1"));
}
#[test]
fn hiding_history_from_a_resolved_row_lands_somewhere_real() {
let mut modal = OutboxModal {
history: true,
selected: 1,
..OutboxModal::new(rows())
};
assert_eq!(modal.selected_row().map(|r| r.id.as_str()), Some("bbb1"));
modal.toggle_history();
assert_eq!(modal.selected_row().map(|r| r.id.as_str()), Some("aaa1"));
}
#[test]
fn moving_resets_the_detail_scroll() {
let mut modal = OutboxModal::new(rows());
modal.scroll_detail(15);
modal.move_by(1);
assert_eq!(modal.detail_scroll, 0);
}
#[test]
fn the_detail_leads_with_the_letter_and_ends_with_the_provenance() {
let item = item("aaa1", "pending", OutboxKind::Message);
let body = text(&detail_lines(&item, &[]));
let letter = body.find("hi").expect("the body is shown");
let created = body.find("created").expect("the provenance is kept");
assert!(letter < created, "the draft comes first:\n{body}");
assert!(
!body.contains("body_markdown"),
"no JSON in the read:\n{body}"
);
let raw = text(&raw_lines(&item));
assert!(raw.contains("arguments a release would execute"), "{raw}");
assert!(raw.contains("\"body\""), "{raw}");
}
#[test]
fn the_body_keeps_its_newlines() {
let mut item = item("aaa1", "pending", OutboxKind::Message);
item.args = json!({"to": "a@example.com", "body_markdown": "Dear A,\n\nHello.\n\nLuke"});
let body = text(&detail_lines(&item, &[]));
assert!(body.contains("\nDear A,\n\nHello.\n\nLuke\n"), "{body}");
}
#[test]
fn the_detail_shows_the_release_arguments_and_the_taint_warning() {
let clean = detail_lines(&item("aaa1", "pending", OutboxKind::Message), &[]);
let body = text(&clean);
assert!(body.contains("a@example.com"), "{body}");
assert!(
!body.contains("attacker"),
"untainted drafts warn of nothing"
);
let mut tainted = item("aaa1", "pending", OutboxKind::Message);
tainted.taint = mecha_core::agent::Taint {
private: true,
untrusted: true,
};
let body = text(&detail_lines(&tainted, &[]));
assert!(body.contains("attacker"), "{body}");
}
#[test]
fn the_source_read_sits_below_the_letter_and_never_reads_as_part_of_it() {
let read = SourceRead {
tool: "mail__mail_get_thread".into(),
keys: vec!["thread_id".into()],
join: mecha_core::outbox_source::Join::Asked,
text: "Dear Dr. Chang,\n\nI am an incoming freshman.".into(),
};
let body = text(&detail_lines(
&item("aaa1", "pending", OutboxKind::Message),
std::slice::from_ref(&read),
));
let (draft, source) = body
.split_once(read.heading().split(" — ").next().unwrap())
.expect("the source read is headed, not appended silently");
assert!(draft.contains("\nhi\n"), "the draft comes first: {body}");
assert!(
source.contains("mail__mail_get_thread") && source.contains("third-party"),
"the heading names the tool and says whose words these are: {body}"
);
assert!(source.contains("│ Dear Dr. Chang,"), "{body}");
assert!(body.find("\nhi\n") < body.find("Dear Dr. Chang,"), "{body}");
}
#[test]
fn an_edited_item_shows_the_diff_the_learning_capture_will_mine() {
let mut edited = item("aaa1", "pending", OutboxKind::Message);
edited.args = json!({"to": "a@example.com", "body": "hello"});
let body = text(&detail_lines(&edited, &[]));
assert!(body.contains("edited since drafting"), "{body}");
assert!(body.contains("hello"), "{body}");
}
#[test]
fn a_publish_detail_leads_with_the_page_and_warns_when_it_is_gone() {
let mut publish = item("bbb1", "pending", OutboxKind::Publish);
publish.args = json!({"bundle": "/nonexistent/bundle-dir", "visibility": "public"});
let body = text(&detail_lines(&publish, &[]));
assert!(
body.contains("rendered bundle: /nonexistent/bundle-dir"),
"{body}"
);
assert!(body.contains("⚠ gone"), "{body}");
assert!(body.contains("what a release would publish"), "{body}");
}
#[test]
fn pending_sorts_first_so_the_queue_opens_on_what_needs_deciding() {
let mut items = [
item("sent1", "sent", OutboxKind::Message),
item("pend1", "pending", OutboxKind::Message),
item("pend2", "pending", OutboxKind::Message),
];
items.sort_by_key(|i| i.status != "pending");
assert_eq!(
items.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(),
vec!["pend1", "pend2", "sent1"]
);
}
#[test]
fn a_tiny_terminal_shrinks_the_list_rather_than_panicking() {
let modal = OutboxModal::new(rows());
for height in 0..=6u16 {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, height.max(1)))
.unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
}
}
fn rendered(modal: &OutboxModal, w: u16, h: u16) -> String {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(w, h)).unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
fn confirming(args: &str) -> OutboxModal {
OutboxModal {
confirm: Some(SendConfirm {
id: "aaa1".into(),
summary: "docs__docs_replace".into(),
tainted: true,
args_text: args.into(),
error_before: None,
scroll: 0,
}),
..OutboxModal::new(rows())
}
}
#[test]
fn the_prompt_survives_arguments_longer_than_the_terminal() {
let long = (0..200)
.map(|i| format!("schedule line {i} with enough width to wrap on a narrow box"))
.collect::<Vec<_>>()
.join("\n");
let screen = rendered(&confirming(&long), 100, 24);
assert!(
screen.contains("y approve"),
"the approve prompt was pushed off screen"
);
assert!(
screen.contains("more line(s) below"),
"nothing told the reviewer there was more to read"
);
}
#[test]
fn scrolling_moves_through_the_arguments() {
let long = (0..200)
.map(|i| format!("schedule line {i}"))
.collect::<Vec<_>>()
.join("\n");
let top = rendered(&confirming(&long), 100, 24);
assert!(top.contains("schedule line 0"), "top should show the start");
let mut scrolled = confirming(&long);
scrolled.confirm.as_mut().unwrap().scroll = 60;
let lower = rendered(&scrolled, 100, 24);
assert!(
!lower.contains("schedule line 0 "),
"scrolling did not move the view"
);
assert!(
lower.contains("y approve"),
"the prompt must stay pinned while scrolling"
);
}
#[test]
fn a_short_draft_gets_no_scroll_hint() {
let screen = rendered(&confirming("find Spring 2024"), 100, 24);
assert!(screen.contains("y approve"));
assert!(!screen.contains("more line(s) below"));
}
}