Skip to main content

teams_cli/
tui.rs

1//! The keyboard-first chat reader.
2//!
3//! A calm two-pane ledger: chats on the left, the open conversation on the
4//! right, a header that always says where you are and a footer that always
5//! says what the keys do. Reads run in the background so the keyboard never
6//! waits on the network, results are cached per chat, and every screen stays
7//! legible without colour (`NO_COLOR`).
8use std::collections::{HashMap, HashSet};
9use std::io::{self, IsTerminal};
10use std::sync::{Arc, mpsc};
11use std::time::{Duration, Instant};
12
13use crossterm::event::{
14    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
15    KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
16};
17use crossterm::execute;
18use crossterm::terminal::{
19    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
20};
21use jiff::{ToSpan, Zoned, tz::TimeZone};
22use ratatui::Terminal;
23use ratatui::backend::{CrosstermBackend, TestBackend};
24use ratatui::layout::{Alignment, Constraint, Layout, Rect};
25use ratatui::style::{Color, Modifier, Style};
26use ratatui::text::{Line, Span};
27use ratatui::widgets::{
28    Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Wrap,
29};
30use serde_json::{Value, json};
31use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
32
33use crate::backend::TeamsBackend;
34use crate::config;
35use crate::desktop::MAX_BODY;
36use crate::error::AppError;
37use crate::graph::Page;
38use crate::render::{self, ChatRow, Entry, View};
39
40/// Records requested per read; the most both backends accept.
41const PAGE: u16 = 50;
42/// How long a selection must rest before its conversation is fetched, so
43/// scrolling through the ledger does not fire a read per row.
44const DWELL: Duration = Duration::from_millis(250);
45const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
46/// Below this width the ledger and the conversation take turns.
47const SPLIT_WIDTH: u16 = 80;
48const MIN_WIDTH: u16 = 50;
49const MIN_HEIGHT: u16 = 14;
50/// How often a live session re-reads the open chat and the chat list. Desktop reads come
51/// from the local cache, so they can be frequent; Graph is kept within its rate limits.
52const DESKTOP_POLL: (Duration, Duration) = (Duration::from_secs(3), Duration::from_secs(15));
53const GRAPH_POLL: (Duration, Duration) = (Duration::from_secs(15), Duration::from_secs(60));
54/// Rows the composer grows to before its draft scrolls.
55const COMPOSE_ROWS: usize = 5;
56/// Lines one ledger entry takes: title, preview and a blank separator.
57const CHAT_ROW_HEIGHT: u16 = 3;
58/// Conversation lines one mouse-wheel notch scrolls.
59const WHEEL_LINES: usize = 3;
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum TuiExit {
63    Quit,
64    Authenticate,
65}
66
67/// Where chats come from: a connected profile or deterministic sample data.
68pub enum Source {
69    Live {
70        backend: Arc<TeamsBackend>,
71        profile: String,
72        /// Whether the profile allowed writes at launch; checked again before every send.
73        can_send: bool,
74    },
75    Demo,
76}
77
78/// Raw mode and the alternate screen for as long as it lives; with `mouse`, the
79/// terminal also reports wheel and clicks instead of turning the wheel into arrow keys.
80struct TerminalGuard {
81    mouse: bool,
82}
83
84impl TerminalGuard {
85    fn enter(mouse: bool) -> Result<Self, AppError> {
86        enable_raw_mode()?;
87        let entered = if mouse {
88            execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)
89        } else {
90            execute!(io::stdout(), EnterAlternateScreen)
91        };
92        if let Err(error) = entered {
93            let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
94            let _ = disable_raw_mode();
95            return Err(error.into());
96        }
97        Ok(Self { mouse })
98    }
99}
100
101impl Drop for TerminalGuard {
102    fn drop(&mut self) {
103        let _ = disable_raw_mode();
104        if self.mouse {
105            let _ = execute!(io::stdout(), DisableMouseCapture);
106        }
107        let _ = execute!(io::stdout(), LeaveAlternateScreen);
108    }
109}
110
111fn require_terminal(hint: &str) -> Result<(), AppError> {
112    if io::stdout().is_terminal() && io::stdin().is_terminal() {
113        Ok(())
114    } else {
115        Err(AppError::NonInteractive(format!(
116            "`teams tui` needs a terminal; {hint}"
117        )))
118    }
119}
120
121/// Opens the chat reader and returns how the person left it.
122pub fn run(source: Source) -> Result<TuiExit, AppError> {
123    require_terminal("use `teams tui --demo --snapshot` for an inspectable frame")?;
124    let color = std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty());
125    let mut app = App::new(
126        source,
127        Theme { color },
128        Zoned::now().with_time_zone(TimeZone::system()),
129    );
130    app.start();
131    let _guard = TerminalGuard::enter(true)?;
132    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
133    loop {
134        app.tick();
135        terminal.draw(|frame| app.draw(frame))?;
136        if !event::poll(Duration::from_millis(100))? {
137            continue;
138        }
139        let action = match event::read()? {
140            Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key),
141            Event::Mouse(mouse) => {
142                app.handle_mouse(mouse);
143                Action::None
144            }
145            _ => Action::None,
146        };
147        match action {
148            Action::None => {}
149            Action::Quit => return Ok(TuiExit::Quit),
150            Action::Authenticate => return Ok(TuiExit::Authenticate),
151        }
152    }
153}
154
155/// One ANSI-free frame of the demo, for documentation and tests.
156pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
157    let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
158        .parse()
159        .expect("valid demo clock");
160    let mut app = App::new(Source::Demo, Theme { color: false }, now);
161    app.start();
162    app.open_selected();
163    let mut terminal = Terminal::new(TestBackend::new(
164        width.max(MIN_WIDTH),
165        height.max(MIN_HEIGHT),
166    ))
167    .expect("test backend is infallible");
168    terminal
169        .draw(|frame| app.draw(frame))
170        .expect("test backend is infallible");
171    Ok(buffer_text(terminal.backend().buffer()))
172}
173
174fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
175    let mut output = String::new();
176    for y in 0..buffer.area.height {
177        let mut line = String::new();
178        for x in 0..buffer.area.width {
179            line.push_str(buffer[(x, y)].symbol());
180        }
181        output.push_str(line.trim_end());
182        output.push('\n');
183    }
184    while output.ends_with("\n\n") {
185        output.pop();
186    }
187    output
188}
189
190enum Action {
191    None,
192    Quit,
193    Authenticate,
194}
195
196enum Outcome {
197    Chats {
198        more: bool,
199        result: Result<Page, AppError>,
200    },
201    Messages {
202        chat: String,
203        older: bool,
204        result: Result<Page, AppError>,
205    },
206    Me(Result<Value, AppError>),
207    Sent {
208        chat: String,
209        result: Result<Value, AppError>,
210    },
211}
212
213enum Load {
214    Loading,
215    Ready,
216    Failed { message: String, auth: bool },
217}
218
219impl Load {
220    fn failed(error: &AppError) -> Self {
221        Self::Failed {
222            message: error.to_string(),
223            auth: matches!(error, AppError::Auth(_)),
224        }
225    }
226}
227
228struct Thread {
229    items: Vec<Value>,
230    cursor: Option<String>,
231    load: Load,
232    loading_older: bool,
233    /// A newest-page read is in flight, so polls do not stack up.
234    fetching: bool,
235}
236
237#[derive(Clone, Copy, Eq, PartialEq)]
238enum Focus {
239    Chats,
240    Thread,
241}
242
243#[derive(Clone, Copy, Eq, PartialEq)]
244enum Overlay {
245    Help,
246    Filter,
247}
248
249#[derive(Clone, Copy, Eq, PartialEq)]
250enum Stage {
251    Editing,
252    /// Enter was pressed; only `y` sends.
253    Confirming,
254    Sending,
255}
256
257/// A draft for one chat; `cursor` is a byte offset into `text`.
258struct Compose {
259    chat: String,
260    text: String,
261    cursor: usize,
262    stage: Stage,
263}
264
265impl Compose {
266    fn insert(&mut self, c: char) {
267        self.text.insert(self.cursor, c);
268        self.cursor += c.len_utf8();
269    }
270}
271
272#[derive(Clone, Copy)]
273enum Tone {
274    Quiet,
275    Success,
276    Error,
277}
278
279struct Notice {
280    tone: Tone,
281    text: String,
282}
283
284impl Notice {
285    fn new(tone: Tone, text: impl Into<String>) -> Self {
286        Self {
287            tone,
288            text: text.into(),
289        }
290    }
291}
292
293struct App {
294    theme: Theme,
295    source: Source,
296    outcomes: (mpsc::Sender<Outcome>, mpsc::Receiver<Outcome>),
297    now: Zoned,
298    account: Option<String>,
299    viewer: Option<String>,
300    chats: Vec<Value>,
301    rows: Vec<ChatRow>,
302    chat_cursor: Option<String>,
303    chats_load: Load,
304    loading_more: bool,
305    filter: String,
306    /// Indices into `rows` that pass the filter, in ledger order.
307    visible: Vec<usize>,
308    list: ListState,
309    selected_at: Option<Instant>,
310    threads: HashMap<String, Thread>,
311    focus: Focus,
312    /// Conversation lines hidden below the viewport; 0 follows the newest.
313    scroll: usize,
314    thread_height: usize,
315    /// Conversation lines above the viewport when scrolled to the newest.
316    thread_overflow: usize,
317    /// Where the last frame drew the ledger rows and the conversation pane, for the mouse.
318    ledger_area: Option<Rect>,
319    thread_area: Option<Rect>,
320    overlay: Option<Overlay>,
321    notice: Notice,
322    frame: usize,
323    compose: Option<Compose>,
324    /// Closed, unsent drafts per chat; kept in memory only.
325    drafts: HashMap<String, String>,
326    /// Set by `r` so its result is announced; quiet reloads say nothing.
327    refreshing: bool,
328    fetching_chats: bool,
329    /// When a newest-page read last succeeded, and whether a refresh has failed since.
330    synced_at: Option<Zoned>,
331    stale: bool,
332    thread_polled: Instant,
333    chats_polled: Instant,
334}
335
336impl App {
337    fn new(source: Source, theme: Theme, now: Zoned) -> Self {
338        Self {
339            theme,
340            source,
341            outcomes: mpsc::channel(),
342            now,
343            account: None,
344            viewer: None,
345            chats: Vec::new(),
346            rows: Vec::new(),
347            chat_cursor: None,
348            chats_load: Load::Loading,
349            loading_more: false,
350            filter: String::new(),
351            visible: Vec::new(),
352            list: ListState::default(),
353            selected_at: None,
354            threads: HashMap::new(),
355            focus: Focus::Chats,
356            scroll: 0,
357            thread_height: 10,
358            thread_overflow: 0,
359            ledger_area: None,
360            thread_area: None,
361            overlay: None,
362            notice: Notice::new(Tone::Quiet, "Reading your chats…"),
363            frame: 0,
364            compose: None,
365            drafts: HashMap::new(),
366            refreshing: false,
367            fetching_chats: false,
368            synced_at: None,
369            stale: false,
370            thread_polled: Instant::now(),
371            chats_polled: Instant::now(),
372        }
373    }
374
375    /// Records the outcome of reading the newest chats or messages for the header.
376    fn synced(&mut self, ok: bool) {
377        if ok {
378            self.synced_at = Some(match self.source {
379                Source::Live { .. } => Zoned::now().with_time_zone(TimeZone::system()),
380                Source::Demo => self.now.clone(),
381            });
382            self.stale = false;
383        } else {
384            self.stale = true;
385        }
386    }
387
388    fn view(&self) -> View {
389        View {
390            now: self.now.clone(),
391            width: 100,
392        }
393    }
394
395    fn backend(&self) -> Option<Arc<TeamsBackend>> {
396        match &self.source {
397            Source::Live { backend, .. } => Some(backend.clone()),
398            Source::Demo => None,
399        }
400    }
401
402    fn can_send(&self) -> bool {
403        match &self.source {
404            Source::Live { can_send, .. } => *can_send,
405            Source::Demo => true,
406        }
407    }
408
409    fn profile(&self) -> &str {
410        match &self.source {
411            Source::Live { profile, .. } => profile,
412            Source::Demo => "demo",
413        }
414    }
415
416    /// The second header line as parts, most useful first; whole parts are dropped
417    /// from the end when the terminal is narrow.
418    fn context(&self) -> Vec<Vec<Span<'static>>> {
419        let theme = self.theme;
420        let mut parts = Vec::new();
421        if matches!(self.chats_load, Load::Ready) {
422            let more = if self.chat_cursor.is_some() { "+" } else { "" };
423            parts.push(vec![
424                Span::styled(format!("{}{more}", self.rows.len()), theme.figure()),
425                Span::styled(" chats", theme.muted()),
426            ]);
427            let unread = self.rows.iter().filter(|row| row.unread).count();
428            if unread > 0 {
429                parts.push(vec![
430                    Span::styled(unread.to_string(), theme.unread()),
431                    Span::styled(" unread", theme.unread_label()),
432                ]);
433            }
434        }
435        parts.push(vec![Span::styled(
436            match &self.source {
437                Source::Live { backend, .. } => match backend.as_ref() {
438                    TeamsBackend::Graph(_) => "Microsoft Graph",
439                    TeamsBackend::Desktop(_) => "Teams desktop cache",
440                },
441                Source::Demo => "sample data",
442            },
443            theme.muted(),
444        )]);
445        parts
446    }
447
448    /// How fresh the chats are, as a status dot: live, syncing, or since when a
449    /// refresh keeps failing. Sample data has nothing to be fresh about.
450    fn freshness(&self) -> Option<Vec<Span<'static>>> {
451        let theme = self.theme;
452        let dot = |style: Style, text: String| {
453            vec![
454                Span::styled("● ", style),
455                Span::styled(text, style.remove_modifier(Modifier::BOLD)),
456            ]
457        };
458        if self.busy() {
459            return Some(vec![Span::styled(
460                format!("{} syncing", self.spinner()),
461                theme.accent(),
462            )]);
463        }
464        if let Load::Failed { auth, .. } = self.chats_load {
465            let text = if auth {
466                "sign-in needed"
467            } else {
468                "not connected"
469            };
470            return Some(dot(theme.error(), text.into()));
471        }
472        if matches!(self.source, Source::Demo) {
473            return None;
474        }
475        let at = self.synced_at.as_ref()?.strftime("%H:%M");
476        Some(if self.stale {
477            dot(theme.error(), format!("stale since {at}"))
478        } else {
479            dot(theme.success(), "live".into())
480        })
481    }
482
483    fn start(&mut self) {
484        if let Some(backend) = self.backend()
485            && matches!(backend.as_ref(), TeamsBackend::Graph(_))
486        {
487            let sender = self.outcomes.0.clone();
488            tokio::spawn(async move {
489                let _ = sender.send(Outcome::Me(backend.me().await));
490            });
491        }
492        self.load_chats(false);
493    }
494
495    fn load_chats(&mut self, more: bool) {
496        let cursor = if more {
497            if self.loading_more || self.chat_cursor.is_none() {
498                return;
499            }
500            self.loading_more = true;
501            self.chat_cursor.clone()
502        } else {
503            if !matches!(self.chats_load, Load::Ready) {
504                self.chats_load = Load::Loading;
505            }
506            self.fetching_chats = true;
507            None
508        };
509        match self.backend() {
510            Some(backend) => {
511                let sender = self.outcomes.0.clone();
512                tokio::spawn(async move {
513                    let result = backend.chats(PAGE, cursor.as_deref()).await;
514                    let _ = sender.send(Outcome::Chats { more, result });
515                });
516            }
517            None => self.apply(Outcome::Chats {
518                more,
519                result: Ok(demo_chats(&self.now)),
520            }),
521        }
522    }
523
524    fn load_messages(&mut self, chat: String, older: bool) {
525        let cursor = match self.threads.get_mut(&chat) {
526            Some(thread) if older => {
527                if thread.loading_older || thread.cursor.is_none() {
528                    return;
529                }
530                thread.loading_older = true;
531                thread.cursor.clone()
532            }
533            Some(thread) => {
534                if !matches!(thread.load, Load::Ready) {
535                    thread.load = Load::Loading;
536                }
537                thread.fetching = true;
538                None
539            }
540            None => {
541                self.threads.insert(
542                    chat.clone(),
543                    Thread {
544                        items: Vec::new(),
545                        cursor: None,
546                        load: Load::Loading,
547                        loading_older: false,
548                        fetching: true,
549                    },
550                );
551                None
552            }
553        };
554        match self.backend() {
555            Some(backend) => {
556                let sender = self.outcomes.0.clone();
557                tokio::spawn(async move {
558                    let result = backend
559                        .messages(Some(&chat), None, None, PAGE, cursor.as_deref())
560                        .await;
561                    let _ = sender.send(Outcome::Messages {
562                        chat,
563                        older,
564                        result,
565                    });
566                });
567            }
568            None => {
569                let result = Ok(demo_messages(&chat, &self.now));
570                self.apply(Outcome::Messages {
571                    chat,
572                    older,
573                    result,
574                });
575            }
576        }
577    }
578
579    fn apply(&mut self, outcome: Outcome) {
580        match outcome {
581            Outcome::Chats { more, result } => {
582                if more {
583                    self.loading_more = false;
584                } else {
585                    self.fetching_chats = false;
586                }
587                match result {
588                    Ok(page) => {
589                        if page.viewer.is_some() {
590                            self.viewer = page.viewer;
591                        }
592                        let keep = self.selected_row().map(|row| row.id.clone());
593                        let cursor = page.next_cursor.filter(|_| page.truncated);
594                        if more {
595                            let known: HashSet<String> =
596                                self.rows.iter().map(|row| row.id.clone()).collect();
597                            self.chats.extend(page.items.into_iter().filter(|chat| {
598                                !chat["id"].as_str().is_some_and(|id| known.contains(id))
599                            }));
600                            self.chat_cursor = cursor;
601                        } else {
602                            if !matches!(self.chats_load, Load::Ready) {
603                                self.notice = Notice::new(
604                                    Tone::Quiet,
605                                    "Enter opens a chat  ·  / filters  ·  ? shows every key",
606                                );
607                            } else if std::mem::take(&mut self.refreshing) {
608                                self.notice = Notice::new(Tone::Success, "Up to date.");
609                            }
610                            let old = std::mem::take(&mut self.chats);
611                            (self.chats, self.chat_cursor) =
612                                merge_newest(page.items, cursor, old, self.chat_cursor.take());
613                        }
614                        self.chats_load = Load::Ready;
615                        self.rebuild(keep.as_deref());
616                        if !more {
617                            self.synced(true);
618                        }
619                    }
620                    Err(error) if more || matches!(self.chats_load, Load::Ready) => {
621                        self.refreshing = false;
622                        self.notice = Notice::new(Tone::Error, error.to_string());
623                        if !more {
624                            self.synced(false);
625                        }
626                    }
627                    Err(error) => {
628                        self.notice = Notice::new(Tone::Error, "Could not read your chats.");
629                        self.chats_load = Load::failed(&error);
630                    }
631                }
632            }
633            Outcome::Messages {
634                chat,
635                older,
636                result,
637            } => {
638                let opened = self.selected_row().is_some_and(|row| row.id == chat);
639                let Some(thread) = self.threads.get_mut(&chat) else {
640                    return;
641                };
642                if older {
643                    thread.loading_older = false;
644                } else {
645                    thread.fetching = false;
646                }
647                match result {
648                    Ok(page) => {
649                        if older {
650                            let known: HashSet<&str> = thread
651                                .items
652                                .iter()
653                                .filter_map(|m| m["id"].as_str())
654                                .collect();
655                            let fresh: Vec<Value> = page
656                                .items
657                                .into_iter()
658                                .filter(|m| !m["id"].as_str().is_some_and(|id| known.contains(id)))
659                                .collect();
660                            let added = fresh.len();
661                            thread.items.extend(fresh);
662                            self.notice = Notice::new(
663                                Tone::Success,
664                                format!(
665                                    "Loaded {added} older message{}.",
666                                    if added == 1 { "" } else { "s" }
667                                ),
668                            );
669                            thread.cursor = page.next_cursor.filter(|_| page.truncated);
670                        } else {
671                            let cursor = page.next_cursor.filter(|_| page.truncated);
672                            let old = std::mem::take(&mut thread.items);
673                            (thread.items, thread.cursor) =
674                                merge_newest(page.items, cursor, old, thread.cursor.take());
675                        }
676                        thread.load = Load::Ready;
677                        if self.viewer.is_none() && page.viewer.is_some() {
678                            self.viewer = page.viewer;
679                            self.rebuild(None);
680                        }
681                        if opened && !older {
682                            self.prefetch_neighbours();
683                            self.synced(true);
684                        }
685                    }
686                    Err(error) if older || matches!(thread.load, Load::Ready) => {
687                        self.notice = Notice::new(Tone::Error, error.to_string());
688                        if opened && !older {
689                            self.synced(false);
690                        }
691                    }
692                    Err(error) => thread.load = Load::failed(&error),
693                }
694            }
695            Outcome::Me(Ok(me)) => {
696                self.account = me["displayName"].as_str().map(String::from);
697                if let Some(id) = me["id"].as_str() {
698                    self.viewer = Some(id.into());
699                    self.rebuild(None);
700                }
701            }
702            // The header simply stays without a name; chats still load.
703            Outcome::Me(Err(_)) => {}
704            Outcome::Sent { chat, result } => {
705                let compose = self.compose.as_mut().filter(|c| c.chat == chat);
706                match result {
707                    Ok(_) => {
708                        if compose.is_some() {
709                            self.compose = None;
710                        }
711                        self.drafts.remove(&chat);
712                        self.notice = Notice::new(Tone::Success, "Sent.");
713                    }
714                    Err(error) => {
715                        if let Some(compose) = compose {
716                            compose.stage = Stage::Editing;
717                        }
718                        self.notice = Notice::new(Tone::Error, error.to_string());
719                    }
720                }
721                // Even a failed send may have reached Teams, so show what the chat holds now.
722                if self.backend().is_some() {
723                    self.load_chats(false);
724                    self.load_messages(chat, false);
725                }
726            }
727        }
728    }
729
730    /// Recomputes rows and the filtered ledger, keeping the selected chat.
731    fn rebuild(&mut self, keep: Option<&str>) {
732        let before = self.selected_row().map(|row| row.id.clone());
733        let keep = keep.map(String::from).or_else(|| before.clone());
734        let view = self.view();
735        self.rows = self
736            .chats
737            .iter()
738            .map(|chat| ChatRow::new(chat, &view, self.viewer.as_deref()))
739            .collect();
740        let needle = self.filter.to_lowercase();
741        self.visible = (0..self.rows.len())
742            .filter(|&i| {
743                let row = &self.rows[i];
744                needle.is_empty()
745                    || row.title.to_lowercase().contains(&needle)
746                    || row
747                        .preview
748                        .as_deref()
749                        .is_some_and(|p| p.to_lowercase().contains(&needle))
750            })
751            .collect();
752        let position = keep
753            .and_then(|id| self.visible.iter().position(|&i| self.rows[i].id == id))
754            .or_else(|| (!self.visible.is_empty()).then_some(0));
755        self.list.select(position);
756        // A reload can move the open chat in the ledger; only a different chat starts afresh.
757        if self.selected_row().map(|row| row.id.clone()) != before {
758            self.selected_at = Some(Instant::now());
759            self.scroll = 0;
760        }
761    }
762
763    fn selected_row(&self) -> Option<&ChatRow> {
764        self.list
765            .selected()
766            .and_then(|i| self.visible.get(i))
767            .map(|&i| &self.rows[i])
768    }
769
770    fn select(&mut self, index: usize) {
771        if self.visible.is_empty() {
772            return;
773        }
774        let index = index.min(self.visible.len() - 1);
775        if self.list.selected() != Some(index) {
776            self.list.select(Some(index));
777            self.selected_at = Some(Instant::now());
778            self.scroll = 0;
779        }
780        if index + 3 >= self.visible.len() && self.filter.is_empty() {
781            self.load_chats(true);
782        }
783    }
784
785    fn open_selected(&mut self) {
786        self.selected_at = None;
787        // A chat that failed while prefetched gets one fresh attempt when opened.
788        if let Some(id) = self.selected_row().map(|row| row.id.clone())
789            && self
790                .threads
791                .get(&id)
792                .is_none_or(|thread| matches!(thread.load, Load::Failed { .. }))
793        {
794            self.load_messages(id, false);
795        }
796    }
797
798    /// Loads the chats either side of the open one, so stepping through the ledger
799    /// shows them without waiting.
800    fn prefetch_neighbours(&mut self) {
801        let Some(index) = self.list.selected() else {
802            return;
803        };
804        let neighbours: Vec<String> = [index.checked_sub(1), index.checked_add(1)]
805            .into_iter()
806            .flatten()
807            .filter_map(|i| self.visible.get(i))
808            .map(|&i| self.rows[i].id.clone())
809            .filter(|id| !self.threads.contains_key(id))
810            .collect();
811        for id in neighbours {
812            self.load_messages(id, false);
813        }
814    }
815
816    fn tick(&mut self) {
817        self.frame = self.frame.wrapping_add(1);
818        while let Ok(outcome) = self.outcomes.1.try_recv() {
819            self.apply(outcome);
820        }
821        if self
822            .selected_at
823            .is_some_and(|at| at.elapsed() >= DWELL || self.focus == Focus::Thread)
824        {
825            self.open_selected();
826        }
827        if let Some(backend) = self.backend() {
828            let (thread_every, chats_every) = match backend.as_ref() {
829                TeamsBackend::Desktop(_) => DESKTOP_POLL,
830                TeamsBackend::Graph(_) => GRAPH_POLL,
831            };
832            if self.chats_polled.elapsed() >= chats_every
833                && matches!(self.chats_load, Load::Ready)
834                && !self.loading_more
835                && !self.fetching_chats
836            {
837                self.chats_polled = Instant::now();
838                self.now = Zoned::now().with_time_zone(TimeZone::system());
839                self.load_chats(false);
840            }
841            if self.thread_polled.elapsed() >= thread_every {
842                self.thread_polled = Instant::now();
843                if let Some(id) = self.selected_row().map(|row| row.id.clone())
844                    && self.threads.get(&id).is_some_and(|t| {
845                        matches!(t.load, Load::Ready) && !t.fetching && !t.loading_older
846                    })
847                {
848                    self.load_messages(id, false);
849                }
850            }
851        }
852    }
853
854    /// Background prefetches stay quiet; only the open chat shows activity.
855    fn busy(&self) -> bool {
856        matches!(self.chats_load, Load::Loading)
857            || self.loading_more
858            || self.selected_row().is_some_and(|row| {
859                self.threads
860                    .get(&row.id)
861                    .is_some_and(|t| t.loading_older || matches!(t.load, Load::Loading))
862            })
863    }
864
865    fn sign_in_needed(&self) -> bool {
866        matches!(self.chats_load, Load::Failed { auth: true, .. })
867            || self.selected_row().is_some_and(|row| {
868                matches!(
869                    self.threads.get(&row.id).map(|t| &t.load),
870                    Some(Load::Failed { auth: true, .. })
871                )
872            })
873    }
874
875    fn refresh(&mut self) {
876        self.notice = Notice::new(Tone::Quiet, "Refreshing…");
877        self.refreshing = true;
878        self.reload();
879    }
880
881    /// Reloads the ledger and the open chat, keeping older pages, selection and scroll.
882    fn reload(&mut self) {
883        self.thread_polled = Instant::now();
884        self.chats_polled = Instant::now();
885        if matches!(self.source, Source::Live { .. }) {
886            self.now = Zoned::now().with_time_zone(TimeZone::system());
887        }
888        self.load_chats(false);
889        if let Some(id) = self.selected_row().map(|row| row.id.clone())
890            && self.threads.get(&id).is_none_or(|t| !t.loading_older)
891        {
892            self.load_messages(id, false);
893        }
894    }
895
896    fn open_compose(&mut self) {
897        let Some(chat) = self.selected_row().map(|row| row.id.clone()) else {
898            return;
899        };
900        if !self.can_send() {
901            self.notice = Notice::new(
902                Tone::Error,
903                "This profile is read-only; sending needs a profile created with `teams init --backend desktop --allow-send`.",
904            );
905            return;
906        }
907        let text = self.drafts.remove(&chat).unwrap_or_default();
908        self.focus = Focus::Thread;
909        self.compose = Some(Compose {
910            chat,
911            cursor: text.len(),
912            text,
913            stage: Stage::Editing,
914        });
915    }
916
917    /// Every key but ctrl-c belongs to the composer while it is open.
918    fn compose_key(&mut self, key: KeyEvent) {
919        let control = key.modifiers.contains(KeyModifiers::CONTROL);
920        let Some(compose) = self.compose.as_mut() else {
921            return;
922        };
923        match compose.stage {
924            Stage::Sending => {}
925            Stage::Confirming => {
926                if matches!(key.code, KeyCode::Char('y' | 'Y')) {
927                    self.send();
928                } else {
929                    compose.stage = Stage::Editing;
930                    self.notice = Notice::new(Tone::Quiet, "Not sent; keep editing.");
931                }
932            }
933            Stage::Editing => match key.code {
934                KeyCode::Esc => {
935                    let compose = self.compose.take().expect("composer is open");
936                    if !compose.text.trim().is_empty() {
937                        self.drafts.insert(compose.chat, compose.text);
938                        self.notice = Notice::new(Tone::Quiet, "Draft kept; c continues it.");
939                    }
940                }
941                KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => compose.insert('\n'),
942                KeyCode::Char('j') if control => compose.insert('\n'),
943                KeyCode::Enter => {
944                    let length = compose.text.chars().count();
945                    if compose.text.trim().is_empty() {
946                        self.notice = Notice::new(Tone::Quiet, "Write something first.");
947                    } else if length > MAX_BODY {
948                        self.notice = Notice::new(
949                            Tone::Error,
950                            format!("Too long to send: {length} of at most {MAX_BODY} characters."),
951                        );
952                    } else {
953                        compose.stage = Stage::Confirming;
954                    }
955                }
956                KeyCode::Backspace => {
957                    if let Some(c) = compose.text[..compose.cursor].chars().next_back() {
958                        compose.cursor -= c.len_utf8();
959                        compose.text.remove(compose.cursor);
960                    }
961                }
962                KeyCode::Delete if compose.cursor < compose.text.len() => {
963                    compose.text.remove(compose.cursor);
964                }
965                KeyCode::Left => {
966                    if let Some(c) = compose.text[..compose.cursor].chars().next_back() {
967                        compose.cursor -= c.len_utf8();
968                    }
969                }
970                KeyCode::Right => {
971                    if let Some(c) = compose.text[compose.cursor..].chars().next() {
972                        compose.cursor += c.len_utf8();
973                    }
974                }
975                KeyCode::Home => compose.cursor = 0,
976                KeyCode::End => compose.cursor = compose.text.len(),
977                KeyCode::Char(c) if !control && c != '\0' => compose.insert(c),
978                _ => {}
979            },
980        }
981    }
982
983    fn send(&mut self) {
984        let Some(compose) = self.compose.as_mut() else {
985            return;
986        };
987        compose.stage = Stage::Sending;
988        let (chat, text) = (compose.chat.clone(), compose.text.clone());
989        self.notice = Notice::new(Tone::Quiet, "Sending…");
990        match &self.source {
991            Source::Live {
992                backend, profile, ..
993            } => {
994                let (backend, profile) = (backend.clone(), profile.clone());
995                let sender = self.outcomes.0.clone();
996                tokio::spawn(async move {
997                    let result = async {
998                        config::load(Some(&profile))?.1.require_writable()?;
999                        backend.send(Some(&chat), None, None, &text).await
1000                    }
1001                    .await;
1002                    let _ = sender.send(Outcome::Sent { chat, result });
1003                });
1004            }
1005            Source::Demo => {
1006                let message = json!({
1007                    "id": format!("{chat}/sent{}", self.frame),
1008                    "messageType": "message",
1009                    "createdDateTime": self.now.timestamp().to_string(),
1010                    "from": {"user": demo_person("me")},
1011                    "body": {"contentType": "text", "content": text},
1012                });
1013                if let Some(thread) = self.threads.get_mut(&chat) {
1014                    thread.items.insert(0, message.clone());
1015                }
1016                self.apply(Outcome::Sent {
1017                    chat,
1018                    result: Ok(message),
1019                });
1020            }
1021        }
1022    }
1023
1024    fn handle_key(&mut self, key: KeyEvent) -> Action {
1025        let control = key.modifiers.contains(KeyModifiers::CONTROL);
1026        if control && key.code == KeyCode::Char('c') {
1027            return Action::Quit;
1028        }
1029        if self.compose.is_some() {
1030            self.compose_key(key);
1031            return Action::None;
1032        }
1033        match self.overlay {
1034            Some(Overlay::Help) => {
1035                if matches!(
1036                    key.code,
1037                    KeyCode::Char('?' | 'q') | KeyCode::Esc | KeyCode::Enter
1038                ) {
1039                    self.overlay = None;
1040                }
1041                return Action::None;
1042            }
1043            Some(Overlay::Filter) => {
1044                match key.code {
1045                    KeyCode::Esc => {
1046                        self.filter.clear();
1047                        self.overlay = None;
1048                        self.rebuild(None);
1049                    }
1050                    KeyCode::Enter => self.overlay = None,
1051                    KeyCode::Backspace => {
1052                        self.filter.pop();
1053                        self.rebuild(None);
1054                    }
1055                    KeyCode::Down => self.select(self.list.selected().map_or(0, |i| i + 1)),
1056                    KeyCode::Up => self.select(self.list.selected().unwrap_or(0).saturating_sub(1)),
1057                    KeyCode::Char(c) if !control => {
1058                        self.filter.push(c);
1059                        self.rebuild(None);
1060                    }
1061                    _ => {}
1062                }
1063                return Action::None;
1064            }
1065            None => {}
1066        }
1067        let half = (self.thread_height / 2).max(1);
1068        let in_thread = self.focus == Focus::Thread;
1069        match key.code {
1070            KeyCode::Char('q') => return Action::Quit,
1071            KeyCode::Char('?') => self.overlay = Some(Overlay::Help),
1072            KeyCode::Char('/') => {
1073                self.focus = Focus::Chats;
1074                self.overlay = Some(Overlay::Filter);
1075            }
1076            KeyCode::Char('r') => self.refresh(),
1077            KeyCode::Char('c') => self.open_compose(),
1078            KeyCode::Enter | KeyCode::Char('a') if self.sign_in_needed() => {
1079                return Action::Authenticate;
1080            }
1081            KeyCode::Tab | KeyCode::BackTab => {
1082                self.focus = if in_thread || self.selected_row().is_none() {
1083                    Focus::Chats
1084                } else {
1085                    Focus::Thread
1086                };
1087            }
1088            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
1089                if self.selected_row().is_some() {
1090                    self.focus = Focus::Thread;
1091                    self.open_selected();
1092                }
1093            }
1094            KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => {
1095                if in_thread {
1096                    self.focus = Focus::Chats;
1097                } else if !self.filter.is_empty() && key.code == KeyCode::Esc {
1098                    self.filter.clear();
1099                    self.rebuild(None);
1100                }
1101            }
1102            KeyCode::Char('d') if control => self.scroll = self.scroll.saturating_sub(half),
1103            KeyCode::Char('u') if control => self.scroll += half,
1104            KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(half),
1105            KeyCode::PageUp => self.scroll += half,
1106            KeyCode::Down | KeyCode::Char('j') if in_thread => {
1107                self.scroll = self.scroll.saturating_sub(1)
1108            }
1109            KeyCode::Up | KeyCode::Char('k') if in_thread => self.scroll += 1,
1110            KeyCode::Down | KeyCode::Char('j') => {
1111                self.select(self.list.selected().map_or(0, |i| i + 1))
1112            }
1113            KeyCode::Up | KeyCode::Char('k') => {
1114                self.select(self.list.selected().unwrap_or(0).saturating_sub(1))
1115            }
1116            KeyCode::Home | KeyCode::Char('g') if in_thread => self.scroll = usize::MAX,
1117            KeyCode::End | KeyCode::Char('G') if in_thread => self.scroll = 0,
1118            KeyCode::Home | KeyCode::Char('g') => self.select(0),
1119            KeyCode::End | KeyCode::Char('G') => self.select(usize::MAX),
1120            KeyCode::Char('o') => self.load_older(true),
1121            _ => {}
1122        }
1123        Action::None
1124    }
1125
1126    /// Reads the page before the oldest loaded message; `announce` also says when
1127    /// there is none, which the wheel stays quiet about.
1128    fn load_older(&mut self, announce: bool) {
1129        let Some(id) = self.selected_row().map(|row| row.id.clone()) else {
1130            return;
1131        };
1132        match self.threads.get(&id) {
1133            Some(thread) if thread.loading_older => {}
1134            Some(thread) if thread.cursor.is_some() => {
1135                self.notice = Notice::new(Tone::Quiet, "Reading older messages…");
1136                self.load_messages(id, true);
1137            }
1138            _ if announce => {
1139                self.notice = Notice::new(Tone::Quiet, "No older messages to load.");
1140            }
1141            _ => {}
1142        }
1143    }
1144
1145    /// The wheel scrolls whichever pane is under the pointer, and a click picks a
1146    /// chat or focuses the conversation. The composer keeps its chat, so clicks on
1147    /// the ledger wait until it is closed.
1148    fn handle_mouse(&mut self, mouse: MouseEvent) {
1149        let over = |area: Option<Rect>| {
1150            area.is_some_and(|a| {
1151                a.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
1152            })
1153        };
1154        if self.overlay == Some(Overlay::Help) {
1155            if matches!(mouse.kind, MouseEventKind::Down(_)) {
1156                self.overlay = None;
1157            }
1158            return;
1159        }
1160        let in_thread = over(self.thread_area);
1161        let in_ledger = over(self.ledger_area);
1162        match mouse.kind {
1163            MouseEventKind::ScrollUp if in_thread => {
1164                if self.scroll >= self.thread_overflow {
1165                    self.load_older(false);
1166                } else {
1167                    self.scroll = (self.scroll + WHEEL_LINES).min(self.thread_overflow);
1168                }
1169            }
1170            MouseEventKind::ScrollDown if in_thread => {
1171                self.scroll = self.scroll.saturating_sub(WHEEL_LINES);
1172            }
1173            MouseEventKind::ScrollUp if in_ledger && self.compose.is_none() => {
1174                self.select(self.list.selected().unwrap_or(0).saturating_sub(1));
1175            }
1176            MouseEventKind::ScrollDown if in_ledger && self.compose.is_none() => {
1177                self.select(self.list.selected().map_or(0, |i| i + 1));
1178            }
1179            MouseEventKind::Down(MouseButton::Left) if in_thread => {
1180                if self.selected_row().is_some() {
1181                    self.focus = Focus::Thread;
1182                }
1183            }
1184            MouseEventKind::Down(MouseButton::Left) if in_ledger && self.compose.is_none() => {
1185                let Some(area) = self.ledger_area else {
1186                    return;
1187                };
1188                let row = usize::from((mouse.row - area.y) / CHAT_ROW_HEIGHT);
1189                let index = self.list.offset() + row;
1190                if index < self.visible.len() {
1191                    self.select(index);
1192                    self.focus = Focus::Thread;
1193                    self.open_selected();
1194                }
1195            }
1196            _ => {}
1197        }
1198    }
1199
1200    fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
1201        let area = frame.area();
1202        let theme = self.theme;
1203        self.ledger_area = None;
1204        self.thread_area = None;
1205        frame.render_widget(Block::default().style(theme.canvas()), area);
1206        if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
1207            let message = Paragraph::new(vec![
1208                Line::styled("NEEDS MORE ROOM", theme.title()),
1209                Line::styled(
1210                    format!("Make the terminal at least {MIN_WIDTH}×{MIN_HEIGHT}."),
1211                    theme.muted(),
1212                ),
1213            ])
1214            .alignment(Alignment::Center)
1215            .wrap(Wrap { trim: true });
1216            frame.render_widget(message, centered(area, area.width, 2));
1217            return;
1218        }
1219        let [header, body, footer] = Layout::vertical([
1220            Constraint::Length(3),
1221            Constraint::Min(4),
1222            Constraint::Length(3),
1223        ])
1224        .areas(area);
1225        self.draw_header(frame, header);
1226        match &self.chats_load {
1227            Load::Ready => {
1228                if body.width >= SPLIT_WIDTH {
1229                    let ledger = (body.width * 38 / 100).clamp(34, 48);
1230                    let [left, right] =
1231                        Layout::horizontal([Constraint::Length(ledger), Constraint::Min(30)])
1232                            .areas(body);
1233                    self.draw_ledger(frame, left, true);
1234                    self.draw_thread(frame, right);
1235                } else if self.focus == Focus::Thread {
1236                    self.draw_thread(frame, body);
1237                } else {
1238                    self.draw_ledger(frame, body, false);
1239                }
1240            }
1241            Load::Loading => state_panel(
1242                frame,
1243                body,
1244                theme,
1245                "READING CHATS",
1246                vec![Line::styled(
1247                    format!("{}  Asking Teams for your recent chats", self.spinner()),
1248                    theme.muted(),
1249                )],
1250                theme.rule(),
1251            ),
1252            Load::Failed { message, auth } => {
1253                let (title, lines) = failure_copy(theme, message, *auth);
1254                state_panel(frame, body, theme, title, lines, theme.active_rule());
1255            }
1256        }
1257        self.draw_footer(frame, footer);
1258        if self.overlay == Some(Overlay::Help) {
1259            self.draw_help(frame);
1260        }
1261    }
1262
1263    fn spinner(&self) -> &'static str {
1264        SPINNER[self.frame % SPINNER.len()]
1265    }
1266
1267    fn draw_header(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
1268        let theme = self.theme;
1269        let width = usize::from(area.width);
1270        let spans_width = |spans: &[Span]| spans.iter().map(Span::width).sum::<usize>();
1271        let separator = || Span::styled("  ·  ", theme.rule());
1272
1273        // Right: the send mode always shows, with a one-column margin like the left.
1274        let mut right = if self.can_send() {
1275            vec![Span::styled("✎ can send", theme.badge_send())]
1276        } else {
1277            vec![Span::styled("read-only", theme.muted())]
1278        };
1279        right.push(Span::raw(" "));
1280        let brand = Span::styled(" TEAMS ", theme.brand());
1281        let fixed = brand.width() + 2 + spans_width(&right) + 2;
1282        let profile_width = self.profile().width();
1283        // Then freshness, as long as some of the profile name still fits beside it.
1284        if let Some(mut freshness) = self.freshness() {
1285            let needed = spans_width(&freshness) + 3;
1286            if fixed + needed + profile_width.min(10) <= width {
1287                freshness.push(Span::raw("   "));
1288                freshness.extend(right);
1289                right = freshness;
1290            }
1291        }
1292        let used = brand.width() + 2 + spans_width(&right) + 2;
1293        let mut left = vec![
1294            brand,
1295            Span::raw("  "),
1296            Span::styled(
1297                render::truncate(self.profile(), width.saturating_sub(used)),
1298                theme.title(),
1299            ),
1300        ];
1301        // Last, the signed-in account next to the profile when there is room.
1302        if let Some(account) = &self.account
1303            && used + profile_width + 5 + account.width() <= width
1304        {
1305            left.push(separator());
1306            left.push(Span::styled(account.clone(), theme.muted()));
1307        }
1308        let gap = width.saturating_sub(spans_width(&left) + spans_width(&right));
1309        let mut title = left;
1310        title.push(Span::raw(" ".repeat(gap)));
1311        title.extend(right);
1312
1313        let mut context = vec![Span::raw(" ")];
1314        for (index, part) in self.context().into_iter().enumerate() {
1315            let extra = if index == 0 { 0 } else { 5 };
1316            if spans_width(&context) + extra + spans_width(&part) + 1 > width {
1317                break;
1318            }
1319            if index > 0 {
1320                context.push(separator());
1321            }
1322            context.extend(part);
1323        }
1324        let header = Paragraph::new(vec![Line::from(title), Line::from(context)]).block(
1325            Block::default()
1326                .borders(Borders::BOTTOM)
1327                .border_style(theme.rule()),
1328        );
1329        frame.render_widget(header, area);
1330    }
1331
1332    fn draw_ledger(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect, split: bool) {
1333        let theme = self.theme;
1334        let active = self.focus == Focus::Chats || !split;
1335        let block = Block::default()
1336            .borders(if split { Borders::RIGHT } else { Borders::NONE })
1337            .border_style(if active {
1338                theme.active_rule()
1339            } else {
1340                theme.rule()
1341            });
1342        let inner = block.inner(area);
1343        frame.render_widget(block, area);
1344        let [heading, list_area] =
1345            Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
1346        let label = if self.filter.is_empty() {
1347            " RECENT".to_string()
1348        } else {
1349            format!(" MATCHING “{}”", self.filter)
1350        };
1351        let count = format!("{} ", self.visible.len());
1352        let gap = usize::from(heading.width).saturating_sub(label.width() + count.width());
1353        frame.render_widget(
1354            Paragraph::new(Line::from(vec![
1355                Span::styled(label, theme.column_header()),
1356                Span::styled(" ".repeat(gap), theme.column_header()),
1357                Span::styled(count, theme.column_header()),
1358            ])),
1359            heading,
1360        );
1361        if self.visible.is_empty() {
1362            let text = if self.filter.is_empty() {
1363                vec![
1364                    Line::styled("No chats yet", theme.title()),
1365                    Line::styled("Teams has nothing cached for this profile.", theme.muted()),
1366                ]
1367            } else {
1368                vec![
1369                    Line::styled("Nothing matches", theme.title()),
1370                    Line::styled("esc clears the filter", theme.muted()),
1371                ]
1372            };
1373            frame.render_widget(
1374                Paragraph::new(text)
1375                    .alignment(Alignment::Center)
1376                    .wrap(Wrap { trim: true }),
1377                centered(list_area, list_area.width, 2),
1378            );
1379            return;
1380        }
1381        let width = usize::from(list_area.width);
1382        let selected = self.list.selected();
1383        let mut items: Vec<ListItem> = self
1384            .visible
1385            .iter()
1386            .enumerate()
1387            .map(|(position, &i)| {
1388                chat_item(
1389                    &self.rows[i],
1390                    width,
1391                    selected == Some(position),
1392                    active,
1393                    theme,
1394                )
1395            })
1396            .collect();
1397        if self.chat_cursor.is_some() {
1398            items.push(ListItem::new(Line::styled(
1399                if self.loading_more {
1400                    format!("   {} reading more chats", self.spinner())
1401                } else {
1402                    "   more chats load as you reach the end".into()
1403                },
1404                theme.muted(),
1405            )));
1406        }
1407        frame.render_stateful_widget(List::new(items), list_area, &mut self.list);
1408        self.ledger_area = Some(list_area);
1409    }
1410
1411    fn draw_thread(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
1412        let theme = self.theme;
1413        self.thread_area = Some(area);
1414        self.thread_overflow = 0;
1415        let area = Rect {
1416            x: area.x + 2,
1417            width: area.width.saturating_sub(3),
1418            ..area
1419        };
1420        let Some(row) = self.selected_row() else {
1421            frame.render_widget(
1422                Paragraph::new(vec![
1423                    Line::styled("No chat selected", theme.title()),
1424                    Line::styled("Pick a chat on the left to read it here.", theme.muted()),
1425                ])
1426                .alignment(Alignment::Center),
1427                centered(area, area.width, 2),
1428            );
1429            return;
1430        };
1431        let [heading, body] =
1432            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(area);
1433        let width = usize::from(heading.width);
1434        let kind = match (row.kind, row.members) {
1435            (Some(kind), n) if n > 0 => format!("{kind} chat  ·  {n} people"),
1436            (Some(kind), _) => format!("{kind} chat"),
1437            (None, _) => "one-to-one chat".into(),
1438        };
1439        let mut meta = vec![Span::styled(kind, theme.muted())];
1440        if self.scroll > 0 {
1441            meta.push(Span::styled("  ·  ", theme.muted()));
1442            meta.push(Span::styled("G", theme.key()));
1443            meta.push(Span::styled(" newest", theme.muted()));
1444        }
1445        frame.render_widget(
1446            Paragraph::new(vec![
1447                Line::styled(render::truncate(&row.title, width), theme.detail_title()),
1448                Line::from(meta),
1449            ])
1450            .block(Block::default().borders(Borders::BOTTOM).border_style(
1451                if self.focus == Focus::Thread {
1452                    theme.active_rule()
1453                } else {
1454                    theme.rule()
1455                },
1456            )),
1457            heading,
1458        );
1459        let mut body = Rect {
1460            y: body.y + 1,
1461            height: body.height.saturating_sub(1),
1462            ..body
1463        };
1464        if let Some(compose) = self.compose.as_ref().filter(|c| c.chat == row.id) {
1465            let (rows, (line, column)) =
1466                layout_draft(&compose.text, compose.cursor, usize::from(body.width));
1467            let shown = rows.len().min(COMPOSE_ROWS);
1468            let [conversation, editor] =
1469                Layout::vertical([Constraint::Min(1), Constraint::Length(shown as u16 + 1)])
1470                    .areas(body);
1471            body = conversation;
1472            let title = match compose.stage {
1473                Stage::Editing => " MESSAGE ",
1474                Stage::Confirming => " SEND THIS? ",
1475                Stage::Sending => " SENDING ",
1476            };
1477            let block = Block::default()
1478                .borders(Borders::TOP)
1479                .border_style(theme.active_rule())
1480                .title(Span::styled(title, theme.column_header()));
1481            let inner = block.inner(editor);
1482            frame.render_widget(block, editor);
1483            let start = (line + 1).saturating_sub(COMPOSE_ROWS);
1484            let lines: Vec<Line> = if compose.text.is_empty() {
1485                vec![Line::styled("Write a message…", theme.muted())]
1486            } else {
1487                rows.into_iter()
1488                    .skip(start)
1489                    .take(COMPOSE_ROWS)
1490                    .map(|row| Line::styled(row, theme.body()))
1491                    .collect()
1492            };
1493            frame.render_widget(Paragraph::new(lines), inner);
1494            if compose.stage == Stage::Editing {
1495                let x = inner.x + (column as u16).min(inner.width.saturating_sub(1));
1496                frame.set_cursor_position((x, inner.y + (line - start) as u16));
1497            }
1498        }
1499        let id = row.id.clone();
1500        self.thread_height = usize::from(body.height);
1501        let Some(thread) = self.threads.get(&id) else {
1502            frame.render_widget(
1503                Paragraph::new(Line::styled("…", theme.muted())).alignment(Alignment::Center),
1504                centered(body, body.width, 1),
1505            );
1506            return;
1507        };
1508        match &thread.load {
1509            Load::Loading => {
1510                frame.render_widget(
1511                    Paragraph::new(Line::styled(
1512                        format!("{}  Reading messages", self.spinner()),
1513                        theme.muted(),
1514                    ))
1515                    .alignment(Alignment::Center),
1516                    centered(body, body.width, 1),
1517                );
1518                return;
1519            }
1520            Load::Failed { message, auth } => {
1521                let (title, lines) = failure_copy(theme, message, *auth);
1522                let mut text = vec![Line::styled(title, theme.error()), Line::raw("")];
1523                text.extend(lines);
1524                frame.render_widget(
1525                    Paragraph::new(text)
1526                        .alignment(Alignment::Center)
1527                        .wrap(Wrap { trim: true }),
1528                    centered(body, body.width, 6),
1529                );
1530                return;
1531            }
1532            Load::Ready => {}
1533        }
1534        let entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
1535        let mut lines = Vec::new();
1536        if thread.loading_older {
1537            lines.push(
1538                Line::styled(
1539                    format!("{}  reading older messages", self.spinner()),
1540                    theme.muted(),
1541                )
1542                .alignment(Alignment::Center),
1543            );
1544        } else if thread.cursor.is_some() {
1545            lines.push(
1546                Line::from(vec![
1547                    Span::styled("older messages  ·  ", theme.muted()),
1548                    Span::styled("o", theme.key()),
1549                    Span::styled(" load", theme.muted()),
1550                ])
1551                .alignment(Alignment::Center),
1552            );
1553        }
1554        if entries.is_empty() {
1555            lines.push(Line::raw(""));
1556            lines.push(Line::styled("No messages in this chat yet.", theme.muted()).centered());
1557        }
1558        lines.extend(conversation_lines(&entries, usize::from(body.width), theme));
1559        let height = usize::from(body.height);
1560        let overflow = lines.len().saturating_sub(height);
1561        self.thread_overflow = overflow;
1562        self.scroll = self.scroll.min(overflow);
1563        let start = overflow - self.scroll;
1564        let mut shown: Vec<Line> = lines.into_iter().skip(start).take(height).collect();
1565        // Short conversations sit at the bottom, next to where new messages arrive.
1566        let pad = height.saturating_sub(shown.len());
1567        shown.splice(0..0, std::iter::repeat_n(Line::raw(""), pad));
1568        frame.render_widget(Paragraph::new(shown), body);
1569    }
1570
1571    fn draw_footer(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
1572        let theme = self.theme;
1573        let block = Block::default()
1574            .borders(Borders::TOP)
1575            .border_style(theme.rule());
1576        let inner = block.inner(area);
1577        frame.render_widget(block, area);
1578        let [status, keys] =
1579            Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
1580        if self.overlay == Some(Overlay::Filter) {
1581            let prompt = Line::from(vec![
1582                Span::styled("› filter ", theme.key()),
1583                Span::styled(self.filter.clone(), theme.body()),
1584            ]);
1585            frame.render_widget(Paragraph::new(prompt), status);
1586            let x = status.x + 9 + self.filter.width() as u16;
1587            frame.set_cursor_position((x.min(status.right().saturating_sub(1)), status.y));
1588        } else if let Some(compose) = self
1589            .compose
1590            .as_ref()
1591            .filter(|c| c.stage == Stage::Confirming)
1592        {
1593            let title = self
1594                .rows
1595                .iter()
1596                .find(|row| row.id == compose.chat)
1597                .map_or("this chat", |row| row.title.as_str());
1598            let prompt =
1599                format!("Send to {title} as you?  y sends  ·  any other key keeps editing");
1600            frame.render_widget(
1601                Paragraph::new(Line::styled(
1602                    render::truncate(&prompt, usize::from(status.width)),
1603                    theme.accent(),
1604                )),
1605                status,
1606            );
1607        } else {
1608            let style = match self.notice.tone {
1609                Tone::Quiet => theme.muted(),
1610                Tone::Success => theme.success(),
1611                Tone::Error => theme.error(),
1612            };
1613            frame.render_widget(
1614                Paragraph::new(Line::styled(
1615                    render::truncate(&self.notice.text, usize::from(status.width)),
1616                    style,
1617                )),
1618                status,
1619            );
1620        }
1621        frame.render_widget(Paragraph::new(self.hints(usize::from(keys.width))), keys);
1622    }
1623
1624    /// Key hints for the current context, dropping the least important ones
1625    /// first when the terminal is narrow. Lower rank means more important.
1626    fn hints(&self, width: usize) -> Line<'static> {
1627        let mut all: Vec<(&str, &str, u8)> = if let Some(compose) = &self.compose {
1628            match compose.stage {
1629                Stage::Editing => vec![
1630                    ("enter", "send", 0),
1631                    ("ctrl-j", "new line", 1),
1632                    ("esc", "close, keep draft", 0),
1633                ],
1634                Stage::Confirming => vec![("y", "send", 0), ("any key", "keep editing", 0)],
1635                Stage::Sending => vec![("ctrl-c", "quit", 1)],
1636            }
1637        } else if self.overlay == Some(Overlay::Filter) {
1638            vec![("enter", "keep", 0), ("esc", "clear", 0), ("↑↓", "move", 1)]
1639        } else if self.sign_in_needed() {
1640            vec![
1641                ("enter / a", "sign in", 0),
1642                ("r", "retry", 1),
1643                ("q", "quit", 0),
1644            ]
1645        } else if self.focus == Focus::Thread {
1646            vec![
1647                ("↑↓", "scroll", 1),
1648                ("pgup/pgdn", "page", 2),
1649                ("o", "older", 1),
1650                ("esc", "chats", 0),
1651                ("r", "refresh", 2),
1652                ("?", "help", 1),
1653                ("q", "quit", 0),
1654            ]
1655        } else {
1656            vec![
1657                ("↑↓", "move", 1),
1658                ("enter", "read", 0),
1659                ("/", "filter", 1),
1660                ("tab", "switch pane", 3),
1661                ("r", "refresh", 2),
1662                ("?", "help", 1),
1663                ("q", "quit", 0),
1664            ]
1665        };
1666        if self.compose.is_none()
1667            && self.overlay.is_none()
1668            && !self.sign_in_needed()
1669            && self.can_send()
1670        {
1671            let rank = if self.focus == Focus::Thread { 0 } else { 2 };
1672            all.insert(all.len() - 3, ("c", "reply", rank));
1673        }
1674        let mut chosen = vec![false; all.len()];
1675        let mut used = 0;
1676        for rank in 0..=3 {
1677            for (i, (key, description, r)) in all.iter().enumerate() {
1678                let need = key.width() + 1 + description.width() + 3;
1679                if *r == rank && used + need <= width {
1680                    chosen[i] = true;
1681                    used += need;
1682                }
1683            }
1684        }
1685        let mut spans = Vec::new();
1686        for (i, (key, description, _)) in all.into_iter().enumerate() {
1687            if chosen[i] {
1688                if !spans.is_empty() {
1689                    spans.push(Span::raw("   "));
1690                }
1691                spans.push(Span::styled(key.to_string(), self.theme.key()));
1692                spans.push(Span::styled(format!(" {description}"), self.theme.muted()));
1693            }
1694        }
1695        Line::from(spans)
1696    }
1697
1698    fn draw_help(&self, frame: &mut ratatui::Frame<'_>) {
1699        let theme = self.theme;
1700        let rows = [
1701            (
1702                "↑ / k, ↓ / j",
1703                "Move through chats, or scroll a conversation",
1704            ),
1705            ("enter / → / l", "Read the selected chat"),
1706            ("esc / ← / h", "Back to the chat list"),
1707            ("tab", "Switch between the list and the conversation"),
1708            ("pgup / pgdn", "Scroll the conversation by half a screen"),
1709            ("g / G", "First / last chat, or oldest / newest message"),
1710            ("o", "Load older messages in this chat"),
1711            (
1712                "wheel",
1713                "Scroll the pane under the pointer; older messages load at the top",
1714            ),
1715            ("click", "Open a chat, or focus the conversation"),
1716            (
1717                "shift-drag",
1718                "Select text to copy, while the mouse is in use here",
1719            ),
1720            ("/", "Filter chats by name or last message"),
1721            ("c", "Write a message in the open chat"),
1722            ("r", "Refresh now; chats also refresh on their own"),
1723            ("enter / a", "Sign in when the session needs attention"),
1724            ("q / ctrl-c", "Return to the shell"),
1725        ];
1726        let mut lines = vec![
1727            Line::styled(
1728                "Read your Teams chats without leaving the terminal",
1729                theme.muted(),
1730            ),
1731            Line::raw(""),
1732        ];
1733        for (key, description) in rows {
1734            lines.push(Line::from(vec![
1735                Span::styled(format!("{key:<16}"), theme.key()),
1736                Span::styled(description, theme.body()),
1737            ]));
1738        }
1739        lines.push(Line::raw(""));
1740        lines.push(Line::styled(
1741            if self.can_send() {
1742                "Nothing is sent until you confirm with y; nothing is edited or marked read."
1743            } else {
1744                "Read-only: nothing here sends, edits or marks messages as read."
1745            },
1746            theme.success(),
1747        ));
1748        let height = lines.len() as u16 + 2;
1749        let area = centered(frame.area(), 70, height);
1750        frame.render_widget(Clear, area);
1751        frame.render_widget(
1752            Paragraph::new(lines).wrap(Wrap { trim: false }).block(
1753                Block::default()
1754                    .title(" KEYBOARD MAP  ·  ? OR ESC TO CLOSE ")
1755                    .title_style(theme.title())
1756                    .borders(Borders::ALL)
1757                    .border_style(theme.active_rule())
1758                    .padding(Padding::horizontal(1))
1759                    .style(theme.canvas()),
1760            ),
1761            area,
1762        );
1763    }
1764}
1765
1766fn chat_item(
1767    row: &ChatRow,
1768    width: usize,
1769    selected: bool,
1770    active: bool,
1771    theme: Theme,
1772) -> ListItem<'static> {
1773    let marker = if selected {
1774        Span::styled(
1775            "▌",
1776            if active {
1777                theme.active_rule()
1778            } else {
1779                theme.muted()
1780            },
1781        )
1782    } else {
1783        Span::raw(" ")
1784    };
1785    let dot = if row.unread {
1786        Span::styled("● ", theme.unread())
1787    } else {
1788        Span::raw("  ")
1789    };
1790    let kind = row
1791        .kind
1792        .map(|kind| format!(" · {kind}"))
1793        .unwrap_or_default();
1794    let time = format!("{} ", row.time);
1795    let room = width.saturating_sub(4 + time.width() + 1);
1796    // The kind is a hint; when space is short the name wins.
1797    let kind = if row.title.width() + kind.width() <= room {
1798        kind
1799    } else {
1800        String::new()
1801    };
1802    let title = render::truncate(&row.title, room);
1803    let gap = width.saturating_sub(4 + title.width() + kind.width() + time.width());
1804    let title_style = if selected && active {
1805        theme.accent()
1806    } else if row.unread {
1807        theme.title()
1808    } else {
1809        theme.body()
1810    };
1811    let first = Line::from(vec![
1812        marker.clone(),
1813        Span::raw(" "),
1814        dot,
1815        Span::styled(title, title_style),
1816        Span::styled(kind, theme.muted()),
1817        Span::raw(" ".repeat(gap)),
1818        Span::styled(
1819            time,
1820            if row.unread {
1821                theme.unread()
1822            } else {
1823                theme.muted()
1824            },
1825        ),
1826    ]);
1827    let preview = row.preview.as_deref().unwrap_or("");
1828    let second = Line::from(vec![
1829        marker,
1830        Span::raw("   "),
1831        Span::styled(
1832            render::truncate(preview, width.saturating_sub(5)),
1833            if row.unread {
1834                theme.body()
1835            } else {
1836                theme.muted()
1837            },
1838        ),
1839    ]);
1840    let item = ListItem::new(vec![first, second, Line::raw("")]);
1841    if selected {
1842        item.style(theme.selection())
1843    } else {
1844        item
1845    }
1846}
1847
1848/// Wraps a draft to `width` columns, honouring line breaks, and finds the row and
1849/// column of the byte offset `cursor`.
1850fn layout_draft(text: &str, cursor: usize, width: usize) -> (Vec<String>, (usize, usize)) {
1851    let width = width.max(1);
1852    let mut rows = vec![String::new()];
1853    let mut column = 0;
1854    let mut at = None;
1855    for (i, c) in text.char_indices() {
1856        if c == '\n' {
1857            if i == cursor {
1858                at = Some((rows.len() - 1, column));
1859            }
1860            rows.push(String::new());
1861            column = 0;
1862            continue;
1863        }
1864        let w = c.width().unwrap_or(0);
1865        if column + w > width {
1866            rows.push(String::new());
1867            column = 0;
1868        }
1869        if i == cursor {
1870            at = Some((rows.len() - 1, column));
1871        }
1872        rows.last_mut().expect("never empty").push(c);
1873        column += w;
1874    }
1875    let at = at.unwrap_or((rows.len() - 1, column));
1876    (rows, at)
1877}
1878
1879/// Puts a fresh first page ahead of what was loaded before. Older pages are kept, with
1880/// their cursor, only when the fresh page overlaps them, so a burst of new items never
1881/// leaves a silent gap.
1882fn merge_newest(
1883    fresh: Vec<Value>,
1884    cursor: Option<String>,
1885    old: Vec<Value>,
1886    old_cursor: Option<String>,
1887) -> (Vec<Value>, Option<String>) {
1888    let ids: HashSet<&str> = fresh.iter().filter_map(|v| v["id"].as_str()).collect();
1889    let overlaps = old
1890        .iter()
1891        .any(|v| v["id"].as_str().is_some_and(|id| ids.contains(id)));
1892    if cursor.is_none() || !overlaps {
1893        return (fresh, cursor);
1894    }
1895    let rest: Vec<Value> = old
1896        .into_iter()
1897        .filter(|v| !v["id"].as_str().is_some_and(|id| ids.contains(id)))
1898        .collect();
1899    let cursor = if rest.is_empty() { cursor } else { old_cursor };
1900    let mut items = fresh;
1901    items.extend(rest);
1902    (items, cursor)
1903}
1904
1905/// Lays a conversation out for a pane: centred day rules, coloured senders,
1906/// indented and wrapped bodies, quotes dimmed.
1907fn conversation_lines(entries: &[Entry], width: usize, theme: Theme) -> Vec<Line<'static>> {
1908    let mut lines: Vec<Line> = Vec::new();
1909    let mut after_day = false;
1910    for entry in entries {
1911        match entry {
1912            Entry::Day(day) => {
1913                if !lines.is_empty() {
1914                    lines.push(Line::raw(""));
1915                }
1916                let label = format!(" {day} ");
1917                let side = width.saturating_sub(label.width()) / 2;
1918                let rest = width.saturating_sub(label.width() + side);
1919                lines.push(
1920                    Line::from(vec![
1921                        Span::styled("─".repeat(side.min(24)), theme.rule()),
1922                        Span::styled(label, theme.muted()),
1923                        Span::styled("─".repeat(rest.min(24)), theme.rule()),
1924                    ])
1925                    .centered(),
1926                );
1927                after_day = true;
1928            }
1929            Entry::Sender { name, time, own } => {
1930                if !after_day {
1931                    lines.push(Line::raw(""));
1932                }
1933                after_day = false;
1934                let mut spans = vec![Span::styled(
1935                    render::truncate(name, width.saturating_sub(8)),
1936                    theme.sender(name, *own),
1937                )];
1938                if let Some(time) = time {
1939                    spans.push(Span::styled(format!("  {time}"), theme.muted()));
1940                }
1941                lines.push(Line::from(spans));
1942            }
1943            Entry::Text(text) if text.is_empty() => lines.push(Line::raw("")),
1944            Entry::Text(text) => {
1945                let style = if text.starts_with('>') {
1946                    theme.quote()
1947                } else {
1948                    theme.body()
1949                };
1950                lines.extend(
1951                    render::wrap(text, width.saturating_sub(2))
1952                        .into_iter()
1953                        .map(|line| Line::from(vec![Span::raw("  "), Span::styled(line, style)])),
1954                );
1955            }
1956        }
1957    }
1958    lines
1959}
1960
1961fn failure_copy(theme: Theme, message: &str, auth: bool) -> (&'static str, Vec<Line<'static>>) {
1962    if auth {
1963        (
1964            "SIGN-IN REQUIRED",
1965            vec![
1966                Line::styled("Your Teams session needs attention.", theme.body()),
1967                Line::styled(message.to_string(), theme.muted()),
1968                Line::raw(""),
1969                Line::from(vec![
1970                    Span::styled("enter / a", theme.key()),
1971                    Span::styled("  sign in   ", theme.muted()),
1972                    Span::styled("q", theme.key()),
1973                    Span::styled("  quit", theme.muted()),
1974                ]),
1975            ],
1976        )
1977    } else {
1978        (
1979            "COULD NOT READ TEAMS",
1980            vec![
1981                Line::styled(message.to_string(), theme.body()),
1982                Line::raw(""),
1983                Line::from(vec![
1984                    Span::styled("r", theme.key()),
1985                    Span::styled("  try again   ", theme.muted()),
1986                    Span::styled("q", theme.key()),
1987                    Span::styled("  quit", theme.muted()),
1988                ]),
1989            ],
1990        )
1991    }
1992}
1993
1994fn state_panel(
1995    frame: &mut ratatui::Frame<'_>,
1996    area: Rect,
1997    theme: Theme,
1998    title: &str,
1999    lines: Vec<Line<'static>>,
2000    border: Style,
2001) {
2002    let width = area.width.saturating_sub(4).min(72);
2003    let room = usize::from(width.saturating_sub(4)).max(1);
2004    let rows: usize = lines
2005        .iter()
2006        .map(|line| line.width().div_ceil(room).max(1))
2007        .sum();
2008    let panel = centered(area, width, rows as u16 + 4);
2009    frame.render_widget(
2010        Paragraph::new(lines)
2011            .alignment(Alignment::Center)
2012            .wrap(Wrap { trim: true })
2013            .block(
2014                Block::default()
2015                    .title(format!(" {title} "))
2016                    .title_style(theme.title())
2017                    .borders(Borders::ALL)
2018                    .border_style(border)
2019                    .padding(Padding::uniform(1)),
2020            ),
2021        panel,
2022    );
2023}
2024
2025fn centered(area: Rect, width: u16, height: u16) -> Rect {
2026    let width = width.min(area.width);
2027    let height = height.min(area.height);
2028    Rect {
2029        x: area.x + (area.width - width) / 2,
2030        y: area.y + (area.height - height) / 2,
2031        width,
2032        height,
2033    }
2034}
2035
2036/// A Teams-like indigo palette. Every style collapses to the terminal
2037/// default when colour is off, so structure carries the meaning.
2038#[derive(Clone, Copy)]
2039struct Theme {
2040    color: bool,
2041}
2042
2043impl Theme {
2044    fn style(self, foreground: Color, background: Color) -> Style {
2045        if self.color {
2046            Style::default().fg(foreground).bg(background)
2047        } else {
2048            Style::default()
2049        }
2050    }
2051
2052    fn fg(self, foreground: Color) -> Style {
2053        self.style(foreground, Color::Reset)
2054    }
2055
2056    fn canvas(self) -> Style {
2057        self.style(Color::Rgb(222, 225, 240), Color::Rgb(15, 16, 28))
2058    }
2059
2060    fn body(self) -> Style {
2061        self.fg(Color::Rgb(222, 225, 240))
2062    }
2063
2064    fn muted(self) -> Style {
2065        self.fg(Color::Rgb(128, 134, 168))
2066    }
2067
2068    fn quote(self) -> Style {
2069        self.muted().add_modifier(Modifier::ITALIC)
2070    }
2071
2072    fn brand(self) -> Style {
2073        self.style(Color::Rgb(15, 16, 28), Color::Rgb(123, 131, 235))
2074            .add_modifier(Modifier::BOLD)
2075    }
2076
2077    fn title(self) -> Style {
2078        self.fg(Color::Rgb(242, 243, 252))
2079            .add_modifier(Modifier::BOLD)
2080    }
2081
2082    fn detail_title(self) -> Style {
2083        self.fg(Color::Rgb(170, 177, 255))
2084            .add_modifier(Modifier::BOLD)
2085    }
2086
2087    fn accent(self) -> Style {
2088        self.fg(Color::Rgb(155, 162, 255))
2089            .add_modifier(Modifier::BOLD)
2090    }
2091
2092    fn column_header(self) -> Style {
2093        self.style(Color::Rgb(150, 156, 190), Color::Rgb(24, 26, 44))
2094            .add_modifier(Modifier::BOLD)
2095    }
2096
2097    fn key(self) -> Style {
2098        self.fg(Color::Rgb(246, 183, 82))
2099            .add_modifier(Modifier::BOLD)
2100    }
2101
2102    fn rule(self) -> Style {
2103        self.fg(Color::Rgb(50, 54, 86))
2104    }
2105
2106    fn active_rule(self) -> Style {
2107        self.fg(Color::Rgb(123, 131, 235))
2108    }
2109
2110    fn selection(self) -> Style {
2111        if self.color {
2112            Style::default().bg(Color::Rgb(30, 33, 60))
2113        } else {
2114            Style::default().add_modifier(Modifier::BOLD)
2115        }
2116    }
2117
2118    fn unread(self) -> Style {
2119        self.fg(Color::Rgb(255, 128, 112))
2120            .add_modifier(Modifier::BOLD)
2121    }
2122
2123    fn success(self) -> Style {
2124        self.fg(Color::Rgb(110, 220, 170))
2125    }
2126
2127    /// Writing is possible on this profile: warm, so it is noticed without alarm.
2128    fn badge_send(self) -> Style {
2129        self.fg(Color::Rgb(246, 183, 82))
2130            .add_modifier(Modifier::BOLD)
2131    }
2132
2133    /// A number worth scanning for in otherwise quiet text.
2134    fn figure(self) -> Style {
2135        self.body().add_modifier(Modifier::BOLD)
2136    }
2137
2138    fn unread_label(self) -> Style {
2139        self.fg(Color::Rgb(255, 128, 112))
2140    }
2141
2142    fn error(self) -> Style {
2143        self.fg(Color::Rgb(255, 126, 119))
2144            .add_modifier(Modifier::BOLD)
2145    }
2146
2147    /// The same person always gets the same colour; you get the accent.
2148    fn sender(self, name: &str, own: bool) -> Style {
2149        const PALETTE: [Color; 6] = [
2150            Color::Rgb(122, 200, 255),
2151            Color::Rgb(240, 160, 220),
2152            Color::Rgb(130, 220, 160),
2153            Color::Rgb(250, 200, 110),
2154            Color::Rgb(190, 160, 255),
2155            Color::Rgb(110, 215, 210),
2156        ];
2157        let color = if own {
2158            Color::Rgb(155, 162, 255)
2159        } else {
2160            let hash = name.bytes().fold(0usize, |h, b| {
2161                h.wrapping_mul(31).wrapping_add(usize::from(b))
2162            });
2163            PALETTE[hash % PALETTE.len()]
2164        };
2165        self.fg(color).add_modifier(Modifier::BOLD)
2166    }
2167}
2168
2169fn demo_time(now: &Zoned, minutes_ago: i64) -> String {
2170    now.checked_sub(minutes_ago.minutes())
2171        .unwrap_or_else(|_| now.clone())
2172        .timestamp()
2173        .to_string()
2174}
2175
2176const DEMO_PEOPLE: [(&str, &str); 7] = [
2177    ("me", "Alex Morgan"),
2178    ("mina", "Mina Okafor"),
2179    ("jon", "Jon Bakker"),
2180    ("ari", "Ari Chen"),
2181    ("priya", "Priya Raman"),
2182    ("sam", "Sam Rivera"),
2183    ("lee", "Lee Novak"),
2184];
2185
2186fn demo_person(id: &str) -> Value {
2187    let name = DEMO_PEOPLE
2188        .iter()
2189        .find(|(person, _)| *person == id)
2190        .map_or(id, |(_, name)| name);
2191    json!({"id": id, "displayName": name})
2192}
2193
2194/// (id, topic, type, members, unread) for each sample chat.
2195type DemoChat = (
2196    &'static str,
2197    Option<&'static str>,
2198    &'static str,
2199    &'static [&'static str],
2200    bool,
2201);
2202
2203const DEMO_CHATS: [DemoChat; 6] = [
2204    (
2205        "19:launch@thread.v2",
2206        Some("Launch room"),
2207        "group",
2208        &["me", "mina", "jon", "priya"],
2209        true,
2210    ),
2211    (
2212        "19:ari@unq.gbl.spaces",
2213        None,
2214        "oneOnOne",
2215        &["me", "ari"],
2216        true,
2217    ),
2218    (
2219        "19:design@thread.v2",
2220        Some("Design crit"),
2221        "group",
2222        &["me", "lee", "ari"],
2223        false,
2224    ),
2225    (
2226        "19:oncall@thread.v2",
2227        Some("Platform on-call"),
2228        "group",
2229        &["me", "priya", "jon", "sam", "lee"],
2230        false,
2231    ),
2232    (
2233        "19:weekly@thread.v2",
2234        Some("Weekly sync"),
2235        "meeting",
2236        &["me", "mina", "jon", "ari"],
2237        false,
2238    ),
2239    (
2240        "19:sam@unq.gbl.spaces",
2241        None,
2242        "oneOnOne",
2243        &["me", "sam"],
2244        false,
2245    ),
2246];
2247
2248/// (minutes ago, sender, html) per sample chat, newest last.
2249fn demo_script(chat: &str) -> &'static [(i64, &'static str, &'static str)] {
2250    match chat {
2251        "19:launch@thread.v2" => &[
2252            (
2253                1460,
2254                "priya",
2255                "<p>Staging is frozen for the launch. Ping me before any deploy.</p>",
2256            ),
2257            (1452, "me", "<p>Noted, thanks <at>Priya</at>.</p>"),
2258            (
2259                43,
2260                "jon",
2261                "<p>Docs are ready. One broken link remains in the migration guide.</p>",
2262            ),
2263            (
2264                41,
2265                "me",
2266                "<p>I’ll take it. Hold the announcement until I clear the link check.</p>",
2267            ),
2268            (
2269                40,
2270                "me",
2271                "<p>Fixed in <code>docs#412</code>, the checker is green.</p>",
2272            ),
2273            (
2274                23,
2275                "mina",
2276                "<ul><li>notes reviewed</li><li>rollback tested</li><li>status page drafted</li></ul>",
2277            ),
2278            (
2279                22,
2280                "mina",
2281                "<p>The release candidate is green. I pinned the final checklist.</p>",
2282            ),
2283        ],
2284        "19:ari@unq.gbl.spaces" => &[
2285            (180, "me", "<p>Thanks for the review yesterday!</p>"),
2286            (
2287                31,
2288                "ari",
2289                "<p>Can you look at the migration guide before lunch? Two screenshots are outdated.</p>",
2290            ),
2291        ],
2292        "19:design@thread.v2" => &[
2293            (300, "lee", "<p>New onboarding flow is in Figma 🎨</p>"),
2294            (95, "me", "<p>Love the empty states. Uploaded my notes.</p>"),
2295        ],
2296        "19:oncall@thread.v2" => &[
2297            (
2298                1600,
2299                "sam",
2300                "<blockquote><p>Is the queue backlog back to normal?</p></blockquote><p>Yes, drained overnight.</p>",
2301            ),
2302            (
2303                1500,
2304                "priya",
2305                "<p>Paging is quiet. Handing over at 18:00.</p>",
2306            ),
2307        ],
2308        "19:weekly@thread.v2" => &[(
2309            4400,
2310            "mina",
2311            "<p>Agenda for Monday is in the meeting notes.</p>",
2312        )],
2313        _ => &[(16000, "sam", "<p>Here are the slides from the offsite.</p>")],
2314    }
2315}
2316
2317fn demo_message(
2318    chat: &str,
2319    index: usize,
2320    now: &Zoned,
2321    (ago, from, html): (i64, &str, &str),
2322) -> Value {
2323    json!({
2324        "id": format!("{chat}/{index}"),
2325        "messageType": "message",
2326        "createdDateTime": demo_time(now, ago),
2327        "from": {"user": demo_person(from)},
2328        "body": {"contentType": "html", "content": html},
2329    })
2330}
2331
2332fn demo_chats(now: &Zoned) -> Page {
2333    let items = DEMO_CHATS
2334        .iter()
2335        .map(|(id, topic, kind, members, unread)| {
2336            let script = demo_script(id);
2337            let last = script[script.len() - 1];
2338            let mut preview = demo_message(id, script.len() - 1, now, last);
2339            preview.as_object_mut().map(|p| p.remove("id"));
2340            let read = if *unread { last.0 + 5 } else { 0 };
2341            json!({
2342                "id": id,
2343                "topic": topic,
2344                "chatType": kind,
2345                "lastUpdatedDateTime": demo_time(now, last.0),
2346                "viewpoint": {"lastMessageReadDateTime": demo_time(now, read)},
2347                "members": members.iter().map(|m| {
2348                    let person = demo_person(m);
2349                    json!({"userId": person["id"], "displayName": person["displayName"]})
2350                }).collect::<Vec<_>>(),
2351                "lastMessagePreview": preview,
2352            })
2353        })
2354        .collect();
2355    demo_page(items)
2356}
2357
2358fn demo_messages(chat: &str, now: &Zoned) -> Page {
2359    demo_page(
2360        demo_script(chat)
2361            .iter()
2362            .enumerate()
2363            .rev()
2364            .map(|(index, line)| demo_message(chat, index, now, *line))
2365            .collect(),
2366    )
2367}
2368
2369fn demo_page(items: Vec<Value>) -> Page {
2370    Page {
2371        items,
2372        next_cursor: None,
2373        next_offset: None,
2374        truncated: false,
2375        source: None,
2376        viewer: Some("me".into()),
2377    }
2378}
2379
2380/// Present an actionable connection state inside the same terminal experience.
2381/// Returning first restores the alternate screen before the caller opens a
2382/// browser or prints device-code instructions.
2383pub fn request_authentication(
2384    profile: &str,
2385    configured: bool,
2386    reason: &str,
2387) -> Result<TuiExit, AppError> {
2388    require_terminal("run `teams auth login --device-code` for headless sign-in")?;
2389    let theme = Theme {
2390        color: std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty()),
2391    };
2392    let _guard = TerminalGuard::enter(false)?;
2393    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
2394    loop {
2395        terminal.draw(|frame| draw_connection(frame, theme, profile, configured, reason))?;
2396        if event::poll(Duration::from_millis(150))?
2397            && let Event::Key(key) = event::read()?
2398            && key.kind == KeyEventKind::Press
2399        {
2400            match key.code {
2401                KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
2402                KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
2403                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2404                    return Ok(TuiExit::Quit);
2405                }
2406                _ => {}
2407            }
2408        }
2409    }
2410}
2411
2412pub fn connection_snapshot(
2413    width: u16,
2414    height: u16,
2415    profile: &str,
2416    configured: bool,
2417    reason: &str,
2418) -> Result<String, AppError> {
2419    let mut terminal = Terminal::new(TestBackend::new(
2420        width.max(MIN_WIDTH),
2421        height.max(MIN_HEIGHT),
2422    ))
2423    .expect("test backend is infallible");
2424    terminal
2425        .draw(|frame| draw_connection(frame, Theme { color: false }, profile, configured, reason))
2426        .expect("test backend is infallible");
2427    Ok(buffer_text(terminal.backend().buffer()))
2428}
2429
2430fn draw_connection(
2431    frame: &mut ratatui::Frame<'_>,
2432    theme: Theme,
2433    profile: &str,
2434    configured: bool,
2435    reason: &str,
2436) {
2437    let area = frame.area();
2438    frame.render_widget(Block::default().style(theme.canvas()), area);
2439    frame.render_widget(
2440        Paragraph::new(Line::from(vec![
2441            Span::styled(" TEAMS ", theme.brand()),
2442            Span::styled("  CONNECTION", theme.title()),
2443        ])),
2444        Rect { height: 1, ..area },
2445    );
2446    let (title, heading, action) = if configured {
2447        (
2448            "SIGN-IN REQUIRED",
2449            "Your Teams session needs attention",
2450            "Continue with Microsoft sign-in. Your password is never shared with teams-cli.",
2451        )
2452    } else {
2453        (
2454            "CONNECT MICROSOFT TEAMS",
2455            "Bring your Teams chats into the terminal",
2456            "Set up this profile and continue with secure Microsoft sign-in.",
2457        )
2458    };
2459    let lines = vec![
2460        Line::styled(heading, theme.title()),
2461        Line::styled(format!("Profile: {profile}"), theme.muted()),
2462        Line::raw(""),
2463        Line::styled(reason.to_string(), theme.body()),
2464        Line::styled(action, theme.muted()),
2465        Line::raw(""),
2466        Line::from(vec![
2467            Span::styled("enter / a", theme.key()),
2468            Span::styled(
2469                if configured {
2470                    "  sign in   "
2471                } else {
2472                    "  set up   "
2473                },
2474                theme.muted(),
2475            ),
2476            Span::styled("q", theme.key()),
2477            Span::styled(" quit", theme.muted()),
2478        ]),
2479    ];
2480    state_panel(frame, area, theme, title, lines, theme.active_rule());
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485    use super::*;
2486
2487    fn demo_app() -> App {
2488        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2489            .parse()
2490            .unwrap();
2491        let mut app = App::new(Source::Demo, Theme { color: false }, now);
2492        app.start();
2493        app
2494    }
2495
2496    fn press(app: &mut App, code: KeyCode) -> Action {
2497        app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
2498    }
2499
2500    #[test]
2501    fn snapshot_shows_the_ledger_and_the_open_conversation() {
2502        let frame = snapshot(90, 26).unwrap();
2503        assert!(frame.contains("TEAMS"));
2504        assert!(frame.contains("Launch room · group"));
2505        assert!(frame.contains("Ari Chen"));
2506        assert!(frame.contains("release candidate is green"));
2507        assert!(frame.contains("── Today ──"));
2508        assert!(frame.contains("You  09:34"));
2509        assert!(frame.contains("q quit"));
2510    }
2511
2512    #[test]
2513    fn narrow_terminals_take_turns_and_tiny_ones_ask_for_room() {
2514        let frame = snapshot(60, 24).unwrap();
2515        assert!(frame.contains("Launch room"));
2516        assert!(!frame.contains("rollback tested"));
2517        let mut app = demo_app();
2518        press(&mut app, KeyCode::Enter);
2519        let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
2520        terminal.draw(|frame| app.draw(frame)).unwrap();
2521        let text = buffer_text(terminal.backend().buffer());
2522        assert!(text.contains("rollback tested"));
2523        assert!(text.contains("esc chats"));
2524        let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
2525        terminal.draw(|frame| app.draw(frame)).unwrap();
2526        assert!(buffer_text(terminal.backend().buffer()).contains("NEEDS MORE ROOM"));
2527    }
2528
2529    #[test]
2530    fn filtering_narrows_the_ledger_and_esc_restores_it() {
2531        let mut app = demo_app();
2532        assert_eq!(app.visible.len(), DEMO_CHATS.len());
2533        press(&mut app, KeyCode::Char('/'));
2534        for c in "on-call".chars() {
2535            press(&mut app, KeyCode::Char(c));
2536        }
2537        assert_eq!(app.visible.len(), 1);
2538        assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
2539        press(&mut app, KeyCode::Enter);
2540        assert!(app.overlay.is_none());
2541        press(&mut app, KeyCode::Esc);
2542        assert_eq!(app.visible.len(), DEMO_CHATS.len());
2543        assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
2544    }
2545
2546    #[test]
2547    fn opening_a_chat_loads_it_once_and_scroll_is_bounded() {
2548        let mut app = demo_app();
2549        press(&mut app, KeyCode::Char('j'));
2550        press(&mut app, KeyCode::Enter);
2551        assert!(app.focus == Focus::Thread);
2552        assert!(app.threads.contains_key("19:ari@unq.gbl.spaces"));
2553        for _ in 0..50 {
2554            press(&mut app, KeyCode::Char('k'));
2555        }
2556        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2557        terminal.draw(|frame| app.draw(frame)).unwrap();
2558        assert_eq!(app.scroll, 0, "a short chat cannot scroll");
2559        press(&mut app, KeyCode::Esc);
2560        assert!(app.focus == Focus::Chats);
2561        press(&mut app, KeyCode::Char('?'));
2562        terminal.draw(|frame| app.draw(frame)).unwrap();
2563        assert!(buffer_text(terminal.backend().buffer()).contains("KEYBOARD MAP"));
2564        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::None));
2565        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
2566    }
2567
2568    fn mouse(app: &mut App, kind: MouseEventKind, column: u16, row: u16) {
2569        app.handle_mouse(MouseEvent {
2570            kind,
2571            column,
2572            row,
2573            modifiers: KeyModifiers::NONE,
2574        });
2575    }
2576
2577    #[test]
2578    fn the_wheel_scrolls_the_pane_under_the_pointer_and_clicks_open_chats() {
2579        let mut app = demo_app();
2580        app.open_selected();
2581        let mut terminal = Terminal::new(TestBackend::new(100, 16)).unwrap();
2582        terminal.draw(|frame| app.draw(frame)).unwrap();
2583        let ledger = app.ledger_area.expect("ledger drawn");
2584        let thread = app.thread_area.expect("conversation drawn");
2585        assert!(
2586            app.thread_overflow > WHEEL_LINES,
2587            "the demo chat overflows 16 rows"
2588        );
2589
2590        // The wheel over the conversation scrolls it even while the ledger has focus.
2591        assert!(app.focus == Focus::Chats);
2592        mouse(
2593            &mut app,
2594            MouseEventKind::ScrollUp,
2595            thread.x + 5,
2596            thread.y + 5,
2597        );
2598        assert_eq!(app.scroll, WHEEL_LINES);
2599        assert_eq!(app.list.selected(), Some(0), "the ledger did not move");
2600        for _ in 0..50 {
2601            mouse(
2602                &mut app,
2603                MouseEventKind::ScrollUp,
2604                thread.x + 5,
2605                thread.y + 5,
2606            );
2607        }
2608        assert_eq!(
2609            app.scroll, app.thread_overflow,
2610            "scrolling stops at the oldest line"
2611        );
2612        mouse(
2613            &mut app,
2614            MouseEventKind::ScrollDown,
2615            thread.x + 5,
2616            thread.y + 5,
2617        );
2618        assert_eq!(app.scroll, app.thread_overflow - WHEEL_LINES);
2619
2620        // The wheel over the ledger moves the selection, which resets the conversation.
2621        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
2622        assert_eq!(app.list.selected(), Some(1));
2623        assert_eq!(app.scroll, 0);
2624        mouse(&mut app, MouseEventKind::ScrollUp, ledger.x + 2, ledger.y);
2625        assert_eq!(app.list.selected(), Some(0));
2626
2627        // A click on the second entry opens it; a click in the conversation focuses it.
2628        let left = MouseEventKind::Down(MouseButton::Left);
2629        mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
2630        assert_eq!(app.list.selected(), Some(1));
2631        assert!(app.focus == Focus::Thread);
2632        let id = app.selected_row().unwrap().id.clone();
2633        assert!(app.threads.contains_key(&id));
2634        press(&mut app, KeyCode::Tab);
2635        assert!(app.focus == Focus::Chats);
2636        mouse(&mut app, left, thread.x + 5, thread.y + 5);
2637        assert!(app.focus == Focus::Thread);
2638
2639        // Outside both panes, and while the help is open, the mouse does nothing else.
2640        mouse(&mut app, MouseEventKind::ScrollDown, 0, 0);
2641        assert_eq!(app.list.selected(), Some(1));
2642        press(&mut app, KeyCode::Char('?'));
2643        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
2644        assert_eq!(app.list.selected(), Some(1));
2645        mouse(&mut app, left, 0, 0);
2646        assert!(app.overlay.is_none(), "a click closes the help");
2647    }
2648
2649    #[test]
2650    fn the_composer_keeps_its_chat_when_the_ledger_is_clicked() {
2651        let mut app = demo_app();
2652        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2653        press(&mut app, KeyCode::Enter);
2654        press(&mut app, KeyCode::Char('c'));
2655        assert!(app.compose.is_some());
2656        terminal.draw(|frame| app.draw(frame)).unwrap();
2657        let ledger = app.ledger_area.expect("ledger drawn");
2658        let left = MouseEventKind::Down(MouseButton::Left);
2659        mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
2660        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
2661        assert_eq!(app.list.selected(), Some(0));
2662    }
2663
2664    #[test]
2665    fn opening_a_chat_prefetches_its_neighbours_and_retries_failures() {
2666        let mut app = demo_app();
2667        press(&mut app, KeyCode::Char('j'));
2668        press(&mut app, KeyCode::Enter);
2669        let ids: Vec<String> = app
2670            .visible
2671            .iter()
2672            .map(|&i| app.rows[i].id.clone())
2673            .collect();
2674        let below = ids[2].clone();
2675        assert!(app.threads.contains_key(&ids[0]) && app.threads.contains_key(&below));
2676        assert!(!app.threads.contains_key(&ids[3]));
2677        app.threads.get_mut(&below).unwrap().load =
2678            Load::failed(&AppError::Desktop("read failed".into()));
2679        press(&mut app, KeyCode::Esc);
2680        press(&mut app, KeyCode::Char('j'));
2681        app.open_selected();
2682        assert!(matches!(app.threads[&below].load, Load::Ready));
2683        assert!(app.threads.contains_key(&ids[3]));
2684    }
2685
2686    #[test]
2687    fn auth_failures_offer_sign_in_from_inside_the_tui() {
2688        let mut app = demo_app();
2689        app.chats_load = Load::failed(&AppError::Auth("session expired".into()));
2690        let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap();
2691        terminal.draw(|frame| app.draw(frame)).unwrap();
2692        let text = buffer_text(terminal.backend().buffer());
2693        assert!(text.contains("SIGN-IN REQUIRED"));
2694        assert!(text.contains("enter / a"));
2695        assert!(matches!(
2696            press(&mut app, KeyCode::Enter),
2697            Action::Authenticate
2698        ));
2699    }
2700
2701    fn frame_text(app: &mut App) -> String {
2702        let mut terminal = Terminal::new(TestBackend::new(100, 28)).unwrap();
2703        terminal.draw(|frame| app.draw(frame)).unwrap();
2704        buffer_text(terminal.backend().buffer())
2705    }
2706
2707    fn type_text(app: &mut App, text: &str) {
2708        for c in text.chars() {
2709            press(app, KeyCode::Char(c));
2710        }
2711    }
2712
2713    #[test]
2714    fn composing_asks_before_sending_and_the_message_appears() {
2715        let mut app = demo_app();
2716        app.open_selected();
2717        press(&mut app, KeyCode::Char('c'));
2718        assert!(app.focus == Focus::Thread);
2719        // List keys are text while composing.
2720        type_text(&mut app, "on it q r /");
2721        app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
2722        type_text(&mut app, "shipping");
2723        assert!(app.overlay.is_none());
2724        let compose = app.compose.as_ref().unwrap();
2725        assert_eq!(compose.text, "on it q r /\nshipping");
2726        assert!(frame_text(&mut app).contains("MESSAGE"));
2727
2728        press(&mut app, KeyCode::Enter);
2729        assert!(app.compose.as_ref().unwrap().stage == Stage::Confirming);
2730        assert!(frame_text(&mut app).contains("Send to Launch room as you?"));
2731        press(&mut app, KeyCode::Char('n'));
2732        assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
2733        assert!(app.threads["19:launch@thread.v2"].items.len() == 7);
2734
2735        press(&mut app, KeyCode::Enter);
2736        press(&mut app, KeyCode::Char('y'));
2737        assert!(app.compose.is_none());
2738        assert_eq!(app.notice.text, "Sent.");
2739        let thread = &app.threads["19:launch@thread.v2"];
2740        assert_eq!(thread.items.len(), 8);
2741        assert_eq!(thread.items[0]["body"]["content"], "on it q r /\nshipping");
2742        assert!(frame_text(&mut app).contains("shipping"));
2743    }
2744
2745    #[test]
2746    fn drafts_survive_closing_and_blank_ones_are_not_offered() {
2747        let mut app = demo_app();
2748        press(&mut app, KeyCode::Char('c'));
2749        press(&mut app, KeyCode::Char(' '));
2750        press(&mut app, KeyCode::Enter);
2751        assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
2752        type_text(&mut app, "draft");
2753        press(&mut app, KeyCode::Left);
2754        press(&mut app, KeyCode::Backspace);
2755        press(&mut app, KeyCode::Esc);
2756        assert!(app.compose.is_none());
2757        press(&mut app, KeyCode::Char('j'));
2758        press(&mut app, KeyCode::Char('k'));
2759        press(&mut app, KeyCode::Char('c'));
2760        let compose = app.compose.as_ref().unwrap();
2761        assert_eq!(compose.text, " drat");
2762        assert_eq!(compose.cursor, compose.text.len());
2763    }
2764
2765    #[test]
2766    fn read_only_profiles_only_explain_how_to_send() {
2767        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2768            .parse()
2769            .unwrap();
2770        let source = Source::Live {
2771            backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
2772                "fixture".into(),
2773            ))),
2774            profile: "fixture".into(),
2775            can_send: false,
2776        };
2777        let mut app = App::new(source, Theme { color: false }, now.clone());
2778        app.apply(Outcome::Chats {
2779            more: false,
2780            result: Ok(demo_chats(&now)),
2781        });
2782        press(&mut app, KeyCode::Char('c'));
2783        assert!(app.compose.is_none());
2784        assert!(app.notice.text.contains("read-only"));
2785        assert!(!frame_text(&mut app).contains("c reply"));
2786    }
2787
2788    fn header(app: &mut App, width: u16) -> Vec<String> {
2789        let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
2790        terminal.draw(|frame| app.draw(frame)).unwrap();
2791        buffer_text(terminal.backend().buffer())
2792            .lines()
2793            .take(2)
2794            .map(String::from)
2795            .collect()
2796    }
2797
2798    fn live_app(can_send: bool, profile: &str) -> App {
2799        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2800            .parse()
2801            .unwrap();
2802        let source = Source::Live {
2803            backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
2804                "fixture".into(),
2805            ))),
2806            profile: profile.into(),
2807            can_send,
2808        };
2809        App::new(source, Theme { color: false }, now)
2810    }
2811
2812    #[test]
2813    fn the_header_shows_the_profile_send_mode_freshness_and_counts() {
2814        let mut app = demo_app();
2815        let [title, context] = header(&mut app, 100).try_into().unwrap();
2816        assert!(title.starts_with(" TEAMS   demo "));
2817        assert!(
2818            title.ends_with("  ✎ can send"),
2819            "sample data has no freshness: {title}"
2820        );
2821        assert_eq!(context, " 6 chats  ·  2 unread  ·  sample data");
2822
2823        let mut app = live_app(false, "work");
2824        let [title, context] = header(&mut app, 100).try_into().unwrap();
2825        assert!(title.ends_with(" syncing   read-only"), "{title}");
2826        assert_eq!(context.trim(), "Microsoft Graph");
2827        app.apply(Outcome::Chats {
2828            more: false,
2829            result: Ok(demo_chats(&app.now.clone())),
2830        });
2831        app.account = Some("Ari Chen".into());
2832        let [title, _] = header(&mut app, 100).try_into().unwrap();
2833        assert!(title.starts_with(" TEAMS   work  ·  Ari Chen "), "{title}");
2834        assert!(title.ends_with(" ● live   read-only"), "{title}");
2835    }
2836
2837    #[test]
2838    fn narrow_headers_keep_the_send_mode_and_drop_the_rest_in_order() {
2839        let mut app = live_app(true, "a-rather-long-profile-name-for-work");
2840        app.apply(Outcome::Chats {
2841            more: false,
2842            result: Ok(demo_chats(&app.now.clone())),
2843        });
2844        app.account = Some("Someone With A Long Display Name".into());
2845        let [title, _] = header(&mut app, 110).try_into().unwrap();
2846        assert!(title.contains("for-work  ·  Someone With"), "{title}");
2847        let [title, _] = header(&mut app, 100).try_into().unwrap();
2848        assert!(
2849            !title.contains("Someone"),
2850            "the account goes first: {title}"
2851        );
2852        assert!(title.contains("for-work "), "{title}");
2853        let [title, context] = header(&mut app, 50).try_into().unwrap();
2854        // The one-column right margin is trimmed from the frame text.
2855        assert_eq!(title.chars().count(), 49, "{title}");
2856        assert!(title.ends_with("● live   ✎ can send"), "{title}");
2857        assert!(title.contains("a-rather-long-pro"), "{title}");
2858        assert!(title.contains('…'), "the profile is shortened: {title}");
2859        assert_eq!(context, " 6 chats  ·  2 unread  ·  Microsoft Graph");
2860    }
2861
2862    #[test]
2863    fn failed_refreshes_mark_the_chats_stale_until_one_succeeds() {
2864        let mut app = live_app(false, "work");
2865        let chats = || Outcome::Chats {
2866            more: false,
2867            result: Ok(demo_chats(
2868                &"2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2869                    .parse()
2870                    .unwrap(),
2871            )),
2872        };
2873        app.apply(chats());
2874        let at = app
2875            .synced_at
2876            .clone()
2877            .expect("a successful read is recorded");
2878        assert!(!app.stale);
2879        app.fetching_chats = true;
2880        app.apply(Outcome::Chats {
2881            more: false,
2882            result: Err(AppError::Api("unavailable".into())),
2883        });
2884        assert!(app.stale);
2885        let [title, _] = header(&mut app, 100).try_into().unwrap();
2886        assert!(
2887            title.contains(&format!("stale since {}", at.strftime("%H:%M"))),
2888            "{title}"
2889        );
2890        app.apply(chats());
2891        assert!(!app.stale);
2892        let [title, _] = header(&mut app, 100).try_into().unwrap();
2893        assert!(title.contains("● live"), "{title}");
2894    }
2895
2896    #[test]
2897    fn reloads_keep_older_pages_only_when_the_fresh_page_overlaps() {
2898        let ids = |items: &[Value]| -> Vec<String> {
2899            items
2900                .iter()
2901                .map(|v| v["id"].as_str().unwrap().to_string())
2902                .collect()
2903        };
2904        let page =
2905            |names: &[&str]| -> Vec<Value> { names.iter().map(|id| json!({"id": id})).collect() };
2906        let old = page(&["c", "b", "a"]);
2907        let (items, cursor) = merge_newest(
2908            page(&["d", "c"]),
2909            Some("new".into()),
2910            old.clone(),
2911            Some("old".into()),
2912        );
2913        assert_eq!(ids(&items), ["d", "c", "b", "a"]);
2914        assert_eq!(cursor.as_deref(), Some("old"));
2915        let (items, cursor) =
2916            merge_newest(page(&["f", "e"]), Some("new".into()), old.clone(), None);
2917        assert_eq!(ids(&items), ["f", "e"], "no overlap means a possible gap");
2918        assert_eq!(cursor.as_deref(), Some("new"));
2919        let (items, cursor) = merge_newest(page(&["d", "c"]), None, old, Some("old".into()));
2920        assert_eq!(
2921            ids(&items),
2922            ["d", "c"],
2923            "a complete page replaces everything"
2924        );
2925        assert!(cursor.is_none());
2926    }
2927
2928    #[test]
2929    fn drafts_wrap_and_track_the_cursor() {
2930        assert_eq!(layout_draft("", 0, 10), (vec![String::new()], (0, 0)));
2931        let (rows, at) = layout_draft("abcdef\ngh", 8, 4);
2932        assert_eq!(rows, ["abcd", "ef", "gh"]);
2933        assert_eq!(at, (2, 1));
2934        assert_eq!(layout_draft("abcd", 4, 4).1, (0, 4));
2935        assert_eq!(layout_draft("ab\n", 3, 4).1, (1, 0));
2936        let (rows, at) = layout_draft("你好你", "你好".len(), 4);
2937        assert_eq!(rows, ["你好", "你"]);
2938        assert_eq!(at, (1, 0));
2939    }
2940
2941    #[test]
2942    fn connection_snapshot_has_a_clear_recovery_action() {
2943        let frame =
2944            connection_snapshot(80, 22, "work", true, "Your saved session has expired.").unwrap();
2945        assert!(frame.contains("SIGN-IN REQUIRED"));
2946        assert!(frame.contains("Profile: work"));
2947        assert!(frame.contains("enter / a"));
2948        assert!(frame.contains("sign in"));
2949        assert!(frame.contains("q quit"));
2950    }
2951}