1use std::io;
11use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
15use crossterm::execute;
16use crossterm::terminal::{
17 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
18};
19use ratatui::backend::{Backend, CrosstermBackend};
20use ratatui::layout::{Constraint, Direction, Layout};
21use ratatui::style::{Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, HighlightSpacing, Paragraph, Row, Table, TableState};
24use ratatui::{Frame, Terminal};
25
26use crate::tmux;
27use crate::ui::theme;
28
29use super::attention::{self, AttentionEntry};
30use super::collector;
31use super::proc::ProcSnapshot;
32use super::{Agent, AgentKind, Snapshot, Status};
33
34const TICK: Duration = Duration::from_millis(1000);
35const POLL: Duration = Duration::from_millis(200);
36
37struct StderrGuard;
38
39impl Drop for StderrGuard {
40 fn drop(&mut self) {
41 let _ = execute!(io::stderr(), LeaveAlternateScreen);
42 let _ = disable_raw_mode();
43 }
44}
45
46pub fn run() -> Result<()> {
47 let action = {
48 enable_raw_mode()?;
49 let _guard = StderrGuard;
50 execute!(io::stderr(), EnterAlternateScreen)?;
51 let backend = CrosstermBackend::new(io::stderr());
52 let mut terminal = Terminal::new(backend)?;
53 run_loop(&mut terminal)?
54 };
56 match action {
57 Some(Action::Switch(target)) => {
61 tmux::switch_to_pane(&target).map_err(|e| anyhow::anyhow!("switch to {target}: {e}"))?
62 }
63 Some(Action::OpenAttentionPicker) => run_helper_script(
64 "TMX_ATTENTION_PICKER",
65 ".claude/scripts/attention-picker.sh",
66 )?,
67 Some(Action::JumpLatestAttention) => {
68 run_helper_script("TMX_ATTENTION_JUMP", ".claude/scripts/jump-attention.sh")?
69 }
70 None => {}
71 }
72 Ok(())
73}
74
75fn run_helper_script(env_var: &str, default_rel: &str) -> Result<()> {
80 let path = std::env::var(env_var).ok().map(std::path::PathBuf::from);
81 let path = match path {
82 Some(p) => p,
83 None => dirs::home_dir()
84 .map(|h| h.join(default_rel))
85 .ok_or_else(|| anyhow::anyhow!("cannot resolve $HOME for {default_rel}"))?,
86 };
87 if !path.exists() {
88 anyhow::bail!(
89 "attention helper not found at {} (set {env_var} to override)",
90 path.display()
91 );
92 }
93 let status = std::process::Command::new("bash").arg(&path).status()?;
94 if !status.success() {
95 anyhow::bail!("{} exited with status {status}", path.display());
96 }
97 Ok(())
98}
99
100struct Model {
101 snapshot: Snapshot,
102 cursor: usize,
103 quit: bool,
104 pending_action: Option<Action>,
107 notice: Option<String>,
111 table_state: TableState,
115}
116
117enum Action {
118 Switch(String),
120 OpenAttentionPicker,
122 JumpLatestAttention,
124}
125
126impl Model {
127 fn new(snapshot: Snapshot) -> Self {
128 let mut table_state = TableState::default();
129 if !snapshot.agents.is_empty() {
130 table_state.select(Some(0));
131 }
132 Self {
133 snapshot,
134 cursor: 0,
135 quit: false,
136 pending_action: None,
137 notice: None,
138 table_state,
139 }
140 }
141
142 fn handle_key(&mut self, key: KeyEvent) {
143 if key.kind != KeyEventKind::Press {
144 return;
145 }
146 self.notice = None;
149 match key.code {
150 KeyCode::Esc | KeyCode::Char('q') => self.quit = true,
151 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
152 self.quit = true;
153 }
154 KeyCode::Up | KeyCode::Char('k') => self.move_up(),
155 KeyCode::Down | KeyCode::Char('j') => self.move_down(),
156 KeyCode::Enter => self.activate_selection(),
157 KeyCode::Char('a') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
168 if self.snapshot.attention.is_empty() {
169 self.notice = Some("no fresh attention to jump to".to_string());
170 } else {
171 self.pending_action = Some(Action::JumpLatestAttention);
172 self.quit = true;
173 }
174 }
175 KeyCode::Char('A') => {
176 self.pending_action = Some(Action::OpenAttentionPicker);
177 self.quit = true;
178 }
179 _ => {}
180 }
181 }
182
183 fn move_up(&mut self) {
184 self.cursor = self.cursor.saturating_sub(1);
185 self.sync_selection();
186 }
187
188 fn move_down(&mut self) {
189 if self.cursor + 1 < self.snapshot.agents.len() {
190 self.cursor += 1;
191 self.sync_selection();
192 }
193 }
194
195 fn sync_selection(&mut self) {
196 if self.snapshot.agents.is_empty() {
197 self.table_state.select(None);
198 } else {
199 self.table_state.select(Some(self.cursor));
200 }
201 }
202
203 fn activate_selection(&mut self) {
204 let Some(agent) = self.snapshot.agents.get(self.cursor) else {
205 return;
206 };
207 let Some(pane) = agent.pane.as_ref() else {
208 return; };
210 self.pending_action = Some(Action::Switch(pane.target()));
211 self.quit = true;
212 }
213
214 fn replace_snapshot(&mut self, next: Snapshot) {
218 let anchor = self.snapshot.agents.get(self.cursor).map(|a| a.id.clone());
219 self.snapshot = next;
220 self.cursor = match anchor {
221 Some(id) => self
222 .snapshot
223 .agents
224 .iter()
225 .position(|a| a.id == id)
226 .unwrap_or(0),
227 None => 0,
228 };
229 if self.cursor >= self.snapshot.agents.len() {
230 self.cursor = self.snapshot.agents.len().saturating_sub(1);
231 }
232 self.sync_selection();
233 }
234}
235
236fn run_loop<B: Backend>(terminal: &mut Terminal<B>) -> Result<Option<Action>> {
237 let mut proc = ProcSnapshot::new();
238 let mut model = Model::new(collector::collect(&proc));
239 let mut last_tick = Instant::now();
240 let mut last_rendered: Option<RenderKey> = None;
241
242 while !model.quit {
243 let key_now = RenderKey::from(&model);
244 if last_rendered.as_ref() != Some(&key_now) {
245 terminal.draw(|frame| render(frame, &mut model))?;
246 last_rendered = Some(key_now);
247 }
248
249 if event::poll(POLL)?
250 && let Event::Key(key) = event::read()?
251 {
252 model.handle_key(key);
253 }
254
255 if last_tick.elapsed() >= TICK {
256 proc.refresh();
257 let next = collector::collect(&proc);
258 model.replace_snapshot(next);
259 last_tick = Instant::now();
260 }
261 }
262
263 Ok(model.pending_action)
264}
265
266#[derive(PartialEq, Eq)]
270struct RenderKey {
271 cursor: usize,
272 blocked: usize,
273 panes_error: Option<String>,
274 rows: Vec<RenderRow>,
275 attention: Vec<(i64, String, String, String, String)>,
278 minute_bucket: i64,
282 notice: Option<String>,
283}
284
285#[derive(PartialEq, Eq)]
286struct RenderRow {
287 pane_target: Option<String>,
288 kind: AgentKind,
289 status: Status,
290 repo: String,
291 has_intent: bool,
292 reviewed_fresh: bool,
293 extra: String,
294}
295
296impl From<&Model> for RenderKey {
297 fn from(model: &Model) -> Self {
298 let rows = model
299 .snapshot
300 .agents
301 .iter()
302 .map(|a| RenderRow {
303 pane_target: a.pane.as_ref().map(|p| p.target()),
304 kind: a.kind,
305 status: a.status,
306 repo: a.repo_name.clone(),
307 has_intent: a.flags.has_intent,
308 reviewed_fresh: a.flags.reviewed_fresh,
309 extra: a.extra.clone(),
310 })
311 .collect();
312 let attention = model
313 .snapshot
314 .attention
315 .iter()
316 .map(|a| {
317 (
318 a.ts,
319 a.kind.clone(),
320 a.source.clone(),
321 a.tmux_session.clone(),
322 a.body.clone(),
323 )
324 })
325 .collect();
326 let minute_bucket = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map(|d| (d.as_secs() / 60) as i64)
329 .unwrap_or(0);
330 Self {
331 cursor: model.cursor,
332 blocked: model.snapshot.global_blocked,
333 panes_error: model.snapshot.panes_error.clone(),
334 rows,
335 attention,
336 minute_bucket,
337 notice: model.notice.clone(),
338 }
339 }
340}
341
342const ATTENTION_PANEL_MIN_ROWS: u16 = 4;
345
346fn render(frame: &mut Frame, model: &mut Model) {
347 let area = frame.area();
348 let attention = model.snapshot.attention.as_slice();
349 let max_attention_rows = (area.height / 2).max(ATTENTION_PANEL_MIN_ROWS);
353 let attention_height = if attention.is_empty() {
354 0
355 } else {
356 ((attention.len() as u16) + 1).min(max_attention_rows)
358 };
359
360 let layout = Layout::default()
361 .direction(Direction::Vertical)
362 .constraints([
363 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(attention_height), Constraint::Length(1), Constraint::Length(1), ])
370 .split(area);
371
372 frame.render_widget(
373 Paragraph::new(Span::styled(" tmx agents", theme::title_style())),
374 layout[0],
375 );
376
377 let header = Row::new(vec![
378 Span::styled("STATUS", theme::muted_style()),
379 Span::styled("KIND", theme::muted_style()),
380 Span::styled("SESSION", theme::muted_style()),
381 Span::styled("REPO", theme::muted_style()),
382 Span::styled("FLAGS", theme::muted_style()),
383 Span::styled("INFO", theme::muted_style()),
384 ]);
385
386 let rows = model.snapshot.agents.iter().map(build_row);
387
388 let widths = [
389 Constraint::Length(10),
390 Constraint::Length(7),
391 Constraint::Length(28),
392 Constraint::Length(20),
393 Constraint::Length(20),
394 Constraint::Min(8),
395 ];
396
397 let table = Table::new(rows, widths)
401 .header(header)
402 .block(Block::default())
403 .highlight_symbol("▶ ")
404 .highlight_spacing(HighlightSpacing::Always)
405 .row_highlight_style(Style::default().add_modifier(Modifier::BOLD));
406 frame.render_stateful_widget(table, layout[2], &mut model.table_state);
407
408 if attention_height > 0 {
409 render_attention_panel(frame, layout[3], attention);
410 }
411
412 let summary = build_summary(&model.snapshot);
413 frame.render_widget(
414 Paragraph::new(Span::styled(summary, theme::status_bar_style())),
415 layout[4],
416 );
417
418 let (keys_text, keys_style) = match model.notice.as_deref() {
419 Some(msg) => (
420 format!(" ⓘ {}", sanitize(msg)),
421 Style::default()
422 .fg(theme::YELLOW)
423 .add_modifier(Modifier::BOLD),
424 ),
425 None => (
426 " j/k nav enter switch a jump A picker q quit".to_string(),
427 theme::muted_style(),
428 ),
429 };
430 frame.render_widget(
431 Paragraph::new(Span::styled(keys_text, keys_style)),
432 layout[5],
433 );
434
435 let banner = match (
439 &model.snapshot.panes_error,
440 model.snapshot.agents.is_empty(),
441 ) {
442 (Some(err), _) => Some((
443 format!(" tmux unavailable: {}", sanitize(err)),
444 Style::default().fg(theme::RED),
445 )),
446 (None, true) => Some((
447 " no tmux panes or codex jobs found".to_string(),
448 theme::muted_style(),
449 )),
450 _ => None,
451 };
452 if let Some((text, style)) = banner {
453 frame.render_widget(Paragraph::new(Span::styled(text, style)), layout[1]);
454 }
455}
456
457fn render_attention_panel(
458 frame: &mut Frame,
459 area: ratatui::layout::Rect,
460 entries: &[AttentionEntry],
461) {
462 let now = std::time::SystemTime::now()
463 .duration_since(std::time::UNIX_EPOCH)
464 .map(|d| d.as_secs() as i64)
465 .unwrap_or(0);
466 let header = Line::from(Span::styled(
467 format!(" attention queue ({} pending)", entries.len()),
468 theme::status_bar_style(),
469 ));
470
471 let entry_capacity = (area.height as usize).saturating_sub(1);
475 let truncated = entries.len() > entry_capacity;
476 let visible_count = if truncated {
477 entry_capacity.saturating_sub(1)
478 } else {
479 entries.len()
480 };
481
482 let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible_count + 2);
483 lines.push(header);
484 for entry in entries.iter().take(visible_count) {
485 let age = attention::human_age(entry.ts, now);
486 let session_cell = if entry.tmux_session.is_empty() {
487 "—".to_string()
488 } else {
489 entry.tmux_session.clone()
490 };
491 let kind_label = format!("{}·{}", entry.source, short_kind(&entry.kind));
492 let prefix_width = 4 + 1 + 14 + 1 + 18 + 2; let body_max = (area.width as usize).saturating_sub(prefix_width);
496 let body = truncate_for_width(&sanitize(&entry.body), body_max);
497 lines.push(Line::from(vec![
498 Span::styled(format!(" {:>4} ", age), theme::muted_style()),
499 Span::styled(pad(&sanitize(&session_cell), 14), theme::normal_style()),
500 Span::styled(pad(&sanitize(&kind_label), 18), theme::branch_style()),
501 Span::raw(" "),
502 Span::styled(body, theme::normal_style()),
503 ]));
504 }
505 if truncated {
506 let hidden = entries.len() - visible_count;
507 lines.push(Line::from(Span::styled(
508 format!(" … +{hidden} more — press A for full picker"),
509 theme::muted_style(),
510 )));
511 }
512 frame.render_widget(Paragraph::new(lines), area);
513}
514
515fn short_kind(kind: &str) -> String {
520 match kind {
521 "notification" => "notif".to_string(),
522 "stop" => "stop".to_string(),
523 "codex-turn" => "turn".to_string(),
524 "" => "?".to_string(),
525 other => other.chars().take(8).collect(),
526 }
527}
528
529fn pad(s: &str, width: usize) -> String {
530 let n = s.chars().count();
531 if n >= width {
532 s.chars().take(width).collect()
533 } else {
534 let mut out = s.to_string();
535 out.extend(std::iter::repeat_n(' ', width - n));
536 out
537 }
538}
539
540fn build_row(agent: &Agent) -> Row<'static> {
541 let status_style = match agent.status {
542 Status::Working => Style::default().fg(theme::GREEN),
543 Status::Ready => Style::default().fg(theme::BLUE),
544 Status::AwaitingDecision => Style::default()
545 .fg(theme::YELLOW)
546 .add_modifier(Modifier::BOLD),
547 Status::Idle => theme::muted_style(),
548 Status::Background => Style::default().fg(theme::PURPLE),
549 };
550 let status_cell = Line::from(vec![
551 Span::styled(agent.status.glyph().to_string(), status_style),
552 Span::raw(" "),
553 Span::styled(agent.status.label().to_string(), theme::muted_style()),
554 ]);
555
556 let kind_style = match agent.kind {
557 AgentKind::Claude => Style::default().fg(theme::ORANGE),
558 AgentKind::Codex => Style::default().fg(theme::BLUE),
559 AgentKind::Shell => theme::muted_style(),
560 AgentKind::Other => theme::muted_style(),
561 };
562 let kind_cell = Line::from(Span::styled(sanitize(agent.kind.label()), kind_style));
563
564 let session_cell = match &agent.pane {
565 Some(p) => Line::from(vec![
566 Span::styled(sanitize(&p.session), theme::normal_style()),
567 Span::styled(format!(" {}.{}", p.window, p.pane), theme::muted_style()),
568 ]),
569 None => Line::from(Span::styled("—", theme::muted_style())),
570 };
571
572 let repo_cell = Line::from(Span::styled(
573 sanitize(&agent.repo_name),
574 theme::branch_style(),
575 ));
576
577 let flags_cell = build_flag_line(agent);
578
579 let info_cell = Line::from(Span::styled(sanitize(&agent.extra), theme::muted_style()));
580
581 Row::new(vec![
582 status_cell,
583 kind_cell,
584 session_cell,
585 repo_cell,
586 flags_cell,
587 info_cell,
588 ])
589}
590
591fn build_flag_line(agent: &Agent) -> Line<'static> {
592 let mut spans: Vec<Span<'static>> = Vec::new();
593 if agent.flags.has_intent {
594 spans.push(Span::styled("intent ", Style::default().fg(theme::VIOLET)));
595 }
596 if agent.flags.reviewed_fresh {
597 spans.push(Span::styled("reviewed ", Style::default().fg(theme::GREEN)));
598 }
599 if spans.is_empty() {
600 spans.push(Span::styled("—", theme::muted_style()));
601 }
602 Line::from(spans)
603}
604
605fn build_summary(snap: &Snapshot) -> String {
606 let total = snap.agents.len();
607 let decisions = snap
608 .agents
609 .iter()
610 .filter(|a| a.status == Status::AwaitingDecision)
611 .count();
612 let working = snap
613 .agents
614 .iter()
615 .filter(|a| a.status == Status::Working)
616 .count();
617 let ready = snap
618 .agents
619 .iter()
620 .filter(|a| a.status == Status::Ready)
621 .count();
622 let bg = snap
623 .agents
624 .iter()
625 .filter(|a| a.status == Status::Background)
626 .count();
627 let attention = snap.attention.len();
628 format!(
629 " rows: {total} • working: {working} • ready: {ready} • decision: {decisions} • bg: {bg} • attention: {attention} • blocked: {}",
630 snap.global_blocked,
631 )
632}
633
634fn truncate_for_width(s: &str, max: usize) -> String {
636 if max == 0 {
637 return String::new();
638 }
639 let chars: Vec<char> = s.chars().collect();
640 if chars.len() <= max {
641 return s.to_string();
642 }
643 let mut out: String = chars[..max.saturating_sub(1)].iter().collect();
644 out.push('…');
645 out
646}
647
648fn sanitize(s: &str) -> String {
653 s.chars()
654 .filter(|c| !c.is_control() && *c != '\x7f')
655 .collect()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::agents::{Flags, PaneLocator};
662 use std::path::PathBuf;
663
664 fn agent(kind: AgentKind, status: Status, repo: &str) -> Agent {
665 Agent {
666 id: format!("pane:s:0.0-{repo}"),
667 pane: Some(PaneLocator {
668 session: "s".into(),
669 window: 0,
670 pane: 0,
671 pane_pid: 1,
672 }),
673 kind,
674 status,
675 cwd: PathBuf::from("/x"),
676 repo_name: repo.into(),
677 flags: Flags::default(),
678 extra: String::new(),
679 }
680 }
681
682 #[test]
683 fn sanitize_strips_escape_sequences() {
684 let dirty = "evil\x1b[2J\x07path";
687 assert_eq!(sanitize(dirty), "evil[2Jpath");
688 }
689
690 #[test]
691 fn sanitize_strips_all_control_chars() {
692 assert_eq!(sanitize("\x1b\x07\x00\x7f"), "");
694 }
695
696 #[test]
697 fn sanitize_preserves_normal_chars() {
698 assert_eq!(sanitize("hello-world_123 ./"), "hello-world_123 ./");
699 }
700
701 #[test]
702 fn build_summary_counts_states() {
703 let snap = Snapshot {
704 agents: vec![
705 agent(AgentKind::Claude, Status::Working, "a"),
706 agent(AgentKind::Claude, Status::Ready, "b"),
707 agent(AgentKind::Claude, Status::AwaitingDecision, "c"),
708 agent(AgentKind::Codex, Status::Background, "d"),
709 ],
710 captured_at: std::time::SystemTime::now(),
711 global_blocked: 2,
712 panes_error: None,
713 attention: Vec::new(),
714 };
715 let s = build_summary(&snap);
716 assert!(s.contains("rows: 4"));
717 assert!(s.contains("working: 1"));
718 assert!(s.contains("ready: 1"));
719 assert!(s.contains("decision: 1"));
720 assert!(s.contains("bg: 1"));
721 assert!(s.contains("attention: 0"));
722 assert!(s.contains("blocked: 2"));
723 }
724
725 #[test]
726 fn build_summary_reports_attention_count() {
727 let snap = Snapshot {
728 agents: Vec::new(),
729 captured_at: std::time::SystemTime::now(),
730 global_blocked: 0,
731 panes_error: None,
732 attention: vec![
733 AttentionEntry {
734 ts: 1,
735 kind: "stop".into(),
736 source: "claude".into(),
737 title: String::new(),
738 body: "b".into(),
739 session_id: String::new(),
740 cwd: PathBuf::new(),
741 tmux_target: String::new(),
742 tmux_session: String::new(),
743 },
744 AttentionEntry {
745 ts: 2,
746 kind: "notification".into(),
747 source: "claude".into(),
748 title: String::new(),
749 body: "b".into(),
750 session_id: String::new(),
751 cwd: PathBuf::new(),
752 tmux_target: String::new(),
753 tmux_session: String::new(),
754 },
755 ],
756 };
757 let s = build_summary(&snap);
758 assert!(s.contains("attention: 2"));
759 }
760
761 #[test]
762 fn truncate_for_width_basic() {
763 assert_eq!(truncate_for_width("hello", 10), "hello");
764 assert_eq!(truncate_for_width("hello world", 5), "hell…");
765 assert_eq!(truncate_for_width("a", 0), "");
766 }
767
768 #[test]
769 fn pad_extends_or_truncates() {
770 assert_eq!(pad("abc", 5), "abc ");
771 assert_eq!(pad("abcdef", 4), "abcd");
772 assert_eq!(pad("", 3), " ");
773 }
774}