1use 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
43const FAKE_FETCH: Duration = Duration::from_millis(800);
46const SPINNER: [&str; 4] = ["⠋", "⠙", "⠹", "⠸"];
47
48const WORDMARK_T: &str = r" ___
53 /\ \
54 \:\ \
55 \:\ \
56 /::\ \
57 /:/\:\__\
58 /:/ \/__/
59/:/ /
60\/__/";
61
62#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Outcome {
66 Selected(String),
68 CoDesign,
70 Cancelled,
72}
73
74#[derive(Clone, Copy, PartialEq, Eq)]
75enum Screen {
76 Branch,
77 Browse,
78}
79
80#[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
90pub 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
100pub struct PickerState {
103 caps: Capabilities,
104 screen: Screen,
105 branch_idx: usize, entries: Vec<Entry>,
107 list_idx: usize,
108 loading: bool, spinner: usize,
110}
111
112impl PickerState {
113 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 pub fn load(caps: Capabilities, examples_dir: &Path) -> Self {
130 Self::new(caps, load_entries(examples_dir))
131 }
132
133 pub fn browsing(mut self) -> Self {
135 self.screen = Screen::Browse;
136 self.loading = false;
137 self
138 }
139
140 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 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
205fn 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
214pub 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), Constraint::Length(9), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
246 .split(area);
247
248 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 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 Paragraph::new(lines).render(inner, buf);
422}
423
424fn 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 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
480fn 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 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
517pub 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 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 let Err(e) = execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture) {
547 let _ = disable_raw_mode();
548 return Err(e.into());
549 }
550 Ok(())
551}
552
553fn 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
570fn 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
636fn 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
656fn 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
671fn 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 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 let mut state = PickerState::new(mono(), vec![fixture_entry()]);
785 state.on_key(crossterm::event::KeyCode::Enter); 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); 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}