teams-cli 0.1.4

A Microsoft Teams CLI and TUI designed equally for humans and agents
Documentation
use std::io::{self, IsTerminal};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::{CrosstermBackend, TestBackend};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};

use crate::error::AppError;

const INK: Color = Color::Rgb(230, 233, 239);
const MUTED: Color = Color::Rgb(151, 158, 171);
const FOCUS: Color = Color::Rgb(102, 194, 255);
const LIVE: Color = Color::Rgb(255, 119, 107);
const SURFACE: Color = Color::Rgb(20, 23, 30);

#[derive(Debug, Clone)]
pub struct TuiData {
    pub account: String,
    pub conversations: Vec<Conversation>,
}

#[derive(Debug, Clone)]
pub struct Conversation {
    pub name: String,
    pub unread: u16,
    pub messages: Vec<Message>,
}

#[derive(Debug, Clone)]
pub struct Message {
    pub sender: String,
    pub time: String,
    pub body: String,
}

pub fn demo_data() -> TuiData {
    TuiData {
        account: "Alex Morgan · Northstar Studio".into(),
        conversations: vec![
            Conversation {
                name: "Launch room".into(),
                unread: 3,
                messages: vec![
                    Message {
                        sender: "Mina".into(),
                        time: "09:42".into(),
                        body: "The release candidate is green. I pinned the final checklist."
                            .into(),
                    },
                    Message {
                        sender: "Jon".into(),
                        time: "09:46".into(),
                        body: "Docs are ready. One broken link remains in the migration guide."
                            .into(),
                    },
                    Message {
                        sender: "You".into(),
                        time: "09:48".into(),
                        body: "I’ll take it. Hold the announcement until I clear the link check."
                            .into(),
                    },
                ],
            },
            Conversation {
                name: "Design crit".into(),
                unread: 0,
                messages: vec![],
            },
            Conversation {
                name: "Ari Chen".into(),
                unread: 1,
                messages: vec![],
            },
            Conversation {
                name: "Platform on-call".into(),
                unread: 0,
                messages: vec![],
            },
        ],
    }
}

struct App {
    data: TuiData,
    selected: usize,
}

pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
    let backend = TestBackend::new(width.max(48), height.max(18));
    let mut terminal = Terminal::new(backend).expect("test backend is infallible");
    let app = App {
        data: demo_data(),
        selected: 0,
    };
    terminal
        .draw(|frame| draw(frame, &app))
        .expect("test backend is infallible");
    let buffer = terminal.backend().buffer();
    let mut output = String::new();
    for y in 0..buffer.area.height {
        let mut line = String::new();
        for x in 0..buffer.area.width {
            line.push_str(buffer[(x, y)].symbol());
        }
        output.push_str(line.trim_end());
        output.push('\n');
    }
    while output.ends_with("\n\n") {
        output.pop();
    }
    Ok(output)
}

pub fn run(data: TuiData) -> Result<(), AppError> {
    if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
        return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
    }
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut app = App { data, selected: 0 };
    let result = (|| -> Result<(), AppError> {
        loop {
            terminal.draw(|frame| draw(frame, &app))?;
            if event::poll(Duration::from_millis(150))?
                && let Event::Key(key) = event::read()?
            {
                if key.kind != KeyEventKind::Press {
                    continue;
                }
                match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    KeyCode::Down | KeyCode::Char('j') => {
                        app.selected =
                            (app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
                    }
                    KeyCode::Up | KeyCode::Char('k') => {
                        app.selected = app.selected.saturating_sub(1)
                    }
                    _ => {}
                }
            }
        }
        Ok(())
    })();
    let cleanup = (|| -> Result<(), AppError> {
        disable_raw_mode()?;
        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
        terminal.show_cursor()?;
        Ok(())
    })();
    result.and(cleanup)
}

