use std::io;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Row, Table};
use ratatui::{Frame, Terminal};
use crate::tmux;
use crate::ui::theme;
use super::attention::{self, AttentionEntry};
use super::collector;
use super::proc::ProcSnapshot;
use super::{Agent, AgentKind, Snapshot, Status};
const TICK: Duration = Duration::from_millis(1000);
const POLL: Duration = Duration::from_millis(200);
struct StderrGuard;
impl Drop for StderrGuard {
fn drop(&mut self) {
let _ = execute!(io::stderr(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub fn run() -> Result<()> {
let action = {
enable_raw_mode()?;
let _guard = StderrGuard;
execute!(io::stderr(), EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stderr());
let mut terminal = Terminal::new(backend)?;
run_loop(&mut terminal)?
};
match action {
Some(Action::Switch(target)) => {
tmux::switch_to_pane(&target).map_err(|e| anyhow::anyhow!("switch to {target}: {e}"))?
}
Some(Action::OpenAttentionPicker) => run_helper_script(
"TMX_ATTENTION_PICKER",
".claude/scripts/attention-picker.sh",
)?,
Some(Action::JumpLatestAttention) => {
run_helper_script("TMX_ATTENTION_JUMP", ".claude/scripts/jump-attention.sh")?
}
None => {}
}
Ok(())
}
fn run_helper_script(env_var: &str, default_rel: &str) -> Result<()> {
let path = std::env::var(env_var).ok().map(std::path::PathBuf::from);
let path = match path {
Some(p) => p,
None => dirs::home_dir()
.map(|h| h.join(default_rel))
.ok_or_else(|| anyhow::anyhow!("cannot resolve $HOME for {default_rel}"))?,
};
if !path.exists() {
anyhow::bail!(
"attention helper not found at {} (set {env_var} to override)",
path.display()
);
}
let status = std::process::Command::new("bash").arg(&path).status()?;
if !status.success() {
anyhow::bail!("{} exited with status {status}", path.display());
}
Ok(())
}
struct Model {
snapshot: Snapshot,
cursor: usize,
quit: bool,
pending_action: Option<Action>,
notice: Option<String>,
}
enum Action {
Switch(String),
OpenAttentionPicker,
JumpLatestAttention,
}
impl Model {
fn new(snapshot: Snapshot) -> Self {
Self {
snapshot,
cursor: 0,
quit: false,
pending_action: None,
notice: None,
}
}
fn handle_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
self.notice = None;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.quit = true;
}
KeyCode::Up | KeyCode::Char('k') => self.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.move_down(),
KeyCode::Enter => self.activate_selection(),
KeyCode::Char('a') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.snapshot.attention.is_empty() {
self.notice = Some("no fresh attention to jump to".to_string());
} else {
self.pending_action = Some(Action::JumpLatestAttention);
self.quit = true;
}
}
KeyCode::Char('A') => {
self.pending_action = Some(Action::OpenAttentionPicker);
self.quit = true;
}
_ => {}
}
}
fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn move_down(&mut self) {
if self.cursor + 1 < self.snapshot.agents.len() {
self.cursor += 1;
}
}
fn activate_selection(&mut self) {
let Some(agent) = self.snapshot.agents.get(self.cursor) else {
return;
};
let Some(pane) = agent.pane.as_ref() else {
return; };
self.pending_action = Some(Action::Switch(pane.target()));
self.quit = true;
}
fn replace_snapshot(&mut self, next: Snapshot) {
let anchor = self.snapshot.agents.get(self.cursor).map(|a| a.id.clone());
self.snapshot = next;
self.cursor = match anchor {
Some(id) => self
.snapshot
.agents
.iter()
.position(|a| a.id == id)
.unwrap_or(0),
None => 0,
};
if self.cursor >= self.snapshot.agents.len() {
self.cursor = self.snapshot.agents.len().saturating_sub(1);
}
}
}
fn run_loop<B: Backend>(terminal: &mut Terminal<B>) -> Result<Option<Action>> {
let mut proc = ProcSnapshot::new();
let mut model = Model::new(collector::collect(&proc));
let mut last_tick = Instant::now();
let mut last_rendered: Option<RenderKey> = None;
while !model.quit {
let key_now = RenderKey::from(&model);
if last_rendered.as_ref() != Some(&key_now) {
terminal.draw(|frame| render(frame, &model))?;
last_rendered = Some(key_now);
}
if event::poll(POLL)?
&& let Event::Key(key) = event::read()?
{
model.handle_key(key);
}
if last_tick.elapsed() >= TICK {
proc.refresh();
let next = collector::collect(&proc);
model.replace_snapshot(next);
last_tick = Instant::now();
}
}
Ok(model.pending_action)
}
#[derive(PartialEq, Eq)]
struct RenderKey {
cursor: usize,
blocked: usize,
panes_error: Option<String>,
rows: Vec<RenderRow>,
attention: Vec<(i64, String, String, String, String)>,
minute_bucket: i64,
notice: Option<String>,
}
#[derive(PartialEq, Eq)]
struct RenderRow {
pane_target: Option<String>,
kind: AgentKind,
status: Status,
repo: String,
has_intent: bool,
reviewed_fresh: bool,
extra: String,
}
impl From<&Model> for RenderKey {
fn from(model: &Model) -> Self {
let rows = model
.snapshot
.agents
.iter()
.map(|a| RenderRow {
pane_target: a.pane.as_ref().map(|p| p.target()),
kind: a.kind,
status: a.status,
repo: a.repo_name.clone(),
has_intent: a.flags.has_intent,
reviewed_fresh: a.flags.reviewed_fresh,
extra: a.extra.clone(),
})
.collect();
let attention = model
.snapshot
.attention
.iter()
.map(|a| {
(
a.ts,
a.kind.clone(),
a.source.clone(),
a.tmux_session.clone(),
a.body.clone(),
)
})
.collect();
let minute_bucket = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.as_secs() / 60) as i64)
.unwrap_or(0);
Self {
cursor: model.cursor,
blocked: model.snapshot.global_blocked,
panes_error: model.snapshot.panes_error.clone(),
rows,
attention,
minute_bucket,
notice: model.notice.clone(),
}
}
}
const ATTENTION_PANEL_MAX_ROWS: u16 = 8;
fn render(frame: &mut Frame, model: &Model) {
let area = frame.area();
let attention = model.snapshot.attention.as_slice();
let attention_height = if attention.is_empty() {
0
} else {
(attention.len() as u16).min(ATTENTION_PANEL_MAX_ROWS) + 1
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(attention_height), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(" tmx agents", theme::title_style())),
layout[0],
);
let header = Row::new(vec![
Span::styled(" STATUS", theme::muted_style()),
Span::styled("KIND", theme::muted_style()),
Span::styled("SESSION", theme::muted_style()),
Span::styled("REPO", theme::muted_style()),
Span::styled("FLAGS", theme::muted_style()),
Span::styled("INFO", theme::muted_style()),
]);
let rows = model
.snapshot
.agents
.iter()
.enumerate()
.map(|(i, agent)| build_row(agent, i == model.cursor));
let widths = [
Constraint::Length(10),
Constraint::Length(7),
Constraint::Length(28),
Constraint::Length(20),
Constraint::Length(20),
Constraint::Min(8),
];
let table = Table::new(rows, widths)
.header(header)
.block(Block::default());
frame.render_widget(table, layout[2]);
if attention_height > 0 {
render_attention_panel(frame, layout[3], attention);
}
let summary = build_summary(&model.snapshot);
frame.render_widget(
Paragraph::new(Span::styled(summary, theme::status_bar_style())),
layout[4],
);
let (keys_text, keys_style) = match model.notice.as_deref() {
Some(msg) => (
format!(" ⓘ {}", sanitize(msg)),
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
),
None => (
" j/k nav enter switch a jump A picker q quit".to_string(),
theme::muted_style(),
),
};
frame.render_widget(
Paragraph::new(Span::styled(keys_text, keys_style)),
layout[5],
);
let banner = match (
&model.snapshot.panes_error,
model.snapshot.agents.is_empty(),
) {
(Some(err), _) => Some((
format!(" tmux unavailable: {}", sanitize(err)),
Style::default().fg(theme::RED),
)),
(None, true) => Some((
" no tmux panes or codex jobs found".to_string(),
theme::muted_style(),
)),
_ => None,
};
if let Some((text, style)) = banner {
frame.render_widget(Paragraph::new(Span::styled(text, style)), layout[1]);
}
}
fn render_attention_panel(
frame: &mut Frame,
area: ratatui::layout::Rect,
entries: &[AttentionEntry],
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let header = Line::from(Span::styled(
format!(" attention queue ({} pending)", entries.len()),
theme::status_bar_style(),
));
let mut lines: Vec<Line<'static>> = Vec::with_capacity(entries.len() + 1);
lines.push(header);
for entry in entries.iter().take(ATTENTION_PANEL_MAX_ROWS as usize) {
let age = attention::human_age(entry.ts, now);
let session_cell = if entry.tmux_session.is_empty() {
"—".to_string()
} else {
entry.tmux_session.clone()
};
let kind_label = format!("{}·{}", entry.source, short_kind(&entry.kind));
let prefix_width = 4 + 1 + 14 + 1 + 18 + 2; let body_max = (area.width as usize).saturating_sub(prefix_width);
let body = truncate_for_width(&sanitize(&entry.body), body_max);
lines.push(Line::from(vec![
Span::styled(format!(" {:>4} ", age), theme::muted_style()),
Span::styled(pad(&sanitize(&session_cell), 14), theme::normal_style()),
Span::styled(pad(&sanitize(&kind_label), 18), theme::branch_style()),
Span::raw(" "),
Span::styled(body, theme::normal_style()),
]));
}
frame.render_widget(Paragraph::new(lines), area);
}
fn short_kind(kind: &str) -> String {
match kind {
"notification" => "notif".to_string(),
"stop" => "stop".to_string(),
"codex-turn" => "turn".to_string(),
"" => "?".to_string(),
other => other.chars().take(8).collect(),
}
}
fn pad(s: &str, width: usize) -> String {
let n = s.chars().count();
if n >= width {
s.chars().take(width).collect()
} else {
let mut out = s.to_string();
out.extend(std::iter::repeat_n(' ', width - n));
out
}
}
fn build_row(agent: &Agent, is_cursor: bool) -> Row<'static> {
let cursor_marker = if is_cursor { "▶ " } else { " " };
let status_style = match agent.status {
Status::Working => Style::default().fg(theme::GREEN),
Status::Ready => Style::default().fg(theme::BLUE),
Status::AwaitingDecision => Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
Status::Idle => theme::muted_style(),
Status::Background => Style::default().fg(theme::PURPLE),
};
let status_cell = Line::from(vec![
Span::raw(cursor_marker),
Span::styled(agent.status.glyph().to_string(), status_style),
Span::raw(" "),
Span::styled(agent.status.label().to_string(), theme::muted_style()),
]);
let kind_style = match agent.kind {
AgentKind::Claude => Style::default().fg(theme::ORANGE),
AgentKind::Codex => Style::default().fg(theme::BLUE),
AgentKind::Shell => theme::muted_style(),
AgentKind::Other => theme::muted_style(),
};
let kind_cell = Line::from(Span::styled(sanitize(agent.kind.label()), kind_style));
let session_cell = match &agent.pane {
Some(p) => Line::from(vec![
Span::styled(sanitize(&p.session), theme::normal_style()),
Span::styled(format!(" {}.{}", p.window, p.pane), theme::muted_style()),
]),
None => Line::from(Span::styled("—", theme::muted_style())),
};
let repo_cell = Line::from(Span::styled(
sanitize(&agent.repo_name),
theme::branch_style(),
));
let flags_cell = build_flag_line(agent);
let info_cell = Line::from(Span::styled(sanitize(&agent.extra), theme::muted_style()));
let mut row = Row::new(vec![
status_cell,
kind_cell,
session_cell,
repo_cell,
flags_cell,
info_cell,
]);
if is_cursor {
row = row.style(Style::default().add_modifier(Modifier::BOLD));
}
row
}
fn build_flag_line(agent: &Agent) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if agent.flags.has_intent {
spans.push(Span::styled("intent ", Style::default().fg(theme::VIOLET)));
}
if agent.flags.reviewed_fresh {
spans.push(Span::styled("reviewed ", Style::default().fg(theme::GREEN)));
}
if spans.is_empty() {
spans.push(Span::styled("—", theme::muted_style()));
}
Line::from(spans)
}
fn build_summary(snap: &Snapshot) -> String {
let total = snap.agents.len();
let decisions = snap
.agents
.iter()
.filter(|a| a.status == Status::AwaitingDecision)
.count();
let working = snap
.agents
.iter()
.filter(|a| a.status == Status::Working)
.count();
let ready = snap
.agents
.iter()
.filter(|a| a.status == Status::Ready)
.count();
let bg = snap
.agents
.iter()
.filter(|a| a.status == Status::Background)
.count();
let attention = snap.attention.len();
format!(
" rows: {total} • working: {working} • ready: {ready} • decision: {decisions} • bg: {bg} • attention: {attention} • blocked: {}",
snap.global_blocked,
)
}
fn truncate_for_width(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
return s.to_string();
}
let mut out: String = chars[..max.saturating_sub(1)].iter().collect();
out.push('…');
out
}
fn sanitize(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && *c != '\x7f')
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::{Flags, PaneLocator};
use std::path::PathBuf;
fn agent(kind: AgentKind, status: Status, repo: &str) -> Agent {
Agent {
id: format!("pane:s:0.0-{repo}"),
pane: Some(PaneLocator {
session: "s".into(),
window: 0,
pane: 0,
pane_pid: 1,
}),
kind,
status,
cwd: PathBuf::from("/x"),
repo_name: repo.into(),
flags: Flags::default(),
extra: String::new(),
}
}
#[test]
fn sanitize_strips_escape_sequences() {
let dirty = "evil\x1b[2J\x07path";
assert_eq!(sanitize(dirty), "evil[2Jpath");
}
#[test]
fn sanitize_strips_all_control_chars() {
assert_eq!(sanitize("\x1b\x07\x00\x7f"), "");
}
#[test]
fn sanitize_preserves_normal_chars() {
assert_eq!(sanitize("hello-world_123 ./"), "hello-world_123 ./");
}
#[test]
fn build_summary_counts_states() {
let snap = Snapshot {
agents: vec![
agent(AgentKind::Claude, Status::Working, "a"),
agent(AgentKind::Claude, Status::Ready, "b"),
agent(AgentKind::Claude, Status::AwaitingDecision, "c"),
agent(AgentKind::Codex, Status::Background, "d"),
],
captured_at: std::time::SystemTime::now(),
global_blocked: 2,
panes_error: None,
attention: Vec::new(),
};
let s = build_summary(&snap);
assert!(s.contains("rows: 4"));
assert!(s.contains("working: 1"));
assert!(s.contains("ready: 1"));
assert!(s.contains("decision: 1"));
assert!(s.contains("bg: 1"));
assert!(s.contains("attention: 0"));
assert!(s.contains("blocked: 2"));
}
#[test]
fn build_summary_reports_attention_count() {
let snap = Snapshot {
agents: Vec::new(),
captured_at: std::time::SystemTime::now(),
global_blocked: 0,
panes_error: None,
attention: vec![
AttentionEntry {
ts: 1,
kind: "stop".into(),
source: "claude".into(),
title: String::new(),
body: "b".into(),
session_id: String::new(),
cwd: PathBuf::new(),
tmux_target: String::new(),
tmux_session: String::new(),
},
AttentionEntry {
ts: 2,
kind: "notification".into(),
source: "claude".into(),
title: String::new(),
body: "b".into(),
session_id: String::new(),
cwd: PathBuf::new(),
tmux_target: String::new(),
tmux_session: String::new(),
},
],
};
let s = build_summary(&snap);
assert!(s.contains("attention: 2"));
}
#[test]
fn truncate_for_width_basic() {
assert_eq!(truncate_for_width("hello", 10), "hello");
assert_eq!(truncate_for_width("hello world", 5), "hell…");
assert_eq!(truncate_for_width("a", 0), "");
}
#[test]
fn pad_extends_or_truncates() {
assert_eq!(pad("abc", 5), "abc ");
assert_eq!(pad("abcdef", 4), "abcd");
assert_eq!(pad("", 3), " ");
}
}