use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use agent_client_protocol::schema::v1::StopReason;
use bitrouter_substrate::translate::{PermissionOutcome, SessionUpdateKind, ToolStatus};
use crate::risk::Risk;
use crate::tui::event::{AppEvent, DiffData, Effect, PermOption};
use super::diff::{Line, unified_to_lines};
use super::keys::{
reduce_click, reduce_key_command, reduce_key_confirm, reduce_key_leader, reduce_key_normal,
reduce_key_picker,
};
use super::layout::DetailLayout;
use super::overlay::Mode;
use super::pane::{Autonomy, Ownership, PaneKind, PaneState, PendingView, TailKind};
use super::{AppState, CHECK_RETRY_CAP, NOTICE_DECAY_TICKS, mark_shown_seen, stop_label};
pub(super) fn reduce_inner(state: &mut AppState, event: &AppEvent) -> Vec<Effect> {
match event {
AppEvent::Update { record_id, update } => on_update(state, record_id, update),
AppEvent::TurnEnded {
record_id,
stop_reason,
} => on_turn_ended(state, record_id, stop_reason),
AppEvent::Exited { record_id } => on_exited(state, record_id),
AppEvent::Permission {
record_id,
title,
diff,
options,
risk,
} => on_permission(state, record_id, title, diff, options, risk),
AppEvent::AgentSpawned {
record_id,
agent_id,
port,
} => on_agent_spawned(state, record_id, agent_id, port),
#[cfg(unix)]
AppEvent::BridgeConnected { conn } => on_bridge_connected(state, conn),
#[cfg(unix)]
AppEvent::BridgeSpawned {
record_id,
agent_id,
port,
} => on_bridge_spawned(state, record_id, agent_id, port),
#[cfg(unix)]
AppEvent::BridgeState {
record_id,
state: s,
} => on_bridge_state(state, record_id, s),
#[cfg(unix)]
AppEvent::BridgeGone { record_ids } => on_bridge_gone(state, record_ids),
#[cfg(unix)]
AppEvent::BridgeNotify { message } => on_bridge_notify(state, message),
#[cfg(unix)]
AppEvent::BridgeRequestAttach { record_id } => on_bridge_request_attach(state, record_id),
#[cfg(unix)]
AppEvent::BridgeRequestReview { record_id } => on_bridge_request_review(state, record_id),
AppEvent::AgentSpawnFailed { agent_id, error } => {
on_agent_spawn_failed(state, agent_id, error)
}
AppEvent::PtyAttached {
record_id,
agent_id,
} => on_pty_attached(state, record_id, agent_id),
AppEvent::SessionSpawned {
record_id,
binary,
model,
} => on_session_spawned(state, record_id, binary, model),
AppEvent::PromptFailed { record_id, error } => on_prompt_failed(state, record_id, error),
AppEvent::ReviewReady {
record_id,
files,
adds,
dels,
} => on_review_ready(state, record_id, files, adds, dels),
AppEvent::ChecksFailed { record_id, output } => on_checks_failed(state, record_id, output),
AppEvent::DiffLoaded { record_id, text } => on_diff_loaded(state, record_id, text),
AppEvent::OpDone {
record_id,
message,
ok,
} => on_op_done(state, record_id, message, ok),
AppEvent::Paste(text) => on_paste(state, text),
AppEvent::Scroll { up } => on_scroll(state, up),
AppEvent::Click { col, row } => reduce_click(state, *col, *row),
AppEvent::Focus(gained) => on_focus(state, gained),
AppEvent::Tick => on_tick(state),
AppEvent::ServeStatus { ok } => on_serve_status(state, ok),
AppEvent::ForceQuit => on_force_quit(state),
AppEvent::Key(key) => on_key(state, key),
}
}
fn on_update(state: &mut AppState, record_id: &str, update: &SessionUpdateKind) -> Vec<Effect> {
if let Some(pane) = state.pane_by_id_mut(record_id) {
apply_update(pane, update);
}
Vec::new()
}
fn on_turn_ended(state: &mut AppState, record_id: &str, stop_reason: &StopReason) -> Vec<Effect> {
let seen = state.is_shown(record_id) && state.term_focused;
let notify = !state.term_focused;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.flush_tail();
pane.turn_active = false;
pane.last_stop = Some(*stop_reason);
if *stop_reason != StopReason::EndTurn {
pane.push(Line::Note(format!(
"turn ended: {}",
stop_label(stop_reason)
)));
} else {
effects.push(Effect::CheckReview {
record_id: record_id.to_owned(),
});
}
if !seen {
pane.done = true;
}
if notify {
effects.push(Effect::Notify {
title: format!("{} finished", pane.agent_id),
body: if *stop_reason == StopReason::EndTurn {
"turn complete".to_string()
} else {
stop_label(stop_reason).to_string()
},
});
}
}
effects
}
fn on_exited(state: &mut AppState, record_id: &str) -> Vec<Effect> {
let shown = state.is_shown(record_id);
let seen = shown && state.term_focused;
let notify = !state.term_focused;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.flush_tail();
pane.exited = true;
pane.turn_active = false;
pane.pending = None;
if !seen {
pane.attention = true;
}
if !shown {
effects.push(Effect::Bell);
}
if notify {
effects.push(Effect::Notify {
title: format!("{} exited", pane.agent_id),
body: "the agent process ended".to_string(),
});
}
}
effects
}
fn on_permission(
state: &mut AppState,
record_id: &str,
title: &str,
diff: &Option<DiffData>,
options: &[PermOption],
risk: &Risk,
) -> Vec<Effect> {
let shown = state.is_shown(record_id);
let seen = shown && state.term_focused;
let notify = !state.term_focused;
state.perm_seq += 1;
let seq = state.perm_seq;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
let auto_allow = match pane.autonomy {
Autonomy::Manual => false,
Autonomy::Assisted => *risk == Risk::Low,
Autonomy::Auto => true,
};
if auto_allow {
let label = pane.autonomy.label();
pane.push_external(Line::AutoResolved(format!(
"auto-allowed ({label}): {title}"
)));
effects.push(Effect::ResolvePermission {
record_id: record_id.to_owned(),
outcome: PermissionOutcome::AllowOnce,
});
} else {
pane.pending = Some(PendingView {
title: title.to_owned(),
diff: diff.clone(),
options: options.to_vec(),
risk: *risk,
});
pane.pending_seq = seq;
if !seen {
pane.attention = true;
}
if !shown {
effects.push(Effect::Bell);
}
if notify {
let risk_tag = match risk {
Risk::High => "high risk · ",
Risk::Low => "",
};
effects.push(Effect::Notify {
title: format!("{} needs approval", pane.agent_id),
body: format!("{risk_tag}{title}"),
});
}
}
} else {
effects.push(Effect::ResolvePermission {
record_id: record_id.to_owned(),
outcome: PermissionOutcome::Deny,
});
}
effects
}
fn on_agent_spawned(
state: &mut AppState,
record_id: &str,
agent_id: &str,
port: &Option<u16>,
) -> Vec<Effect> {
let mut pane = PaneState::new(record_id.to_owned(), agent_id.to_owned());
if let Some(h) = state.harness_by_agent.get(agent_id) {
pane.harness = h.clone();
}
pane.port = *port;
state.agents.push(pane);
state.detail = DetailLayout::solo(record_id.to_owned());
state.notice = None;
Vec::new()
}
#[cfg(unix)]
fn on_bridge_connected(state: &mut AppState, conn: &u64) -> Vec<Effect> {
vec![Effect::BridgeHello {
conn: *conn,
bootstrap_approved: state.bootstrap_decision == Some(true),
}]
}
#[cfg(unix)]
fn on_bridge_spawned(
state: &mut AppState,
record_id: &str,
agent_id: &str,
port: &Option<u16>,
) -> Vec<Effect> {
let mut pane = PaneState::new(record_id.to_owned(), agent_id.to_owned());
pane.owner = Ownership::Orchestrator;
pane.harness = "mcp".to_string();
pane.port = *port;
pane.turn_active = true;
pane.push(Line::Note(
"spawned by the orchestrator (MCP) — monitor only; steer it there".into(),
));
state.agents.push(pane);
Vec::new()
}
#[cfg(unix)]
fn on_bridge_state(state: &mut AppState, record_id: &str, s: &str) -> Vec<Effect> {
if let Some(pane) = state.pane_by_id_mut(record_id) {
match s {
"working" => {
pane.turn_active = true;
pane.done = false;
}
"failed" => {
pane.turn_active = false;
pane.attention = true;
pane.push_external(Line::Error("turn failed (see orchestrator)".into()));
}
_ => {
pane.turn_active = false;
pane.done = true;
}
}
}
Vec::new()
}
#[cfg(unix)]
fn on_bridge_gone(state: &mut AppState, record_ids: &[String]) -> Vec<Effect> {
for id in record_ids {
if let Some(pane) = state.pane_by_id_mut(id) {
pane.exited = true;
pane.turn_active = false;
pane.pending = None;
pane.push_external(Line::Note("bridge disconnected".into()));
}
}
Vec::new()
}
#[cfg(unix)]
fn on_bridge_notify(state: &mut AppState, message: &str) -> Vec<Effect> {
state.notice = Some(one_line(message));
Vec::new()
}
#[cfg(unix)]
fn on_bridge_request_attach(state: &mut AppState, record_id: &str) -> Vec<Effect> {
let name = if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.attention = true;
pane.push_external(Line::Note(
"the orchestrator asks you to attach and drive this subagent".into(),
));
pane.agent_id.clone()
} else {
record_id.to_owned()
};
state.notice = Some(one_line(&format!("attach requested: {name}")));
Vec::new()
}
#[cfg(unix)]
fn on_bridge_request_review(state: &mut AppState, record_id: &str) -> Vec<Effect> {
let name = if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.review = Some((0, 0, 0));
pane.attention = true;
pane.push_external(Line::Note(
"the orchestrator flagged this subagent's work for your review".into(),
));
pane.agent_id.clone()
} else {
record_id.to_owned()
};
state.notice = Some(one_line(&format!("review requested: {name}")));
Vec::new()
}
fn on_agent_spawn_failed(state: &mut AppState, agent_id: &str, error: &str) -> Vec<Effect> {
state.notice = Some(one_line(&format!("failed to spawn {agent_id}: {error}")));
Vec::new()
}
fn on_pty_attached(state: &mut AppState, record_id: &str, agent_id: &str) -> Vec<Effect> {
let mut pane = PaneState::new(record_id.to_owned(), agent_id.to_owned());
pane.kind = PaneKind::Pty;
pane.harness = "attach".to_string();
state.agents.push(pane);
state.detail = DetailLayout::solo(record_id.to_owned());
state.mode = Mode::Normal;
state.notice = Some(format!("attached — {} c detaches", state.leader_label()));
Vec::new()
}
fn on_session_spawned(
state: &mut AppState,
record_id: &str,
binary: &str,
model: &Option<String>,
) -> Vec<Effect> {
let mut pane = PaneState::new(record_id.to_owned(), binary.to_owned());
pane.kind = PaneKind::Pty;
pane.harness = "pty".to_string();
pane.model = model.clone();
state.agents.push(pane);
state.detail = DetailLayout::solo(record_id.to_owned());
state.mode = Mode::Normal;
state.notice = None;
Vec::new()
}
fn on_prompt_failed(state: &mut AppState, record_id: &str, error: &str) -> Vec<Effect> {
let shown = state.is_shown(record_id);
let seen = shown && state.term_focused;
let notify = !state.term_focused;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.push_external(Line::Error(format!("prompt failed: {error}")));
pane.turn_active = false;
if !seen {
pane.attention = true;
}
if !shown {
effects.push(Effect::Bell);
}
if notify {
effects.push(Effect::Notify {
title: format!("{} prompt failed", pane.agent_id),
body: error.to_owned(),
});
}
}
effects
}
fn on_review_ready(
state: &mut AppState,
record_id: &str,
files: &u64,
adds: &u64,
dels: &u64,
) -> Vec<Effect> {
let seen = state.is_shown(record_id) && state.term_focused;
let notify = !state.term_focused;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.review = Some((*files, *adds, *dels));
pane.push(Line::Note(format!(
"ready to review: {files} file(s), +{adds}/-{dels}"
)));
if !seen {
pane.done = true; }
if notify {
effects.push(Effect::Notify {
title: format!("{} ready to review", pane.agent_id),
body: format!("{files} file(s), +{adds}/-{dels}"),
});
}
}
effects
}
fn on_checks_failed(state: &mut AppState, record_id: &str, output: &str) -> Vec<Effect> {
let shown = state.is_shown(record_id);
let seen = shown && state.term_focused;
let notify = !state.term_focused;
let mut effects = Vec::new();
if let Some(pane) = state.pane_by_id_mut(record_id) {
if pane.check_retries < CHECK_RETRY_CAP {
pane.check_retries += 1;
let retry = pane.check_retries;
pane.push(Line::Note(format!(
"checks failed — sent back to the agent (retry {retry}/{CHECK_RETRY_CAP})"
)));
pane.turn_active = true;
pane.done = false;
effects.push(Effect::Prompt {
record_id: record_id.to_owned(),
text: format!(
"The verification checks failed in your worktree. Fix the failures and make the checks pass.\n\nCheck output:\n{output}"
),
});
} else {
pane.review = Some((0, 0, 0));
pane.push_external(Line::Error(format!(
"checks still failing after {CHECK_RETRY_CAP} retries — review manually"
)));
if !seen {
pane.attention = true;
}
if !shown {
effects.push(Effect::Bell);
}
if notify {
effects.push(Effect::Notify {
title: format!("{} checks failing", pane.agent_id),
body: format!(
"still failing after {CHECK_RETRY_CAP} retries — review manually"
),
});
}
}
}
effects
}
fn on_diff_loaded(state: &mut AppState, record_id: &str, text: &str) -> Vec<Effect> {
if let Some(pane) = state.pane_by_id_mut(record_id) {
pane.flush_tail();
for line in unified_to_lines(text) {
pane.push(line);
}
}
Vec::new()
}
fn on_op_done(state: &mut AppState, record_id: &str, message: &str, ok: &bool) -> Vec<Effect> {
if let Some(pane) = state.pane_by_id_mut(record_id) {
if *ok {
pane.review = None;
pane.done = false;
pane.attention = false;
pane.push_external(Line::Note(message.to_owned()));
} else {
pane.push_external(Line::Error(message.to_owned()));
}
}
Vec::new()
}
fn on_paste(state: &mut AppState, text: &str) -> Vec<Effect> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
match state.mode {
Mode::Command => {
if let Some(palette) = state.palette.as_mut() {
palette.input.push_str(one_line(&text).trim());
palette.selected = 0;
}
Vec::new()
}
Mode::Normal => match state.focused() {
Some(p) if p.kind == PaneKind::Pty && !p.exited => {
vec![Effect::PtyPaste {
record_id: p.record_id.clone(),
text,
}]
}
_ => Vec::new(),
},
_ => Vec::new(),
}
}
fn on_scroll(state: &mut AppState, up: &bool) -> Vec<Effect> {
if state.mode != Mode::Normal || state.keys_help {
return Vec::new();
}
let Some(pane) = state.focused_mut() else {
return Vec::new();
};
match pane.kind {
PaneKind::Monitor => {
if *up {
pane.scroll_page_up();
} else {
pane.scroll_page_down();
}
Vec::new()
}
PaneKind::Pty => vec![Effect::PtyScroll {
record_id: pane.record_id.clone(),
up: *up,
page: false,
}],
}
}
fn on_focus(state: &mut AppState, gained: &bool) -> Vec<Effect> {
state.term_focused = *gained;
if *gained {
mark_shown_seen(state);
}
Vec::new()
}
fn on_tick(state: &mut AppState) -> Vec<Effect> {
state.tick = state.tick.wrapping_add(1);
if state.notice.is_some() && state.tick.wrapping_sub(state.notice_at) > NOTICE_DECAY_TICKS {
state.notice = None;
}
Vec::new()
}
fn on_serve_status(state: &mut AppState, ok: &bool) -> Vec<Effect> {
state.serve_ok = Some(*ok);
Vec::new()
}
fn on_force_quit(state: &mut AppState) -> Vec<Effect> {
state.should_quit = true;
vec![Effect::Quit]
}
fn on_key(state: &mut AppState, key: &KeyEvent) -> Vec<Effect> {
if state.keys_help {
state.keys_help = false;
return Vec::new();
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if state.mode != Mode::Normal {
let esc = KeyEvent::from(KeyCode::Esc);
return match state.mode {
Mode::Leader => reduce_key_leader(state, &esc),
Mode::Picker => reduce_key_picker(state, &esc),
Mode::Command => reduce_key_command(state, &esc),
Mode::Confirm => reduce_key_confirm(state, &esc),
Mode::Normal => Vec::new(),
};
}
if let Some(pane) = state.focused()
&& !pane.exited
{
let record_id = pane.record_id.clone();
return match (pane.kind, pane.owner) {
(PaneKind::Pty, _) => vec![Effect::PtyKey {
record_id,
key: *key,
}],
(PaneKind::Monitor, Ownership::Human) => {
vec![Effect::CancelTurn { record_id }]
}
(PaneKind::Monitor, Ownership::Orchestrator) => {
state.notice = Some(
"orchestrator-managed subagent — interrupt it from the orchestrator".into(),
);
Vec::new()
}
};
}
state.notice = Some(format!(
"nothing to interrupt — quit via {} p → quit",
state.leader_label()
));
return Vec::new();
}
match state.mode {
Mode::Normal => reduce_key_normal(state, key),
Mode::Leader => reduce_key_leader(state, key),
Mode::Picker => reduce_key_picker(state, key),
Mode::Command => reduce_key_command(state, key),
Mode::Confirm => reduce_key_confirm(state, key),
}
}
fn one_line(text: &str) -> String {
const CAP: usize = 200;
let mut out = String::new();
let mut count = 0usize;
for word in text.split_whitespace() {
if count > 0 {
out.push(' ');
}
out.push_str(word);
count += word.chars().count() + 1;
if count > CAP {
out.push('…');
break;
}
}
out
}
fn apply_update(pane: &mut PaneState, update: &SessionUpdateKind) {
match update {
SessionUpdateKind::MessageChunk { text, .. } => pane.stream(TailKind::Message, text),
SessionUpdateKind::ThoughtChunk { text, .. } => pane.stream(TailKind::Thought, text),
SessionUpdateKind::ToolCall {
id,
title,
status,
diff,
} => {
pane.push_external(Line::Tool {
id: id.clone(),
title: title.clone(),
status: status.clone(),
});
if let Some(raw) = diff {
pane.push_tool_diff(id, raw);
}
}
SessionUpdateKind::ToolCallUpdate {
id,
status,
title,
diff,
} => {
if let Some(Line::Tool {
title: t,
status: s,
..
}) = pane
.lines
.iter_mut()
.rev()
.find(|l| matches!(l, Line::Tool { id: lid, .. } if lid == id))
{
if let Some(new_status) = status {
*s = new_status.clone();
}
if let Some(new_title) = title {
*t = new_title.clone();
}
} else {
pane.push_external(Line::Tool {
id: id.clone(),
title: title.clone().unwrap_or_default(),
status: status.clone().unwrap_or(ToolStatus::Pending),
});
}
if let Some(raw) = diff {
pane.push_tool_diff(id, raw);
}
}
SessionUpdateKind::Usage { used, size, cost } => {
pane.usage = Some((*used, *size));
if cost.is_some() {
pane.cost = cost.clone();
}
}
}
}