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, 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        // _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}
112
113enum Action {
114    /// `tmux select-pane + switch-client` to a specific pane target.
115    Switch(String),
116    /// Hand off to the fzf-based attention picker.
117    OpenAttentionPicker,
118    /// Hand off to the "jump to newest attention" fast-path.
119    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        // Any real keypress clears the previous notice; specific arms
138        // re-set it if they have new feedback to surface.
139        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            // Mirror the existing `prefix+a` / `prefix+A` tmux bindings:
149            // lowercase a = jump to newest attention, uppercase A = full
150            // fzf picker over the queue. Both hand off to the bash scripts
151            // after raw mode is torn down.
152            //
153            // 'a' is guarded against the empty-queue case. Without the
154            // guard, pressing 'a' with no fresh entries would close the
155            // popup with no visible action (the script just exits) —
156            // looks identical to a crash. Better to keep the dashboard
157            // open and tell the user why nothing happened.
158            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; // codex background jobs have no tmux target
190        };
191        self.pending_action = Some(Action::Switch(pane.target()));
192        self.quit = true;
193    }
194
195    /// Replace the snapshot while keeping the cursor on the same agent if
196    /// possible (matched by stable `Agent::id`). Works for both tmux pane
197    /// rows and codex background rows. Falls back to clamping.
198    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/// Cheap equality key. Comparing the full `Snapshot` would re-trigger on
247/// every tick because `captured_at` always advances; this restricts to the
248/// fields that actually affect what the user sees.
249#[derive(PartialEq, Eq)]
250struct RenderKey {
251    cursor: usize,
252    blocked: usize,
253    panes_error: Option<String>,
254    rows: Vec<RenderRow>,
255    /// `(ts, kind, source, tmux_session, body)` per attention entry —
256    /// the visible projection of the queue.
257    attention: Vec<(i64, String, String, String, String)>,
258    /// Coarse minute bucket so relative-age strings (`2m`, `1h`) tick
259    /// over even when no underlying snapshot field changes. Without
260    /// this, an attention entry's age display freezes at first paint.
261    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),                // title
337            Constraint::Length(1),                // banner
338            Constraint::Min(3),                   // agents table
339            Constraint::Length(attention_height), // attention queue (0 = hidden)
340            Constraint::Length(1),                // summary
341            Constraint::Length(1),                // keys
342        ])
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    // Banner line: prefer surfacing a tmux failure over the bare "empty"
408    // state so the user doesn't mistake a misconfigured tmux for a quiet
409    // workstation. Falls back to the empty hint, or stays blank.
410    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        // Body fills remaining columns; truncate to fit width minus the
453        // fixed-width prefix columns.
454        let prefix_width = 4 + 1 + 14 + 1 + 18 + 2; // age + session + kind + padding
455        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
468/// Render an attention-queue `kind` value compactly. Known kinds get
469/// hand-picked short aliases (`notif`/`stop`/`turn`); anything else falls
470/// through so a future `notify-*.sh` hook adding a new kind still surfaces
471/// meaningfully rather than collapsing to a generic placeholder.
472fn 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
593/// Truncate to `max` *char* count, appending `…` when cut.
594fn 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
607/// Strip ANSI/control chars so a hostile path or repo name can't inject
608/// escape sequences via the TUI. ratatui passes spans to the backend as
609/// raw bytes; without this a `\x1b[2J` in a process cwd would clear the
610/// screen mid-render.
611fn 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        // Stripping the leading ESC + BEL is sufficient: `[2J` without a
644        // preceding ESC is inert text that the terminal won't interpret.
645        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        // Pure control payloads collapse to empty.
652        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}