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, Paragraph, Row, Table};
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}
112
113enum Action {
114 Switch(String),
116 OpenAttentionPicker,
118 JumpLatestAttention,
120}
121
122impl Model {
123 fn new(snapshot: Snapshot) -> Self {
124 Self {
125 snapshot,
126 cursor: 0,
127 quit: false,
128 pending_action: None,
129 notice: None,
130 }
131 }
132
133 fn handle_key(&mut self, key: KeyEvent) {
134 if key.kind != KeyEventKind::Press {
135 return;
136 }
137 self.notice = None;
140 match key.code {
141 KeyCode::Esc | KeyCode::Char('q') => self.quit = true,
142 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
143 self.quit = true;
144 }
145 KeyCode::Up | KeyCode::Char('k') => self.move_up(),
146 KeyCode::Down | KeyCode::Char('j') => self.move_down(),
147 KeyCode::Enter => self.activate_selection(),
148 KeyCode::Char('a') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
159 if self.snapshot.attention.is_empty() {
160 self.notice = Some("no fresh attention to jump to".to_string());
161 } else {
162 self.pending_action = Some(Action::JumpLatestAttention);
163 self.quit = true;
164 }
165 }
166 KeyCode::Char('A') => {
167 self.pending_action = Some(Action::OpenAttentionPicker);
168 self.quit = true;
169 }
170 _ => {}
171 }
172 }
173
174 fn move_up(&mut self) {
175 self.cursor = self.cursor.saturating_sub(1);
176 }
177
178 fn move_down(&mut self) {
179 if self.cursor + 1 < self.snapshot.agents.len() {
180 self.cursor += 1;
181 }
182 }
183
184 fn activate_selection(&mut self) {
185 let Some(agent) = self.snapshot.agents.get(self.cursor) else {
186 return;
187 };
188 let Some(pane) = agent.pane.as_ref() else {
189 return; };
191 self.pending_action = Some(Action::Switch(pane.target()));
192 self.quit = true;
193 }
194
195 fn replace_snapshot(&mut self, next: Snapshot) {
199 let anchor = self.snapshot.agents.get(self.cursor).map(|a| a.id.clone());
200 self.snapshot = next;
201 self.cursor = match anchor {
202 Some(id) => self
203 .snapshot
204 .agents
205 .iter()
206 .position(|a| a.id == id)
207 .unwrap_or(0),
208 None => 0,
209 };
210 if self.cursor >= self.snapshot.agents.len() {
211 self.cursor = self.snapshot.agents.len().saturating_sub(1);
212 }
213 }
214}
215
216fn run_loop<B: Backend>(terminal: &mut Terminal<B>) -> Result<Option<Action>> {
217 let mut proc = ProcSnapshot::new();
218 let mut model = Model::new(collector::collect(&proc));
219 let mut last_tick = Instant::now();
220 let mut last_rendered: Option<RenderKey> = None;
221
222 while !model.quit {
223 let key_now = RenderKey::from(&model);
224 if last_rendered.as_ref() != Some(&key_now) {
225 terminal.draw(|frame| render(frame, &model))?;
226 last_rendered = Some(key_now);
227 }
228
229 if event::poll(POLL)?
230 && let Event::Key(key) = event::read()?
231 {
232 model.handle_key(key);
233 }
234
235 if last_tick.elapsed() >= TICK {
236 proc.refresh();
237 let next = collector::collect(&proc);
238 model.replace_snapshot(next);
239 last_tick = Instant::now();
240 }
241 }
242
243 Ok(model.pending_action)
244}
245
246#[derive(PartialEq, Eq)]
250struct RenderKey {
251 cursor: usize,
252 blocked: usize,
253 panes_error: Option<String>,
254 rows: Vec<RenderRow>,
255 attention: Vec<(i64, String, String, String, String)>,
258 minute_bucket: i64,
262 notice: Option<String>,
263}
264
265#[derive(PartialEq, Eq)]
266struct RenderRow {
267 pane_target: Option<String>,
268 kind: AgentKind,
269 status: Status,
270 repo: String,
271 has_intent: bool,
272 reviewed_fresh: bool,
273 extra: String,
274}
275
276impl From<&Model> for RenderKey {
277 fn from(model: &Model) -> Self {
278 let rows = model
279 .snapshot
280 .agents
281 .iter()
282 .map(|a| RenderRow {
283 pane_target: a.pane.as_ref().map(|p| p.target()),
284 kind: a.kind,
285 status: a.status,
286 repo: a.repo_name.clone(),
287 has_intent: a.flags.has_intent,
288 reviewed_fresh: a.flags.reviewed_fresh,
289 extra: a.extra.clone(),
290 })
291 .collect();
292 let attention = model
293 .snapshot
294 .attention
295 .iter()
296 .map(|a| {
297 (
298 a.ts,
299 a.kind.clone(),
300 a.source.clone(),
301 a.tmux_session.clone(),
302 a.body.clone(),
303 )
304 })
305 .collect();
306 let minute_bucket = std::time::SystemTime::now()
307 .duration_since(std::time::UNIX_EPOCH)
308 .map(|d| (d.as_secs() / 60) as i64)
309 .unwrap_or(0);
310 Self {
311 cursor: model.cursor,
312 blocked: model.snapshot.global_blocked,
313 panes_error: model.snapshot.panes_error.clone(),
314 rows,
315 attention,
316 minute_bucket,
317 notice: model.notice.clone(),
318 }
319 }
320}
321
322const ATTENTION_PANEL_MAX_ROWS: u16 = 8;
323
324fn render(frame: &mut Frame, model: &Model) {
325 let area = frame.area();
326 let attention = model.snapshot.attention.as_slice();
327 let attention_height = if attention.is_empty() {
328 0
329 } else {
330 (attention.len() as u16).min(ATTENTION_PANEL_MAX_ROWS) + 1
331 };
332
333 let layout = Layout::default()
334 .direction(Direction::Vertical)
335 .constraints([
336 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(attention_height), Constraint::Length(1), Constraint::Length(1), ])
343 .split(area);
344
345 frame.render_widget(
346 Paragraph::new(Span::styled(" tmx agents", theme::title_style())),
347 layout[0],
348 );
349
350 let header = Row::new(vec![
351 Span::styled(" STATUS", theme::muted_style()),
352 Span::styled("KIND", theme::muted_style()),
353 Span::styled("SESSION", theme::muted_style()),
354 Span::styled("REPO", theme::muted_style()),
355 Span::styled("FLAGS", theme::muted_style()),
356 Span::styled("INFO", theme::muted_style()),
357 ]);
358
359 let rows = model
360 .snapshot
361 .agents
362 .iter()
363 .enumerate()
364 .map(|(i, agent)| build_row(agent, i == model.cursor));
365
366 let widths = [
367 Constraint::Length(10),
368 Constraint::Length(7),
369 Constraint::Length(28),
370 Constraint::Length(20),
371 Constraint::Length(20),
372 Constraint::Min(8),
373 ];
374
375 let table = Table::new(rows, widths)
376 .header(header)
377 .block(Block::default());
378 frame.render_widget(table, layout[2]);
379
380 if attention_height > 0 {
381 render_attention_panel(frame, layout[3], attention);
382 }
383
384 let summary = build_summary(&model.snapshot);
385 frame.render_widget(
386 Paragraph::new(Span::styled(summary, theme::status_bar_style())),
387 layout[4],
388 );
389
390 let (keys_text, keys_style) = match model.notice.as_deref() {
391 Some(msg) => (
392 format!(" ⓘ {}", sanitize(msg)),
393 Style::default()
394 .fg(theme::YELLOW)
395 .add_modifier(Modifier::BOLD),
396 ),
397 None => (
398 " j/k nav enter switch a jump A picker q quit".to_string(),
399 theme::muted_style(),
400 ),
401 };
402 frame.render_widget(
403 Paragraph::new(Span::styled(keys_text, keys_style)),
404 layout[5],
405 );
406
407 let banner = match (
411 &model.snapshot.panes_error,
412 model.snapshot.agents.is_empty(),
413 ) {
414 (Some(err), _) => Some((
415 format!(" tmux unavailable: {}", sanitize(err)),
416 Style::default().fg(theme::RED),
417 )),
418 (None, true) => Some((
419 " no tmux panes or codex jobs found".to_string(),
420 theme::muted_style(),
421 )),
422 _ => None,
423 };
424 if let Some((text, style)) = banner {
425 frame.render_widget(Paragraph::new(Span::styled(text, style)), layout[1]);
426 }
427}
428
429fn render_attention_panel(
430 frame: &mut Frame,
431 area: ratatui::layout::Rect,
432 entries: &[AttentionEntry],
433) {
434 let now = std::time::SystemTime::now()
435 .duration_since(std::time::UNIX_EPOCH)
436 .map(|d| d.as_secs() as i64)
437 .unwrap_or(0);
438 let header = Line::from(Span::styled(
439 format!(" attention queue ({} pending)", entries.len()),
440 theme::status_bar_style(),
441 ));
442 let mut lines: Vec<Line<'static>> = Vec::with_capacity(entries.len() + 1);
443 lines.push(header);
444 for entry in entries.iter().take(ATTENTION_PANEL_MAX_ROWS as usize) {
445 let age = attention::human_age(entry.ts, now);
446 let session_cell = if entry.tmux_session.is_empty() {
447 "—".to_string()
448 } else {
449 entry.tmux_session.clone()
450 };
451 let kind_label = format!("{}·{}", entry.source, short_kind(&entry.kind));
452 let prefix_width = 4 + 1 + 14 + 1 + 18 + 2; let body_max = (area.width as usize).saturating_sub(prefix_width);
456 let body = truncate_for_width(&sanitize(&entry.body), body_max);
457 lines.push(Line::from(vec![
458 Span::styled(format!(" {:>4} ", age), theme::muted_style()),
459 Span::styled(pad(&sanitize(&session_cell), 14), theme::normal_style()),
460 Span::styled(pad(&sanitize(&kind_label), 18), theme::branch_style()),
461 Span::raw(" "),
462 Span::styled(body, theme::normal_style()),
463 ]));
464 }
465 frame.render_widget(Paragraph::new(lines), area);
466}
467
468fn short_kind(kind: &str) -> String {
473 match kind {
474 "notification" => "notif".to_string(),
475 "stop" => "stop".to_string(),
476 "codex-turn" => "turn".to_string(),
477 "" => "?".to_string(),
478 other => other.chars().take(8).collect(),
479 }
480}
481
482fn pad(s: &str, width: usize) -> String {
483 let n = s.chars().count();
484 if n >= width {
485 s.chars().take(width).collect()
486 } else {
487 let mut out = s.to_string();
488 out.extend(std::iter::repeat_n(' ', width - n));
489 out
490 }
491}
492
493fn build_row(agent: &Agent, is_cursor: bool) -> Row<'static> {
494 let cursor_marker = if is_cursor { "▶ " } else { " " };
495 let status_style = match agent.status {
496 Status::Working => Style::default().fg(theme::GREEN),
497 Status::Ready => Style::default().fg(theme::BLUE),
498 Status::AwaitingDecision => Style::default()
499 .fg(theme::YELLOW)
500 .add_modifier(Modifier::BOLD),
501 Status::Idle => theme::muted_style(),
502 Status::Background => Style::default().fg(theme::PURPLE),
503 };
504 let status_cell = Line::from(vec![
505 Span::raw(cursor_marker),
506 Span::styled(agent.status.glyph().to_string(), status_style),
507 Span::raw(" "),
508 Span::styled(agent.status.label().to_string(), theme::muted_style()),
509 ]);
510
511 let kind_style = match agent.kind {
512 AgentKind::Claude => Style::default().fg(theme::ORANGE),
513 AgentKind::Codex => Style::default().fg(theme::BLUE),
514 AgentKind::Shell => theme::muted_style(),
515 AgentKind::Other => theme::muted_style(),
516 };
517 let kind_cell = Line::from(Span::styled(sanitize(agent.kind.label()), kind_style));
518
519 let session_cell = match &agent.pane {
520 Some(p) => Line::from(vec![
521 Span::styled(sanitize(&p.session), theme::normal_style()),
522 Span::styled(format!(" {}.{}", p.window, p.pane), theme::muted_style()),
523 ]),
524 None => Line::from(Span::styled("—", theme::muted_style())),
525 };
526
527 let repo_cell = Line::from(Span::styled(
528 sanitize(&agent.repo_name),
529 theme::branch_style(),
530 ));
531
532 let flags_cell = build_flag_line(agent);
533
534 let info_cell = Line::from(Span::styled(sanitize(&agent.extra), theme::muted_style()));
535
536 let mut row = Row::new(vec![
537 status_cell,
538 kind_cell,
539 session_cell,
540 repo_cell,
541 flags_cell,
542 info_cell,
543 ]);
544 if is_cursor {
545 row = row.style(Style::default().add_modifier(Modifier::BOLD));
546 }
547 row
548}
549
550fn build_flag_line(agent: &Agent) -> Line<'static> {
551 let mut spans: Vec<Span<'static>> = Vec::new();
552 if agent.flags.has_intent {
553 spans.push(Span::styled("intent ", Style::default().fg(theme::VIOLET)));
554 }
555 if agent.flags.reviewed_fresh {
556 spans.push(Span::styled("reviewed ", Style::default().fg(theme::GREEN)));
557 }
558 if spans.is_empty() {
559 spans.push(Span::styled("—", theme::muted_style()));
560 }
561 Line::from(spans)
562}
563
564fn build_summary(snap: &Snapshot) -> String {
565 let total = snap.agents.len();
566 let decisions = snap
567 .agents
568 .iter()
569 .filter(|a| a.status == Status::AwaitingDecision)
570 .count();
571 let working = snap
572 .agents
573 .iter()
574 .filter(|a| a.status == Status::Working)
575 .count();
576 let ready = snap
577 .agents
578 .iter()
579 .filter(|a| a.status == Status::Ready)
580 .count();
581 let bg = snap
582 .agents
583 .iter()
584 .filter(|a| a.status == Status::Background)
585 .count();
586 let attention = snap.attention.len();
587 format!(
588 " rows: {total} • working: {working} • ready: {ready} • decision: {decisions} • bg: {bg} • attention: {attention} • blocked: {}",
589 snap.global_blocked,
590 )
591}
592
593fn truncate_for_width(s: &str, max: usize) -> String {
595 if max == 0 {
596 return String::new();
597 }
598 let chars: Vec<char> = s.chars().collect();
599 if chars.len() <= max {
600 return s.to_string();
601 }
602 let mut out: String = chars[..max.saturating_sub(1)].iter().collect();
603 out.push('…');
604 out
605}
606
607fn sanitize(s: &str) -> String {
612 s.chars()
613 .filter(|c| !c.is_control() && *c != '\x7f')
614 .collect()
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::agents::{Flags, PaneLocator};
621 use std::path::PathBuf;
622
623 fn agent(kind: AgentKind, status: Status, repo: &str) -> Agent {
624 Agent {
625 id: format!("pane:s:0.0-{repo}"),
626 pane: Some(PaneLocator {
627 session: "s".into(),
628 window: 0,
629 pane: 0,
630 pane_pid: 1,
631 }),
632 kind,
633 status,
634 cwd: PathBuf::from("/x"),
635 repo_name: repo.into(),
636 flags: Flags::default(),
637 extra: String::new(),
638 }
639 }
640
641 #[test]
642 fn sanitize_strips_escape_sequences() {
643 let dirty = "evil\x1b[2J\x07path";
646 assert_eq!(sanitize(dirty), "evil[2Jpath");
647 }
648
649 #[test]
650 fn sanitize_strips_all_control_chars() {
651 assert_eq!(sanitize("\x1b\x07\x00\x7f"), "");
653 }
654
655 #[test]
656 fn sanitize_preserves_normal_chars() {
657 assert_eq!(sanitize("hello-world_123 ./"), "hello-world_123 ./");
658 }
659
660 #[test]
661 fn build_summary_counts_states() {
662 let snap = Snapshot {
663 agents: vec![
664 agent(AgentKind::Claude, Status::Working, "a"),
665 agent(AgentKind::Claude, Status::Ready, "b"),
666 agent(AgentKind::Claude, Status::AwaitingDecision, "c"),
667 agent(AgentKind::Codex, Status::Background, "d"),
668 ],
669 captured_at: std::time::SystemTime::now(),
670 global_blocked: 2,
671 panes_error: None,
672 attention: Vec::new(),
673 };
674 let s = build_summary(&snap);
675 assert!(s.contains("rows: 4"));
676 assert!(s.contains("working: 1"));
677 assert!(s.contains("ready: 1"));
678 assert!(s.contains("decision: 1"));
679 assert!(s.contains("bg: 1"));
680 assert!(s.contains("attention: 0"));
681 assert!(s.contains("blocked: 2"));
682 }
683
684 #[test]
685 fn build_summary_reports_attention_count() {
686 let snap = Snapshot {
687 agents: Vec::new(),
688 captured_at: std::time::SystemTime::now(),
689 global_blocked: 0,
690 panes_error: None,
691 attention: vec![
692 AttentionEntry {
693 ts: 1,
694 kind: "stop".into(),
695 source: "claude".into(),
696 title: String::new(),
697 body: "b".into(),
698 session_id: String::new(),
699 cwd: PathBuf::new(),
700 tmux_target: String::new(),
701 tmux_session: String::new(),
702 },
703 AttentionEntry {
704 ts: 2,
705 kind: "notification".into(),
706 source: "claude".into(),
707 title: String::new(),
708 body: "b".into(),
709 session_id: String::new(),
710 cwd: PathBuf::new(),
711 tmux_target: String::new(),
712 tmux_session: String::new(),
713 },
714 ],
715 };
716 let s = build_summary(&snap);
717 assert!(s.contains("attention: 2"));
718 }
719
720 #[test]
721 fn truncate_for_width_basic() {
722 assert_eq!(truncate_for_width("hello", 10), "hello");
723 assert_eq!(truncate_for_width("hello world", 5), "hell…");
724 assert_eq!(truncate_for_width("a", 0), "");
725 }
726
727 #[test]
728 fn pad_extends_or_truncates() {
729 assert_eq!(pad("abc", 5), "abc ");
730 assert_eq!(pad("abcdef", 4), "abcd");
731 assert_eq!(pad("", 3), " ");
732 }
733}