Skip to main content

teams_cli/
tui.rs

1use std::io::{self, IsTerminal};
2use std::time::Duration;
3
4use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
5use crossterm::execute;
6use crossterm::terminal::{
7    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
8};
9use ratatui::Terminal;
10use ratatui::backend::{CrosstermBackend, TestBackend};
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
15
16use crate::error::AppError;
17
18const INK: Color = Color::Rgb(230, 233, 239);
19const MUTED: Color = Color::Rgb(151, 158, 171);
20const FOCUS: Color = Color::Rgb(102, 194, 255);
21const LIVE: Color = Color::Rgb(255, 119, 107);
22const SURFACE: Color = Color::Rgb(20, 23, 30);
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum TuiExit {
26    Quit,
27    Authenticate,
28}
29
30struct TerminalGuard;
31
32impl TerminalGuard {
33    fn enter() -> Result<Self, AppError> {
34        enable_raw_mode()?;
35        if let Err(error) = execute!(io::stdout(), EnterAlternateScreen) {
36            let _ = disable_raw_mode();
37            return Err(error.into());
38        }
39        Ok(Self)
40    }
41}
42
43impl Drop for TerminalGuard {
44    fn drop(&mut self) {
45        let _ = disable_raw_mode();
46        let _ = execute!(io::stdout(), LeaveAlternateScreen);
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct TuiData {
52    pub account: String,
53    pub conversations: Vec<Conversation>,
54}
55
56#[derive(Debug, Clone)]
57pub struct Conversation {
58    pub name: String,
59    pub unread: u16,
60    pub messages: Vec<Message>,
61}
62
63#[derive(Debug, Clone)]
64pub struct Message {
65    pub sender: String,
66    pub time: String,
67    pub body: String,
68}
69
70pub fn demo_data() -> TuiData {
71    TuiData {
72        account: "Alex Morgan · Northstar Studio".into(),
73        conversations: vec![
74            Conversation {
75                name: "Launch room".into(),
76                unread: 3,
77                messages: vec![
78                    Message {
79                        sender: "Mina".into(),
80                        time: "09:42".into(),
81                        body: "The release candidate is green. I pinned the final checklist."
82                            .into(),
83                    },
84                    Message {
85                        sender: "Jon".into(),
86                        time: "09:46".into(),
87                        body: "Docs are ready. One broken link remains in the migration guide."
88                            .into(),
89                    },
90                    Message {
91                        sender: "You".into(),
92                        time: "09:48".into(),
93                        body: "I’ll take it. Hold the announcement until I clear the link check."
94                            .into(),
95                    },
96                ],
97            },
98            Conversation {
99                name: "Design crit".into(),
100                unread: 0,
101                messages: vec![],
102            },
103            Conversation {
104                name: "Ari Chen".into(),
105                unread: 1,
106                messages: vec![],
107            },
108            Conversation {
109                name: "Platform on-call".into(),
110                unread: 0,
111                messages: vec![],
112            },
113        ],
114    }
115}
116
117struct App {
118    data: TuiData,
119    selected: usize,
120}
121
122pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
123    let backend = TestBackend::new(width.max(48), height.max(18));
124    let mut terminal = Terminal::new(backend).expect("test backend is infallible");
125    let app = App {
126        data: demo_data(),
127        selected: 0,
128    };
129    terminal
130        .draw(|frame| draw(frame, &app))
131        .expect("test backend is infallible");
132    let buffer = terminal.backend().buffer();
133    let mut output = String::new();
134    for y in 0..buffer.area.height {
135        let mut line = String::new();
136        for x in 0..buffer.area.width {
137            line.push_str(buffer[(x, y)].symbol());
138        }
139        output.push_str(line.trim_end());
140        output.push('\n');
141    }
142    while output.ends_with("\n\n") {
143        output.pop();
144    }
145    Ok(output)
146}
147
148pub fn connection_snapshot(
149    width: u16,
150    height: u16,
151    profile: &str,
152    configured: bool,
153    reason: &str,
154) -> Result<String, AppError> {
155    let backend = TestBackend::new(width.max(48), height.max(18));
156    let mut terminal = Terminal::new(backend).expect("test backend is infallible");
157    terminal
158        .draw(|frame| draw_connection(frame, profile, configured, reason))
159        .expect("test backend is infallible");
160    Ok(buffer_text(terminal.backend().buffer()))
161}
162
163fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
164    let mut output = String::new();
165    for y in 0..buffer.area.height {
166        let mut line = String::new();
167        for x in 0..buffer.area.width {
168            line.push_str(buffer[(x, y)].symbol());
169        }
170        output.push_str(line.trim_end());
171        output.push('\n');
172    }
173    while output.ends_with("\n\n") {
174        output.pop();
175    }
176    output
177}
178
179pub fn run(data: TuiData) -> Result<(), AppError> {
180    if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
181        return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
182    }
183    let _terminal_guard = TerminalGuard::enter()?;
184    let backend = CrosstermBackend::new(io::stdout());
185    let mut terminal = Terminal::new(backend)?;
186    let mut app = App { data, selected: 0 };
187    (|| -> Result<(), AppError> {
188        loop {
189            terminal.draw(|frame| draw(frame, &app))?;
190            if event::poll(Duration::from_millis(150))?
191                && let Event::Key(key) = event::read()?
192            {
193                if key.kind != KeyEventKind::Press {
194                    continue;
195                }
196                match key.code {
197                    KeyCode::Char('q') | KeyCode::Esc => break,
198                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
199                    KeyCode::Down | KeyCode::Char('j') => {
200                        app.selected =
201                            (app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
202                    }
203                    KeyCode::Up | KeyCode::Char('k') => {
204                        app.selected = app.selected.saturating_sub(1)
205                    }
206                    _ => {}
207                }
208            }
209        }
210        Ok(())
211    })()
212}
213
214/// Present an actionable connection state inside the same terminal experience.
215/// Returning first restores the alternate screen before the caller opens a
216/// browser or prints device-code instructions.
217pub fn request_authentication(
218    profile: &str,
219    configured: bool,
220    reason: &str,
221) -> Result<TuiExit, AppError> {
222    if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
223        return Err(AppError::NonInteractive(
224            "`teams tui` needs a terminal; run `teams auth login --device-code` for headless sign-in"
225                .into(),
226        ));
227    }
228    let _terminal_guard = TerminalGuard::enter()?;
229    let backend = CrosstermBackend::new(io::stdout());
230    let mut terminal = Terminal::new(backend)?;
231    loop {
232        terminal.draw(|frame| draw_connection(frame, profile, configured, reason))?;
233        if event::poll(Duration::from_millis(150))?
234            && let Event::Key(key) = event::read()?
235        {
236            if key.kind != KeyEventKind::Press {
237                continue;
238            }
239            match key.code {
240                KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
241                KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
242                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
243                    return Ok(TuiExit::Quit);
244                }
245                _ => {}
246            }
247        }
248    }
249}
250
251fn draw_connection(frame: &mut ratatui::Frame, profile: &str, configured: bool, reason: &str) {
252    let area = frame.area();
253    frame.render_widget(Block::default().style(Style::default().bg(SURFACE)), area);
254    let panel_width = area.width.saturating_sub(2).clamp(1, 74);
255    let panel_height = area.height.saturating_sub(2).clamp(1, 12);
256    let panel = Rect {
257        x: area.x + area.width.saturating_sub(panel_width) / 2,
258        y: area.y + area.height.saturating_sub(panel_height) / 2,
259        width: panel_width,
260        height: panel_height,
261    };
262    let title = if configured {
263        " SIGN-IN REQUIRED "
264    } else {
265        " CONNECT MICROSOFT TEAMS "
266    };
267    let action = if configured {
268        "Continue with Microsoft sign-in. Your password is never shared with teams-cli."
269    } else {
270        "Set up this profile and continue with secure Microsoft sign-in."
271    };
272    let content = vec![
273        Line::styled(
274            if configured {
275                "Your Teams session needs attention"
276            } else {
277                "Bring your Teams workspace into focus"
278            },
279            Style::default().fg(INK).add_modifier(Modifier::BOLD),
280        ),
281        Line::styled(format!("Profile: {profile}"), Style::default().fg(MUTED)),
282        Line::raw(""),
283        Line::styled(reason, Style::default().fg(INK)),
284        Line::styled(action, Style::default().fg(MUTED)),
285        Line::raw(""),
286        Line::from(vec![
287            Span::styled(
288                " enter / a ",
289                Style::default()
290                    .fg(Color::Black)
291                    .bg(FOCUS)
292                    .add_modifier(Modifier::BOLD),
293            ),
294            Span::styled(
295                if configured {
296                    " sign in   "
297                } else {
298                    " set up   "
299                },
300                Style::default().fg(MUTED),
301            ),
302            Span::styled("q", Style::default().fg(INK)),
303            Span::styled(" quit", Style::default().fg(MUTED)),
304        ]),
305    ];
306    frame.render_widget(
307        Paragraph::new(content).wrap(Wrap { trim: true }).block(
308            Block::default()
309                .title(title)
310                .borders(Borders::ALL)
311                .border_style(Style::default().fg(FOCUS))
312                .padding(ratatui::widgets::Padding::horizontal(2)),
313        ),
314        panel,
315    );
316}
317
318fn draw(frame: &mut ratatui::Frame, app: &App) {
319    let area = frame.area();
320    let vertical = Layout::default()
321        .direction(Direction::Vertical)
322        .constraints([
323            Constraint::Length(3),
324            Constraint::Min(10),
325            Constraint::Length(2),
326        ])
327        .split(area);
328    header(frame, vertical[0], app);
329    if area.width < 76 {
330        conversation(frame, vertical[1], app);
331    } else {
332        let body = Layout::default()
333            .direction(Direction::Horizontal)
334            .constraints([Constraint::Length(28), Constraint::Min(44)])
335            .split(vertical[1]);
336        rail(frame, body[0], app);
337        conversation(frame, body[1], app);
338    }
339    let footer = Paragraph::new(Line::from(vec![
340        Span::styled(
341            " j/k ",
342            Style::default()
343                .fg(Color::Black)
344                .bg(FOCUS)
345                .add_modifier(Modifier::BOLD),
346        ),
347        Span::styled(" move   ", Style::default().fg(MUTED)),
348        Span::styled("q", Style::default().fg(INK)),
349        Span::styled(" quit", Style::default().fg(MUTED)),
350    ]))
351    .style(Style::default().bg(SURFACE));
352    frame.render_widget(footer, vertical[2]);
353}
354
355fn header(frame: &mut ratatui::Frame, area: Rect, app: &App) {
356    let title = Paragraph::new(Line::from(vec![
357        Span::styled(
358            " teams ",
359            Style::default()
360                .fg(Color::Black)
361                .bg(LIVE)
362                .add_modifier(Modifier::BOLD),
363        ),
364        Span::styled(
365            "  INBOX",
366            Style::default().fg(INK).add_modifier(Modifier::BOLD),
367        ),
368        Span::styled(
369            format!("   {}", app.data.account),
370            Style::default().fg(MUTED),
371        ),
372    ]))
373    .block(
374        Block::default()
375            .borders(Borders::BOTTOM)
376            .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
377    );
378    frame.render_widget(title, area);
379}
380
381fn rail(frame: &mut ratatui::Frame, area: Rect, app: &App) {
382    let items = app
383        .data
384        .conversations
385        .iter()
386        .enumerate()
387        .map(|(index, conversation)| {
388            let marker = if index == app.selected { "›" } else { " " };
389            let unread = if conversation.unread > 0 {
390                format!("  {}", conversation.unread)
391            } else {
392                String::new()
393            };
394            let style = if index == app.selected {
395                Style::default().fg(FOCUS).add_modifier(Modifier::BOLD)
396            } else {
397                Style::default().fg(INK)
398            };
399            ListItem::new(Line::from(vec![
400                Span::styled(format!(" {marker} {}", conversation.name), style),
401                Span::styled(unread, Style::default().fg(LIVE)),
402            ]))
403        });
404    frame.render_widget(
405        List::new(items).block(
406            Block::default()
407                .title(" Conversations ")
408                .borders(Borders::RIGHT)
409                .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
410        ),
411        area,
412    );
413}
414
415fn conversation(frame: &mut ratatui::Frame, area: Rect, app: &App) {
416    let conversation = &app.data.conversations[app.selected];
417    let mut lines = vec![
418        Line::styled(
419            conversation.name.clone(),
420            Style::default().fg(INK).add_modifier(Modifier::BOLD),
421        ),
422        Line::styled("Channel · 12 members", Style::default().fg(MUTED)),
423        Line::raw(""),
424    ];
425    if conversation.messages.is_empty() {
426        lines.push(Line::styled(
427            "No recent messages",
428            Style::default().fg(MUTED),
429        ));
430        lines.push(Line::styled(
431            "Press c to start the conversation.",
432            Style::default().fg(MUTED),
433        ));
434    } else {
435        for message in &conversation.messages {
436            lines.push(Line::from(vec![
437                Span::styled(
438                    &message.sender,
439                    Style::default().fg(FOCUS).add_modifier(Modifier::BOLD),
440                ),
441                Span::styled(format!("  {}", message.time), Style::default().fg(MUTED)),
442            ]));
443            lines.push(Line::styled(&message.body, Style::default().fg(INK)));
444            lines.push(Line::raw(""));
445        }
446    }
447    let block = Block::default()
448        .title(" Now ")
449        .borders(Borders::NONE)
450        .padding(ratatui::widgets::Padding::horizontal(2));
451    frame.render_widget(
452        Paragraph::new(lines)
453            .wrap(Wrap { trim: false })
454            .block(block),
455        area,
456    );
457}
458
459#[cfg(test)]
460mod tests {
461    #[test]
462    fn snapshot_contains_real_states() {
463        let frame = super::snapshot(90, 26).unwrap();
464        assert!(frame.contains("Launch room"));
465        assert!(frame.contains("release candidate is green"));
466        assert!(frame.contains("q quit"));
467    }
468
469    #[test]
470    fn connection_snapshot_has_a_clear_recovery_action() {
471        let frame =
472            super::connection_snapshot(80, 22, "work", true, "Your saved session has expired.")
473                .unwrap();
474        assert!(frame.contains("SIGN-IN REQUIRED"));
475        assert!(frame.contains("Profile: work"));
476        assert!(frame.contains("enter / a"));
477        assert!(frame.contains("sign in"));
478        assert!(frame.contains("q quit"));
479    }
480}