fn draw(frame: &mut ratatui::Frame, app: &App) {
    let area = frame.area();
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(10),
            Constraint::Length(2),
        ])
        .split(area);
    header(frame, vertical[0], app);
    if area.width < 76 {
        conversation(frame, vertical[1], app);
    } else {
        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(28), Constraint::Min(44)])
            .split(vertical[1]);
        rail(frame, body[0], app);
        conversation(frame, body[1], app);
    }
    let footer = Paragraph::new(Line::from(vec![
        Span::styled(
            " j/k ",
            Style::default()
                .fg(Color::Black)
                .bg(FOCUS)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(" move   ", Style::default().fg(MUTED)),
        Span::styled("q", Style::default().fg(INK)),
        Span::styled(" quit", Style::default().fg(MUTED)),
    ]))
    .style(Style::default().bg(SURFACE));
    frame.render_widget(footer, vertical[2]);
}

fn header(frame: &mut ratatui::Frame, area: Rect, app: &App) {
    let title = Paragraph::new(Line::from(vec![
        Span::styled(
            " teams ",
            Style::default()
                .fg(Color::Black)
                .bg(LIVE)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            "  INBOX",
            Style::default().fg(INK).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("   {}", app.data.account),
            Style::default().fg(MUTED),
        ),
    ]))
    .block(
        Block::default()
            .borders(Borders::BOTTOM)
            .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
    );
    frame.render_widget(title, area);
}

fn rail(frame: &mut ratatui::Frame, area: Rect, app: &App) {
    let items = app
        .data
        .conversations
        .iter()
        .enumerate()
        .map(|(index, conversation)| {
            let marker = if index == app.selected { "›" } else { " " };
            let unread = if conversation.unread > 0 {
                format!("  {}", conversation.unread)
            } else {
                String::new()
            };
            let style = if index == app.selected {
                Style::default().fg(FOCUS).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(INK)
            };
            ListItem::new(Line::from(vec![
                Span::styled(format!(" {marker} {}", conversation.name), style),
                Span::styled(unread, Style::default().fg(LIVE)),
            ]))
        });
    frame.render_widget(
        List::new(items).block(
            Block::default()
                .title(" Conversations ")
                .borders(Borders::RIGHT)
                .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
        ),
        area,
    );
}

fn conversation(frame: &mut ratatui::Frame, area: Rect, app: &App) {
    let conversation = &app.data.conversations[app.selected];
    let mut lines = vec![
        Line::styled(
            conversation.name.clone(),
            Style::default().fg(INK).add_modifier(Modifier::BOLD),
        ),
        Line::styled("Channel · 12 members", Style::default().fg(MUTED)),
        Line::raw(""),
    ];
    if conversation.messages.is_empty() {
        lines.push(Line::styled(
            "No recent messages",
            Style::default().fg(MUTED),
        ));
        lines.push(Line::styled(
            "Press c to start the conversation.",
            Style::default().fg(MUTED),
        ));
    } else {
        for message in &conversation.messages {
            lines.push(Line::from(vec![
                Span::styled(
                    &message.sender,
                    Style::default().fg(FOCUS).add_modifier(Modifier::BOLD),
                ),
                Span::styled(format!("  {}", message.time), Style::default().fg(MUTED)),
            ]));
            lines.push(Line::styled(&message.body, Style::default().fg(INK)));
            lines.push(Line::raw(""));
        }
    }
    let block = Block::default()
        .title(" Now ")
        .borders(Borders::NONE)
        .padding(ratatui::widgets::Padding::horizontal(2));
    frame.render_widget(
        Paragraph::new(lines)
            .wrap(Wrap { trim: false })
            .block(block),
        area,
    );
}

#[cfg(test)]
mod tests {
    #[test]
    fn snapshot_contains_real_states() {
        let frame = super::snapshot(90, 26).unwrap();
        assert!(frame.contains("Launch room"));
        assert!(frame.contains("release candidate is green"));
        assert!(frame.contains("q quit"));
    }
}