Skip to main content

teamctl_ui/
init_picker.rs

1//! Interactive `teamctl init` picker — a two-pane ratatui screen launched
2//! by `teamctl init` (via `teamctl-ui --init-picker`). Branch-first:
3//! **Browse a template** vs **Co-design with AI**; choosing Browse opens a
4//! list ↔ live-detail view of the on-disk example teams, with a team-shape
5//! preview (the reporting tree + per-agent capability counts) drawn from
6//! `team_core::preview`.
7//!
8//! Self-contained on purpose: it shares teamctl-ui's theme + terminal
9//! lifecycle but NOT the dashboard `app::run` loop, which is wired to tmux
10//! panes / mailbox.db / a file-watcher. This is a small standalone app.
11//!
12//! The binary entry (`--init-picker` in `main.rs`) renders to **stderr** and
13//! prints the chosen key to **stdout**, so `teamctl init` can capture the
14//! selection while the UI still shows on the operator's terminal (the fzf
15//! pattern). `run_standalone` owns that stderr terminal lifecycle.
16
17use std::io;
18use std::panic;
19use std::path::{Path, PathBuf};
20use std::time::{Duration, Instant};
21
22use anyhow::Result;
23use crossterm::event::{
24    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
25};
26use crossterm::execute;
27use crossterm::terminal::{
28    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
29};
30use ratatui::backend::{Backend, CrosstermBackend};
31use ratatui::buffer::Buffer;
32use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
33use ratatui::style::{Modifier, Style};
34use ratatui::text::{Line, Span};
35use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget};
36use ratatui::{Frame, Terminal};
37
38use team_core::compose::{Global, Project};
39use team_core::preview::{team_shape, ShapeKind, ShapeRow};
40
41use crate::theme::{detect_capabilities, Capabilities};
42
43/// Artificial fetch delay so the operator sees the lazy/loading UX the real
44/// remote store (fast-follow #495) will have — without any network call.
45const FAKE_FETCH: Duration = Duration::from_millis(800);
46const SPINNER: [&str; 4] = ["⠋", "⠙", "⠹", "⠸"];
47
48/// The "t" lifted from teamctl-ui's splash wordmark (figlet isometric4) —
49/// shown atop the start screen so it matches the glyph `teamctl ui` opens
50/// with. Leading whitespace is load-bearing (the 3D slant); trailing is
51/// trimmed and the mark is rendered left-aligned in a centered column.
52const WORDMARK_T: &str = r"   ___
53  /\  \
54  \:\  \
55   \:\  \
56   /::\  \
57  /:/\:\__\
58 /:/  \/__/
59/:/  /
60\/__/";
61
62/// What the picker resolves to. The binary entry maps this to stdout + an
63/// exit code that `teamctl init` consumes.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Outcome {
66    /// An example/template key the operator chose (Browse → Enter).
67    Selected(String),
68    /// The "Co-design with AI" branch — `teamctl init` runs the guided flow.
69    CoDesign,
70    /// Esc / `q` without a choice.
71    Cancelled,
72}
73
74#[derive(Clone, Copy, PartialEq, Eq)]
75enum Screen {
76    Branch,
77    Browse,
78}
79
80/// Aggregate per-team capability counts, for the detail headline.
81#[derive(Default, Clone, Copy)]
82pub struct Counts {
83    pub agents: usize,
84    pub subagents: usize,
85    pub skills: usize,
86    pub hooks: usize,
87    pub mcps: usize,
88}
89
90/// One browsable catalog entry — an example team, already parsed into its
91/// reporting shape so the detail pane is a pure render.
92pub struct Entry {
93    pub key: String,
94    pub name: String,
95    pub blurb: String,
96    pub rows: Vec<ShapeRow>,
97    pub counts: Counts,
98}
99
100/// The picker's full state. Deterministic and terminal-free, so
101/// `render_to_buffer` can snapshot any screen without a real terminal.
102pub struct PickerState {
103    caps: Capabilities,
104    screen: Screen,
105    branch_idx: usize, // 0 = Browse, 1 = Co-design
106    entries: Vec<Entry>,
107    list_idx: usize,
108    loading: bool, // faked-fetch spinner gate (Browse first paint)
109    spinner: usize,
110}
111
112impl PickerState {
113    /// Build a picker over the given catalog entries, starting on the
114    /// branch screen.
115    pub fn new(caps: Capabilities, entries: Vec<Entry>) -> Self {
116        Self {
117            caps,
118            screen: Screen::Branch,
119            branch_idx: 0,
120            entries,
121            list_idx: 0,
122            loading: false,
123            spinner: 0,
124        }
125    }
126
127    /// Load the catalog from a directory of example teams (each an
128    /// `<name>/.team/team-compose.yaml` tree) and build a fresh picker.
129    pub fn load(caps: Capabilities, examples_dir: &Path) -> Self {
130        Self::new(caps, load_entries(examples_dir))
131    }
132
133    /// Jump straight to the Browse screen (used by snapshot tests).
134    pub fn browsing(mut self) -> Self {
135        self.screen = Screen::Browse;
136        self.loading = false;
137        self
138    }
139
140    /// Handle one key; returns `Some(outcome)` when the picker should exit.
141    fn on_key(&mut self, code: KeyCode) -> Option<Outcome> {
142        match self.screen {
143            Screen::Branch => match code {
144                KeyCode::Up | KeyCode::Char('k') => {
145                    self.branch_idx = self.branch_idx.saturating_sub(1);
146                    None
147                }
148                KeyCode::Down | KeyCode::Char('j') => {
149                    self.branch_idx = (self.branch_idx + 1).min(1);
150                    None
151                }
152                KeyCode::Enter => {
153                    if self.branch_idx == 0 {
154                        self.screen = Screen::Browse;
155                        self.loading = true;
156                        self.spinner = 0;
157                        None
158                    } else {
159                        Some(Outcome::CoDesign)
160                    }
161                }
162                KeyCode::Esc | KeyCode::Char('q') => Some(Outcome::Cancelled),
163                _ => None,
164            },
165            Screen::Browse => {
166                if self.loading {
167                    // Only let the operator back out while the (faked) fetch
168                    // is in flight; nav/select waits for the list to land.
169                    return match code {
170                        KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => {
171                            self.screen = Screen::Branch;
172                            self.loading = false;
173                            None
174                        }
175                        _ => None,
176                    };
177                }
178                match code {
179                    KeyCode::Up | KeyCode::Char('k') => {
180                        self.list_idx = self.list_idx.saturating_sub(1);
181                        None
182                    }
183                    KeyCode::Down | KeyCode::Char('j') => {
184                        if self.list_idx + 1 < self.entries.len() {
185                            self.list_idx += 1;
186                        }
187                        None
188                    }
189                    KeyCode::Enter => self
190                        .entries
191                        .get(self.list_idx)
192                        .map(|e| Outcome::Selected(e.key.clone())),
193                    KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => {
194                        self.screen = Screen::Branch;
195                        None
196                    }
197                    KeyCode::Char('q') => Some(Outcome::Cancelled),
198                    _ => None,
199                }
200            }
201        }
202    }
203}
204
205/// Render the current screen into `buf` — the single rendering entry point,
206/// shared by the live loop (`draw`) and the snapshot helper.
207fn render(state: &PickerState, area: Rect, buf: &mut Buffer) {
208    match state.screen {
209        Screen::Branch => render_branch(state, area, buf),
210        Screen::Browse => render_browse(state, area, buf),
211    }
212}
213
214/// Snapshot helper: render `state` into a fresh `width × height` buffer.
215pub fn render_to_buffer(state: &PickerState, width: u16, height: u16) -> Buffer {
216    let area = Rect::new(0, 0, width, height);
217    let mut buf = Buffer::empty(area);
218    render(state, area, &mut buf);
219    buf
220}
221
222fn draw(frame: &mut Frame, state: &PickerState) {
223    render(state, frame.area(), frame.buffer_mut());
224}
225
226fn render_branch(state: &PickerState, area: Rect, buf: &mut Buffer) {
227    let accent = Style::default()
228        .fg(state.caps.accent())
229        .add_modifier(Modifier::BOLD);
230    let muted = Style::default().fg(state.caps.muted());
231
232    let rows = Layout::default()
233        .direction(Direction::Vertical)
234        .constraints([
235            Constraint::Min(0),    // top spacer
236            Constraint::Length(9), // "t" splash glyph
237            Constraint::Length(1), // gap
238            Constraint::Length(1), // title
239            Constraint::Length(1), // gap
240            Constraint::Length(1), // option 0
241            Constraint::Length(1), // option 1
242            Constraint::Length(1), // gap
243            Constraint::Length(1), // hint
244            Constraint::Min(0),    // bottom spacer
245        ])
246        .split(area);
247
248    // Render the splash glyph left-aligned inside a horizontally-centered
249    // column so its diagonal stays intact (per-line centering would skew it).
250    let mark_w = WORDMARK_T
251        .lines()
252        .map(|l| l.chars().count())
253        .max()
254        .unwrap_or(0) as u16;
255    let mark_area = Rect {
256        x: area.x + area.width.saturating_sub(mark_w) / 2,
257        y: rows[1].y,
258        width: mark_w.min(area.width),
259        height: rows[1].height,
260    };
261    Paragraph::new(WORDMARK_T)
262        .style(accent)
263        .render(mark_area, buf);
264
265    Paragraph::new("Create a new team")
266        .style(accent)
267        .alignment(Alignment::Center)
268        .render(rows[3], buf);
269
270    let option = |idx: usize, label: &str, desc: &str| -> Line<'static> {
271        let selected = state.branch_idx == idx;
272        let marker = if selected { "▸ " } else { "  " };
273        let style = if selected {
274            Style::default()
275                .fg(state.caps.accent())
276                .add_modifier(Modifier::BOLD)
277        } else {
278            Style::default()
279        };
280        Line::from(vec![
281            Span::styled(format!("{marker}{label}"), style),
282            Span::styled(
283                format!("   {desc}"),
284                Style::default().fg(state.caps.muted()),
285            ),
286        ])
287    };
288
289    // Both options share one left edge: render them left-aligned inside a
290    // horizontally-centered column so the labels line up, instead of each
291    // line centering on its own (which reads ragged-left).
292    let opt0 = option(0, "Browse a template", "pick from ready-made teams");
293    let opt1 = option(1, "Co-design with AI", "let Claude Code shape it with you");
294    let col_w = (opt0.width().max(opt1.width()) as u16).min(area.width);
295    let col_x = area.x + area.width.saturating_sub(col_w) / 2;
296    let line_area = |row: Rect| Rect {
297        x: col_x,
298        y: row.y,
299        width: col_w,
300        height: 1,
301    };
302    Paragraph::new(opt0).render(line_area(rows[5]), buf);
303    Paragraph::new(opt1).render(line_area(rows[6]), buf);
304
305    Paragraph::new("↑/↓ select · Enter choose · Esc cancel")
306        .style(muted)
307        .alignment(Alignment::Center)
308        .render(rows[8], buf);
309}
310
311fn render_browse(state: &PickerState, area: Rect, buf: &mut Buffer) {
312    let vchunks = Layout::default()
313        .direction(Direction::Vertical)
314        .constraints([Constraint::Min(0), Constraint::Length(1)])
315        .split(area);
316
317    let panes = Layout::default()
318        .direction(Direction::Horizontal)
319        .constraints([Constraint::Length(34), Constraint::Min(0)])
320        .split(vchunks[0]);
321
322    render_list(state, panes[0], buf);
323    render_detail(state, panes[1], buf);
324
325    Paragraph::new("↑/↓ select · Enter choose · Esc back")
326        .style(Style::default().fg(state.caps.muted()))
327        .alignment(Alignment::Center)
328        .render(vchunks[1], buf);
329}
330
331fn render_list(state: &PickerState, area: Rect, buf: &mut Buffer) {
332    let block = Block::default()
333        .title(" Templates ")
334        .borders(Borders::ALL)
335        .border_style(Style::default().fg(state.caps.muted()))
336        .padding(Padding::horizontal(1));
337    let inner = block.inner(area);
338    block.render(area, buf);
339
340    if state.loading {
341        Paragraph::new(format!("{} Fetching templates…", spinner_glyph(state)))
342            .style(Style::default().fg(state.caps.muted()))
343            .alignment(Alignment::Center)
344            .render(inner, buf);
345        return;
346    }
347    if state.entries.is_empty() {
348        Paragraph::new("(no templates found)")
349            .style(Style::default().fg(state.caps.muted()))
350            .alignment(Alignment::Center)
351            .render(inner, buf);
352        return;
353    }
354
355    let lines: Vec<Line<'_>> = state
356        .entries
357        .iter()
358        .enumerate()
359        .map(|(i, e)| {
360            let selected = i == state.list_idx;
361            let marker = if selected { "▸ " } else { "  " };
362            let style = if selected {
363                Style::default()
364                    .fg(state.caps.accent())
365                    .add_modifier(Modifier::REVERSED)
366            } else {
367                Style::default()
368            };
369            Line::styled(format!("{marker}{}", e.name), style)
370        })
371        .collect();
372    Paragraph::new(lines).render(inner, buf);
373}
374
375fn render_detail(state: &PickerState, area: Rect, buf: &mut Buffer) {
376    let entry = state.entries.get(state.list_idx);
377    let title = entry.map(|e| format!(" {} ", e.name)).unwrap_or_default();
378    let block = Block::default()
379        .title(title)
380        .borders(Borders::ALL)
381        .border_style(Style::default().fg(state.caps.accent()))
382        .padding(Padding::horizontal(1));
383    let inner = block.inner(area);
384    block.render(area, buf);
385
386    if state.loading {
387        Paragraph::new(format!("{} loading…", spinner_glyph(state)))
388            .style(Style::default().fg(state.caps.muted()))
389            .alignment(Alignment::Center)
390            .render(inner, buf);
391        return;
392    }
393    let Some(entry) = entry else { return };
394
395    let mut lines: Vec<Line<'static>> = Vec::new();
396    if !entry.blurb.is_empty() {
397        lines.push(Line::styled(
398            entry.blurb.clone(),
399            Style::default().fg(state.caps.muted()),
400        ));
401        lines.push(Line::raw(""));
402    }
403    let c = entry.counts;
404    lines.push(Line::styled(
405        format!(
406            "{} · {} · {} · {} · {}",
407            plural(c.agents, "agent"),
408            plural(c.subagents, "sub-agent"),
409            plural(c.skills, "skill"),
410            plural(c.hooks, "hook"),
411            plural(c.mcps, "mcp"),
412        ),
413        Style::default().fg(state.caps.muted()),
414    ));
415    lines.push(Line::raw(""));
416    lines.extend(shape_to_lines(&entry.rows, state.caps));
417
418    // No wrap: the tree lines are structured, so on a narrow terminal we
419    // truncate at the pane edge rather than wrap a descriptor onto a second
420    // line without its tree prefix (which reads as broken).
421    Paragraph::new(lines).render(inner, buf);
422}
423
424/// Turn the front-end-agnostic `ShapeRow`s into styled tree lines, mapping
425/// `is_last` back to the `└──`/`├──` connectors and drawing continuation
426/// columns for ancestor levels. Mirrors init.rs's box-drawing tree.
427fn shape_to_lines(rows: &[ShapeRow], caps: Capabilities) -> Vec<Line<'static>> {
428    let mut last_at: Vec<bool> = Vec::new();
429    let mut out: Vec<Line<'static>> = Vec::new();
430    for r in rows {
431        if matches!(r.kind, ShapeKind::Root) {
432            out.push(Line::styled(
433                r.label.clone(),
434                Style::default()
435                    .fg(caps.accent())
436                    .add_modifier(Modifier::BOLD),
437            ));
438            last_at = vec![true];
439            continue;
440        }
441        let depth = r.depth as usize;
442        // Continuation columns for ancestor depths 1..depth.
443        let mut prefix = String::new();
444        for d in 1..depth {
445            let ancestor_last = last_at.get(d).copied().unwrap_or(true);
446            prefix.push_str(if ancestor_last { "    " } else { "│   " });
447        }
448        let connector = if r.is_last {
449            "└── "
450        } else {
451            "├── "
452        };
453        if last_at.len() <= depth {
454            last_at.resize(depth + 1, true);
455        }
456        last_at[depth] = r.is_last;
457
458        let mut spans = vec![
459            Span::styled(
460                format!("{prefix}{connector}"),
461                Style::default().fg(caps.muted()),
462            ),
463            Span::styled(r.label.clone(), Style::default().fg(caps.accent())),
464        ];
465        if !r.descriptor.is_empty() {
466            spans.push(Span::styled(
467                format!("  {}", r.descriptor),
468                Style::default().fg(caps.muted()),
469            ));
470        }
471        out.push(Line::from(spans));
472    }
473    out
474}
475
476fn spinner_glyph(state: &PickerState) -> &'static str {
477    SPINNER[state.spinner % SPINNER.len()]
478}
479
480/// Drive the picker against `terminal` until the operator chooses or
481/// cancels. Polls on a short timeout so the faked-fetch spinner animates
482/// and the loading deadline resolves.
483fn run<B: Backend>(terminal: &mut Terminal<B>, mut state: PickerState) -> Result<Outcome> {
484    let mut fetch_deadline: Option<Instant> = None;
485    loop {
486        if state.loading && fetch_deadline.is_none() {
487            fetch_deadline = Some(Instant::now() + FAKE_FETCH);
488        }
489        if let Some(dl) = fetch_deadline {
490            if Instant::now() >= dl {
491                state.loading = false;
492                fetch_deadline = None;
493            }
494        }
495
496        terminal.draw(|f| draw(f, &state))?;
497
498        if event::poll(Duration::from_millis(120))? {
499            if let Event::Key(key) = event::read()? {
500                if key.kind != KeyEventKind::Press {
501                    continue;
502                }
503                if let Some(outcome) = state.on_key(key.code) {
504                    return Ok(outcome);
505                }
506                // Leaving Browse cancels any in-flight fetch timer.
507                if !state.loading {
508                    fetch_deadline = None;
509                }
510            }
511        } else if state.loading {
512            state.spinner = state.spinner.wrapping_add(1);
513        }
514    }
515}
516
517/// Binary entry: own the **stderr** terminal lifecycle (so stdout stays
518/// clean for the selection token), run the picker, and restore the terminal
519/// on every exit path including panics.
520pub fn run_standalone(examples_dir: &Path) -> Result<Outcome> {
521    let caps = detect_capabilities();
522    let state = PickerState::load(caps, examples_dir);
523
524    install_panic_hook();
525    enter_terminal()?;
526    // Everything past raw-mode-on runs inside this closure so the
527    // unconditional `leave_terminal()` below restores the terminal on
528    // EVERY exit path — a `run` error, a `Terminal::new` failure, or a
529    // clean return. Panics are caught by the hook, which calls the same
530    // infallible teardown.
531    let result = (move || {
532        let backend = CrosstermBackend::new(io::stderr());
533        let mut terminal = Terminal::new(backend)?;
534        let outcome = run(&mut terminal, state);
535        let _ = terminal.show_cursor();
536        outcome
537    })();
538    leave_terminal();
539    result
540}
541
542fn enter_terminal() -> Result<()> {
543    enable_raw_mode()?;
544    // If the alternate-screen step fails, undo raw mode before bailing so
545    // the caller never returns with the shell stranded in raw mode.
546    if let Err(e) = execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture) {
547        let _ = disable_raw_mode();
548        return Err(e.into());
549    }
550    Ok(())
551}
552
553/// Best-effort, unconditional teardown: every step runs regardless of an
554/// earlier failure, so `disable_raw_mode()` always fires (the step that
555/// actually un-wedges the operator's shell). Safe on any exit path,
556/// including the panic hook.
557fn leave_terminal() {
558    let _ = execute!(io::stderr(), DisableMouseCapture, LeaveAlternateScreen);
559    let _ = disable_raw_mode();
560}
561
562fn install_panic_hook() {
563    let original = panic::take_hook();
564    panic::set_hook(Box::new(move |info| {
565        leave_terminal();
566        original(info);
567    }));
568}
569
570// ── catalog loading ────────────────────────────────────────────────
571
572/// Scan `examples_dir` for `<name>/.team/team-compose.yaml` teams, parse
573/// each into its reporting shape, and return the catalog (dir-name sorted).
574/// Unparseable or agent-less examples are skipped — the picker never fails
575/// on a bad example.
576fn load_entries(examples_dir: &Path) -> Vec<Entry> {
577    let Ok(read) = std::fs::read_dir(examples_dir) else {
578        return Vec::new();
579    };
580    let mut dirs: Vec<PathBuf> = read
581        .flatten()
582        .map(|e| e.path())
583        .filter(|p| p.is_dir())
584        .collect();
585    dirs.sort();
586    dirs.iter().filter_map(|d| load_entry(d)).collect()
587}
588
589fn load_entry(dir: &Path) -> Option<Entry> {
590    let team_dir = dir.join(".team");
591    let compose_str = std::fs::read_to_string(team_dir.join("team-compose.yaml")).ok()?;
592    let global: Global = serde_yaml::from_str(&compose_str).ok()?;
593
594    let mut projects: Vec<Project> = Vec::new();
595    for p in &global.projects {
596        if let Ok(s) = std::fs::read_to_string(team_dir.join(&p.file)) {
597            if let Ok(project) = serde_yaml::from_str::<Project>(&s) {
598                projects.push(project);
599            }
600        }
601    }
602    if projects.is_empty() {
603        return None;
604    }
605
606    let refs: Vec<&Project> = projects.iter().collect();
607    let rows = team_shape(&refs);
608    let counts = counts_of(&projects);
609    let key = dir.file_name()?.to_string_lossy().into_owned();
610    let name = humanize(&key);
611    let blurb = first_comment_blurb(&compose_str).unwrap_or_default();
612
613    Some(Entry {
614        key,
615        name,
616        blurb,
617        rows,
618        counts,
619    })
620}
621
622fn counts_of(projects: &[Project]) -> Counts {
623    let mut c = Counts::default();
624    for p in projects {
625        for agent in p.managers.values().chain(p.workers.values()) {
626            c.agents += 1;
627            c.subagents += agent.subagents.len();
628            c.skills += agent.skills.len();
629            c.hooks += agent.hooks.len();
630            c.mcps += agent.mcps.len();
631        }
632    }
633    c
634}
635
636/// First comment line of a compose file, as a one-line blurb. Prefers the
637/// text after an em-dash (`# product-team — does X` → `does X`).
638fn first_comment_blurb(compose: &str) -> Option<String> {
639    for line in compose.lines() {
640        let trimmed = line.trim_start();
641        if let Some(rest) = trimmed.strip_prefix('#') {
642            let rest = rest.trim();
643            if rest.is_empty() {
644                continue;
645            }
646            let blurb = rest.split('—').nth(1).map(str::trim).unwrap_or(rest);
647            return Some(blurb.to_string());
648        }
649        if !trimmed.is_empty() {
650            break;
651        }
652    }
653    None
654}
655
656/// `product-team` → `Product Team`.
657fn humanize(key: &str) -> String {
658    key.split(['-', '_'])
659        .filter(|w| !w.is_empty())
660        .map(|w| {
661            let mut chars = w.chars();
662            match chars.next() {
663                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
664                None => String::new(),
665            }
666        })
667        .collect::<Vec<_>>()
668        .join(" ")
669}
670
671/// Pluralize a count for the detail headline: `1 hook`, `2 hooks`, `0 mcps`.
672fn plural(n: usize, noun: &str) -> String {
673    format!("{n} {noun}{}", if n == 1 { "" } else { "s" })
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn humanize_splits_and_titlecases() {
682        assert_eq!(humanize("product-team"), "Product Team");
683        assert_eq!(humanize("oss_maintainer"), "Oss Maintainer");
684        assert_eq!(humanize("blank"), "Blank");
685    }
686
687    #[test]
688    fn blurb_prefers_text_after_em_dash() {
689        let compose = "# product-team — discovery while a team builds.\nversion: \"2.0.0\"\n";
690        assert_eq!(
691            first_comment_blurb(compose).as_deref(),
692            Some("discovery while a team builds.")
693        );
694    }
695
696    #[test]
697    fn blurb_falls_back_to_whole_comment() {
698        assert_eq!(
699            first_comment_blurb("# a tidy little team\n").as_deref(),
700            Some("a tidy little team")
701        );
702        assert_eq!(first_comment_blurb("version: 2\n"), None);
703    }
704
705    fn buf_to_string(buf: &Buffer) -> String {
706        let a = buf.area();
707        let mut out = String::new();
708        for y in 0..a.height {
709            for x in 0..a.width {
710                out.push_str(buf[(x, y)].symbol());
711            }
712            out.push('\n');
713        }
714        out
715    }
716
717    fn mono() -> Capabilities {
718        Capabilities {
719            color: crate::theme::ColorMode::Monochrome,
720        }
721    }
722
723    /// A deterministic two-manager / one-worker team for the browse snapshot.
724    fn fixture_entry() -> Entry {
725        Entry {
726            key: "product-team".into(),
727            name: "Product Team".into(),
728            blurb: "product discovery while an engineering team builds".into(),
729            rows: vec![
730                ShapeRow {
731                    depth: 0,
732                    kind: ShapeKind::Root,
733                    label: "You".into(),
734                    descriptor: String::new(),
735                    is_last: true,
736                },
737                ShapeRow {
738                    depth: 1,
739                    kind: ShapeKind::Manager,
740                    label: "Product Manager".into(),
741                    descriptor: "Claude Code · Opus 4.8 · 2×a 0×s 0×h 0×m".into(),
742                    is_last: false,
743                },
744                ShapeRow {
745                    depth: 2,
746                    kind: ShapeKind::Worker,
747                    label: "Engineer (Claude)".into(),
748                    descriptor: "Claude Code · Sonnet 4.6 · 6×a 0×s 1×h 0×m".into(),
749                    is_last: true,
750                },
751                ShapeRow {
752                    depth: 1,
753                    kind: ShapeKind::Manager,
754                    label: "Engineering Manager".into(),
755                    descriptor: "Claude Code · Opus 4.8 · 2×a 0×s 0×h 0×m".into(),
756                    is_last: true,
757                },
758            ],
759            counts: Counts {
760                agents: 4,
761                subagents: 10,
762                skills: 0,
763                hooks: 1,
764                mcps: 0,
765            },
766        }
767    }
768
769    #[test]
770    fn branch_screen_snapshot() {
771        let state = PickerState::new(mono(), vec![]);
772        insta::assert_snapshot!(buf_to_string(&render_to_buffer(&state, 100, 24)));
773    }
774
775    #[test]
776    fn browse_screen_snapshot() {
777        let state = PickerState::new(mono(), vec![fixture_entry()]).browsing();
778        insta::assert_snapshot!(buf_to_string(&render_to_buffer(&state, 100, 20)));
779    }
780
781    #[test]
782    fn loading_screen_shows_spinner() {
783        // Browse before the faked fetch resolves: a spinner, no list yet.
784        let mut state = PickerState::new(mono(), vec![fixture_entry()]);
785        state.on_key(crossterm::event::KeyCode::Enter); // Branch → Browse (loading)
786        let out = buf_to_string(&render_to_buffer(&state, 100, 16));
787        assert!(out.contains("Fetching templates"), "spinner state:\n{out}");
788    }
789
790    #[test]
791    fn enter_on_browse_selects_current_entry() {
792        let mut state = PickerState::new(mono(), vec![fixture_entry()]).browsing();
793        assert_eq!(
794            state.on_key(crossterm::event::KeyCode::Enter),
795            Some(Outcome::Selected("product-team".into()))
796        );
797    }
798
799    #[test]
800    fn co_design_branch_returns_codesign() {
801        let mut state = PickerState::new(mono(), vec![]);
802        state.on_key(crossterm::event::KeyCode::Down); // → Co-design
803        assert_eq!(
804            state.on_key(crossterm::event::KeyCode::Enter),
805            Some(Outcome::CoDesign)
806        );
807    }
808
809    #[test]
810    fn esc_on_branch_cancels() {
811        let mut state = PickerState::new(mono(), vec![]);
812        assert_eq!(
813            state.on_key(crossterm::event::KeyCode::Esc),
814            Some(Outcome::Cancelled)
815        );
816    }
817}