Skip to main content

tmx/agents/
ui.rs

1//! Agent dashboard TUI. Renders to stderr so it composes with shell wrappers
2//! that capture stdout (matches the picker convention in `src/ui/picker.rs`).
3//!
4//! Resource discipline:
5//!   - tick = 1s; key poll = 200ms so input stays snappy without 5 wakes/s
6//!   - `ProcSnapshot` is reused across ticks; only PID-tree fields refreshed
7//!   - redraw only when the visible snapshot content changes (not every tick)
8//!   - all user-displayed strings are stripped of control chars before render
9
10use 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        // _guard drops here → alt-screen left, raw mode off.
55    };
56    match action {
57        // Surface switch-pane failures so a misformed target or vanished
58        // pane doesn't silently no-op. The caller (cli::run) propagates
59        // the error and tmx exits non-zero.
60        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
75/// Resolve the path to one of the attention helper scripts and exec it.
76/// Override is via env var (`TMX_ATTENTION_PICKER` / `TMX_ATTENTION_JUMP`),
77/// default is `$HOME/<relative>`. We surface a clear error rather than
78/// silently no-op so the user notices a misconfigured environment.
79fn 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    /// Action requested by the last keypress that the outer loop should
105    /// service after raw-mode is torn down (e.g. tmux switch-client).
106    pending_action: Option<Action>,
107    /// Short ephemeral note shown in the footer for one redraw — used to
108    /// explain a swallowed keypress (e.g. "no fresh attention to jump").
109    /// Cleared on the next state-changing event.
110    notice: Option<String>,
111    /// Drives ratatui's auto-scroll for the agents table — kept in sync
112    /// with `cursor` so the selected row stays visible when the list is
113    /// taller than its rendered area (many open tmux panes).
114    table_state: TableState,
115}
116
117enum Action {
118    /// `tmux select-pane + switch-client` to a specific pane target.
119    Switch(String),
120    /// Hand off to the fzf-based attention picker.
121    OpenAttentionPicker,
122    /// Hand off to the "jump to newest attention" fast-path.
123    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        // Any real keypress clears the previous notice; specific arms
147        // re-set it if they have new feedback to surface.
148        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            // Mirror the existing `prefix+a` / `prefix+A` tmux bindings:
158            // lowercase a = jump to newest attention, uppercase A = full
159            // fzf picker over the queue. Both hand off to the bash scripts
160            // after raw mode is torn down.
161            //
162            // 'a' is guarded against the empty-queue case. Without the
163            // guard, pressing 'a' with no fresh entries would close the
164            // popup with no visible action (the script just exits) —
165            // looks identical to a crash. Better to keep the dashboard
166            // open and tell the user why nothing happened.
167            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; // codex background jobs have no tmux target
209        };
210        self.pending_action = Some(Action::Switch(pane.target()));
211        self.quit = true;
212    }
213
214    /// Replace the snapshot while keeping the cursor on the same agent if
215    /// possible (matched by stable `Agent::id`). Works for both tmux pane
216    /// rows and codex background rows. Falls back to clamping.
217    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/// Cheap equality key. Comparing the full `Snapshot` would re-trigger on
267/// every tick because `captured_at` always advances; this restricts to the
268/// fields that actually affect what the user sees.
269#[derive(PartialEq, Eq)]
270struct RenderKey {
271    cursor: usize,
272    blocked: usize,
273    panes_error: Option<String>,
274    rows: Vec<RenderRow>,
275    /// `(ts, kind, source, tmux_session, body)` per attention entry —
276    /// the visible projection of the queue.
277    attention: Vec<(i64, String, String, String, String)>,
278    /// Coarse minute bucket so relative-age strings (`2m`, `1h`) tick
279    /// over even when no underlying snapshot field changes. Without
280    /// this, an attention entry's age display freezes at first paint.
281    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
342/// Lower bound on the queue panel — header + ≥3 entry rows so a non-empty
343/// queue never collapses into a single header line on tiny popups.
344const 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 the queue grow up to half the popup so a long agent list can't
350    // push its bottom entries off-screen. Surplus entries beyond what
351    // fits are surfaced via a "+N more" indicator inside the panel.
352    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        // +1 for the header row.
357        ((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),                // title
364            Constraint::Length(1),                // banner
365            Constraint::Min(3),                   // agents table
366            Constraint::Length(attention_height), // attention queue (0 = hidden)
367            Constraint::Length(1),                // summary
368            Constraint::Length(1),                // keys
369        ])
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    // `TableState` selection drives both the visible highlight and the
398    // auto-scroll offset — ratatui shifts `offset` whenever the selected
399    // row would fall outside the viewport, so j past the bottom scrolls.
400    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    // Banner line: prefer surfacing a tmux failure over the bare "empty"
436    // state so the user doesn't mistake a misconfigured tmux for a quiet
437    // workstation. Falls back to the empty hint, or stays blank.
438    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    // Reserve 1 row for the header; if we'd still truncate after that,
472    // reserve another row for the "+N more" hint so the user knows the
473    // queue continues offscreen instead of silently swallowing the tail.
474    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        // Body fills remaining columns; truncate to fit width minus the
493        // fixed-width prefix columns.
494        let prefix_width = 4 + 1 + 14 + 1 + 18 + 2; // age + session + kind + padding
495        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
515/// Render an attention-queue `kind` value compactly. Known kinds get
516/// hand-picked short aliases (`notif`/`stop`/`turn`); anything else falls
517/// through so a future `notify-*.sh` hook adding a new kind still surfaces
518/// meaningfully rather than collapsing to a generic placeholder.
519fn 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
634/// Truncate to `max` *char* count, appending `…` when cut.
635fn 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
648/// Strip ANSI/control chars so a hostile path or repo name can't inject
649/// escape sequences via the TUI. ratatui passes spans to the backend as
650/// raw bytes; without this a `\x1b[2J` in a process cwd would clear the
651/// screen mid-render.
652fn 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        // Stripping the leading ESC + BEL is sufficient: `[2J` without a
685        // preceding ESC is inert text that the terminal won't interpret.
686        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        // Pure control payloads collapse to empty.
693        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}