Skip to main content

git_paw/
dashboard.rs

1//! Ratatui TUI status table for pane 0.
2//!
3//! Reads from [`BrokerState`] on a 1-second tick
4//! and renders a read-only agent status table. The v0.3.0 dashboard is
5//! display-only — the only interaction is quitting with `q`.
6
7use std::collections::HashMap;
8use std::io::{self, Stdout};
9use std::process::Command;
10use std::sync::Arc;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crossterm::event::{self, Event, KeyCode, KeyEventKind};
15use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
16use ratatui::Frame;
17use ratatui::Terminal;
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Alignment, Constraint, Layout};
20use ratatui::style::{Modifier, Style};
21use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
22
23use crate::broker::delivery;
24use crate::broker::messages::BrokerMessage;
25use crate::broker::{AgentStatusEntry, BrokerHandle, BrokerState};
26use crate::error::PawError;
27
28/// Maximum number of pending questions rendered in the prompts section.
29const MAX_VISIBLE_QUESTIONS: usize = 5;
30
31/// Tick interval for the dashboard draw loop.
32///
33/// Also bounds the worst-case typing latency: any keystroke that arrives
34/// mid-sleep is picked up on the next tick. 50ms is comfortably below the
35/// ~100ms perceptual threshold for interactive UIs while keeping the
36/// broker-state snapshot rate modest (~20 Hz against an in-process lock).
37const TICK_INTERVAL: Duration = Duration::from_millis(50);
38
39/// A pending question from an agent awaiting a human reply.
40///
41/// `pane_index` is the tmux pane the agent is running in; it is the routing
42/// target when the supervisor presses `Enter` to send a reply via
43/// `tmux send-keys`.
44#[derive(Debug, Clone)]
45pub struct QuestionEntry {
46    /// Slugified branch name of the asking agent.
47    pub agent_id: String,
48    /// Tmux pane index the agent process is running in.
49    pub pane_index: usize,
50    /// The question text.
51    pub question: String,
52    /// Broker sequence number used for ordering and dedup.
53    pub seq: u64,
54}
55
56impl QuestionEntry {
57    /// Creates a `QuestionEntry` from a `BrokerMessage::Question`.
58    ///
59    /// `pane_index` is the tmux pane the agent is running in.
60    ///
61    /// # Panics
62    ///
63    /// Panics if `msg` is not a `BrokerMessage::Question` variant.
64    pub fn from_broker_message(msg: &BrokerMessage, pane_index: usize) -> Self {
65        if let BrokerMessage::Question { agent_id, payload } = msg {
66            Self {
67                agent_id: agent_id.clone(),
68                pane_index,
69                question: payload.question.clone(),
70                seq: 0, // seq is not used in tests, set to 0
71            }
72        } else {
73            panic!("Expected BrokerMessage::Question, got {msg:?}");
74        }
75    }
76}
77
78/// A formatted row for display in the agent status table.
79#[derive(Debug, Clone)]
80pub struct AgentRow {
81    /// The agent identifier (slugified branch name).
82    pub agent_id: String,
83    /// The CLI name (e.g. `"claude"`).
84    pub cli: String,
85    /// Status symbol and label (e.g. `"🔵 working"`).
86    pub status: String,
87    /// Relative time since last message (e.g. `"3m ago"`).
88    pub age: String,
89    /// One-line summary from the last message.
90    pub summary: String,
91}
92
93/// Maximum number of messages displayed in the broker messages panel.
94const MAX_VISIBLE_MESSAGES: usize = 20;
95
96/// Maps an agent status label to a Unicode symbol.
97///
98/// | Input | Output |
99/// |---|---|
100/// | `"working"` | `"🔵"` |
101/// | `"done"` | `"🟢"` |
102/// | `"verified"` | `"🟢"` |
103/// | `"committed"` | `"🟣"` |
104/// | `"blocked"` | `"🟡"` |
105/// | anything else | `"⚪"` |
106pub fn status_symbol(status: &str) -> &'static str {
107    match status {
108        "working" => "🔵",
109        "done" | "verified" => "🟢",
110        "committed" => "🟣",
111        "blocked" => "🟡",
112        _ => "⚪",
113    }
114}
115
116/// Formats an elapsed duration as a human-readable relative time string.
117///
118/// - Less than 60 seconds: `"Xs ago"` (e.g. `"30s ago"`)
119/// - 1 to 59 minutes: `"Xm ago"` (e.g. `"3m ago"`)
120/// - 60 minutes or more: `"Xh Ym ago"` (e.g. `"1h 15m ago"`)
121pub fn format_age(elapsed: Duration) -> String {
122    let secs = elapsed.as_secs();
123    if secs < 60 {
124        format!("{secs}s ago")
125    } else if secs < 3600 {
126        let mins = secs / 60;
127        format!("{mins}m ago")
128    } else {
129        let hours = secs / 3600;
130        let mins = (secs % 3600) / 60;
131        format!("{hours}h {mins}m ago")
132    }
133}
134
135/// A formatted broker message for display in the messages panel.
136#[derive(Debug, Clone)]
137pub struct MessageEntry {
138    /// Formatted timestamp (e.g., "14:30:45").
139    pub timestamp: String,
140    /// The agent identifier (slugified branch name).
141    pub agent_id: String,
142    /// Message type symbol and label (e.g., "📤 status").
143    pub message_type: String,
144    /// The formatted message content.
145    pub content: String,
146}
147
148/// Maps a broker message type to a Unicode symbol.
149pub fn message_type_symbol(msg_type: &str) -> &'static str {
150    match msg_type {
151        "agent.status" => "📤",
152        "agent.artifact" => "📦",
153        "agent.blocked" => "🚧",
154        "agent.verified" => "✅",
155        "agent.feedback" => "💬",
156        "agent.question" => "❓",
157        _ => "📄",
158    }
159}
160
161/// Formats a broker message for display in the messages panel.
162pub fn format_message_entry(
163    _seq: u64,
164    timestamp: std::time::SystemTime,
165    msg: &BrokerMessage,
166) -> MessageEntry {
167    // Format timestamp as HH:MM:SS
168    let time = timestamp.duration_since(std::time::UNIX_EPOCH).map_or_else(
169        |_| "00:00:00".to_string(),
170        |d| {
171            let secs = d.as_secs() % 86400; // seconds in day
172            let hours = secs / 3600;
173            let mins = (secs % 3600) / 60;
174            let secs = secs % 60;
175            format!("{hours:02}:{mins:02}:{secs:02}")
176        },
177    );
178
179    let msg_type = match msg {
180        BrokerMessage::Status { .. } => "status",
181        BrokerMessage::Artifact { .. } => "artifact",
182        BrokerMessage::Blocked { .. } => "blocked",
183        BrokerMessage::Verified { .. } => "verified",
184        BrokerMessage::Feedback { .. } => "feedback",
185        BrokerMessage::Question { .. } => "question",
186    };
187    let symbol = message_type_symbol(&format!("agent.{msg_type}"));
188    let _status_label = msg.status_label().to_string();
189
190    MessageEntry {
191        timestamp: time,
192        agent_id: msg.agent_id().to_string(),
193        message_type: format!("{symbol} {msg_type}"),
194        content: msg.to_string(),
195    }
196}
197
198/// Formats a list of broker messages for display.
199pub fn format_message_entries(
200    messages: &[(u64, std::time::SystemTime, BrokerMessage)],
201) -> Vec<MessageEntry> {
202    messages
203        .iter()
204        .map(|(seq, ts, msg)| format_message_entry(*seq, *ts, msg))
205        .collect()
206}
207
208/// Converts raw agent status entries into formatted display rows.
209///
210/// Pure function: performs no I/O, holds no locks, and is deterministic
211/// given the same inputs.
212pub fn format_agent_rows(agents: &[AgentStatusEntry], now: Instant) -> Vec<AgentRow> {
213    agents
214        .iter()
215        .map(|agent| {
216            let elapsed = now.saturating_duration_since(agent.last_seen);
217            let symbol = status_symbol(&agent.status);
218            AgentRow {
219                agent_id: agent.agent_id.clone(),
220                cli: agent.cli.clone(),
221                status: format!("{symbol} {}", agent.status),
222                age: format_age(elapsed),
223                summary: agent.summary.clone(),
224            }
225        })
226        .collect()
227}
228
229/// Produces a summary status line for the dashboard footer.
230///
231/// Returns a string like `"5 agents: 2 working, 1 done, 1 blocked, 1 committed"`.
232pub fn format_status_line(
233    total: usize,
234    working: usize,
235    done: usize,
236    blocked: usize,
237    committed: usize,
238) -> String {
239    format!(
240        "{total} agents: {working} working, {done} done, {blocked} blocked, {committed} committed"
241    )
242}
243
244// ---------------------------------------------------------------------------
245// Terminal lifecycle
246// ---------------------------------------------------------------------------
247
248/// Guard that restores the terminal on drop, ensuring cleanup even on panic
249/// or early return.
250struct TerminalGuard {
251    terminal: Terminal<CrosstermBackend<Stdout>>,
252}
253
254impl Drop for TerminalGuard {
255    fn drop(&mut self) {
256        let _ = terminal::disable_raw_mode();
257        let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
258        let _ = self.terminal.show_cursor();
259    }
260}
261
262/// Enters raw mode and the alternate screen, returning a configured terminal.
263fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, PawError> {
264    terminal::enable_raw_mode()
265        .map_err(|e| PawError::DashboardError(format!("failed to enable raw mode: {e}")))?;
266    crossterm::execute!(io::stdout(), EnterAlternateScreen)
267        .map_err(|e| PawError::DashboardError(format!("failed to enter alternate screen: {e}")))?;
268    Terminal::new(CrosstermBackend::new(io::stdout()))
269        .map_err(|e| PawError::DashboardError(format!("failed to create terminal: {e}")))
270}
271
272/// Disables raw mode, leaves the alternate screen, and shows the cursor.
273fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), PawError> {
274    terminal::disable_raw_mode()
275        .map_err(|e| PawError::DashboardError(format!("failed to disable raw mode: {e}")))?;
276    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)
277        .map_err(|e| PawError::DashboardError(format!("failed to leave alternate screen: {e}")))?;
278    terminal
279        .show_cursor()
280        .map_err(|e| PawError::DashboardError(format!("failed to show cursor: {e}")))
281}
282
283// ---------------------------------------------------------------------------
284// Draw
285// ---------------------------------------------------------------------------
286
287/// Renders one frame of the dashboard TUI to the given `Frame`.
288///
289/// Public wrapper around the internal `draw_frame` so integration tests can
290/// drive a real frame with `ratatui::backend::TestBackend` and assert against
291/// the resulting buffer.
292#[allow(clippy::too_many_arguments)]
293pub fn render_dashboard(
294    frame: &mut Frame,
295    rows: &[AgentRow],
296    status_line: &str,
297    questions: &[QuestionEntry],
298    focused_question: Option<usize>,
299    input_buffer: &str,
300    message_entries: &[MessageEntry],
301    show_message_log: bool,
302) {
303    draw_frame(
304        frame,
305        rows,
306        status_line,
307        questions,
308        focused_question,
309        input_buffer,
310        message_entries,
311        show_message_log,
312    );
313}
314
315/// Drives one tick of the dashboard's question-polling path.
316///
317/// Polls the supervisor inbox in `state` for messages newer than `last_seq`,
318/// converts any `agent.question` messages into `QuestionEntry` values (using
319/// `pane_map` to resolve the routing pane), and appends them to `questions`.
320/// `last_seq` is updated to the highest sequence number observed.
321///
322/// Pulled out as a free function so tests can exercise the production
323/// poll → enqueue path without spinning up a terminal.
324pub fn drive_question_tick<S: std::hash::BuildHasher>(
325    state: &Arc<BrokerState>,
326    pane_map: &HashMap<String, usize, S>,
327    questions: &mut Vec<QuestionEntry>,
328    last_seq: &mut u64,
329) {
330    let (new_msgs, observed_seq) = delivery::poll_messages(state, "supervisor", *last_seq);
331    if observed_seq > *last_seq {
332        *last_seq = observed_seq;
333    }
334    for msg in new_msgs {
335        if let BrokerMessage::Question { agent_id, payload } = msg {
336            let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
337            questions.push(QuestionEntry {
338                agent_id,
339                pane_index,
340                question: payload.question,
341                seq: observed_seq,
342            });
343        }
344    }
345}
346
347/// Renders one frame of the dashboard TUI.
348#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
349fn draw_frame(
350    frame: &mut Frame,
351    rows: &[AgentRow],
352    status_line: &str,
353    questions: &[QuestionEntry],
354    focused_question: Option<usize>,
355    input_buffer: &str,
356    message_entries: &[MessageEntry],
357    show_message_log: bool,
358) {
359    // Calculate layout constraints based on whether message log is shown
360    let layout_constraints = if show_message_log {
361        vec![
362            Constraint::Length(1),  // title
363            Constraint::Min(0),     // agent table
364            Constraint::Length(1),  // status line
365            Constraint::Length(12), // messages panel
366            Constraint::Length(7),  // prompts section
367            Constraint::Length(3),  // input field
368        ]
369    } else {
370        vec![
371            Constraint::Length(1), // title
372            Constraint::Min(0),    // agent table
373            Constraint::Length(1), // status line
374            Constraint::Length(7), // prompts section
375            Constraint::Length(3), // input field
376        ]
377    };
378
379    let chunks = Layout::vertical(layout_constraints).split(frame.area());
380
381    let title =
382        Paragraph::new("git-paw dashboard").style(Style::default().add_modifier(Modifier::BOLD));
383    frame.render_widget(title, chunks[0]);
384
385    if rows.is_empty() {
386        let empty = Paragraph::new("No agents connected yet").alignment(Alignment::Center);
387        frame.render_widget(empty, chunks[1]);
388    } else {
389        let header = Row::new(["Agent", "CLI", "Status", "Last Update", "Summary"])
390            .style(Style::default().add_modifier(Modifier::BOLD));
391        let table_rows: Vec<Row> = rows
392            .iter()
393            .map(|r| {
394                Row::new([
395                    r.agent_id.as_str(),
396                    r.cli.as_str(),
397                    r.status.as_str(),
398                    r.age.as_str(),
399                    r.summary.as_str(),
400                ])
401            })
402            .collect();
403        let widths = [
404            Constraint::Min(15),
405            Constraint::Length(10),
406            Constraint::Length(15),
407            Constraint::Length(10),
408            Constraint::Min(20),
409        ];
410        let table = Table::new(table_rows, widths).header(header);
411        frame.render_widget(table, chunks[1]);
412    }
413
414    let status = Paragraph::new(status_line.to_string());
415    frame.render_widget(status, chunks[2]);
416
417    // Messages panel (only shown when enabled)
418    if show_message_log {
419        let messages_title = format!("Messages ({} recent)", message_entries.len());
420        let messages_block = Block::default().borders(Borders::ALL).title(messages_title);
421        let messages_text = if message_entries.is_empty() {
422            "(no recent messages)".to_string()
423        } else {
424            message_entries
425                .iter()
426                .take(MAX_VISIBLE_MESSAGES)
427                .map(|entry| {
428                    format!(
429                        "{} [{}] {}: {}",
430                        entry.timestamp, entry.agent_id, entry.message_type, entry.content
431                    )
432                })
433                .collect::<Vec<_>>()
434                .join("\n")
435        };
436        let messages = Paragraph::new(messages_text).block(messages_block);
437        frame.render_widget(messages, chunks[3]);
438
439        // Adjust indices for remaining sections when messages panel is shown
440        let prompts_chunk_idx = 4;
441        let input_chunk_idx = 5;
442
443        // Prompts section
444        let prompts_title = format!("Questions ({} pending)", questions.len());
445        let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
446        let prompts_text = if questions.is_empty() {
447            "(no pending questions)".to_string()
448        } else {
449            questions
450                .iter()
451                .take(MAX_VISIBLE_QUESTIONS)
452                .enumerate()
453                .map(|(i, q)| {
454                    let marker = if Some(i) == focused_question {
455                        ">"
456                    } else {
457                        " "
458                    };
459                    format!("{marker} [{}] {}", q.agent_id, q.question)
460                })
461                .collect::<Vec<_>>()
462                .join("\n")
463        };
464        let prompts = Paragraph::new(prompts_text).block(prompts_block);
465        frame.render_widget(prompts, chunks[prompts_chunk_idx]);
466
467        // Input field
468        let focused_agent = focused_question
469            .and_then(|i| questions.get(i))
470            .map_or("(none)", |q| q.agent_id.as_str());
471        let input_block = Block::default().borders(Borders::ALL);
472        let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
473        let input = Paragraph::new(input_text).block(input_block);
474        frame.render_widget(input, chunks[input_chunk_idx]);
475    } else {
476        // Original layout without messages panel
477        let prompts_chunk_idx = 3;
478        let input_chunk_idx = 4;
479
480        // Prompts section
481        let prompts_title = format!("Questions ({} pending)", questions.len());
482        let prompts_block = Block::default().borders(Borders::ALL).title(prompts_title);
483        let prompts_text = if questions.is_empty() {
484            "(no pending questions)".to_string()
485        } else {
486            questions
487                .iter()
488                .take(MAX_VISIBLE_QUESTIONS)
489                .enumerate()
490                .map(|(i, q)| {
491                    let marker = if Some(i) == focused_question {
492                        ">"
493                    } else {
494                        " "
495                    };
496                    format!("{marker} [{}] {}", q.agent_id, q.question)
497                })
498                .collect::<Vec<_>>()
499                .join("\n")
500        };
501        let prompts = Paragraph::new(prompts_text).block(prompts_block);
502        frame.render_widget(prompts, chunks[prompts_chunk_idx]);
503
504        // Input field
505        let focused_agent = focused_question
506            .and_then(|i| questions.get(i))
507            .map_or("(none)", |q| q.agent_id.as_str());
508        let input_block = Block::default().borders(Borders::ALL);
509        let input_text = format!("Reply to {focused_agent}> {input_buffer}_");
510        let input = Paragraph::new(input_text).block(input_block);
511        frame.render_widget(input, chunks[input_chunk_idx]);
512    }
513}
514
515/// Sends `text` to the given tmux pane via `tmux send-keys`, followed by
516/// `Enter`. Returns `Ok(())` on success or whenever no pane is configured.
517///
518/// Pulled out as a free function so tests can verify the call shape (the
519/// argument vector) without spawning a real tmux process.
520///
521/// The target string uses the `<session>:<window>.<pane>` form. Window 0 is
522/// the only window git-paw creates, so we pin it; the bare `<session>:<pane>`
523/// form would make tmux interpret the suffix as a window index, not a pane
524/// index, and reply text would land in the wrong place when there are
525/// multiple windows.
526fn build_send_keys_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
527    vec![
528        "send-keys".to_string(),
529        "-t".to_string(),
530        format!("{session_name}:0.{pane_index}"),
531        text.to_string(),
532        "Enter".to_string(),
533    ]
534}
535
536/// Sends `text` to `<session_name>:<pane_index>` via `tmux send-keys`, followed
537/// by `Enter`. This is the production seam used by the prompt-inbox Enter
538/// handler — exposed so integration tests can drive a real tmux session
539/// through the same code path the dashboard event loop uses.
540///
541/// Returns `Ok(())` when `tmux` exits successfully. The function does not
542/// distinguish between "no such pane" and other tmux errors; callers that need
543/// to differentiate should use [`build_send_keys_args`] and run tmux directly.
544pub fn send_reply_to_pane(session_name: &str, pane_index: usize, text: &str) -> io::Result<()> {
545    let args = build_send_keys_args(session_name, pane_index, text);
546    Command::new("tmux").args(&args).status().map(|_| ())
547}
548
549// ---------------------------------------------------------------------------
550// Main loop
551// ---------------------------------------------------------------------------
552
553/// Runs the dashboard TUI, polling broker state on a 1-second tick.
554///
555/// Takes ownership of [`BrokerHandle`] so the broker shuts down automatically
556/// when the dashboard exits. Press `q` to quit, or set `shutdown` to `true`
557/// to trigger a graceful exit (used by the SIGHUP handler when tmux kills the
558/// session).
559///
560/// This entry point uses an empty pane map and disables reply routing.
561/// Use [`run_dashboard_with_panes`] to enable interactive replies.
562/// Outcome of one key press in the dashboard event loop.
563#[derive(Debug, PartialEq, Eq)]
564enum KeyAction {
565    /// Continue the loop.
566    Continue,
567    /// Exit the dashboard.
568    Quit,
569}
570
571/// Applies a key press to dashboard state, using `send` to deliver the reply
572/// when Enter is pressed against a focused question.
573///
574/// The injected sender takes `(session_name, pane_index, text)` and returns
575/// the argument vector that would be passed to `tmux send-keys`. Production
576/// uses [`send_reply_to_pane`], which actually invokes tmux; tests can pass
577/// a recording closure to verify the constructed argument vector without
578/// spawning a real tmux process.
579fn handle_key_with_sender(
580    code: KeyCode,
581    questions: &mut Vec<QuestionEntry>,
582    focused_question: &mut Option<usize>,
583    input_buffer: &mut String,
584    session_name: Option<&str>,
585    send: &mut dyn FnMut(&str, usize, &str),
586) -> KeyAction {
587    match code {
588        KeyCode::Char('q') => return KeyAction::Quit,
589        KeyCode::Tab if !questions.is_empty() => {
590            *focused_question = Some(match *focused_question {
591                Some(i) => (i + 1) % questions.len(),
592                None => 0,
593            });
594        }
595        KeyCode::Backspace => {
596            input_buffer.pop();
597        }
598        KeyCode::Enter => {
599            if !input_buffer.is_empty()
600                && let Some(idx) = *focused_question
601                && idx < questions.len()
602            {
603                let entry = questions[idx].clone();
604                if let Some(session) = session_name {
605                    send(session, entry.pane_index, input_buffer);
606                }
607                questions.remove(idx);
608                input_buffer.clear();
609                *focused_question = if questions.is_empty() {
610                    None
611                } else if idx >= questions.len() {
612                    Some(0)
613                } else {
614                    Some(idx)
615                };
616            }
617        }
618        KeyCode::Char(c) if !c.is_control() => {
619            input_buffer.push(c);
620        }
621        _ => {}
622    }
623    KeyAction::Continue
624}
625
626/// Production wrapper around [`handle_key_with_sender`] that uses
627/// [`send_reply_to_pane`] (i.e. invokes `tmux send-keys`) as the sender.
628fn handle_key(
629    code: KeyCode,
630    questions: &mut Vec<QuestionEntry>,
631    focused_question: &mut Option<usize>,
632    input_buffer: &mut String,
633    session_name: Option<&str>,
634) -> KeyAction {
635    handle_key_with_sender(
636        code,
637        questions,
638        focused_question,
639        input_buffer,
640        session_name,
641        &mut |session, pane, text| {
642            let _ = send_reply_to_pane(session, pane, text);
643        },
644    )
645}
646
647pub fn run_dashboard(
648    state: &Arc<BrokerState>,
649    broker_handle: BrokerHandle,
650    shutdown: &std::sync::atomic::AtomicBool,
651) -> Result<(), PawError> {
652    run_dashboard_with_panes(state, broker_handle, shutdown, &HashMap::new(), None, false)
653}
654
655/// Runs the dashboard with an explicit agent ID → tmux pane index map and
656/// session name for reply routing. `pane_map` may be empty (no replies will
657/// be routed); `session_name` is required only if pane routing is desired.
658/// `show_message_log` controls whether the broker messages panel is displayed.
659pub fn run_dashboard_with_panes<S: std::hash::BuildHasher>(
660    state: &Arc<BrokerState>,
661    broker_handle: BrokerHandle,
662    shutdown: &std::sync::atomic::AtomicBool,
663    pane_map: &HashMap<String, usize, S>,
664    session_name: Option<&str>,
665    show_message_log: bool,
666) -> Result<(), PawError> {
667    let _broker_handle = broker_handle;
668    // Install a panic hook that restores the terminal before printing the panic.
669    let original_hook = std::panic::take_hook();
670    std::panic::set_hook(Box::new(move |info| {
671        let _ = terminal::disable_raw_mode();
672        let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
673        original_hook(info);
674    }));
675
676    let terminal = setup_terminal()?;
677    let mut guard = TerminalGuard { terminal };
678
679    let mut questions: Vec<QuestionEntry> = Vec::new();
680    let mut focused_question: Option<usize> = None;
681    let mut input_buffer = String::new();
682    let mut last_question_seq: u64 = 0;
683
684    loop {
685        // Check for SIGHUP-triggered shutdown (e.g. tmux kill-session)
686        if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
687            break;
688        }
689
690        // Drain up to 32 pending input events before re-rendering so typing
691        // is processed immediately instead of waiting for the next tick.
692        // The cap prevents a hot loop if the pty floods us with non-Key
693        // events (e.g. repeated Resize during tmux teardown) — we'll
694        // return to the top of the outer loop, where the shutdown flag
695        // is checked, within 32 reads.
696        for _ in 0..32 {
697            if !event::poll(Duration::ZERO)
698                .map_err(|e| PawError::DashboardError(format!("event poll failed: {e}")))?
699            {
700                break;
701            }
702            let ev = event::read()
703                .map_err(|e| PawError::DashboardError(format!("event read failed: {e}")))?;
704            if let Event::Key(key) = ev
705                && key.kind == KeyEventKind::Press
706                && handle_key(
707                    key.code,
708                    &mut questions,
709                    &mut focused_question,
710                    &mut input_buffer,
711                    session_name,
712                ) == KeyAction::Quit
713            {
714                return restore_terminal(&mut guard.terminal);
715            }
716        }
717
718        let agents = delivery::agent_status_snapshot(state);
719        let now = Instant::now();
720        let rows = format_agent_rows(&agents, now);
721        let working = agents.iter().filter(|a| a.status == "working").count();
722        let done = agents
723            .iter()
724            .filter(|a| a.status == "done" || a.status == "verified")
725            .count();
726        let blocked = agents.iter().filter(|a| a.status == "blocked").count();
727        let committed = agents.iter().filter(|a| a.status == "committed").count();
728        let status_line = format_status_line(agents.len(), working, done, blocked, committed);
729
730        // Retrieve recent messages for the messages panel
731        let recent_msgs = delivery::recent_messages(state, MAX_VISIBLE_MESSAGES);
732        let message_entries = format_message_entries(&recent_msgs);
733
734        // Poll supervisor inbox for new questions.
735        let (new_msgs, last_seq) = delivery::poll_messages(state, "supervisor", last_question_seq);
736        if last_seq > last_question_seq {
737            last_question_seq = last_seq;
738        }
739        for msg in new_msgs {
740            if let BrokerMessage::Question { agent_id, payload } = msg {
741                let pane_index = pane_map.get(&agent_id).copied().unwrap_or(0);
742                questions.push(QuestionEntry {
743                    agent_id,
744                    pane_index,
745                    question: payload.question,
746                    seq: last_seq,
747                });
748                if focused_question.is_none() {
749                    focused_question = Some(0);
750                }
751            }
752        }
753
754        guard
755            .terminal
756            .draw(|f| {
757                draw_frame(
758                    f,
759                    &rows,
760                    &status_line,
761                    &questions,
762                    focused_question,
763                    &input_buffer,
764                    &message_entries,
765                    show_message_log,
766                );
767            })
768            .map_err(|e| PawError::DashboardError(format!("draw failed: {e}")))?;
769
770        thread::sleep(TICK_INTERVAL);
771    }
772
773    // Explicit restore for clean exit; guard also restores on drop as a safety net.
774    restore_terminal(&mut guard.terminal)?;
775    Ok(())
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::broker::messages::{
782        ArtifactPayload, BlockedPayload, FeedbackPayload, QuestionPayload, StatusPayload,
783        VerifiedPayload,
784    };
785
786    // -----------------------------------------------------------------------
787    // status_symbol
788    // -----------------------------------------------------------------------
789
790    #[test]
791    fn status_symbol_working() {
792        assert_eq!(status_symbol("working"), "🔵");
793    }
794
795    #[test]
796    fn status_symbol_done() {
797        assert_eq!(status_symbol("done"), "🟢");
798    }
799
800    #[test]
801    fn status_symbol_verified() {
802        assert_eq!(status_symbol("verified"), "🟢");
803    }
804
805    #[test]
806    fn status_symbol_blocked() {
807        assert_eq!(status_symbol("blocked"), "🟡");
808    }
809
810    #[test]
811    fn status_symbol_committed() {
812        assert_eq!(status_symbol("committed"), "🟣");
813    }
814
815    #[test]
816    fn status_symbol_idle() {
817        assert_eq!(status_symbol("idle"), "⚪");
818    }
819
820    #[test]
821    fn status_symbol_unknown() {
822        assert_eq!(status_symbol("something-unexpected"), "⚪");
823    }
824
825    // -----------------------------------------------------------------------
826    // message_type_symbol
827    // -----------------------------------------------------------------------
828
829    #[test]
830    fn message_type_symbol_status() {
831        assert_eq!(message_type_symbol("agent.status"), "📤");
832    }
833
834    #[test]
835    fn message_type_symbol_artifact() {
836        assert_eq!(message_type_symbol("agent.artifact"), "📦");
837    }
838
839    #[test]
840    fn message_type_symbol_blocked() {
841        assert_eq!(message_type_symbol("agent.blocked"), "🚧");
842    }
843
844    #[test]
845    fn message_type_symbol_verified() {
846        assert_eq!(message_type_symbol("agent.verified"), "✅");
847    }
848
849    #[test]
850    fn message_type_symbol_feedback() {
851        assert_eq!(message_type_symbol("agent.feedback"), "💬");
852    }
853
854    #[test]
855    fn message_type_symbol_question() {
856        assert_eq!(message_type_symbol("agent.question"), "❓");
857    }
858
859    #[test]
860    fn message_type_symbol_unknown() {
861        assert_eq!(message_type_symbol("agent.unknown"), "📄");
862    }
863
864    // -----------------------------------------------------------------------
865    // format_message_entry
866    // -----------------------------------------------------------------------
867
868    #[test]
869    fn format_message_entry_status() {
870        let msg = BrokerMessage::Status {
871            agent_id: "feat-errors".to_string(),
872            payload: StatusPayload {
873                status: "working".to_string(),
874                modified_files: vec!["src/main.rs".to_string()],
875                message: Some("refactoring".to_string()),
876            },
877        };
878        let entry = format_message_entry(1, std::time::SystemTime::now(), &msg);
879        assert_eq!(entry.agent_id, "feat-errors");
880        assert!(entry.message_type.contains("📤 status"));
881        assert!(entry.content.contains("[feat-errors] status: working"));
882    }
883
884    #[test]
885    fn format_message_entry_artifact() {
886        let msg = BrokerMessage::Artifact {
887            agent_id: "feat-errors".to_string(),
888            payload: ArtifactPayload {
889                status: "done".to_string(),
890                exports: vec!["PawError".to_string()],
891                modified_files: vec!["src/error.rs".to_string()],
892            },
893        };
894        let entry = format_message_entry(2, std::time::SystemTime::now(), &msg);
895        assert_eq!(entry.agent_id, "feat-errors");
896        assert!(entry.message_type.contains("📦 artifact"));
897        assert!(entry.content.contains("[feat-errors] artifact: done"));
898    }
899
900    #[test]
901    fn format_message_entries_empty() {
902        let entries = format_message_entries(&[]);
903        assert!(entries.is_empty());
904    }
905
906    #[test]
907    fn format_message_entries_multiple() {
908        let msg1 = BrokerMessage::Status {
909            agent_id: "feat-a".to_string(),
910            payload: StatusPayload {
911                status: "working".to_string(),
912                modified_files: vec![],
913                message: None,
914            },
915        };
916        let msg2 = BrokerMessage::Artifact {
917            agent_id: "feat-b".to_string(),
918            payload: ArtifactPayload {
919                status: "done".to_string(),
920                exports: vec![],
921                modified_files: vec![],
922            },
923        };
924        let messages = vec![
925            (1, std::time::SystemTime::now(), msg1),
926            (2, std::time::SystemTime::now(), msg2),
927        ];
928        let entries = format_message_entries(&messages);
929        assert_eq!(entries.len(), 2);
930        assert_eq!(entries[0].agent_id, "feat-a");
931        assert_eq!(entries[1].agent_id, "feat-b");
932    }
933
934    #[test]
935    fn format_message_entries_all_types() {
936        let messages = vec![
937            (
938                1,
939                std::time::SystemTime::now(),
940                BrokerMessage::Status {
941                    agent_id: "feat-a".to_string(),
942                    payload: StatusPayload {
943                        status: "working".to_string(),
944                        modified_files: vec![],
945                        message: None,
946                    },
947                },
948            ),
949            (
950                2,
951                std::time::SystemTime::now(),
952                BrokerMessage::Artifact {
953                    agent_id: "feat-b".to_string(),
954                    payload: ArtifactPayload {
955                        status: "done".to_string(),
956                        exports: vec![],
957                        modified_files: vec![],
958                    },
959                },
960            ),
961            (
962                3,
963                std::time::SystemTime::now(),
964                BrokerMessage::Blocked {
965                    agent_id: "feat-c".to_string(),
966                    payload: BlockedPayload {
967                        needs: "types".to_string(),
968                        from: "feat-b".to_string(),
969                    },
970                },
971            ),
972            (
973                4,
974                std::time::SystemTime::now(),
975                BrokerMessage::Verified {
976                    agent_id: "feat-d".to_string(),
977                    payload: VerifiedPayload {
978                        verified_by: "supervisor".to_string(),
979                        message: None,
980                    },
981                },
982            ),
983            (
984                5,
985                std::time::SystemTime::now(),
986                BrokerMessage::Feedback {
987                    agent_id: "feat-e".to_string(),
988                    payload: FeedbackPayload {
989                        from: "supervisor".to_string(),
990                        errors: vec!["error".to_string()],
991                    },
992                },
993            ),
994            (
995                6,
996                std::time::SystemTime::now(),
997                BrokerMessage::Question {
998                    agent_id: "feat-f".to_string(),
999                    payload: QuestionPayload {
1000                        question: "question?".to_string(),
1001                    },
1002                },
1003            ),
1004        ];
1005
1006        let entries = format_message_entries(&messages);
1007        assert_eq!(entries.len(), 6);
1008
1009        // Verify all message types are represented
1010        let type_symbols: Vec<&str> = entries
1011            .iter()
1012            .map(|entry| entry.message_type.split(' ').next().unwrap())
1013            .collect();
1014        assert!(type_symbols.contains(&"📤")); // status
1015        assert!(type_symbols.contains(&"📦")); // artifact
1016        assert!(type_symbols.contains(&"🚧")); // blocked
1017        assert!(type_symbols.contains(&"✅")); // verified
1018        assert!(type_symbols.contains(&"💬")); // feedback
1019        assert!(type_symbols.contains(&"❓")); // question
1020    }
1021
1022    // -----------------------------------------------------------------------
1023    // format_age
1024    // -----------------------------------------------------------------------
1025
1026    #[test]
1027    fn format_age_zero_seconds() {
1028        assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
1029    }
1030
1031    #[test]
1032    fn format_age_thirty_seconds() {
1033        assert_eq!(format_age(Duration::from_secs(30)), "30s ago");
1034    }
1035
1036    #[test]
1037    fn format_age_three_minutes() {
1038        assert_eq!(format_age(Duration::from_mins(3)), "3m ago");
1039    }
1040
1041    #[test]
1042    fn format_age_one_hour_exact() {
1043        assert_eq!(format_age(Duration::from_hours(1)), "1h 0m ago");
1044    }
1045
1046    #[test]
1047    fn format_age_one_hour_fifteen_minutes() {
1048        assert_eq!(format_age(Duration::from_mins(75)), "1h 15m ago");
1049    }
1050
1051    // -----------------------------------------------------------------------
1052    // format_agent_rows
1053    // -----------------------------------------------------------------------
1054
1055    #[test]
1056    fn format_agent_rows_three_agents() {
1057        let now = Instant::now();
1058        let agents = vec![
1059            AgentStatusEntry {
1060                agent_id: "feat-a".to_string(),
1061                cli: "claude".to_string(),
1062                status: "working".to_string(),
1063                last_seen: now.checked_sub(Duration::from_secs(10)).unwrap(),
1064                last_seen_seconds: 10,
1065                summary: "msg a".to_string(),
1066            },
1067            AgentStatusEntry {
1068                agent_id: "feat-b".to_string(),
1069                cli: "cursor".to_string(),
1070                status: "done".to_string(),
1071                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
1072                last_seen_seconds: 60,
1073                summary: "msg b".to_string(),
1074            },
1075            AgentStatusEntry {
1076                agent_id: "feat-c".to_string(),
1077                cli: "claude".to_string(),
1078                status: "blocked".to_string(),
1079                last_seen: now.checked_sub(Duration::from_mins(5)).unwrap(),
1080                last_seen_seconds: 300,
1081                summary: String::new(),
1082            },
1083        ];
1084        let rows = format_agent_rows(&agents, now);
1085        assert_eq!(rows.len(), 3);
1086        assert_eq!(rows[0].agent_id, "feat-a");
1087        assert_eq!(rows[1].agent_id, "feat-b");
1088        assert_eq!(rows[2].agent_id, "feat-c");
1089    }
1090
1091    #[test]
1092    fn format_agent_rows_single_done_three_minutes() {
1093        let now = Instant::now();
1094        let agents = vec![AgentStatusEntry {
1095            agent_id: "feat-errors".to_string(),
1096            cli: "claude".to_string(),
1097            status: "done".to_string(),
1098            last_seen: now.checked_sub(Duration::from_mins(3)).unwrap(),
1099            last_seen_seconds: 180,
1100            summary: "finished".to_string(),
1101        }];
1102        let rows = format_agent_rows(&agents, now);
1103        assert_eq!(rows.len(), 1);
1104        assert_eq!(rows[0].agent_id, "feat-errors");
1105        assert_eq!(rows[0].age, "3m ago");
1106        assert!(rows[0].status.contains("done"));
1107    }
1108
1109    #[test]
1110    fn format_agent_rows_with_committed_status() {
1111        let now = Instant::now();
1112        let agents = vec![
1113            AgentStatusEntry {
1114                agent_id: "feat-committed".to_string(),
1115                cli: "claude".to_string(),
1116                status: "committed".to_string(),
1117                last_seen: now.checked_sub(Duration::from_mins(1)).unwrap(),
1118                last_seen_seconds: 60,
1119                summary: "changes committed".to_string(),
1120            },
1121            AgentStatusEntry {
1122                agent_id: "feat-working".to_string(),
1123                cli: "cursor".to_string(),
1124                status: "working".to_string(),
1125                last_seen: now.checked_sub(Duration::from_secs(30)).unwrap(),
1126                last_seen_seconds: 30,
1127                summary: "in progress".to_string(),
1128            },
1129        ];
1130        let rows = format_agent_rows(&agents, now);
1131        assert_eq!(rows.len(), 2);
1132
1133        // Find the committed agent and verify it has the correct symbol
1134        let committed_row = rows
1135            .iter()
1136            .find(|r| r.agent_id == "feat-committed")
1137            .unwrap();
1138        assert!(committed_row.status.contains("🟣"));
1139        assert!(committed_row.status.contains("committed"));
1140
1141        // Find the working agent and verify it has the correct symbol
1142        let working_row = rows.iter().find(|r| r.agent_id == "feat-working").unwrap();
1143        assert!(working_row.status.contains("🔵"));
1144        assert!(working_row.status.contains("working"));
1145    }
1146
1147    #[test]
1148    fn format_agent_rows_empty_input() {
1149        let rows = format_agent_rows(&[], Instant::now());
1150        assert!(rows.is_empty());
1151    }
1152
1153    // -----------------------------------------------------------------------
1154    // format_status_line
1155    // -----------------------------------------------------------------------
1156
1157    #[test]
1158    fn format_status_line_mixed() {
1159        assert_eq!(
1160            format_status_line(4, 2, 1, 1, 0),
1161            "4 agents: 2 working, 1 done, 1 blocked, 0 committed"
1162        );
1163    }
1164
1165    #[test]
1166    fn format_status_line_all_done() {
1167        assert_eq!(
1168            format_status_line(3, 0, 3, 0, 0),
1169            "3 agents: 0 working, 3 done, 0 blocked, 0 committed"
1170        );
1171    }
1172
1173    #[test]
1174    fn format_status_line_zero_agents() {
1175        assert_eq!(
1176            format_status_line(0, 0, 0, 0, 0),
1177            "0 agents: 0 working, 0 done, 0 blocked, 0 committed"
1178        );
1179    }
1180
1181    #[test]
1182    fn format_status_line_with_committed() {
1183        assert_eq!(
1184            format_status_line(5, 2, 1, 1, 1),
1185            "5 agents: 2 working, 1 done, 1 blocked, 1 committed"
1186        );
1187    }
1188
1189    // -----------------------------------------------------------------------
1190    // Prompt inbox: pure helpers
1191    // -----------------------------------------------------------------------
1192
1193    fn make_q(agent_id: &str, question: &str, pane_index: usize, seq: u64) -> QuestionEntry {
1194        QuestionEntry {
1195            agent_id: agent_id.to_string(),
1196            pane_index,
1197            question: question.to_string(),
1198            seq,
1199        }
1200    }
1201
1202    fn advance_focus(focused: Option<usize>, len: usize) -> Option<usize> {
1203        if len == 0 {
1204            return None;
1205        }
1206        Some(match focused {
1207            Some(i) => (i + 1) % len,
1208            None => 0,
1209        })
1210    }
1211
1212    #[test]
1213    fn tab_advances_focus_to_next() {
1214        let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1215        let next = advance_focus(Some(0), qs.len());
1216        assert_eq!(next, Some(1));
1217    }
1218
1219    #[test]
1220    fn tab_wraps_from_last_to_first() {
1221        let qs = [make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1222        let next = advance_focus(Some(1), qs.len());
1223        assert_eq!(next, Some(0));
1224    }
1225
1226    #[test]
1227    fn tab_with_empty_questions_is_noop() {
1228        let next = advance_focus(None, 0);
1229        assert_eq!(next, None);
1230    }
1231
1232    #[test]
1233    fn build_send_keys_args_shape() {
1234        let args = build_send_keys_args("paw-myproj", 2, "Yes, do it");
1235        assert_eq!(
1236            args,
1237            vec![
1238                "send-keys".to_string(),
1239                "-t".to_string(),
1240                "paw-myproj:0.2".to_string(),
1241                "Yes, do it".to_string(),
1242                "Enter".to_string(),
1243            ]
1244        );
1245    }
1246
1247    /// Simulates the Enter handler: removes the focused question and clears
1248    /// the buffer when input is non-empty. Mirrors the in-loop logic.
1249    fn handle_enter(
1250        questions: &mut Vec<QuestionEntry>,
1251        focused: &mut Option<usize>,
1252        buffer: &mut String,
1253    ) -> bool {
1254        if buffer.is_empty() {
1255            return false;
1256        }
1257        let Some(idx) = *focused else { return false };
1258        if idx >= questions.len() {
1259            return false;
1260        }
1261        questions.remove(idx);
1262        buffer.clear();
1263        *focused = if questions.is_empty() {
1264            None
1265        } else if idx >= questions.len() {
1266            Some(0)
1267        } else {
1268            Some(idx)
1269        };
1270        true
1271    }
1272
1273    #[test]
1274    fn enter_with_empty_input_is_noop() {
1275        let mut qs = vec![make_q("a", "q1", 1, 1)];
1276        let mut focused = Some(0);
1277        let mut buffer = String::new();
1278        let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
1279        assert!(!acted);
1280        assert_eq!(qs.len(), 1);
1281        assert_eq!(focused, Some(0));
1282    }
1283
1284    #[test]
1285    fn enter_with_input_removes_question_and_clears_buffer() {
1286        let mut qs = vec![make_q("a", "q1", 1, 1), make_q("b", "q2", 2, 2)];
1287        let mut focused = Some(0);
1288        let mut buffer = "Yes".to_string();
1289        let acted = handle_enter(&mut qs, &mut focused, &mut buffer);
1290        assert!(acted);
1291        assert_eq!(qs.len(), 1);
1292        assert_eq!(qs[0].agent_id, "b");
1293        assert!(buffer.is_empty());
1294        assert_eq!(focused, Some(0));
1295    }
1296
1297    #[test]
1298    fn enter_clears_focus_when_last_question_answered() {
1299        let mut qs = vec![make_q("a", "q1", 1, 1)];
1300        let mut focused = Some(0);
1301        let mut buffer = "Yes".to_string();
1302        handle_enter(&mut qs, &mut focused, &mut buffer);
1303        assert!(qs.is_empty());
1304        assert_eq!(focused, None);
1305    }
1306
1307    #[test]
1308    fn prompts_section_caps_at_five_questions() {
1309        use ratatui::Terminal;
1310        use ratatui::backend::TestBackend;
1311
1312        // 7 questions, with distinctive markers in the question text so we
1313        // can count visible rows in the rendered buffer.
1314        let many_questions: Vec<_> = (0..7)
1315            .map(|i| {
1316                make_q(
1317                    &format!("agent-{i:02}"),
1318                    &format!("question-marker-{i:02}"),
1319                    i,
1320                    i as u64,
1321                )
1322            })
1323            .collect();
1324
1325        let backend = TestBackend::new(140, 30);
1326        let mut terminal = Terminal::new(backend).unwrap();
1327        terminal
1328            .draw(|f| {
1329                draw_frame(f, &[], "0 agents", &many_questions, Some(0), "", &[], false);
1330            })
1331            .unwrap();
1332
1333        // Flatten the buffer into a string for substring assertions.
1334        let buffer = terminal.backend().buffer().clone();
1335        let mut rendered = String::new();
1336        for y in 0..buffer.area.height {
1337            for x in 0..buffer.area.width {
1338                rendered.push_str(buffer[(x, y)].symbol());
1339            }
1340            rendered.push('\n');
1341        }
1342
1343        // Exactly the first MAX_VISIBLE_QUESTIONS markers must appear; the
1344        // overflow ones must be cropped out.
1345        let mut visible_count = 0;
1346        for i in 0..MAX_VISIBLE_QUESTIONS {
1347            let marker = format!("question-marker-{i:02}");
1348            assert!(
1349                rendered.contains(&marker),
1350                "expected first {MAX_VISIBLE_QUESTIONS} questions to render; missing {marker} in:\n{rendered}"
1351            );
1352            visible_count += 1;
1353        }
1354        for i in MAX_VISIBLE_QUESTIONS..many_questions.len() {
1355            let marker = format!("question-marker-{i:02}");
1356            assert!(
1357                !rendered.contains(&marker),
1358                "questions beyond cap should not render; found {marker} in:\n{rendered}"
1359            );
1360        }
1361        assert_eq!(visible_count, MAX_VISIBLE_QUESTIONS);
1362        // Header still reports the true total.
1363        assert!(
1364            rendered.contains("7 pending"),
1365            "header should still show full pending count; got:\n{rendered}"
1366        );
1367    }
1368
1369    // -----------------------------------------------------------------------
1370    // handle_key_event - printable characters
1371    // -----------------------------------------------------------------------
1372
1373    #[test]
1374    fn printable_char_appends_to_input_buffer() {
1375        let mut buffer = String::new();
1376        let mut focused = None;
1377        let mut questions = vec![];
1378        let action = handle_key(
1379            KeyCode::Char('x'),
1380            &mut questions,
1381            &mut focused,
1382            &mut buffer,
1383            None,
1384        );
1385        assert_eq!(action, KeyAction::Continue);
1386        assert_eq!(buffer, "x");
1387    }
1388
1389    #[test]
1390    fn backspace_removes_last_char_from_input_buffer() {
1391        let mut buffer = "hello".to_string();
1392        let mut focused = None;
1393        let mut questions = vec![];
1394        let action = handle_key(
1395            KeyCode::Backspace,
1396            &mut questions,
1397            &mut focused,
1398            &mut buffer,
1399            None,
1400        );
1401        assert_eq!(action, KeyAction::Continue);
1402        assert_eq!(buffer, "hell");
1403    }
1404
1405    #[test]
1406    fn backspace_on_empty_buffer_is_noop() {
1407        let mut buffer = String::new();
1408        let mut focused = None;
1409        let mut questions = vec![];
1410        let action = handle_key(
1411            KeyCode::Backspace,
1412            &mut questions,
1413            &mut focused,
1414            &mut buffer,
1415            None,
1416        );
1417        assert_eq!(action, KeyAction::Continue);
1418        assert_eq!(buffer, "");
1419    }
1420
1421    // -----------------------------------------------------------------------
1422    // QuestionEntry::from_broker_message
1423    // -----------------------------------------------------------------------
1424
1425    #[test]
1426    fn question_entry_from_broker_message() {
1427        let msg = BrokerMessage::Question {
1428            agent_id: "feat-errors".to_string(),
1429            payload: crate::broker::messages::QuestionPayload {
1430                question: "Should I use anyhow or thiserror?".to_string(),
1431            },
1432        };
1433        let entry = QuestionEntry::from_broker_message(&msg, 2);
1434        assert_eq!(entry.agent_id, "feat-errors");
1435        assert_eq!(entry.pane_index, 2);
1436        assert_eq!(entry.question, "Should I use anyhow or thiserror?");
1437    }
1438
1439    // -----------------------------------------------------------------------
1440    // advance_focus wrapping
1441    // -----------------------------------------------------------------------
1442
1443    #[test]
1444    fn advance_focus_wraps_around_when_at_end() {
1445        let focused = Some(2); // last of 3 items
1446        let questions = [
1447            make_q("a", "q1", 1, 1),
1448            make_q("b", "q2", 2, 2),
1449            make_q("c", "q3", 3, 3),
1450        ];
1451        let new_focused = advance_focus(focused, questions.len());
1452        assert_eq!(new_focused, Some(0));
1453    }
1454
1455    #[test]
1456    fn advance_focus_on_empty_list_is_noop() {
1457        let focused = None;
1458        let questions: Vec<QuestionEntry> = vec![];
1459        let new_focused = advance_focus(focused, questions.len());
1460        assert_eq!(new_focused, None);
1461    }
1462
1463    // -----------------------------------------------------------------------
1464    // Enter key send-reply wiring
1465    // -----------------------------------------------------------------------
1466
1467    /// Drives the production `handle_key_with_sender` path with `Enter`
1468    /// against a fixture pane map, captures the constructed
1469    /// `tmux send-keys` argument vector via the injected sender, and
1470    /// asserts on its shape.
1471    #[test]
1472    fn enter_invokes_send_reply_with_focused_pane() {
1473        let mut questions = vec![
1474            make_q("feat-auth", "q1", 1, 1),
1475            make_q("feat-db", "q2", 7, 2),
1476            make_q("feat-api", "q3", 3, 3),
1477        ];
1478        let mut focused = Some(1); // focus the middle question (pane 7)
1479        let mut buffer = "Yes please".to_string();
1480
1481        // Capture the arguments the production sender would pass to tmux.
1482        let mut captured: Vec<Vec<String>> = Vec::new();
1483        {
1484            let mut record = |session: &str, pane: usize, text: &str| {
1485                captured.push(build_send_keys_args(session, pane, text));
1486            };
1487
1488            let action = handle_key_with_sender(
1489                KeyCode::Enter,
1490                &mut questions,
1491                &mut focused,
1492                &mut buffer,
1493                Some("paw-myproj"),
1494                &mut record,
1495            );
1496            assert_eq!(action, KeyAction::Continue);
1497        }
1498
1499        // The sender must have been invoked exactly once with the focused
1500        // pane's index, the active session name, and the full input buffer.
1501        assert_eq!(
1502            captured.len(),
1503            1,
1504            "send should fire exactly once for one Enter press"
1505        );
1506        assert_eq!(
1507            captured[0],
1508            vec![
1509                "send-keys".to_string(),
1510                "-t".to_string(),
1511                "paw-myproj:0.7".to_string(),
1512                "Yes please".to_string(),
1513                "Enter".to_string(),
1514            ],
1515            "tmux send-keys argv must target the focused pane"
1516        );
1517
1518        // Side effects on dashboard state:
1519        // - the answered question is removed,
1520        // - the input buffer is cleared,
1521        // - focus stays on the same index (now pointing at what was the next
1522        //   question), since the answered one was at idx 1 and there is a
1523        //   question after it (originally feat-api).
1524        assert_eq!(questions.len(), 2);
1525        assert_eq!(questions[0].agent_id, "feat-auth");
1526        assert_eq!(questions[1].agent_id, "feat-api");
1527        assert!(buffer.is_empty(), "input buffer should be cleared");
1528        assert_eq!(
1529            focused,
1530            Some(1),
1531            "focus should remain on the same index when one remains after it"
1532        );
1533    }
1534
1535    /// Enter with no `session_name` configured must not invoke the sender,
1536    /// even if there is a focused question and non-empty input.
1537    #[test]
1538    fn enter_without_session_name_does_not_invoke_sender() {
1539        let mut questions = vec![make_q("feat-auth", "q1", 1, 1)];
1540        let mut focused = Some(0);
1541        let mut buffer = "noop".to_string();
1542
1543        let mut sender_calls = 0;
1544        let mut record = |_: &str, _: usize, _: &str| {
1545            sender_calls += 1;
1546        };
1547        let action = handle_key_with_sender(
1548            KeyCode::Enter,
1549            &mut questions,
1550            &mut focused,
1551            &mut buffer,
1552            None,
1553            &mut record,
1554        );
1555        assert_eq!(action, KeyAction::Continue);
1556        assert_eq!(sender_calls, 0, "sender must not fire without a session");
1557        // Question is still removed (the dashboard considers the question
1558        // answered locally even if no tmux session is configured).
1559        assert!(questions.is_empty());
1560        assert!(buffer.is_empty());
1561    }
1562}