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, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
15    Event, KeyCode, KeyEvent, KeyEventKind, 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!(
89                io::stdout(),
90                EnterAlternateScreen,
91                EnableMouseCapture,
92                EnableBracketedPaste
93            )
94        } else {
95            execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)
96        };
97        if let Err(error) = entered {
98            let _ = execute!(
99                io::stdout(),
100                DisableBracketedPaste,
101                DisableMouseCapture,
102                LeaveAlternateScreen
103            );
104            let _ = disable_raw_mode();
105            return Err(error.into());
106        }
107        Ok(Self { mouse })
108    }
109}
110
111impl Drop for TerminalGuard {
112    fn drop(&mut self) {
113        let _ = disable_raw_mode();
114        if self.mouse {
115            let _ = execute!(io::stdout(), DisableMouseCapture);
116        }
117        let _ = execute!(io::stdout(), DisableBracketedPaste, LeaveAlternateScreen);
118    }
119}
120
121fn require_terminal(hint: &str) -> Result<(), AppError> {
122    if io::stdout().is_terminal() && io::stdin().is_terminal() {
123        Ok(())
124    } else {
125        Err(AppError::NonInteractive(format!(
126            "`teams tui` needs a terminal; {hint}"
127        )))
128    }
129}
130
131/// Opens the chat reader and returns how the person left it.
132pub fn run(source: Source) -> Result<TuiExit, AppError> {
133    run_with_watch(source, None, &mut InboxState::default())
134}
135
136/// Drafts stay in memory while the CLI steps out of the inbox to reauthenticate.
137#[derive(Default)]
138pub struct InboxState {
139    drafts: HashMap<String, String>,
140    pending_sends: HashMap<String, String>,
141}
142
143pub fn run_with_watch(
144    source: Source,
145    mut watch: Option<crate::desktop::ConnectionWatch>,
146    saved: &mut InboxState,
147) -> Result<TuiExit, AppError> {
148    require_terminal("use `teams tui --demo --snapshot` for an inspectable frame")?;
149    let color = std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty());
150    let mut app = App::new(
151        source,
152        Theme { color },
153        Zoned::now().with_time_zone(TimeZone::system()),
154    );
155    app.drafts = std::mem::take(&mut saved.drafts);
156    app.pending_sends = std::mem::take(&mut saved.pending_sends);
157    app.start();
158    let _guard = TerminalGuard::enter(true)?;
159    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
160    loop {
161        app.tick();
162        if let Some(result) = watch
163            .as_ref()
164            .and_then(crate::desktop::ConnectionWatch::result)
165        {
166            if let Err(error) = result {
167                app.startup_failed(&error);
168            }
169            watch = None;
170        }
171        terminal.draw(|frame| app.draw(frame))?;
172        if !event::poll(Duration::from_millis(100))? {
173            continue;
174        }
175        let action = match event::read()? {
176            Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key),
177            Event::Paste(text) => {
178                app.paste(&text);
179                Action::None
180            }
181            Event::Mouse(mouse) => {
182                app.handle_mouse(mouse);
183                Action::None
184            }
185            _ => Action::None,
186        };
187        match action {
188            Action::None => {}
189            Action::Quit => {
190                app.preserve_drafts(saved);
191                return Ok(TuiExit::Quit);
192            }
193            Action::Authenticate => {
194                app.preserve_drafts(saved);
195                return Ok(TuiExit::Authenticate);
196            }
197        }
198    }
199}
200
201/// One ANSI-free frame of the demo, for documentation and tests.
202pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
203    let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
204        .parse()
205        .expect("valid demo clock");
206    let mut app = App::new(Source::Demo, Theme { color: false }, now);
207    app.start();
208    app.open_selected();
209    let mut terminal = Terminal::new(TestBackend::new(
210        width.max(MIN_WIDTH),
211        height.max(MIN_HEIGHT),
212    ))
213    .expect("test backend is infallible");
214    terminal
215        .draw(|frame| app.draw(frame))
216        .expect("test backend is infallible");
217    Ok(buffer_text(terminal.backend().buffer()))
218}
219
220fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
221    let mut output = String::new();
222    for y in 0..buffer.area.height {
223        let mut line = String::new();
224        for x in 0..buffer.area.width {
225            line.push_str(buffer[(x, y)].symbol());
226        }
227        output.push_str(line.trim_end());
228        output.push('\n');
229    }
230    while output.ends_with("\n\n") {
231        output.pop();
232    }
233    output
234}
235
236enum Action {
237    None,
238    Quit,
239    Authenticate,
240}
241
242enum Outcome {
243    DebuggingRequired,
244    Restarted(Result<(), AppError>),
245    StrayFlags(Vec<String>),
246    FlagsCleared(Result<(), AppError>),
247    Chats {
248        more: bool,
249        result: Result<Page, AppError>,
250    },
251    Messages {
252        chat: String,
253        older: bool,
254        result: Result<Page, AppError>,
255    },
256    Me(Result<Value, AppError>),
257    Delivery {
258        chat: String,
259        id: String,
260        result: Result<Value, AppError>,
261    },
262    Sent {
263        chat: String,
264        result: Result<Value, AppError>,
265    },
266}
267
268#[derive(Clone, Copy, PartialEq, Eq)]
269enum DebuggingRestart {
270    Offered,
271    Confirming,
272    Running,
273}
274
275/// Cleanup of user-wide WebView2 debugging flags that let other apps hold the port.
276#[derive(Clone, PartialEq, Eq)]
277struct FlagCleanup {
278    stage: Cleanup,
279    /// Applications that served the port, to quit and reopen afterwards.
280    apps: Vec<String>,
281}
282
283#[derive(Clone, Copy, PartialEq, Eq)]
284enum Cleanup {
285    Offered,
286    Confirming,
287    Running,
288    Done,
289}
290
291impl FlagCleanup {
292    fn apps(&self) -> String {
293        if self.apps.is_empty() {
294            "the apps that use WebView2".into()
295        } else {
296            self.apps.join(", ")
297        }
298    }
299}
300
301enum Load {
302    Loading,
303    Ready,
304    Failed { message: String, auth: bool },
305}
306
307impl Load {
308    fn failed(error: &AppError) -> Self {
309        Self::Failed {
310            message: error.to_string(),
311            auth: matches!(error, AppError::Auth(_)),
312        }
313    }
314}
315
316struct Thread {
317    items: Vec<Value>,
318    cursor: Option<String>,
319    load: Load,
320    loading_older: bool,
321    /// A newest-page read is in flight, so polls do not stack up.
322    fetching: bool,
323}
324
325#[derive(Clone, Copy, Eq, PartialEq)]
326enum Focus {
327    Chats,
328    Thread,
329}
330
331#[derive(Clone, Copy, Eq, PartialEq)]
332enum Overlay {
333    Help,
334    Filter,
335}
336
337#[derive(Clone, Copy, Eq, PartialEq)]
338enum Stage {
339    Editing,
340    Sending,
341    Unconfirmed,
342    Checking,
343    ConfirmResend,
344}
345
346/// A draft for one chat; `cursor` is a byte offset into `text`.
347struct Compose {
348    chat: String,
349    text: String,
350    cursor: usize,
351    stage: Stage,
352}
353
354impl Compose {
355    fn insert(&mut self, c: char) {
356        self.text.insert(self.cursor, c);
357        self.cursor += c.len_utf8();
358    }
359}
360
361#[derive(Clone, Copy)]
362enum Tone {
363    Quiet,
364    Success,
365    Error,
366}
367
368struct Notice {
369    tone: Tone,
370    text: String,
371}
372
373impl Notice {
374    fn new(tone: Tone, text: impl Into<String>) -> Self {
375        Self {
376            tone,
377            text: text.into(),
378        }
379    }
380}
381
382struct App {
383    theme: Theme,
384    source: Source,
385    outcomes: (mpsc::Sender<Outcome>, mpsc::Receiver<Outcome>),
386    now: Zoned,
387    account: Option<String>,
388    viewer: Option<String>,
389    chats: Vec<Value>,
390    rows: Vec<ChatRow>,
391    chat_cursor: Option<String>,
392    chats_load: Load,
393    startup_auth_error: Option<String>,
394    sending_paused: bool,
395    debugging_restart: Option<DebuggingRestart>,
396    flag_cleanup: Option<FlagCleanup>,
397    loading_more: bool,
398    filter: String,
399    /// Indices into `rows` that pass the filter, in ledger order.
400    visible: Vec<usize>,
401    list: ListState,
402    selected_at: Option<Instant>,
403    threads: HashMap<String, Thread>,
404    focus: Focus,
405    /// Conversation lines hidden below the viewport; 0 follows the newest.
406    scroll: usize,
407    thread_height: usize,
408    /// Conversation lines above the viewport when scrolled to the newest.
409    thread_overflow: usize,
410    /// Where the last frame drew the ledger rows and the conversation pane, for the mouse.
411    ledger_area: Option<Rect>,
412    thread_area: Option<Rect>,
413    overlay: Option<Overlay>,
414    notice: Notice,
415    frame: usize,
416    compose: Option<Compose>,
417    /// Closed, unsent drafts per chat; kept in memory only.
418    drafts: HashMap<String, String>,
419    /// Correlation IDs for possibly-sent drafts; kept across closing the composer.
420    pending_sends: HashMap<String, String>,
421    /// Set by `r` so its result is announced; quiet reloads say nothing.
422    refreshing: bool,
423    fetching_chats: bool,
424    /// When a newest-page read last succeeded, and whether a refresh has failed since.
425    synced_at: Option<Zoned>,
426    stale: bool,
427    thread_polled: Instant,
428    chats_polled: Instant,
429}
430
431impl App {
432    fn preserve_drafts(&mut self, saved: &mut InboxState) {
433        if let Some(compose) = self.compose.take() {
434            self.drafts.insert(compose.chat, compose.text);
435        }
436        saved.drafts = std::mem::take(&mut self.drafts);
437        saved.pending_sends = std::mem::take(&mut self.pending_sends);
438    }
439
440    fn startup_failed(&mut self, error: &AppError) {
441        self.sending_paused |= self.can_send();
442        if let Source::Live { can_send, .. } = &mut self.source {
443            *can_send = false;
444        }
445        if matches!(error, AppError::Auth(_)) {
446            if let Some(compose) = self.compose.take() {
447                self.drafts.insert(compose.chat, compose.text);
448            }
449            self.overlay = None;
450            self.debugging_restart = None;
451            self.flag_cleanup = None;
452            self.startup_auth_error = Some(error.to_string());
453            self.chats_load = Load::failed(error);
454            self.notice = Notice::new(Tone::Error, error.to_string());
455        } else {
456            let message = if self.sending_paused {
457                format!("{error} Sending is paused; reopen the inbox to check it again.")
458            } else {
459                error.to_string()
460            };
461            self.notice = Notice::new(Tone::Error, message);
462        }
463    }
464
465    fn new(source: Source, theme: Theme, now: Zoned) -> Self {
466        Self {
467            theme,
468            source,
469            outcomes: mpsc::channel(),
470            now,
471            account: None,
472            viewer: None,
473            chats: Vec::new(),
474            rows: Vec::new(),
475            chat_cursor: None,
476            chats_load: Load::Loading,
477            startup_auth_error: None,
478            sending_paused: false,
479            debugging_restart: None,
480            flag_cleanup: None,
481            loading_more: false,
482            filter: String::new(),
483            visible: Vec::new(),
484            list: ListState::default(),
485            selected_at: None,
486            threads: HashMap::new(),
487            focus: Focus::Chats,
488            scroll: 0,
489            thread_height: 10,
490            thread_overflow: 0,
491            ledger_area: None,
492            thread_area: None,
493            overlay: None,
494            notice: Notice::new(Tone::Quiet, "Reading your chats…"),
495            frame: 0,
496            compose: None,
497            drafts: HashMap::new(),
498            pending_sends: HashMap::new(),
499            refreshing: false,
500            fetching_chats: false,
501            synced_at: None,
502            stale: false,
503            thread_polled: Instant::now(),
504            chats_polled: Instant::now(),
505        }
506    }
507
508    /// Records the outcome of reading the newest chats or messages for the header.
509    fn synced(&mut self, ok: bool) {
510        if ok {
511            self.synced_at = Some(match self.source {
512                Source::Live { .. } => Zoned::now().with_time_zone(TimeZone::system()),
513                Source::Demo => self.now.clone(),
514            });
515            self.stale = false;
516        } else {
517            self.stale = true;
518        }
519    }
520
521    fn view(&self) -> View {
522        View {
523            now: self.now.clone(),
524            width: 100,
525        }
526    }
527
528    fn backend(&self) -> Option<Arc<TeamsBackend>> {
529        match &self.source {
530            Source::Live { backend, .. } => Some(backend.clone()),
531            Source::Demo => None,
532        }
533    }
534
535    fn can_send(&self) -> bool {
536        match &self.source {
537            Source::Live { can_send, .. } => *can_send,
538            Source::Demo => true,
539        }
540    }
541
542    fn profile(&self) -> &str {
543        match &self.source {
544            Source::Live { profile, .. } => profile,
545            Source::Demo => "demo",
546        }
547    }
548
549    /// The second header line as parts, most useful first; whole parts are dropped
550    /// from the end when the terminal is narrow.
551    fn context(&self) -> Vec<Vec<Span<'static>>> {
552        let theme = self.theme;
553        let mut parts = Vec::new();
554        if matches!(self.chats_load, Load::Ready) {
555            let more = if self.chat_cursor.is_some() { "+" } else { "" };
556            parts.push(vec![
557                Span::styled(format!("{}{more}", self.rows.len()), theme.figure()),
558                Span::styled(" chats", theme.muted()),
559            ]);
560            let unread = self.rows.iter().filter(|row| row.unread).count();
561            if unread > 0 {
562                parts.push(vec![
563                    Span::styled(unread.to_string(), theme.unread()),
564                    Span::styled(" unread", theme.unread_label()),
565                ]);
566            }
567        }
568        parts.push(vec![Span::styled(
569            match &self.source {
570                Source::Live { backend, .. } => match backend.as_ref() {
571                    TeamsBackend::Graph(_) => "Microsoft Graph",
572                    TeamsBackend::Desktop(_) => "Teams desktop cache",
573                },
574                Source::Demo => "sample data",
575            },
576            theme.muted(),
577        )]);
578        parts
579    }
580
581    /// How fresh the chats are, as a status dot: live, syncing, or since when a
582    /// refresh keeps failing. Sample data has nothing to be fresh about.
583    fn freshness(&self) -> Option<Vec<Span<'static>>> {
584        let theme = self.theme;
585        let dot = |style: Style, text: String| {
586            vec![
587                Span::styled("● ", style),
588                Span::styled(text, style.remove_modifier(Modifier::BOLD)),
589            ]
590        };
591        if self.busy() {
592            return Some(vec![Span::styled(
593                format!("{} syncing", self.spinner()),
594                theme.accent(),
595            )]);
596        }
597        if let Load::Failed { auth, .. } = self.chats_load {
598            let text = if auth {
599                "sign-in needed"
600            } else {
601                "not connected"
602            };
603            return Some(dot(theme.error(), text.into()));
604        }
605        if matches!(self.source, Source::Demo) {
606            return None;
607        }
608        let at = self.synced_at.as_ref()?.strftime("%H:%M");
609        Some(if self.stale {
610            dot(theme.error(), format!("stale since {at}"))
611        } else {
612            dot(theme.success(), "live".into())
613        })
614    }
615
616    fn start(&mut self) {
617        if let Some(backend) = self.backend()
618            && matches!(backend.as_ref(), TeamsBackend::Graph(_))
619        {
620            let sender = self.outcomes.0.clone();
621            tokio::spawn(async move {
622                let _ = sender.send(Outcome::Me(backend.me().await));
623            });
624        }
625        self.load_chats(false);
626    }
627
628    fn load_chats(&mut self, more: bool) {
629        self.debugging_restart = None;
630        self.flag_cleanup = None;
631        let initial = !more && !matches!(self.chats_load, Load::Ready);
632        let cursor = if more {
633            if self.loading_more || self.chat_cursor.is_none() {
634                return;
635            }
636            self.loading_more = true;
637            self.chat_cursor.clone()
638        } else {
639            if !matches!(self.chats_load, Load::Ready) {
640                self.chats_load = Load::Loading;
641            }
642            self.fetching_chats = true;
643            None
644        };
645        match self.backend() {
646            Some(backend) => {
647                let sender = self.outcomes.0.clone();
648                tokio::spawn(async move {
649                    if initial
650                        && let TeamsBackend::Desktop(client) = backend.as_ref()
651                        && client.needs_debugging_restart().await
652                    {
653                        let _ = sender.send(Outcome::DebuggingRequired);
654                        return;
655                    }
656                    let result = backend.chats(PAGE, cursor.as_deref()).await;
657                    if initial
658                        && result.is_err()
659                        && let TeamsBackend::Desktop(client) = backend.as_ref()
660                        && let Some(apps) = client.stray_debugging_flags().await
661                    {
662                        let _ = sender.send(Outcome::StrayFlags(apps));
663                        return;
664                    }
665                    let _ = sender.send(Outcome::Chats { more, result });
666                });
667            }
668            None => self.apply(Outcome::Chats {
669                more,
670                result: Ok(demo_chats(&self.now)),
671            }),
672        }
673    }
674
675    fn load_messages(&mut self, chat: String, older: bool) {
676        let cursor = match self.threads.get_mut(&chat) {
677            Some(thread) if older => {
678                if thread.loading_older || thread.cursor.is_none() {
679                    return;
680                }
681                thread.loading_older = true;
682                thread.cursor.clone()
683            }
684            Some(thread) => {
685                if !matches!(thread.load, Load::Ready) {
686                    thread.load = Load::Loading;
687                }
688                thread.fetching = true;
689                None
690            }
691            None => {
692                self.threads.insert(
693                    chat.clone(),
694                    Thread {
695                        items: Vec::new(),
696                        cursor: None,
697                        load: Load::Loading,
698                        loading_older: false,
699                        fetching: true,
700                    },
701                );
702                None
703            }
704        };
705        match self.backend() {
706            Some(backend) => {
707                let sender = self.outcomes.0.clone();
708                tokio::spawn(async move {
709                    let result = backend
710                        .messages(Some(&chat), None, None, PAGE, cursor.as_deref())
711                        .await;
712                    let _ = sender.send(Outcome::Messages {
713                        chat,
714                        older,
715                        result,
716                    });
717                });
718            }
719            None => {
720                let result = Ok(demo_messages(&chat, &self.now));
721                self.apply(Outcome::Messages {
722                    chat,
723                    older,
724                    result,
725                });
726            }
727        }
728    }
729
730    fn apply(&mut self, outcome: Outcome) {
731        match outcome {
732            Outcome::DebuggingRequired => {
733                self.fetching_chats = false;
734                self.debugging_restart = Some(DebuggingRestart::Offered);
735                self.notice = Notice::new(Tone::Quiet, "Teams needs debugging enabled to connect");
736            }
737            Outcome::StrayFlags(apps) => {
738                self.fetching_chats = false;
739                self.flag_cleanup = Some(FlagCleanup {
740                    stage: Cleanup::Offered,
741                    apps,
742                });
743                self.notice =
744                    Notice::new(Tone::Quiet, "Another app holds the Teams debugging port");
745            }
746            Outcome::FlagsCleared(result) => match result {
747                Ok(()) => {
748                    if let Some(cleanup) = &mut self.flag_cleanup {
749                        cleanup.stage = Cleanup::Done;
750                    }
751                    self.notice = Notice::new(Tone::Quiet, "Debugging flags removed");
752                }
753                Err(error) => {
754                    self.flag_cleanup = None;
755                    self.chats_load = Load::failed(&error);
756                    self.notice =
757                        Notice::new(Tone::Error, "Flags were not removed; press r to retry");
758                }
759            },
760            Outcome::Restarted(result) => {
761                self.debugging_restart = None;
762                match result {
763                    Ok(()) => self.load_chats(false),
764                    Err(error) => {
765                        self.chats_load = Load::failed(&error);
766                        self.notice =
767                            Notice::new(Tone::Error, "Restart did not connect; press r to retry");
768                    }
769                }
770            }
771            Outcome::Chats { more, result } => {
772                if more {
773                    self.loading_more = false;
774                } else {
775                    self.fetching_chats = false;
776                }
777                match result {
778                    Ok(page) => {
779                        if page.viewer.is_some() {
780                            self.viewer = page.viewer;
781                        }
782                        let keep = self.selected_row().map(|row| row.id.clone());
783                        let cursor = page.next_cursor.filter(|_| page.truncated);
784                        if more {
785                            let known: HashSet<String> =
786                                self.rows.iter().map(|row| row.id.clone()).collect();
787                            self.chats.extend(page.items.into_iter().filter(|chat| {
788                                !chat["id"].as_str().is_some_and(|id| known.contains(id))
789                            }));
790                            self.chat_cursor = cursor;
791                        } else {
792                            if !matches!(self.chats_load, Load::Ready) {
793                                self.notice = Notice::new(
794                                    Tone::Quiet,
795                                    "Enter opens a chat  ·  / filters  ·  ? shows every key",
796                                );
797                            } else if std::mem::take(&mut self.refreshing) {
798                                self.notice = Notice::new(Tone::Success, "Up to date.");
799                            }
800                            let old = std::mem::take(&mut self.chats);
801                            (self.chats, self.chat_cursor) =
802                                merge_newest(page.items, cursor, old, self.chat_cursor.take());
803                        }
804                        self.chats_load = Load::Ready;
805                        self.rebuild(keep.as_deref());
806                        if !more {
807                            self.synced(true);
808                        }
809                    }
810                    Err(error) if more || matches!(self.chats_load, Load::Ready) => {
811                        self.refreshing = false;
812                        self.notice = Notice::new(Tone::Error, error.to_string());
813                        if !more {
814                            self.synced(false);
815                        }
816                    }
817                    Err(error) => {
818                        self.notice = Notice::new(Tone::Error, "Could not read your chats.");
819                        self.chats_load = Load::failed(&error);
820                    }
821                }
822            }
823            Outcome::Messages {
824                chat,
825                older,
826                result,
827            } => {
828                let opened = self.selected_row().is_some_and(|row| row.id == chat);
829                let Some(thread) = self.threads.get_mut(&chat) else {
830                    return;
831                };
832                if older {
833                    thread.loading_older = false;
834                } else {
835                    thread.fetching = false;
836                }
837                match result {
838                    Ok(page) => {
839                        if older {
840                            let known: HashSet<&str> = thread
841                                .items
842                                .iter()
843                                .filter_map(|m| m["id"].as_str())
844                                .collect();
845                            let fresh: Vec<Value> = page
846                                .items
847                                .into_iter()
848                                .filter(|m| !m["id"].as_str().is_some_and(|id| known.contains(id)))
849                                .collect();
850                            let added = fresh.len();
851                            thread.items.extend(fresh);
852                            self.notice = Notice::new(
853                                Tone::Success,
854                                format!(
855                                    "Loaded {added} older message{}.",
856                                    if added == 1 { "" } else { "s" }
857                                ),
858                            );
859                            thread.cursor = page.next_cursor.filter(|_| page.truncated);
860                        } else {
861                            let cursor = page.next_cursor.filter(|_| page.truncated);
862                            let old = std::mem::take(&mut thread.items);
863                            (thread.items, thread.cursor) =
864                                merge_newest(page.items, cursor, old, thread.cursor.take());
865                        }
866                        thread.load = Load::Ready;
867                        if self.viewer.is_none() && page.viewer.is_some() {
868                            self.viewer = page.viewer;
869                            self.rebuild(None);
870                        }
871                        if opened && !older {
872                            self.prefetch_neighbours();
873                            self.synced(true);
874                        }
875                    }
876                    Err(error) if older || matches!(thread.load, Load::Ready) => {
877                        self.notice = Notice::new(Tone::Error, error.to_string());
878                        if opened && !older {
879                            self.synced(false);
880                        }
881                    }
882                    Err(error) => thread.load = Load::failed(&error),
883                }
884            }
885            Outcome::Me(Ok(me)) => {
886                self.account = me["displayName"].as_str().map(String::from);
887                if let Some(id) = me["id"].as_str() {
888                    self.viewer = Some(id.into());
889                    self.rebuild(None);
890                }
891            }
892            // The header simply stays without a name; chats still load.
893            Outcome::Me(Err(_)) => {}
894            Outcome::Delivery { chat, id, result } => {
895                if self.pending_sends.get(&chat) != Some(&id) {
896                    return;
897                }
898                match result {
899                    Ok(value)
900                        if value["found"] == true
901                            && matches!(
902                                value["delivery_state"].as_str(),
903                                Some("delivered" | "queued")
904                            ) =>
905                    {
906                        self.pending_sends.remove(&chat);
907                        self.drafts.remove(&chat);
908                        if self.compose.as_ref().is_some_and(|c| c.chat == chat) {
909                            self.compose = None;
910                        }
911                        self.notice = Notice::new(
912                            Tone::Success,
913                            if value["delivery_state"] == "delivered" {
914                                "Delivered; matched the original client message ID."
915                            } else {
916                                "Found queued in Teams; delivery is not confirmed."
917                            },
918                        );
919                    }
920                    result => {
921                        if let Some(compose) = self.compose.as_mut().filter(|c| c.chat == chat) {
922                            compose.stage = Stage::Unconfirmed;
923                        }
924                        self.notice = Notice::new(Tone::Error, match result {
925                            Err(error) => error.to_string(),
926                            Ok(value) if value["found"] == true => "Found the original message in Teams' cache; delivery is still unconfirmed.".into(),
927                            Ok(_) => "Delivery remains uncertain. Checked the newest 50 cached messages; absence does not prove failure.".into(),
928                        });
929                    }
930                }
931            }
932            Outcome::Sent { chat, result } => {
933                match result {
934                    Ok(value) => {
935                        let state = crate::delivery::State::from_message(&value);
936                        if value["source"] == "desktop"
937                            && matches!(
938                                state,
939                                crate::delivery::State::Failed | crate::delivery::State::Unknown
940                            )
941                        {
942                            if let Some(compose) = self.compose.as_mut().filter(|c| c.chat == chat)
943                            {
944                                compose.stage = Stage::Unconfirmed;
945                            }
946                            self.notice = Notice::new(Tone::Error, state.notice());
947                        } else {
948                            if self.compose.as_ref().is_some_and(|c| c.chat == chat) {
949                                self.compose = None;
950                            }
951                            self.drafts.remove(&chat);
952                            self.pending_sends.remove(&chat);
953                            self.notice = Notice::new(
954                                Tone::Success,
955                                if value["source"] == "desktop" {
956                                    state.notice()
957                                } else {
958                                    "Sent."
959                                },
960                            );
961                        }
962                    }
963                    Err(error) => {
964                        let uncertain = matches!(
965                            error,
966                            AppError::Unconfirmed(_) | AppError::UnconfirmedSend { .. }
967                        );
968                        if let Some(compose) = self.compose.as_mut().filter(|c| c.chat == chat) {
969                            compose.stage = if uncertain {
970                                Stage::Unconfirmed
971                            } else {
972                                Stage::Editing
973                            };
974                        }
975                        if !uncertain {
976                            self.pending_sends.remove(&chat);
977                        }
978                        self.notice = Notice::new(Tone::Error, error.to_string());
979                    }
980                }
981                // Even a failed send may have reached Teams, so show what the chat holds now.
982                if self.backend().is_some() {
983                    self.load_chats(false);
984                    self.load_messages(chat, false);
985                }
986            }
987        }
988    }
989
990    /// Recomputes rows and the filtered ledger, keeping the selected chat.
991    fn rebuild(&mut self, keep: Option<&str>) {
992        let before = self.selected_row().map(|row| row.id.clone());
993        let keep = keep.map(String::from).or_else(|| before.clone());
994        let view = self.view();
995        self.rows = self
996            .chats
997            .iter()
998            .map(|chat| ChatRow::new(chat, &view, self.viewer.as_deref()))
999            .collect();
1000        let needle = self.filter.to_lowercase();
1001        self.visible = (0..self.rows.len())
1002            .filter(|&i| {
1003                let row = &self.rows[i];
1004                needle.is_empty()
1005                    || row.title.to_lowercase().contains(&needle)
1006                    || row
1007                        .preview
1008                        .as_deref()
1009                        .is_some_and(|p| p.to_lowercase().contains(&needle))
1010            })
1011            .collect();
1012        let position = keep
1013            .and_then(|id| self.visible.iter().position(|&i| self.rows[i].id == id))
1014            .or_else(|| (!self.visible.is_empty()).then_some(0));
1015        self.list.select(position);
1016        // A reload can move the open chat in the ledger; only a different chat starts afresh.
1017        if self.selected_row().map(|row| row.id.clone()) != before {
1018            self.selected_at = Some(Instant::now());
1019            self.scroll = 0;
1020        }
1021    }
1022
1023    fn selected_row(&self) -> Option<&ChatRow> {
1024        self.list
1025            .selected()
1026            .and_then(|i| self.visible.get(i))
1027            .map(|&i| &self.rows[i])
1028    }
1029
1030    fn select(&mut self, index: usize) {
1031        if self.visible.is_empty() {
1032            return;
1033        }
1034        let index = index.min(self.visible.len() - 1);
1035        if self.list.selected() != Some(index) {
1036            self.list.select(Some(index));
1037            self.selected_at = Some(Instant::now());
1038            self.scroll = 0;
1039        }
1040        if index + 3 >= self.visible.len() && self.filter.is_empty() {
1041            self.load_chats(true);
1042        }
1043    }
1044
1045    fn open_selected(&mut self) {
1046        self.selected_at = None;
1047        // A chat that failed while prefetched gets one fresh attempt when opened.
1048        if let Some(id) = self.selected_row().map(|row| row.id.clone())
1049            && self
1050                .threads
1051                .get(&id)
1052                .is_none_or(|thread| matches!(thread.load, Load::Failed { .. }))
1053        {
1054            self.load_messages(id, false);
1055        }
1056    }
1057
1058    /// Loads the chats either side of the open one, so stepping through the ledger
1059    /// shows them without waiting.
1060    fn prefetch_neighbours(&mut self) {
1061        let Some(index) = self.list.selected() else {
1062            return;
1063        };
1064        let neighbours: Vec<String> = [index.checked_sub(1), index.checked_add(1)]
1065            .into_iter()
1066            .flatten()
1067            .filter_map(|i| self.visible.get(i))
1068            .map(|&i| self.rows[i].id.clone())
1069            .filter(|id| !self.threads.contains_key(id))
1070            .collect();
1071        for id in neighbours {
1072            self.load_messages(id, false);
1073        }
1074    }
1075
1076    fn tick(&mut self) {
1077        self.frame = self.frame.wrapping_add(1);
1078        while let Ok(outcome) = self.outcomes.1.try_recv() {
1079            self.apply(outcome);
1080        }
1081        if let Some(message) = &self.startup_auth_error {
1082            self.chats_load = Load::Failed {
1083                message: message.clone(),
1084                auth: true,
1085            };
1086            self.notice = Notice::new(
1087                Tone::Error,
1088                "Sign-in is needed. Reply drafts are kept while you reconnect.",
1089            );
1090            return;
1091        }
1092        if self
1093            .selected_at
1094            .is_some_and(|at| at.elapsed() >= DWELL || self.focus == Focus::Thread)
1095        {
1096            self.open_selected();
1097        }
1098        if let Some(backend) = self.backend() {
1099            let (thread_every, chats_every) = match backend.as_ref() {
1100                TeamsBackend::Desktop(_) => DESKTOP_POLL,
1101                TeamsBackend::Graph(_) => GRAPH_POLL,
1102            };
1103            if self.chats_polled.elapsed() >= chats_every
1104                && matches!(self.chats_load, Load::Ready)
1105                && !self.loading_more
1106                && !self.fetching_chats
1107            {
1108                self.chats_polled = Instant::now();
1109                self.now = Zoned::now().with_time_zone(TimeZone::system());
1110                self.load_chats(false);
1111            }
1112            if self.thread_polled.elapsed() >= thread_every {
1113                self.thread_polled = Instant::now();
1114                if let Some(id) = self.selected_row().map(|row| row.id.clone())
1115                    && self.threads.get(&id).is_some_and(|t| {
1116                        matches!(t.load, Load::Ready) && !t.fetching && !t.loading_older
1117                    })
1118                {
1119                    self.load_messages(id, false);
1120                }
1121            }
1122        }
1123    }
1124
1125    /// Background prefetches stay quiet; only the open chat shows activity.
1126    fn busy(&self) -> bool {
1127        matches!(self.chats_load, Load::Loading)
1128            || self.loading_more
1129            || self.selected_row().is_some_and(|row| {
1130                self.threads
1131                    .get(&row.id)
1132                    .is_some_and(|t| t.loading_older || matches!(t.load, Load::Loading))
1133            })
1134    }
1135
1136    fn sign_in_needed(&self) -> bool {
1137        matches!(self.chats_load, Load::Failed { auth: true, .. })
1138            || self.selected_row().is_some_and(|row| {
1139                matches!(
1140                    self.threads.get(&row.id).map(|t| &t.load),
1141                    Some(Load::Failed { auth: true, .. })
1142                )
1143            })
1144    }
1145
1146    fn refresh(&mut self) {
1147        self.notice = Notice::new(Tone::Quiet, "Refreshing…");
1148        self.refreshing = true;
1149        self.reload();
1150    }
1151
1152    /// Reloads the ledger and the open chat, keeping older pages, selection and scroll.
1153    fn reload(&mut self) {
1154        self.thread_polled = Instant::now();
1155        self.chats_polled = Instant::now();
1156        if matches!(self.source, Source::Live { .. }) {
1157            self.now = Zoned::now().with_time_zone(TimeZone::system());
1158        }
1159        self.load_chats(false);
1160        if let Some(id) = self.selected_row().map(|row| row.id.clone())
1161            && self.threads.get(&id).is_none_or(|t| !t.loading_older)
1162        {
1163            self.load_messages(id, false);
1164        }
1165    }
1166
1167    fn open_compose(&mut self) {
1168        let Some(chat) = self.selected_row().map(|row| row.id.clone()) else {
1169            return;
1170        };
1171        if !self.can_send() {
1172            self.notice = Notice::new(
1173                Tone::Error,
1174                if self.sending_paused {
1175                    "Sending is paused after a startup check failed. Reopen the inbox to check the connection again; your profile's send permission is unchanged."
1176                } else {
1177                    "This profile is read-only; sending needs a profile created with `teams init --backend desktop --allow-send`."
1178                },
1179            );
1180            return;
1181        }
1182        let text = self.drafts.remove(&chat).unwrap_or_default();
1183        self.focus = Focus::Thread;
1184        let stage = if self.pending_sends.contains_key(&chat) {
1185            Stage::Unconfirmed
1186        } else {
1187            Stage::Editing
1188        };
1189        self.compose = Some(Compose {
1190            chat,
1191            cursor: text.len(),
1192            text,
1193            stage,
1194        });
1195    }
1196
1197    /// Every key but ctrl-c belongs to the composer while it is open.
1198    fn compose_key(&mut self, key: KeyEvent) {
1199        let control = key.modifiers.contains(KeyModifiers::CONTROL);
1200        let Some(compose) = self.compose.as_mut() else {
1201            return;
1202        };
1203        match compose.stage {
1204            Stage::Sending | Stage::Checking => {}
1205            Stage::ConfirmResend => {
1206                if key.code == KeyCode::Char('y') {
1207                    self.send();
1208                } else {
1209                    compose.stage = Stage::Unconfirmed;
1210                    self.notice = Notice::new(
1211                        Tone::Quiet,
1212                        "Resend cancelled; delivery is still uncertain.",
1213                    );
1214                }
1215            }
1216            Stage::Unconfirmed => match key.code {
1217                KeyCode::Char('r') => self.check_delivery(),
1218                KeyCode::Char('s') => {
1219                    compose.stage = Stage::ConfirmResend;
1220                    self.notice = Notice::new(
1221                        Tone::Error,
1222                        "The original may still arrive. Send a second copy? Press y to resend; any other key cancels.",
1223                    );
1224                }
1225                KeyCode::Esc => {
1226                    let compose = self.compose.take().expect("composer is open");
1227                    self.drafts.insert(compose.chat, compose.text);
1228                    self.notice = Notice::new(
1229                        Tone::Quiet,
1230                        "Possibly-sent draft kept; c reopens delivery checks.",
1231                    );
1232                }
1233                _ => {
1234                    self.notice = Notice::new(
1235                        Tone::Error,
1236                        "Possibly sent: r checks delivery; s explicitly resends; esc keeps the draft.",
1237                    );
1238                }
1239            },
1240            Stage::Editing => match key.code {
1241                KeyCode::Esc => {
1242                    let compose = self.compose.take().expect("composer is open");
1243                    if !compose.text.trim().is_empty() {
1244                        self.drafts.insert(compose.chat, compose.text);
1245                        self.notice = Notice::new(Tone::Quiet, "Draft kept; c continues it.");
1246                    }
1247                }
1248                KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => compose.insert('\n'),
1249                KeyCode::Char('j') if control => compose.insert('\n'),
1250                KeyCode::Enter => {
1251                    let length = compose.text.chars().count();
1252                    if compose.text.trim().is_empty() {
1253                        self.notice = Notice::new(Tone::Quiet, "Write something first.");
1254                    } else if length > MAX_BODY {
1255                        self.notice = Notice::new(
1256                            Tone::Error,
1257                            format!("Too long to send: {length} of at most {MAX_BODY} characters."),
1258                        );
1259                    } else {
1260                        self.send();
1261                    }
1262                }
1263                KeyCode::Backspace => {
1264                    if let Some(c) = compose.text[..compose.cursor].chars().next_back() {
1265                        compose.cursor -= c.len_utf8();
1266                        compose.text.remove(compose.cursor);
1267                    }
1268                }
1269                KeyCode::Delete if compose.cursor < compose.text.len() => {
1270                    compose.text.remove(compose.cursor);
1271                }
1272                KeyCode::Left => {
1273                    if let Some(c) = compose.text[..compose.cursor].chars().next_back() {
1274                        compose.cursor -= c.len_utf8();
1275                    }
1276                }
1277                KeyCode::Right => {
1278                    if let Some(c) = compose.text[compose.cursor..].chars().next() {
1279                        compose.cursor += c.len_utf8();
1280                    }
1281                }
1282                KeyCode::Home => compose.cursor = 0,
1283                KeyCode::End => compose.cursor = compose.text.len(),
1284                KeyCode::Char(c) if !control && c != '\0' => compose.insert(c),
1285                _ => {}
1286            },
1287        }
1288    }
1289
1290    /// Bracketed paste is text, never a sequence of commands or send keystrokes.
1291    fn paste(&mut self, text: &str) {
1292        if let Some(compose) = self.compose.as_mut().filter(|c| c.stage == Stage::Editing) {
1293            let text = text.replace("\r\n", "\n").replace('\r', "\n");
1294            for c in text
1295                .chars()
1296                .filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
1297            {
1298                compose.insert(c);
1299            }
1300        }
1301    }
1302
1303    fn send(&mut self) {
1304        if !self.can_send() {
1305            self.notice = Notice::new(
1306                Tone::Error,
1307                "Sending is paused; reconnect Teams before sending this draft",
1308            );
1309            return;
1310        }
1311        let Some(compose) = self.compose.as_mut() else {
1312            return;
1313        };
1314        compose.stage = Stage::Sending;
1315        let (chat, text) = (compose.chat.clone(), compose.text.clone());
1316        let id = crate::delivery::client_message_id();
1317        self.pending_sends.insert(chat.clone(), id.clone());
1318        self.notice = Notice::new(Tone::Quiet, "Sending…");
1319        match &self.source {
1320            Source::Live {
1321                backend, profile, ..
1322            } => {
1323                let (backend, profile) = (backend.clone(), profile.clone());
1324                let sender = self.outcomes.0.clone();
1325                tokio::spawn(async move {
1326                    let result = async {
1327                        config::load(Some(&profile))?.1.require_writable()?;
1328                        backend.send_with_id(&chat, &text, &id).await
1329                    }
1330                    .await;
1331                    let _ = sender.send(Outcome::Sent { chat, result });
1332                });
1333            }
1334            Source::Demo => {
1335                let message = json!({
1336                    "id": format!("{chat}/sent{}", self.frame),
1337                    "messageType": "message",
1338                    "createdDateTime": self.now.timestamp().to_string(),
1339                    "from": {"user": demo_person("me")},
1340                    "body": {"contentType": "text", "content": text},
1341                });
1342                if let Some(thread) = self.threads.get_mut(&chat) {
1343                    thread.items.insert(0, message.clone());
1344                }
1345                self.apply(Outcome::Sent {
1346                    chat,
1347                    result: Ok(message),
1348                });
1349            }
1350        }
1351    }
1352
1353    fn check_delivery(&mut self) {
1354        let Some(compose) = self.compose.as_mut() else {
1355            return;
1356        };
1357        let chat = compose.chat.clone();
1358        let Some(id) = self.pending_sends.get(&chat).cloned() else {
1359            self.notice = Notice::new(
1360                Tone::Error,
1361                "No correlation ID is available; inspect the chat before explicitly resending.",
1362            );
1363            return;
1364        };
1365        compose.stage = Stage::Checking;
1366        self.notice = Notice::new(Tone::Quiet, "Checking the original client message ID…");
1367        if let Some(backend) = self.backend() {
1368            let sender = self.outcomes.0.clone();
1369            tokio::spawn(async move {
1370                let result = backend.delivery(&chat, &id).await;
1371                let _ = sender.send(Outcome::Delivery { chat, id, result });
1372            });
1373        } else {
1374            let messages = self
1375                .threads
1376                .get(&chat)
1377                .map(|t| t.items.as_slice())
1378                .unwrap_or_default();
1379            let result = Ok(crate::delivery::reconcile(&chat, &id, messages));
1380            self.apply(Outcome::Delivery { chat, id, result });
1381        }
1382    }
1383
1384    fn restart_teams(&mut self) {
1385        let Some(backend) = self.backend() else {
1386            return;
1387        };
1388        self.debugging_restart = Some(DebuggingRestart::Running);
1389        self.notice = Notice::new(
1390            Tone::Quiet,
1391            "Restarting Teams and waiting for the connection…",
1392        );
1393        let sender = self.outcomes.0.clone();
1394        tokio::spawn(async move {
1395            if let TeamsBackend::Desktop(client) = backend.as_ref() {
1396                let _ = sender.send(Outcome::Restarted(client.restart_with_debugging().await));
1397            }
1398        });
1399    }
1400
1401    fn clear_flags(&mut self) {
1402        let Some(backend) = self.backend() else {
1403            return;
1404        };
1405        if let Some(cleanup) = &mut self.flag_cleanup {
1406            cleanup.stage = Cleanup::Running;
1407        }
1408        self.notice = Notice::new(Tone::Quiet, "Removing debugging flags…");
1409        let sender = self.outcomes.0.clone();
1410        tokio::spawn(async move {
1411            if let TeamsBackend::Desktop(client) = backend.as_ref() {
1412                let _ = sender.send(Outcome::FlagsCleared(client.clear_debugging_flags().await));
1413            }
1414        });
1415    }
1416
1417    fn handle_key(&mut self, key: KeyEvent) -> Action {
1418        let control = key.modifiers.contains(KeyModifiers::CONTROL);
1419        if control && key.code == KeyCode::Char('c') {
1420            return Action::Quit;
1421        }
1422        if self.startup_auth_error.is_some() {
1423            return match key.code {
1424                KeyCode::Enter | KeyCode::Char('a') => Action::Authenticate,
1425                KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
1426                _ => Action::None,
1427            };
1428        }
1429        if let Some(restart) = self.debugging_restart {
1430            match (restart, key.code) {
1431                (_, KeyCode::Char('q')) => return Action::Quit,
1432                (DebuggingRestart::Offered, KeyCode::Enter) => {
1433                    self.debugging_restart = Some(DebuggingRestart::Confirming);
1434                }
1435                (DebuggingRestart::Confirming, KeyCode::Char('y')) => self.restart_teams(),
1436                (DebuggingRestart::Confirming, _) => {
1437                    self.debugging_restart = Some(DebuggingRestart::Offered);
1438                }
1439                (DebuggingRestart::Offered, KeyCode::Char('r')) => self.load_chats(false),
1440                (DebuggingRestart::Offered, KeyCode::Esc) => return Action::Quit,
1441                _ => {}
1442            }
1443            return Action::None;
1444        }
1445        if let Some(stage) = self.flag_cleanup.as_ref().map(|cleanup| cleanup.stage) {
1446            match (stage, key.code) {
1447                (_, KeyCode::Char('q')) => return Action::Quit,
1448                (Cleanup::Offered, KeyCode::Enter) => {
1449                    if let Some(cleanup) = &mut self.flag_cleanup {
1450                        cleanup.stage = Cleanup::Confirming;
1451                    }
1452                }
1453                (Cleanup::Confirming, KeyCode::Char('y')) => self.clear_flags(),
1454                (Cleanup::Confirming, _) => {
1455                    if let Some(cleanup) = &mut self.flag_cleanup {
1456                        cleanup.stage = Cleanup::Offered;
1457                    }
1458                }
1459                (Cleanup::Offered | Cleanup::Done, KeyCode::Char('r')) => self.load_chats(false),
1460                (Cleanup::Offered | Cleanup::Done, KeyCode::Esc) => return Action::Quit,
1461                _ => {}
1462            }
1463            return Action::None;
1464        }
1465        if self.compose.is_some() {
1466            self.compose_key(key);
1467            return Action::None;
1468        }
1469        match self.overlay {
1470            Some(Overlay::Help) => {
1471                if matches!(
1472                    key.code,
1473                    KeyCode::Char('?' | 'q') | KeyCode::Esc | KeyCode::Enter
1474                ) {
1475                    self.overlay = None;
1476                }
1477                return Action::None;
1478            }
1479            Some(Overlay::Filter) => {
1480                match key.code {
1481                    KeyCode::Esc => {
1482                        self.filter.clear();
1483                        self.overlay = None;
1484                        self.rebuild(None);
1485                    }
1486                    KeyCode::Enter => self.overlay = None,
1487                    KeyCode::Backspace => {
1488                        self.filter.pop();
1489                        self.rebuild(None);
1490                    }
1491                    KeyCode::Down => self.select(self.list.selected().map_or(0, |i| i + 1)),
1492                    KeyCode::Up => self.select(self.list.selected().unwrap_or(0).saturating_sub(1)),
1493                    KeyCode::Char(c) if !control => {
1494                        self.filter.push(c);
1495                        self.rebuild(None);
1496                    }
1497                    _ => {}
1498                }
1499                return Action::None;
1500            }
1501            None => {}
1502        }
1503        let half = (self.thread_height / 2).max(1);
1504        let in_thread = self.focus == Focus::Thread;
1505        match key.code {
1506            KeyCode::Char('q') => return Action::Quit,
1507            KeyCode::Char('?') => self.overlay = Some(Overlay::Help),
1508            KeyCode::Char('/') => {
1509                self.focus = Focus::Chats;
1510                self.overlay = Some(Overlay::Filter);
1511            }
1512            KeyCode::Char('r') => self.refresh(),
1513            KeyCode::Char('c') => self.open_compose(),
1514            KeyCode::Enter | KeyCode::Char('a') if self.sign_in_needed() => {
1515                return Action::Authenticate;
1516            }
1517            KeyCode::Tab | KeyCode::BackTab => {
1518                self.focus = if in_thread || self.selected_row().is_none() {
1519                    Focus::Chats
1520                } else {
1521                    Focus::Thread
1522                };
1523            }
1524            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
1525                if self.selected_row().is_some() {
1526                    self.focus = Focus::Thread;
1527                    self.open_selected();
1528                }
1529            }
1530            KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => {
1531                if in_thread {
1532                    self.focus = Focus::Chats;
1533                } else if !self.filter.is_empty() && key.code == KeyCode::Esc {
1534                    self.filter.clear();
1535                    self.rebuild(None);
1536                }
1537            }
1538            KeyCode::Char('d') if control => self.scroll = self.scroll.saturating_sub(half),
1539            KeyCode::Char('u') if control => self.scroll += half,
1540            KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(half),
1541            KeyCode::PageUp => self.scroll += half,
1542            KeyCode::Down | KeyCode::Char('j') if in_thread => {
1543                self.scroll = self.scroll.saturating_sub(1)
1544            }
1545            KeyCode::Up | KeyCode::Char('k') if in_thread => self.scroll += 1,
1546            KeyCode::Down | KeyCode::Char('j') => {
1547                self.select(self.list.selected().map_or(0, |i| i + 1))
1548            }
1549            KeyCode::Up | KeyCode::Char('k') => {
1550                self.select(self.list.selected().unwrap_or(0).saturating_sub(1))
1551            }
1552            KeyCode::Home | KeyCode::Char('g') if in_thread => self.scroll = usize::MAX,
1553            KeyCode::End | KeyCode::Char('G') if in_thread => self.scroll = 0,
1554            KeyCode::Home | KeyCode::Char('g') => self.select(0),
1555            KeyCode::End | KeyCode::Char('G') => self.select(usize::MAX),
1556            KeyCode::Char('o') => self.load_older(true),
1557            _ => {}
1558        }
1559        Action::None
1560    }
1561
1562    /// Reads the page before the oldest loaded message; `announce` also says when
1563    /// there is none, which the wheel stays quiet about.
1564    fn load_older(&mut self, announce: bool) {
1565        let Some(id) = self.selected_row().map(|row| row.id.clone()) else {
1566            return;
1567        };
1568        match self.threads.get(&id) {
1569            Some(thread) if thread.loading_older => {}
1570            Some(thread) if thread.cursor.is_some() => {
1571                self.notice = Notice::new(Tone::Quiet, "Reading older messages…");
1572                self.load_messages(id, true);
1573            }
1574            _ if announce => {
1575                self.notice = Notice::new(Tone::Quiet, "No older messages to load.");
1576            }
1577            _ => {}
1578        }
1579    }
1580
1581    /// The wheel scrolls whichever pane is under the pointer, and a click picks a
1582    /// chat or focuses the conversation. The composer keeps its chat, so clicks on
1583    /// the ledger wait until it is closed.
1584    fn handle_mouse(&mut self, mouse: MouseEvent) {
1585        let over = |area: Option<Rect>| {
1586            area.is_some_and(|a| {
1587                a.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
1588            })
1589        };
1590        if self.overlay == Some(Overlay::Help) {
1591            if matches!(mouse.kind, MouseEventKind::Down(_)) {
1592                self.overlay = None;
1593            }
1594            return;
1595        }
1596        let in_thread = over(self.thread_area);
1597        let in_ledger = over(self.ledger_area);
1598        match mouse.kind {
1599            MouseEventKind::ScrollUp if in_thread => {
1600                if self.scroll >= self.thread_overflow {
1601                    self.load_older(false);
1602                } else {
1603                    self.scroll = (self.scroll + WHEEL_LINES).min(self.thread_overflow);
1604                }
1605            }
1606            MouseEventKind::ScrollDown if in_thread => {
1607                self.scroll = self.scroll.saturating_sub(WHEEL_LINES);
1608            }
1609            MouseEventKind::ScrollUp if in_ledger && self.compose.is_none() => {
1610                self.select(self.list.selected().unwrap_or(0).saturating_sub(1));
1611            }
1612            MouseEventKind::ScrollDown if in_ledger && self.compose.is_none() => {
1613                self.select(self.list.selected().map_or(0, |i| i + 1));
1614            }
1615            MouseEventKind::Down(MouseButton::Left) if in_thread => {
1616                if self.selected_row().is_some() {
1617                    self.focus = Focus::Thread;
1618                }
1619            }
1620            MouseEventKind::Down(MouseButton::Left) if in_ledger && self.compose.is_none() => {
1621                let Some(area) = self.ledger_area else {
1622                    return;
1623                };
1624                let row = usize::from((mouse.row - area.y) / CHAT_ROW_HEIGHT);
1625                let index = self.list.offset() + row;
1626                if index < self.visible.len() {
1627                    self.select(index);
1628                    self.focus = Focus::Thread;
1629                    self.open_selected();
1630                }
1631            }
1632            _ => {}
1633        }
1634    }
1635
1636    fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
1637        let area = frame.area();
1638        let theme = self.theme;
1639        self.ledger_area = None;
1640        self.thread_area = None;
1641        frame.render_widget(Block::default().style(theme.canvas()), area);
1642        if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
1643            let message = Paragraph::new(vec![
1644                Line::styled("NEEDS MORE ROOM", theme.title()),
1645                Line::styled(
1646                    format!("Make the terminal at least {MIN_WIDTH}×{MIN_HEIGHT}."),
1647                    theme.muted(),
1648                ),
1649            ])
1650            .alignment(Alignment::Center)
1651            .wrap(Wrap { trim: true });
1652            frame.render_widget(message, centered(area, area.width, 2));
1653            return;
1654        }
1655        let [header, body, footer] = Layout::vertical([
1656            Constraint::Length(3),
1657            Constraint::Min(4),
1658            Constraint::Length(3),
1659        ])
1660        .areas(area);
1661        self.draw_header(frame, header);
1662        if let Some(restart) = self.debugging_restart {
1663            let (title, copy) = match restart {
1664                DebuggingRestart::Offered => (
1665                    "CONNECT TEAMS",
1666                    "Restart Teams with debugging enabled to read your chats.",
1667                ),
1668                DebuggingRestart::Confirming => (
1669                    "RESTART TEAMS?",
1670                    "This ends any call in progress. Restart now?",
1671                ),
1672                DebuggingRestart::Running => (
1673                    "RESTARTING TEAMS",
1674                    "Waiting for Teams to open its debugging port…",
1675                ),
1676            };
1677            state_panel(
1678                frame,
1679                body,
1680                theme,
1681                title,
1682                vec![
1683                    Line::styled(copy, theme.body()),
1684                    Line::styled(
1685                        "While enabled, programs running as you can read chats and send as you.",
1686                        theme.muted(),
1687                    ),
1688                ],
1689                theme.active_rule(),
1690            );
1691        } else if let Some(cleanup) = &self.flag_cleanup {
1692            let apps = cleanup.apps();
1693            let (title, copy) = match cleanup.stage {
1694                Cleanup::Offered => (
1695                    "DEBUGGING LEFT ON",
1696                    format!("{apps} holds the Teams port through a user-wide WebView2 debugging setting. Remove just those flags?"),
1697                ),
1698                Cleanup::Confirming => (
1699                    "REMOVE DEBUGGING FLAGS?",
1700                    "Edits the user-wide WebView2 arguments and keeps its other arguments. Remove now?".into(),
1701                ),
1702                Cleanup::Running => ("REMOVING FLAGS", "Updating the user-wide setting…".into()),
1703                Cleanup::Done => (
1704                    "FLAGS REMOVED",
1705                    format!("Quit {apps} completely and reopen, then press r."),
1706                ),
1707            };
1708            state_panel(
1709                frame,
1710                body,
1711                theme,
1712                title,
1713                vec![
1714                    Line::styled(copy, theme.body()),
1715                    Line::styled("Apps keep the port until fully quit.", theme.muted()),
1716                ],
1717                theme.active_rule(),
1718            );
1719        } else {
1720            match &self.chats_load {
1721                Load::Ready => {
1722                    if body.width >= SPLIT_WIDTH {
1723                        let ledger = (body.width * 38 / 100).clamp(34, 48);
1724                        let [left, right] =
1725                            Layout::horizontal([Constraint::Length(ledger), Constraint::Min(30)])
1726                                .areas(body);
1727                        self.draw_ledger(frame, left, true);
1728                        self.draw_thread(frame, right);
1729                    } else if self.focus == Focus::Thread {
1730                        self.draw_thread(frame, body);
1731                    } else {
1732                        self.draw_ledger(frame, body, false);
1733                    }
1734                }
1735                Load::Loading => state_panel(
1736                    frame,
1737                    body,
1738                    theme,
1739                    "READING CHATS",
1740                    vec![Line::styled(
1741                        format!("{}  Asking Teams for your recent chats", self.spinner()),
1742                        theme.muted(),
1743                    )],
1744                    theme.rule(),
1745                ),
1746                Load::Failed { message, auth } => {
1747                    let (title, lines) = failure_copy(theme, message, *auth);
1748                    state_panel(frame, body, theme, title, lines, theme.active_rule());
1749                }
1750            }
1751        }
1752        self.draw_footer(frame, footer);
1753        if self.overlay == Some(Overlay::Help) {
1754            self.draw_help(frame);
1755        }
1756    }
1757
1758    fn spinner(&self) -> &'static str {
1759        SPINNER[self.frame % SPINNER.len()]
1760    }
1761
1762    fn draw_header(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
1763        let theme = self.theme;
1764        let width = usize::from(area.width);
1765        let spans_width = |spans: &[Span]| spans.iter().map(Span::width).sum::<usize>();
1766        let separator = || Span::styled("  ·  ", theme.rule());
1767
1768        // Right: the send mode always shows, with a one-column margin like the left.
1769        let mut right = if self.can_send() {
1770            vec![Span::styled("✎ can send", theme.badge_send())]
1771        } else if self.sending_paused {
1772            vec![Span::styled("sending paused", theme.error())]
1773        } else {
1774            vec![Span::styled("read-only", theme.muted())]
1775        };
1776        right.push(Span::raw(" "));
1777        let brand = Span::styled(" TEAMS ", theme.brand());
1778        let fixed = brand.width() + 2 + spans_width(&right) + 2;
1779        let profile_width = self.profile().width();
1780        // Then freshness, as long as some of the profile name still fits beside it.
1781        if let Some(mut freshness) = self.freshness() {
1782            let needed = spans_width(&freshness) + 3;
1783            if fixed + needed + profile_width.min(10) <= width {
1784                freshness.push(Span::raw("   "));
1785                freshness.extend(right);
1786                right = freshness;
1787            }
1788        }
1789        let used = brand.width() + 2 + spans_width(&right) + 2;
1790        let mut left = vec![
1791            brand,
1792            Span::raw("  "),
1793            Span::styled(
1794                render::truncate(self.profile(), width.saturating_sub(used)),
1795                theme.title(),
1796            ),
1797        ];
1798        // Last, the signed-in account next to the profile when there is room.
1799        if let Some(account) = &self.account
1800            && used + profile_width + 5 + account.width() <= width
1801        {
1802            left.push(separator());
1803            left.push(Span::styled(account.clone(), theme.muted()));
1804        }
1805        let gap = width.saturating_sub(spans_width(&left) + spans_width(&right));
1806        let mut title = left;
1807        title.push(Span::raw(" ".repeat(gap)));
1808        title.extend(right);
1809
1810        let mut context = vec![Span::raw(" ")];
1811        for (index, part) in self.context().into_iter().enumerate() {
1812            let extra = if index == 0 { 0 } else { 5 };
1813            if spans_width(&context) + extra + spans_width(&part) + 1 > width {
1814                break;
1815            }
1816            if index > 0 {
1817                context.push(separator());
1818            }
1819            context.extend(part);
1820        }
1821        let header = Paragraph::new(vec![Line::from(title), Line::from(context)]).block(
1822            Block::default()
1823                .borders(Borders::BOTTOM)
1824                .border_style(theme.rule()),
1825        );
1826        frame.render_widget(header, area);
1827    }
1828
1829    fn draw_ledger(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect, split: bool) {
1830        let theme = self.theme;
1831        let active = self.focus == Focus::Chats || !split;
1832        let block = Block::default()
1833            .borders(if split { Borders::RIGHT } else { Borders::NONE })
1834            .border_style(if active {
1835                theme.active_rule()
1836            } else {
1837                theme.rule()
1838            });
1839        let inner = block.inner(area);
1840        frame.render_widget(block, area);
1841        let [heading, list_area] =
1842            Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
1843        let label = if self.filter.is_empty() {
1844            " RECENT".to_string()
1845        } else {
1846            format!(" MATCHING “{}”", self.filter)
1847        };
1848        let count = format!("{} ", self.visible.len());
1849        let gap = usize::from(heading.width).saturating_sub(label.width() + count.width());
1850        frame.render_widget(
1851            Paragraph::new(Line::from(vec![
1852                Span::styled(label, theme.column_header()),
1853                Span::styled(" ".repeat(gap), theme.column_header()),
1854                Span::styled(count, theme.column_header()),
1855            ])),
1856            heading,
1857        );
1858        if self.visible.is_empty() {
1859            let text = if self.filter.is_empty() {
1860                vec![
1861                    Line::styled("No chats yet", theme.title()),
1862                    Line::styled("Teams has nothing cached for this profile.", theme.muted()),
1863                ]
1864            } else {
1865                vec![
1866                    Line::styled("Nothing matches", theme.title()),
1867                    Line::styled("esc clears the filter", theme.muted()),
1868                ]
1869            };
1870            frame.render_widget(
1871                Paragraph::new(text)
1872                    .alignment(Alignment::Center)
1873                    .wrap(Wrap { trim: true }),
1874                centered(list_area, list_area.width, 2),
1875            );
1876            return;
1877        }
1878        let width = usize::from(list_area.width);
1879        let selected = self.list.selected();
1880        let mut items: Vec<ListItem> = self
1881            .visible
1882            .iter()
1883            .enumerate()
1884            .map(|(position, &i)| {
1885                chat_item(
1886                    &self.rows[i],
1887                    width,
1888                    selected == Some(position),
1889                    active,
1890                    theme,
1891                )
1892            })
1893            .collect();
1894        if self.chat_cursor.is_some() {
1895            items.push(ListItem::new(Line::styled(
1896                if self.loading_more {
1897                    format!("   {} reading more chats", self.spinner())
1898                } else {
1899                    "   more chats load as you reach the end".into()
1900                },
1901                theme.muted(),
1902            )));
1903        }
1904        frame.render_stateful_widget(List::new(items), list_area, &mut self.list);
1905        self.ledger_area = Some(list_area);
1906    }
1907
1908    fn draw_thread(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
1909        let theme = self.theme;
1910        self.thread_area = Some(area);
1911        self.thread_overflow = 0;
1912        let area = Rect {
1913            x: area.x + 2,
1914            width: area.width.saturating_sub(3),
1915            ..area
1916        };
1917        let Some(row) = self.selected_row() else {
1918            frame.render_widget(
1919                Paragraph::new(vec![
1920                    Line::styled("No chat selected", theme.title()),
1921                    Line::styled("Pick a chat on the left to read it here.", theme.muted()),
1922                ])
1923                .alignment(Alignment::Center),
1924                centered(area, area.width, 2),
1925            );
1926            return;
1927        };
1928        let [heading, body] =
1929            Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(area);
1930        let width = usize::from(heading.width);
1931        let kind = match (row.kind, row.members) {
1932            (Some(kind), n) if n > 0 => format!("{kind} chat  ·  {n} people"),
1933            (Some(kind), _) => format!("{kind} chat"),
1934            (None, _) => "one-to-one chat".into(),
1935        };
1936        let mut meta = vec![Span::styled(kind, theme.muted())];
1937        if self.scroll > 0 {
1938            meta.push(Span::styled("  ·  ", theme.muted()));
1939            meta.push(Span::styled("G", theme.key()));
1940            meta.push(Span::styled(" newest", theme.muted()));
1941        }
1942        frame.render_widget(
1943            Paragraph::new(vec![
1944                Line::styled(render::truncate(&row.title, width), theme.detail_title()),
1945                Line::from(meta),
1946            ])
1947            .block(Block::default().borders(Borders::BOTTOM).border_style(
1948                if self.focus == Focus::Thread {
1949                    theme.active_rule()
1950                } else {
1951                    theme.rule()
1952                },
1953            )),
1954            heading,
1955        );
1956        let mut body = Rect {
1957            y: body.y + 1,
1958            height: body.height.saturating_sub(1),
1959            ..body
1960        };
1961        if let Some(compose) = self.compose.as_ref().filter(|c| c.chat == row.id) {
1962            let (rows, (line, column)) =
1963                layout_draft(&compose.text, compose.cursor, usize::from(body.width));
1964            let shown = rows.len().min(COMPOSE_ROWS);
1965            let [conversation, editor] =
1966                Layout::vertical([Constraint::Min(1), Constraint::Length(shown as u16 + 1)])
1967                    .areas(body);
1968            body = conversation;
1969            let title = match compose.stage {
1970                Stage::Editing => " MESSAGE ",
1971                Stage::Sending => " SENDING ",
1972                Stage::Unconfirmed => " POSSIBLY SENT ",
1973                Stage::Checking => " CHECKING DELIVERY ",
1974                Stage::ConfirmResend => " RESEND? ",
1975            };
1976            let block = Block::default()
1977                .borders(Borders::TOP)
1978                .border_style(theme.active_rule())
1979                .title(Span::styled(title, theme.column_header()));
1980            let inner = block.inner(editor);
1981            frame.render_widget(block, editor);
1982            let start = (line + 1).saturating_sub(COMPOSE_ROWS);
1983            let lines: Vec<Line> = if compose.text.is_empty() {
1984                vec![Line::styled("Write a message…", theme.muted())]
1985            } else {
1986                rows.into_iter()
1987                    .skip(start)
1988                    .take(COMPOSE_ROWS)
1989                    .map(|row| Line::styled(row, theme.body()))
1990                    .collect()
1991            };
1992            frame.render_widget(Paragraph::new(lines), inner);
1993            if compose.stage == Stage::Editing {
1994                let x = inner.x + (column as u16).min(inner.width.saturating_sub(1));
1995                frame.set_cursor_position((x, inner.y + (line - start) as u16));
1996            }
1997        }
1998        let id = row.id.clone();
1999        self.thread_height = usize::from(body.height);
2000        let Some(thread) = self.threads.get(&id) else {
2001            frame.render_widget(
2002                Paragraph::new(Line::styled("…", theme.muted())).alignment(Alignment::Center),
2003                centered(body, body.width, 1),
2004            );
2005            return;
2006        };
2007        match &thread.load {
2008            Load::Loading => {
2009                frame.render_widget(
2010                    Paragraph::new(Line::styled(
2011                        format!("{}  Reading messages", self.spinner()),
2012                        theme.muted(),
2013                    ))
2014                    .alignment(Alignment::Center),
2015                    centered(body, body.width, 1),
2016                );
2017                return;
2018            }
2019            Load::Failed { message, auth } => {
2020                let (title, lines) = failure_copy(theme, message, *auth);
2021                let mut text = vec![Line::styled(title, theme.error()), Line::raw("")];
2022                text.extend(lines);
2023                frame.render_widget(
2024                    Paragraph::new(text)
2025                        .alignment(Alignment::Center)
2026                        .wrap(Wrap { trim: true }),
2027                    centered(body, body.width, 6),
2028                );
2029                return;
2030            }
2031            Load::Ready => {}
2032        }
2033        let entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
2034        let mut lines = Vec::new();
2035        if thread.loading_older {
2036            lines.push(
2037                Line::styled(
2038                    format!("{}  reading older messages", self.spinner()),
2039                    theme.muted(),
2040                )
2041                .alignment(Alignment::Center),
2042            );
2043        } else if thread.cursor.is_some() {
2044            lines.push(
2045                Line::from(vec![
2046                    Span::styled("older messages  ·  ", theme.muted()),
2047                    Span::styled("o", theme.key()),
2048                    Span::styled(" load", theme.muted()),
2049                ])
2050                .alignment(Alignment::Center),
2051            );
2052        }
2053        if entries.is_empty() {
2054            lines.push(Line::raw(""));
2055            lines.push(Line::styled("No messages in this chat yet.", theme.muted()).centered());
2056        }
2057        lines.extend(conversation_lines(&entries, usize::from(body.width), theme));
2058        let height = usize::from(body.height);
2059        let overflow = lines.len().saturating_sub(height);
2060        self.thread_overflow = overflow;
2061        self.scroll = self.scroll.min(overflow);
2062        let start = overflow - self.scroll;
2063        let mut shown: Vec<Line> = lines.into_iter().skip(start).take(height).collect();
2064        // Short conversations sit at the bottom, next to where new messages arrive.
2065        let pad = height.saturating_sub(shown.len());
2066        shown.splice(0..0, std::iter::repeat_n(Line::raw(""), pad));
2067        frame.render_widget(Paragraph::new(shown), body);
2068    }
2069
2070    fn draw_footer(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2071        let theme = self.theme;
2072        let block = Block::default()
2073            .borders(Borders::TOP)
2074            .border_style(theme.rule());
2075        let inner = block.inner(area);
2076        frame.render_widget(block, area);
2077        let [status, keys] =
2078            Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
2079        if self.overlay == Some(Overlay::Filter) {
2080            let prompt = Line::from(vec![
2081                Span::styled("› filter ", theme.key()),
2082                Span::styled(self.filter.clone(), theme.body()),
2083            ]);
2084            frame.render_widget(Paragraph::new(prompt), status);
2085            let x = status.x + 9 + self.filter.width() as u16;
2086            frame.set_cursor_position((x.min(status.right().saturating_sub(1)), status.y));
2087        } else {
2088            let style = match self.notice.tone {
2089                Tone::Quiet => theme.muted(),
2090                Tone::Success => theme.success(),
2091                Tone::Error => theme.error(),
2092            };
2093            frame.render_widget(
2094                Paragraph::new(Line::styled(
2095                    render::truncate(&self.notice.text, usize::from(status.width)),
2096                    style,
2097                )),
2098                status,
2099            );
2100        }
2101        frame.render_widget(Paragraph::new(self.hints(usize::from(keys.width))), keys);
2102    }
2103
2104    /// Key hints for the current context, dropping the least important ones
2105    /// first when the terminal is narrow. Lower rank means more important.
2106    fn hints(&self, width: usize) -> Line<'static> {
2107        let mut all: Vec<(&str, &str, u8)> = if let Some(restart) = self.debugging_restart {
2108            match restart {
2109                DebuggingRestart::Offered => vec![
2110                    ("enter", "restart Teams", 0),
2111                    ("r", "retry", 1),
2112                    ("q", "quit", 0),
2113                ],
2114                DebuggingRestart::Confirming => vec![
2115                    ("y", "restart Teams", 0),
2116                    ("esc", "cancel", 0),
2117                    ("q", "quit", 1),
2118                ],
2119                DebuggingRestart::Running => vec![("q", "quit", 0)],
2120            }
2121        } else if let Some(cleanup) = &self.flag_cleanup {
2122            match cleanup.stage {
2123                Cleanup::Offered => vec![
2124                    ("enter", "remove flags", 0),
2125                    ("r", "retry", 1),
2126                    ("q", "quit", 0),
2127                ],
2128                Cleanup::Confirming => vec![
2129                    ("y", "remove flags", 0),
2130                    ("esc", "cancel", 0),
2131                    ("q", "quit", 1),
2132                ],
2133                Cleanup::Running => vec![("q", "quit", 0)],
2134                Cleanup::Done => vec![("r", "retry", 0), ("q", "quit", 0)],
2135            }
2136        } else if let Some(compose) = &self.compose {
2137            match compose.stage {
2138                Stage::Editing => vec![
2139                    ("enter", "send", 0),
2140                    ("ctrl-j", "new line", 1),
2141                    ("esc", "close, keep draft", 0),
2142                ],
2143                Stage::Sending | Stage::Checking => vec![("ctrl-c", "quit", 1)],
2144                Stage::Unconfirmed => vec![
2145                    ("r", "check", 0),
2146                    ("s", "resend…", 0),
2147                    ("esc", "keep draft", 1),
2148                ],
2149                Stage::ConfirmResend => vec![("y", "send second copy", 0), ("esc", "cancel", 0)],
2150            }
2151        } else if self.overlay == Some(Overlay::Filter) {
2152            vec![("enter", "keep", 0), ("esc", "clear", 0), ("↑↓", "move", 1)]
2153        } else if self.sign_in_needed() {
2154            vec![
2155                ("enter / a", "sign in", 0),
2156                ("r", "retry", 1),
2157                ("q", "quit", 0),
2158            ]
2159        } else if self.focus == Focus::Thread {
2160            vec![
2161                ("↑↓", "scroll", 1),
2162                ("pgup/pgdn", "page", 2),
2163                ("o", "older", 1),
2164                ("esc", "chats", 0),
2165                ("r", "refresh", 2),
2166                ("?", "help", 1),
2167                ("q", "quit", 0),
2168            ]
2169        } else {
2170            vec![
2171                ("↑↓", "move", 1),
2172                ("enter", "read", 0),
2173                ("/", "filter", 1),
2174                ("tab", "switch pane", 3),
2175                ("r", "refresh", 2),
2176                ("?", "help", 1),
2177                ("q", "quit", 0),
2178            ]
2179        };
2180        if self.debugging_restart.is_none()
2181            && self.flag_cleanup.is_none()
2182            && self.compose.is_none()
2183            && self.overlay.is_none()
2184            && !self.sign_in_needed()
2185            && self.can_send()
2186        {
2187            let rank = if self.focus == Focus::Thread { 0 } else { 2 };
2188            all.insert(all.len() - 3, ("c", "reply", rank));
2189        }
2190        let mut chosen = vec![false; all.len()];
2191        let mut used = 0;
2192        for rank in 0..=3 {
2193            for (i, (key, description, r)) in all.iter().enumerate() {
2194                let need = key.width() + 1 + description.width() + 3;
2195                if *r == rank && used + need <= width {
2196                    chosen[i] = true;
2197                    used += need;
2198                }
2199            }
2200        }
2201        let mut spans = Vec::new();
2202        for (i, (key, description, _)) in all.into_iter().enumerate() {
2203            if chosen[i] {
2204                if !spans.is_empty() {
2205                    spans.push(Span::raw("   "));
2206                }
2207                spans.push(Span::styled(key.to_string(), self.theme.key()));
2208                spans.push(Span::styled(format!(" {description}"), self.theme.muted()));
2209            }
2210        }
2211        Line::from(spans)
2212    }
2213
2214    fn draw_help(&self, frame: &mut ratatui::Frame<'_>) {
2215        let theme = self.theme;
2216        let rows = [
2217            (
2218                "↑ / k, ↓ / j",
2219                "Move through chats, or scroll a conversation",
2220            ),
2221            ("enter / → / l", "Read the selected chat"),
2222            ("esc / ← / h", "Back to the chat list"),
2223            ("tab", "Switch between the list and the conversation"),
2224            ("pgup / pgdn", "Scroll the conversation by half a screen"),
2225            ("g / G", "First / last chat, or oldest / newest message"),
2226            ("o", "Load older messages in this chat"),
2227            (
2228                "wheel",
2229                "Scroll the pane under the pointer; older messages load at the top",
2230            ),
2231            ("click", "Open a chat, or focus the conversation"),
2232            (
2233                "shift-drag",
2234                "Select text to copy, while the mouse is in use here",
2235            ),
2236            ("/", "Filter chats by name or last message"),
2237            ("c", "Write a message in the open chat"),
2238            ("r", "Refresh now; chats also refresh on their own"),
2239            ("enter / a", "Sign in when the session needs attention"),
2240            ("q / ctrl-c", "Return to the shell"),
2241        ];
2242        let mut lines = vec![
2243            Line::styled(
2244                "Read your Teams chats without leaving the terminal",
2245                theme.muted(),
2246            ),
2247            Line::raw(""),
2248        ];
2249        for (key, description) in rows {
2250            lines.push(Line::from(vec![
2251                Span::styled(format!("{key:<16}"), theme.key()),
2252                Span::styled(description, theme.body()),
2253            ]));
2254        }
2255        lines.push(Line::raw(""));
2256        lines.push(Line::styled(
2257            if self.can_send() {
2258                "Enter sends · Ctrl-J adds a line · Esc keeps your draft."
2259            } else {
2260                "Read-only: nothing here sends, edits or marks messages as read."
2261            },
2262            theme.success(),
2263        ));
2264        let height = lines.len() as u16 + 2;
2265        let area = centered(frame.area(), 70, height);
2266        frame.render_widget(Clear, area);
2267        frame.render_widget(
2268            Paragraph::new(lines).wrap(Wrap { trim: false }).block(
2269                Block::default()
2270                    .title(" KEYBOARD MAP  ·  ? OR ESC TO CLOSE ")
2271                    .title_style(theme.title())
2272                    .borders(Borders::ALL)
2273                    .border_style(theme.active_rule())
2274                    .padding(Padding::horizontal(1))
2275                    .style(theme.canvas()),
2276            ),
2277            area,
2278        );
2279    }
2280}
2281
2282fn chat_item(
2283    row: &ChatRow,
2284    width: usize,
2285    selected: bool,
2286    active: bool,
2287    theme: Theme,
2288) -> ListItem<'static> {
2289    let marker = if selected {
2290        Span::styled(
2291            "▌",
2292            if active {
2293                theme.active_rule()
2294            } else {
2295                theme.muted()
2296            },
2297        )
2298    } else {
2299        Span::raw(" ")
2300    };
2301    let dot = if row.unread {
2302        Span::styled("● ", theme.unread())
2303    } else {
2304        Span::raw("  ")
2305    };
2306    let kind = row
2307        .kind
2308        .map(|kind| format!(" · {kind}"))
2309        .unwrap_or_default();
2310    let time = format!("{} ", row.time);
2311    let room = width.saturating_sub(4 + time.width() + 1);
2312    // The kind is a hint; when space is short the name wins.
2313    let kind = if row.title.width() + kind.width() <= room {
2314        kind
2315    } else {
2316        String::new()
2317    };
2318    let title = render::truncate(&row.title, room);
2319    let gap = width.saturating_sub(4 + title.width() + kind.width() + time.width());
2320    let title_style = if selected && active {
2321        theme.accent()
2322    } else if row.unread {
2323        theme.title()
2324    } else {
2325        theme.body()
2326    };
2327    let first = Line::from(vec![
2328        marker.clone(),
2329        Span::raw(" "),
2330        dot,
2331        Span::styled(title, title_style),
2332        Span::styled(kind, theme.muted()),
2333        Span::raw(" ".repeat(gap)),
2334        Span::styled(
2335            time,
2336            if row.unread {
2337                theme.unread()
2338            } else {
2339                theme.muted()
2340            },
2341        ),
2342    ]);
2343    let preview = row.preview.as_deref().unwrap_or("");
2344    let second = Line::from(vec![
2345        marker,
2346        Span::raw("   "),
2347        Span::styled(
2348            render::truncate(preview, width.saturating_sub(5)),
2349            if row.unread {
2350                theme.body()
2351            } else {
2352                theme.muted()
2353            },
2354        ),
2355    ]);
2356    let item = ListItem::new(vec![first, second, Line::raw("")]);
2357    if selected {
2358        item.style(theme.selection())
2359    } else {
2360        item
2361    }
2362}
2363
2364/// Wraps a draft to `width` columns, honouring line breaks, and finds the row and
2365/// column of the byte offset `cursor`.
2366fn layout_draft(text: &str, cursor: usize, width: usize) -> (Vec<String>, (usize, usize)) {
2367    let width = width.max(1);
2368    let mut rows = vec![String::new()];
2369    let mut column = 0;
2370    let mut at = None;
2371    for (i, c) in text.char_indices() {
2372        if c == '\n' {
2373            if i == cursor {
2374                at = Some((rows.len() - 1, column));
2375            }
2376            rows.push(String::new());
2377            column = 0;
2378            continue;
2379        }
2380        let w = c.width().unwrap_or(0);
2381        if column + w > width {
2382            rows.push(String::new());
2383            column = 0;
2384        }
2385        if i == cursor {
2386            at = Some((rows.len() - 1, column));
2387        }
2388        rows.last_mut().expect("never empty").push(c);
2389        column += w;
2390    }
2391    let at = at.unwrap_or((rows.len() - 1, column));
2392    (rows, at)
2393}
2394
2395/// Puts a fresh first page ahead of what was loaded before. Older pages are kept, with
2396/// their cursor, only when the fresh page overlaps them, so a burst of new items never
2397/// leaves a silent gap.
2398fn merge_newest(
2399    fresh: Vec<Value>,
2400    cursor: Option<String>,
2401    old: Vec<Value>,
2402    old_cursor: Option<String>,
2403) -> (Vec<Value>, Option<String>) {
2404    let ids: HashSet<&str> = fresh.iter().filter_map(|v| v["id"].as_str()).collect();
2405    let overlaps = old
2406        .iter()
2407        .any(|v| v["id"].as_str().is_some_and(|id| ids.contains(id)));
2408    if cursor.is_none() || !overlaps {
2409        return (fresh, cursor);
2410    }
2411    let rest: Vec<Value> = old
2412        .into_iter()
2413        .filter(|v| !v["id"].as_str().is_some_and(|id| ids.contains(id)))
2414        .collect();
2415    let cursor = if rest.is_empty() { cursor } else { old_cursor };
2416    let mut items = fresh;
2417    items.extend(rest);
2418    (items, cursor)
2419}
2420
2421/// Lays a conversation out for a pane: centred day rules, coloured senders,
2422/// indented and wrapped bodies, quotes dimmed.
2423fn conversation_lines(entries: &[Entry], width: usize, theme: Theme) -> Vec<Line<'static>> {
2424    let mut lines: Vec<Line> = Vec::new();
2425    let mut after_day = false;
2426    for entry in entries {
2427        match entry {
2428            Entry::Day(day) => {
2429                if !lines.is_empty() {
2430                    lines.push(Line::raw(""));
2431                }
2432                let label = format!(" {day} ");
2433                let side = width.saturating_sub(label.width()) / 2;
2434                let rest = width.saturating_sub(label.width() + side);
2435                lines.push(
2436                    Line::from(vec![
2437                        Span::styled("─".repeat(side.min(24)), theme.rule()),
2438                        Span::styled(label, theme.muted()),
2439                        Span::styled("─".repeat(rest.min(24)), theme.rule()),
2440                    ])
2441                    .centered(),
2442                );
2443                after_day = true;
2444            }
2445            Entry::Sender { name, time, own } => {
2446                if !after_day {
2447                    lines.push(Line::raw(""));
2448                }
2449                after_day = false;
2450                let mut spans = vec![Span::styled(
2451                    render::truncate(name, width.saturating_sub(8)),
2452                    theme.sender(name, *own),
2453                )];
2454                if let Some(time) = time {
2455                    spans.push(Span::styled(format!("  {time}"), theme.muted()));
2456                }
2457                lines.push(Line::from(spans));
2458            }
2459            Entry::Text(text) if text.is_empty() => lines.push(Line::raw("")),
2460            Entry::Text(text) => {
2461                let style = if text.starts_with('>') {
2462                    theme.quote()
2463                } else {
2464                    theme.body()
2465                };
2466                lines.extend(
2467                    render::wrap(text, width.saturating_sub(2))
2468                        .into_iter()
2469                        .map(|line| Line::from(vec![Span::raw("  "), Span::styled(line, style)])),
2470                );
2471            }
2472        }
2473    }
2474    lines
2475}
2476
2477fn failure_copy(theme: Theme, message: &str, auth: bool) -> (&'static str, Vec<Line<'static>>) {
2478    if auth {
2479        (
2480            "SIGN-IN REQUIRED",
2481            vec![
2482                Line::styled("Your Teams session needs attention.", theme.body()),
2483                Line::styled(message.to_string(), theme.muted()),
2484                Line::raw(""),
2485                Line::from(vec![
2486                    Span::styled("enter / a", theme.key()),
2487                    Span::styled("  sign in   ", theme.muted()),
2488                    Span::styled("q", theme.key()),
2489                    Span::styled("  quit", theme.muted()),
2490                ]),
2491            ],
2492        )
2493    } else {
2494        (
2495            "COULD NOT READ TEAMS",
2496            vec![
2497                Line::styled(message.to_string(), theme.body()),
2498                Line::raw(""),
2499                Line::from(vec![
2500                    Span::styled("r", theme.key()),
2501                    Span::styled("  try again   ", theme.muted()),
2502                    Span::styled("q", theme.key()),
2503                    Span::styled("  quit", theme.muted()),
2504                ]),
2505            ],
2506        )
2507    }
2508}
2509
2510fn state_panel(
2511    frame: &mut ratatui::Frame<'_>,
2512    area: Rect,
2513    theme: Theme,
2514    title: &str,
2515    lines: Vec<Line<'static>>,
2516    border: Style,
2517) {
2518    let width = area.width.saturating_sub(4).min(72);
2519    let room = usize::from(width.saturating_sub(4)).max(1);
2520    let rows: usize = lines
2521        .iter()
2522        .map(|line| line.width().div_ceil(room).max(1))
2523        .sum();
2524    let panel = centered(area, width, rows as u16 + 4);
2525    frame.render_widget(
2526        Paragraph::new(lines)
2527            .alignment(Alignment::Center)
2528            .wrap(Wrap { trim: true })
2529            .block(
2530                Block::default()
2531                    .title(format!(" {title} "))
2532                    .title_style(theme.title())
2533                    .borders(Borders::ALL)
2534                    .border_style(border)
2535                    .padding(Padding::uniform(1)),
2536            ),
2537        panel,
2538    );
2539}
2540
2541fn centered(area: Rect, width: u16, height: u16) -> Rect {
2542    let width = width.min(area.width);
2543    let height = height.min(area.height);
2544    Rect {
2545        x: area.x + (area.width - width) / 2,
2546        y: area.y + (area.height - height) / 2,
2547        width,
2548        height,
2549    }
2550}
2551
2552/// A Teams-like indigo palette. Every style collapses to the terminal
2553/// default when colour is off, so structure carries the meaning.
2554#[derive(Clone, Copy)]
2555struct Theme {
2556    color: bool,
2557}
2558
2559impl Theme {
2560    fn style(self, foreground: Color, background: Color) -> Style {
2561        if self.color {
2562            Style::default().fg(foreground).bg(background)
2563        } else {
2564            Style::default()
2565        }
2566    }
2567
2568    /// Text colour only: the cell keeps the canvas, selection or header behind it. A
2569    /// `Color::Reset` background here would show the terminal's own colour through the text.
2570    fn fg(self, foreground: Color) -> Style {
2571        if self.color {
2572            Style::default().fg(foreground)
2573        } else {
2574            Style::default()
2575        }
2576    }
2577
2578    fn canvas(self) -> Style {
2579        self.style(Color::Rgb(222, 225, 240), Color::Rgb(15, 16, 28))
2580    }
2581
2582    fn body(self) -> Style {
2583        self.fg(Color::Rgb(222, 225, 240))
2584    }
2585
2586    fn muted(self) -> Style {
2587        self.fg(Color::Rgb(128, 134, 168))
2588    }
2589
2590    fn quote(self) -> Style {
2591        self.muted().add_modifier(Modifier::ITALIC)
2592    }
2593
2594    fn brand(self) -> Style {
2595        self.style(Color::Rgb(15, 16, 28), Color::Rgb(123, 131, 235))
2596            .add_modifier(Modifier::BOLD)
2597    }
2598
2599    fn title(self) -> Style {
2600        self.fg(Color::Rgb(242, 243, 252))
2601            .add_modifier(Modifier::BOLD)
2602    }
2603
2604    fn detail_title(self) -> Style {
2605        self.fg(Color::Rgb(170, 177, 255))
2606            .add_modifier(Modifier::BOLD)
2607    }
2608
2609    fn accent(self) -> Style {
2610        self.fg(Color::Rgb(155, 162, 255))
2611            .add_modifier(Modifier::BOLD)
2612    }
2613
2614    fn column_header(self) -> Style {
2615        self.style(Color::Rgb(150, 156, 190), Color::Rgb(24, 26, 44))
2616            .add_modifier(Modifier::BOLD)
2617    }
2618
2619    fn key(self) -> Style {
2620        self.fg(Color::Rgb(246, 183, 82))
2621            .add_modifier(Modifier::BOLD)
2622    }
2623
2624    fn rule(self) -> Style {
2625        self.fg(Color::Rgb(50, 54, 86))
2626    }
2627
2628    fn active_rule(self) -> Style {
2629        self.fg(Color::Rgb(123, 131, 235))
2630    }
2631
2632    fn selection(self) -> Style {
2633        if self.color {
2634            Style::default().bg(Color::Rgb(30, 33, 60))
2635        } else {
2636            Style::default().add_modifier(Modifier::BOLD)
2637        }
2638    }
2639
2640    fn unread(self) -> Style {
2641        self.fg(Color::Rgb(255, 128, 112))
2642            .add_modifier(Modifier::BOLD)
2643    }
2644
2645    fn success(self) -> Style {
2646        self.fg(Color::Rgb(110, 220, 170))
2647    }
2648
2649    /// Writing is possible on this profile: warm, so it is noticed without alarm.
2650    fn badge_send(self) -> Style {
2651        self.fg(Color::Rgb(246, 183, 82))
2652            .add_modifier(Modifier::BOLD)
2653    }
2654
2655    /// A number worth scanning for in otherwise quiet text.
2656    fn figure(self) -> Style {
2657        self.body().add_modifier(Modifier::BOLD)
2658    }
2659
2660    fn unread_label(self) -> Style {
2661        self.fg(Color::Rgb(255, 128, 112))
2662    }
2663
2664    fn error(self) -> Style {
2665        self.fg(Color::Rgb(255, 126, 119))
2666            .add_modifier(Modifier::BOLD)
2667    }
2668
2669    /// The same person always gets the same colour; you get the accent.
2670    fn sender(self, name: &str, own: bool) -> Style {
2671        const PALETTE: [Color; 6] = [
2672            Color::Rgb(122, 200, 255),
2673            Color::Rgb(240, 160, 220),
2674            Color::Rgb(130, 220, 160),
2675            Color::Rgb(250, 200, 110),
2676            Color::Rgb(190, 160, 255),
2677            Color::Rgb(110, 215, 210),
2678        ];
2679        let color = if own {
2680            Color::Rgb(155, 162, 255)
2681        } else {
2682            let hash = name.bytes().fold(0usize, |h, b| {
2683                h.wrapping_mul(31).wrapping_add(usize::from(b))
2684            });
2685            PALETTE[hash % PALETTE.len()]
2686        };
2687        self.fg(color).add_modifier(Modifier::BOLD)
2688    }
2689}
2690
2691fn demo_time(now: &Zoned, minutes_ago: i64) -> String {
2692    now.checked_sub(minutes_ago.minutes())
2693        .unwrap_or_else(|_| now.clone())
2694        .timestamp()
2695        .to_string()
2696}
2697
2698const DEMO_PEOPLE: [(&str, &str); 7] = [
2699    ("me", "Alex Morgan"),
2700    ("mina", "Mina Okafor"),
2701    ("jon", "Jon Bakker"),
2702    ("ari", "Ari Chen"),
2703    ("priya", "Priya Raman"),
2704    ("sam", "Sam Rivera"),
2705    ("lee", "Lee Novak"),
2706];
2707
2708fn demo_person(id: &str) -> Value {
2709    let name = DEMO_PEOPLE
2710        .iter()
2711        .find(|(person, _)| *person == id)
2712        .map_or(id, |(_, name)| name);
2713    json!({"id": id, "displayName": name})
2714}
2715
2716/// (id, topic, type, members, unread) for each sample chat.
2717type DemoChat = (
2718    &'static str,
2719    Option<&'static str>,
2720    &'static str,
2721    &'static [&'static str],
2722    bool,
2723);
2724
2725const DEMO_CHATS: [DemoChat; 6] = [
2726    (
2727        "19:launch@thread.v2",
2728        Some("Launch room"),
2729        "group",
2730        &["me", "mina", "jon", "priya"],
2731        true,
2732    ),
2733    (
2734        "19:ari@unq.gbl.spaces",
2735        None,
2736        "oneOnOne",
2737        &["me", "ari"],
2738        true,
2739    ),
2740    (
2741        "19:design@thread.v2",
2742        Some("Design crit"),
2743        "group",
2744        &["me", "lee", "ari"],
2745        false,
2746    ),
2747    (
2748        "19:oncall@thread.v2",
2749        Some("Platform on-call"),
2750        "group",
2751        &["me", "priya", "jon", "sam", "lee"],
2752        false,
2753    ),
2754    (
2755        "19:weekly@thread.v2",
2756        Some("Weekly sync"),
2757        "meeting",
2758        &["me", "mina", "jon", "ari"],
2759        false,
2760    ),
2761    (
2762        "19:sam@unq.gbl.spaces",
2763        None,
2764        "oneOnOne",
2765        &["me", "sam"],
2766        false,
2767    ),
2768];
2769
2770/// (minutes ago, sender, html) per sample chat, newest last.
2771fn demo_script(chat: &str) -> &'static [(i64, &'static str, &'static str)] {
2772    match chat {
2773        "19:launch@thread.v2" => &[
2774            (
2775                1460,
2776                "priya",
2777                "<p>Staging is frozen for the launch. Ping me before any deploy.</p>",
2778            ),
2779            (1452, "me", "<p>Noted, thanks <at>Priya</at>.</p>"),
2780            (
2781                43,
2782                "jon",
2783                "<p>Docs are ready. One broken link remains in the migration guide.</p>",
2784            ),
2785            (
2786                41,
2787                "me",
2788                "<p>I’ll take it. Hold the announcement until I clear the link check.</p>",
2789            ),
2790            (
2791                40,
2792                "me",
2793                "<p>Fixed in <code>docs#412</code>, the checker is green.</p>",
2794            ),
2795            (
2796                23,
2797                "mina",
2798                "<ul><li>notes reviewed</li><li>rollback tested</li><li>status page drafted</li></ul>",
2799            ),
2800            (
2801                22,
2802                "mina",
2803                "<p>The release candidate is green. I pinned the final checklist.</p>",
2804            ),
2805        ],
2806        "19:ari@unq.gbl.spaces" => &[
2807            (180, "me", "<p>Thanks for the review yesterday!</p>"),
2808            (
2809                31,
2810                "ari",
2811                "<p>Can you look at the migration guide before lunch? Two screenshots are outdated.</p>",
2812            ),
2813        ],
2814        "19:design@thread.v2" => &[
2815            (300, "lee", "<p>New onboarding flow is in Figma 🎨</p>"),
2816            (95, "me", "<p>Love the empty states. Uploaded my notes.</p>"),
2817        ],
2818        "19:oncall@thread.v2" => &[
2819            (
2820                1600,
2821                "sam",
2822                "<blockquote><p>Is the queue backlog back to normal?</p></blockquote><p>Yes, drained overnight.</p>",
2823            ),
2824            (
2825                1500,
2826                "priya",
2827                "<p>Paging is quiet. Handing over at 18:00.</p>",
2828            ),
2829        ],
2830        "19:weekly@thread.v2" => &[(
2831            4400,
2832            "mina",
2833            "<p>Agenda for Monday is in the meeting notes.</p>",
2834        )],
2835        _ => &[(16000, "sam", "<p>Here are the slides from the offsite.</p>")],
2836    }
2837}
2838
2839fn demo_message(
2840    chat: &str,
2841    index: usize,
2842    now: &Zoned,
2843    (ago, from, html): (i64, &str, &str),
2844) -> Value {
2845    json!({
2846        "id": format!("{chat}/{index}"),
2847        "messageType": "message",
2848        "createdDateTime": demo_time(now, ago),
2849        "from": {"user": demo_person(from)},
2850        "body": {"contentType": "html", "content": html},
2851    })
2852}
2853
2854fn demo_chats(now: &Zoned) -> Page {
2855    let items = DEMO_CHATS
2856        .iter()
2857        .map(|(id, topic, kind, members, unread)| {
2858            let script = demo_script(id);
2859            let last = script[script.len() - 1];
2860            let mut preview = demo_message(id, script.len() - 1, now, last);
2861            preview.as_object_mut().map(|p| p.remove("id"));
2862            let read = if *unread { last.0 + 5 } else { 0 };
2863            json!({
2864                "id": id,
2865                "topic": topic,
2866                "chatType": kind,
2867                "lastUpdatedDateTime": demo_time(now, last.0),
2868                "viewpoint": {"lastMessageReadDateTime": demo_time(now, read)},
2869                "members": members.iter().map(|m| {
2870                    let person = demo_person(m);
2871                    json!({"userId": person["id"], "displayName": person["displayName"]})
2872                }).collect::<Vec<_>>(),
2873                "lastMessagePreview": preview,
2874            })
2875        })
2876        .collect();
2877    demo_page(items)
2878}
2879
2880fn demo_messages(chat: &str, now: &Zoned) -> Page {
2881    demo_page(
2882        demo_script(chat)
2883            .iter()
2884            .enumerate()
2885            .rev()
2886            .map(|(index, line)| demo_message(chat, index, now, *line))
2887            .collect(),
2888    )
2889}
2890
2891fn demo_page(items: Vec<Value>) -> Page {
2892    Page {
2893        items,
2894        next_cursor: None,
2895        next_offset: None,
2896        truncated: false,
2897        source: None,
2898        viewer: Some("me".into()),
2899    }
2900}
2901
2902/// Present an actionable connection state inside the same terminal experience.
2903/// Returning first restores the alternate screen before the caller opens a
2904/// browser or prints device-code instructions.
2905pub fn request_authentication(
2906    profile: &str,
2907    configured: bool,
2908    reason: &str,
2909) -> Result<TuiExit, AppError> {
2910    require_terminal("run `teams auth login --device-code` for headless sign-in")?;
2911    let theme = Theme {
2912        color: std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty()),
2913    };
2914    let _guard = TerminalGuard::enter(false)?;
2915    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
2916    loop {
2917        terminal.draw(|frame| draw_connection(frame, theme, profile, configured, reason))?;
2918        if event::poll(Duration::from_millis(150))?
2919            && let Event::Key(key) = event::read()?
2920            && key.kind == KeyEventKind::Press
2921        {
2922            match key.code {
2923                KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
2924                KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
2925                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2926                    return Ok(TuiExit::Quit);
2927                }
2928                _ => {}
2929            }
2930        }
2931    }
2932}
2933
2934pub fn connection_snapshot(
2935    width: u16,
2936    height: u16,
2937    profile: &str,
2938    configured: bool,
2939    reason: &str,
2940) -> Result<String, AppError> {
2941    let mut terminal = Terminal::new(TestBackend::new(
2942        width.max(MIN_WIDTH),
2943        height.max(MIN_HEIGHT),
2944    ))
2945    .expect("test backend is infallible");
2946    terminal
2947        .draw(|frame| draw_connection(frame, Theme { color: false }, profile, configured, reason))
2948        .expect("test backend is infallible");
2949    Ok(buffer_text(terminal.backend().buffer()))
2950}
2951
2952fn draw_connection(
2953    frame: &mut ratatui::Frame<'_>,
2954    theme: Theme,
2955    profile: &str,
2956    configured: bool,
2957    reason: &str,
2958) {
2959    let area = frame.area();
2960    frame.render_widget(Block::default().style(theme.canvas()), area);
2961    frame.render_widget(
2962        Paragraph::new(Line::from(vec![
2963            Span::styled(" TEAMS ", theme.brand()),
2964            Span::styled("  CONNECTION", theme.title()),
2965        ])),
2966        Rect { height: 1, ..area },
2967    );
2968    let (title, heading, action) = if configured {
2969        (
2970            "SIGN-IN REQUIRED",
2971            "Your Teams session needs attention",
2972            "Continue with Microsoft sign-in. Your password is never shared with teams-cli.",
2973        )
2974    } else {
2975        (
2976            "CONNECT MICROSOFT TEAMS",
2977            "Bring your Teams chats into the terminal",
2978            "Set up this profile and continue with secure Microsoft sign-in.",
2979        )
2980    };
2981    let lines = vec![
2982        Line::styled(heading, theme.title()),
2983        Line::styled(format!("Profile: {profile}"), theme.muted()),
2984        Line::raw(""),
2985        Line::styled(reason.to_string(), theme.body()),
2986        Line::styled(action, theme.muted()),
2987        Line::raw(""),
2988        Line::from(vec![
2989            Span::styled("enter / a", theme.key()),
2990            Span::styled(
2991                if configured {
2992                    "  sign in   "
2993                } else {
2994                    "  set up   "
2995                },
2996                theme.muted(),
2997            ),
2998            Span::styled("q", theme.key()),
2999            Span::styled(" quit", theme.muted()),
3000        ]),
3001    ];
3002    state_panel(frame, area, theme, title, lines, theme.active_rule());
3003}
3004
3005#[cfg(test)]
3006mod tests {
3007    use super::*;
3008
3009    #[test]
3010    fn debugging_restart_requires_confirmation_and_can_be_cancelled() {
3011        let mut app = demo_app();
3012        app.apply(Outcome::DebuggingRequired);
3013        press(&mut app, KeyCode::Char('y'));
3014        assert!(app.debugging_restart == Some(DebuggingRestart::Offered));
3015        press(&mut app, KeyCode::Enter);
3016        assert!(app.debugging_restart == Some(DebuggingRestart::Confirming));
3017        press(&mut app, KeyCode::Esc);
3018        assert!(app.debugging_restart == Some(DebuggingRestart::Offered));
3019        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
3020        press(&mut app, KeyCode::Char('r'));
3021        assert!(app.debugging_restart.is_none());
3022        assert!(matches!(app.chats_load, Load::Ready));
3023    }
3024
3025    #[test]
3026    fn debugging_restart_completion_recovers_or_shows_retry() {
3027        let mut app = demo_app();
3028        app.debugging_restart = Some(DebuggingRestart::Running);
3029        app.apply(Outcome::Restarted(Err(AppError::Desktop(
3030            "Restart failed".into(),
3031        ))));
3032        assert!(app.debugging_restart.is_none());
3033        assert!(matches!(app.chats_load, Load::Failed { auth: false, .. }));
3034        app.apply(Outcome::Restarted(Ok(())));
3035        assert!(matches!(app.chats_load, Load::Ready));
3036    }
3037
3038    #[test]
3039    fn debugging_restart_panels_show_actions_and_consequences() {
3040        for (width, height) in [(50, 14), (80, 24), (120, 30)] {
3041            for state in [
3042                DebuggingRestart::Offered,
3043                DebuggingRestart::Confirming,
3044                DebuggingRestart::Running,
3045            ] {
3046                let mut app = demo_app();
3047                app.chats_load = Load::Loading;
3048                app.debugging_restart = Some(state);
3049                let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
3050                terminal.draw(|frame| app.draw(frame)).unwrap();
3051                let text = buffer_text(terminal.backend().buffer());
3052                assert!(!text.contains("teams doctor"));
3053                let words = text
3054                    .replace('│', " ")
3055                    .split_whitespace()
3056                    .collect::<Vec<_>>()
3057                    .join(" ");
3058                assert!(words.contains("send as you"), "{text}");
3059                assert!(text.contains("quit"), "{text}");
3060                if state == DebuggingRestart::Confirming {
3061                    assert!(text.contains("ends any call"), "{text}");
3062                    assert!(text.contains("cancel"), "{text}");
3063                }
3064            }
3065        }
3066    }
3067
3068    #[test]
3069    fn flag_cleanup_requires_confirmation_and_ends_with_a_retry() {
3070        let mut app = demo_app();
3071        app.apply(Outcome::StrayFlags(vec!["OUTLOOK.EXE".into()]));
3072        let stage = |app: &App| app.flag_cleanup.as_ref().map(|cleanup| cleanup.stage);
3073        press(&mut app, KeyCode::Char('y'));
3074        assert!(stage(&app) == Some(Cleanup::Offered));
3075        press(&mut app, KeyCode::Enter);
3076        assert!(stage(&app) == Some(Cleanup::Confirming));
3077        press(&mut app, KeyCode::Esc);
3078        assert!(stage(&app) == Some(Cleanup::Offered));
3079        press(&mut app, KeyCode::Enter);
3080        // The demo app has no desktop client, so nothing is changed.
3081        press(&mut app, KeyCode::Char('y'));
3082        assert!(stage(&app) == Some(Cleanup::Confirming));
3083        app.apply(Outcome::FlagsCleared(Ok(())));
3084        assert!(stage(&app) == Some(Cleanup::Done));
3085        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
3086        press(&mut app, KeyCode::Char('r'));
3087        assert!(app.flag_cleanup.is_none());
3088        assert!(matches!(app.chats_load, Load::Ready));
3089
3090        app.apply(Outcome::StrayFlags(vec![]));
3091        app.apply(Outcome::FlagsCleared(Err(AppError::Desktop(
3092            "Clearing failed".into(),
3093        ))));
3094        assert!(app.flag_cleanup.is_none());
3095        assert!(matches!(app.chats_load, Load::Failed { auth: false, .. }));
3096    }
3097
3098    #[test]
3099    fn flag_cleanup_panels_name_the_apps_and_the_consequences() {
3100        for (width, height) in [(50, 14), (80, 24), (120, 30)] {
3101            for stage in [
3102                Cleanup::Offered,
3103                Cleanup::Confirming,
3104                Cleanup::Running,
3105                Cleanup::Done,
3106            ] {
3107                let mut app = demo_app();
3108                app.chats_load = Load::Loading;
3109                app.flag_cleanup = Some(FlagCleanup {
3110                    stage,
3111                    apps: vec!["OUTLOOK.EXE".into()],
3112                });
3113                let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
3114                terminal.draw(|frame| app.draw(frame)).unwrap();
3115                let text = buffer_text(terminal.backend().buffer());
3116                let words = text
3117                    .replace('│', " ")
3118                    .split_whitespace()
3119                    .collect::<Vec<_>>()
3120                    .join(" ");
3121                assert!(words.contains("until fully quit"), "{text}");
3122                assert!(text.contains("quit"), "{text}");
3123                match stage {
3124                    Cleanup::Offered | Cleanup::Done => {
3125                        assert!(words.contains("OUTLOOK.EXE"), "{text}")
3126                    }
3127                    Cleanup::Confirming => {
3128                        assert!(words.contains("keeps its other"), "{text}");
3129                        assert!(text.contains("cancel"), "{text}");
3130                    }
3131                    Cleanup::Running => {}
3132                }
3133            }
3134        }
3135    }
3136
3137    fn demo_app() -> App {
3138        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3139            .parse()
3140            .unwrap();
3141        let mut app = App::new(Source::Demo, Theme { color: false }, now);
3142        app.start();
3143        app
3144    }
3145
3146    fn press(app: &mut App, code: KeyCode) -> Action {
3147        app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
3148    }
3149
3150    #[test]
3151    fn snapshot_shows_the_ledger_and_the_open_conversation() {
3152        let frame = snapshot(90, 26).unwrap();
3153        assert!(frame.contains("TEAMS"));
3154        assert!(frame.contains("Launch room · group"));
3155        assert!(frame.contains("Ari Chen"));
3156        assert!(frame.contains("release candidate is green"));
3157        assert!(frame.contains("── Today ──"));
3158        assert!(frame.contains("You  09:34"));
3159        assert!(frame.contains("q quit"));
3160    }
3161
3162    #[test]
3163    fn colour_paints_every_cell_so_the_terminal_background_never_shows() {
3164        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3165            .parse()
3166            .unwrap();
3167        let mut app = App::new(Source::Demo, Theme { color: true }, now);
3168        app.start();
3169        let unpainted = |buffer: &ratatui::buffer::Buffer| {
3170            buffer
3171                .content()
3172                .iter()
3173                .filter(|cell| cell.bg == Color::Reset)
3174                .count()
3175        };
3176        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3177        terminal.draw(|frame| app.draw(frame)).unwrap();
3178        assert_eq!(unpainted(terminal.backend().buffer()), 0);
3179        press(&mut app, KeyCode::Enter);
3180        terminal.draw(|frame| app.draw(frame)).unwrap();
3181        assert_eq!(unpainted(terminal.backend().buffer()), 0);
3182        terminal
3183            .draw(|frame| draw_connection(frame, Theme { color: true }, "default", true, "x"))
3184            .unwrap();
3185        assert_eq!(unpainted(terminal.backend().buffer()), 0);
3186    }
3187
3188    #[test]
3189    fn narrow_terminals_take_turns_and_tiny_ones_ask_for_room() {
3190        let frame = snapshot(60, 24).unwrap();
3191        assert!(frame.contains("Launch room"));
3192        assert!(!frame.contains("rollback tested"));
3193        let mut app = demo_app();
3194        press(&mut app, KeyCode::Enter);
3195        let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
3196        terminal.draw(|frame| app.draw(frame)).unwrap();
3197        let text = buffer_text(terminal.backend().buffer());
3198        assert!(text.contains("rollback tested"));
3199        assert!(text.contains("esc chats"));
3200        let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
3201        terminal.draw(|frame| app.draw(frame)).unwrap();
3202        assert!(buffer_text(terminal.backend().buffer()).contains("NEEDS MORE ROOM"));
3203    }
3204
3205    #[test]
3206    fn filtering_narrows_the_ledger_and_esc_restores_it() {
3207        let mut app = demo_app();
3208        assert_eq!(app.visible.len(), DEMO_CHATS.len());
3209        press(&mut app, KeyCode::Char('/'));
3210        for c in "on-call".chars() {
3211            press(&mut app, KeyCode::Char(c));
3212        }
3213        assert_eq!(app.visible.len(), 1);
3214        assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
3215        press(&mut app, KeyCode::Enter);
3216        assert!(app.overlay.is_none());
3217        press(&mut app, KeyCode::Esc);
3218        assert_eq!(app.visible.len(), DEMO_CHATS.len());
3219        assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
3220    }
3221
3222    #[test]
3223    fn opening_a_chat_loads_it_once_and_scroll_is_bounded() {
3224        let mut app = demo_app();
3225        press(&mut app, KeyCode::Char('j'));
3226        press(&mut app, KeyCode::Enter);
3227        assert!(app.focus == Focus::Thread);
3228        assert!(app.threads.contains_key("19:ari@unq.gbl.spaces"));
3229        for _ in 0..50 {
3230            press(&mut app, KeyCode::Char('k'));
3231        }
3232        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3233        terminal.draw(|frame| app.draw(frame)).unwrap();
3234        assert_eq!(app.scroll, 0, "a short chat cannot scroll");
3235        press(&mut app, KeyCode::Esc);
3236        assert!(app.focus == Focus::Chats);
3237        press(&mut app, KeyCode::Char('?'));
3238        terminal.draw(|frame| app.draw(frame)).unwrap();
3239        assert!(buffer_text(terminal.backend().buffer()).contains("KEYBOARD MAP"));
3240        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::None));
3241        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
3242    }
3243
3244    fn mouse(app: &mut App, kind: MouseEventKind, column: u16, row: u16) {
3245        app.handle_mouse(MouseEvent {
3246            kind,
3247            column,
3248            row,
3249            modifiers: KeyModifiers::NONE,
3250        });
3251    }
3252
3253    #[test]
3254    fn the_wheel_scrolls_the_pane_under_the_pointer_and_clicks_open_chats() {
3255        let mut app = demo_app();
3256        app.open_selected();
3257        let mut terminal = Terminal::new(TestBackend::new(100, 16)).unwrap();
3258        terminal.draw(|frame| app.draw(frame)).unwrap();
3259        let ledger = app.ledger_area.expect("ledger drawn");
3260        let thread = app.thread_area.expect("conversation drawn");
3261        assert!(
3262            app.thread_overflow > WHEEL_LINES,
3263            "the demo chat overflows 16 rows"
3264        );
3265
3266        // The wheel over the conversation scrolls it even while the ledger has focus.
3267        assert!(app.focus == Focus::Chats);
3268        mouse(
3269            &mut app,
3270            MouseEventKind::ScrollUp,
3271            thread.x + 5,
3272            thread.y + 5,
3273        );
3274        assert_eq!(app.scroll, WHEEL_LINES);
3275        assert_eq!(app.list.selected(), Some(0), "the ledger did not move");
3276        for _ in 0..50 {
3277            mouse(
3278                &mut app,
3279                MouseEventKind::ScrollUp,
3280                thread.x + 5,
3281                thread.y + 5,
3282            );
3283        }
3284        assert_eq!(
3285            app.scroll, app.thread_overflow,
3286            "scrolling stops at the oldest line"
3287        );
3288        mouse(
3289            &mut app,
3290            MouseEventKind::ScrollDown,
3291            thread.x + 5,
3292            thread.y + 5,
3293        );
3294        assert_eq!(app.scroll, app.thread_overflow - WHEEL_LINES);
3295
3296        // The wheel over the ledger moves the selection, which resets the conversation.
3297        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
3298        assert_eq!(app.list.selected(), Some(1));
3299        assert_eq!(app.scroll, 0);
3300        mouse(&mut app, MouseEventKind::ScrollUp, ledger.x + 2, ledger.y);
3301        assert_eq!(app.list.selected(), Some(0));
3302
3303        // A click on the second entry opens it; a click in the conversation focuses it.
3304        let left = MouseEventKind::Down(MouseButton::Left);
3305        mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
3306        assert_eq!(app.list.selected(), Some(1));
3307        assert!(app.focus == Focus::Thread);
3308        let id = app.selected_row().unwrap().id.clone();
3309        assert!(app.threads.contains_key(&id));
3310        press(&mut app, KeyCode::Tab);
3311        assert!(app.focus == Focus::Chats);
3312        mouse(&mut app, left, thread.x + 5, thread.y + 5);
3313        assert!(app.focus == Focus::Thread);
3314
3315        // Outside both panes, and while the help is open, the mouse does nothing else.
3316        mouse(&mut app, MouseEventKind::ScrollDown, 0, 0);
3317        assert_eq!(app.list.selected(), Some(1));
3318        press(&mut app, KeyCode::Char('?'));
3319        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
3320        assert_eq!(app.list.selected(), Some(1));
3321        mouse(&mut app, left, 0, 0);
3322        assert!(app.overlay.is_none(), "a click closes the help");
3323    }
3324
3325    #[test]
3326    fn the_composer_keeps_its_chat_when_the_ledger_is_clicked() {
3327        let mut app = demo_app();
3328        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3329        press(&mut app, KeyCode::Enter);
3330        press(&mut app, KeyCode::Char('c'));
3331        assert!(app.compose.is_some());
3332        terminal.draw(|frame| app.draw(frame)).unwrap();
3333        let ledger = app.ledger_area.expect("ledger drawn");
3334        let left = MouseEventKind::Down(MouseButton::Left);
3335        mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
3336        mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
3337        assert_eq!(app.list.selected(), Some(0));
3338    }
3339
3340    #[test]
3341    fn opening_a_chat_prefetches_its_neighbours_and_retries_failures() {
3342        let mut app = demo_app();
3343        press(&mut app, KeyCode::Char('j'));
3344        press(&mut app, KeyCode::Enter);
3345        let ids: Vec<String> = app
3346            .visible
3347            .iter()
3348            .map(|&i| app.rows[i].id.clone())
3349            .collect();
3350        let below = ids[2].clone();
3351        assert!(app.threads.contains_key(&ids[0]) && app.threads.contains_key(&below));
3352        assert!(!app.threads.contains_key(&ids[3]));
3353        app.threads.get_mut(&below).unwrap().load =
3354            Load::failed(&AppError::Desktop("read failed".into()));
3355        press(&mut app, KeyCode::Esc);
3356        press(&mut app, KeyCode::Char('j'));
3357        app.open_selected();
3358        assert!(matches!(app.threads[&below].load, Load::Ready));
3359        assert!(app.threads.contains_key(&ids[3]));
3360    }
3361
3362    #[test]
3363    fn auth_failures_offer_sign_in_from_inside_the_tui() {
3364        let mut app = demo_app();
3365        app.chats_load = Load::failed(&AppError::Auth("session expired".into()));
3366        let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap();
3367        terminal.draw(|frame| app.draw(frame)).unwrap();
3368        let text = buffer_text(terminal.backend().buffer());
3369        assert!(text.contains("SIGN-IN REQUIRED"));
3370        assert!(text.contains("enter / a"));
3371        assert!(matches!(
3372            press(&mut app, KeyCode::Enter),
3373            Action::Authenticate
3374        ));
3375    }
3376
3377    #[test]
3378    fn background_sign_in_loss_preserves_the_draft_and_disables_sending() {
3379        let mut app = live_app(true, "synthetic-work");
3380        app.compose = Some(Compose {
3381            chat: "synthetic-chat".into(),
3382            text: "Keep this draft".into(),
3383            cursor: 15,
3384            stage: Stage::Editing,
3385        });
3386        app.startup_failed(&AppError::Auth("Teams needs its browser window".into()));
3387        assert!(!app.can_send());
3388        assert!(app.sign_in_needed());
3389        assert!(app.compose.is_none());
3390        assert_eq!(app.drafts["synthetic-chat"], "Keep this draft");
3391        assert!(matches!(
3392            press(&mut app, KeyCode::Char('a')),
3393            Action::Authenticate
3394        ));
3395        assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
3396        app.send();
3397        assert!(app.pending_sends.is_empty());
3398        assert_eq!(app.drafts["synthetic-chat"], "Keep this draft");
3399        app.apply(Outcome::Chats {
3400            more: false,
3401            result: Ok(demo_chats(&app.now)),
3402        });
3403        app.tick();
3404        assert!(app.sign_in_needed());
3405        assert!(!app.notice.text.contains("Enter opens a chat"));
3406        let mut saved = InboxState::default();
3407        app.preserve_drafts(&mut saved);
3408        assert_eq!(saved.drafts["synthetic-chat"], "Keep this draft");
3409    }
3410
3411    #[test]
3412    fn a_non_auth_startup_failure_keeps_chats_readable_and_refresh_can_succeed() {
3413        let mut app = live_app(true, "synthetic-work");
3414        app.apply(Outcome::Chats {
3415            more: false,
3416            result: Ok(demo_chats(&app.now)),
3417        });
3418        app.startup_failed(&AppError::Desktop("Temporary verification failure".into()));
3419        assert!(matches!(app.chats_load, Load::Ready));
3420        assert!(!app.can_send());
3421        assert!(app.notice.text.contains("Sending is paused"));
3422        app.chats_load = Load::Loading;
3423        app.apply(Outcome::Chats {
3424            more: false,
3425            result: Ok(demo_chats(&app.now)),
3426        });
3427        app.tick();
3428        assert!(matches!(app.chats_load, Load::Ready));
3429        assert!(!app.sign_in_needed());
3430        assert!(!app.can_send());
3431        press(&mut app, KeyCode::Char('c'));
3432        assert!(app.notice.text.contains("Sending is paused"));
3433        assert!(!app.notice.text.contains("profile is read-only"));
3434        assert!(frame_text(&mut app).contains("sending paused"));
3435    }
3436
3437    #[test]
3438    fn a_read_only_profile_does_not_claim_sending_was_paused() {
3439        let mut app = live_app(false, "synthetic-profile");
3440        app.apply(Outcome::Chats {
3441            more: false,
3442            result: Ok(demo_chats(&app.now)),
3443        });
3444        app.startup_failed(&AppError::Desktop("Temporary verification failure".into()));
3445        assert_eq!(app.notice.text, "Temporary verification failure");
3446        press(&mut app, KeyCode::Char('c'));
3447        assert!(app.notice.text.contains("profile is read-only"));
3448        assert!(!app.notice.text.contains("Sending is paused"));
3449        let text = frame_text(&mut app);
3450        assert!(text.lines().next().unwrap().contains("read-only"));
3451        assert!(!text.contains("sending paused"));
3452    }
3453
3454    fn frame_text(app: &mut App) -> String {
3455        let mut terminal = Terminal::new(TestBackend::new(100, 28)).unwrap();
3456        terminal.draw(|frame| app.draw(frame)).unwrap();
3457        buffer_text(terminal.backend().buffer())
3458    }
3459
3460    fn type_text(app: &mut App, text: &str) {
3461        for c in text.chars() {
3462            press(app, KeyCode::Char(c));
3463        }
3464    }
3465
3466    #[test]
3467    fn enter_sends_the_draft_and_the_message_appears() {
3468        let mut app = demo_app();
3469        app.open_selected();
3470        press(&mut app, KeyCode::Char('c'));
3471        assert!(app.focus == Focus::Thread);
3472        // List keys are text while composing.
3473        type_text(&mut app, "on it q r /");
3474        app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
3475        type_text(&mut app, "shipping");
3476        assert!(app.overlay.is_none());
3477        let compose = app.compose.as_ref().unwrap();
3478        assert_eq!(compose.text, "on it q r /\nshipping");
3479        assert!(frame_text(&mut app).contains("MESSAGE"));
3480
3481        press(&mut app, KeyCode::Enter);
3482        assert!(app.compose.is_none());
3483        assert_eq!(app.notice.text, "Sent.");
3484        let thread = &app.threads["19:launch@thread.v2"];
3485        assert_eq!(thread.items.len(), 8);
3486        assert_eq!(thread.items[0]["body"]["content"], "on it q r /\nshipping");
3487        assert!(frame_text(&mut app).contains("shipping"));
3488    }
3489
3490    #[test]
3491    fn paste_keeps_multiline_text_in_the_draft_until_enter() {
3492        let mut app = demo_app();
3493        app.open_selected();
3494        app.paste("c accidental commands\n");
3495        assert!(app.compose.is_none());
3496        press(&mut app, KeyCode::Char('c'));
3497        type_text(&mut app, "ab");
3498        press(&mut app, KeyCode::Left);
3499        app.paste("é\r\nsecond\rthird\n");
3500        let compose = app.compose.as_ref().unwrap();
3501        assert_eq!(compose.text, "aé\nsecond\nthird\nb");
3502        assert_eq!(&compose.text[..compose.cursor], "aé\nsecond\nthird\n");
3503        assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3504        press(&mut app, KeyCode::Enter);
3505        assert!(app.compose.is_none());
3506        assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 8);
3507    }
3508
3509    #[test]
3510    fn sending_ignores_extra_keys_and_paste_and_failure_keeps_the_draft() {
3511        let mut app = demo_app();
3512        app.open_selected();
3513        press(&mut app, KeyCode::Char('c'));
3514        type_text(&mut app, "keep this");
3515        app.compose.as_mut().unwrap().stage = Stage::Sending;
3516        for key in [KeyCode::Enter, KeyCode::Char('y'), KeyCode::Esc] {
3517            press(&mut app, key);
3518        }
3519        app.paste("more\n");
3520        assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3521        assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3522        app.apply(Outcome::Sent {
3523            chat: "19:launch@thread.v2".into(),
3524            result: Err(AppError::Unconfirmed(
3525                "Check the conversation before sending again".into(),
3526            )),
3527        });
3528        assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3529        assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3530        assert!(app.notice.text.contains("Check the conversation"));
3531        assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3532        press(&mut app, KeyCode::Esc);
3533        press(&mut app, KeyCode::Char('c'));
3534        assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3535    }
3536
3537    #[test]
3538    fn uncertain_send_blocks_enter_paste_and_reopening_until_explicit_resend() {
3539        let mut app = demo_app();
3540        app.open_selected();
3541        press(&mut app, KeyCode::Char('c'));
3542        type_text(&mut app, "one copy");
3543        let chat = app.compose.as_ref().unwrap().chat.clone();
3544        let before = app.threads[&chat].items.len();
3545        app.pending_sends
3546            .insert(chat.clone(), "100000000000000001".into());
3547        app.apply(Outcome::Sent {
3548            chat: chat.clone(),
3549            result: Err(AppError::Unconfirmed("may arrive".into())),
3550        });
3551        for _ in 0..3 {
3552            press(&mut app, KeyCode::Enter);
3553        }
3554        app.paste("another copy\n");
3555        assert_eq!(app.compose.as_ref().unwrap().text, "one copy");
3556        assert_eq!(app.threads[&chat].items.len(), before);
3557        press(&mut app, KeyCode::Esc);
3558        press(&mut app, KeyCode::Char('c'));
3559        assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3560        press(&mut app, KeyCode::Char('r'));
3561        assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3562        assert!(app.notice.text.contains("absence does not prove failure"));
3563        press(&mut app, KeyCode::Char('s'));
3564        press(&mut app, KeyCode::Enter); // Enter is not confirmation.
3565        assert_eq!(app.threads[&chat].items.len(), before);
3566        press(&mut app, KeyCode::Char('s'));
3567        press(&mut app, KeyCode::Char('y'));
3568        assert_eq!(app.threads[&chat].items.len(), before + 1);
3569        assert!(app.compose.is_none());
3570        assert!(!app.pending_sends.contains_key(&chat));
3571    }
3572
3573    #[test]
3574    fn delivery_check_uses_original_id_and_queued_success_is_not_labelled_sent() {
3575        let mut app = demo_app();
3576        app.open_selected();
3577        press(&mut app, KeyCode::Char('c'));
3578        type_text(&mut app, "once");
3579        let chat = app.compose.as_ref().unwrap().chat.clone();
3580        let id = "100000000000000001".to_string();
3581        app.pending_sends.insert(chat.clone(), id.clone());
3582        app.apply(Outcome::Sent {
3583            chat: chat.clone(),
3584            result: Err(AppError::Unconfirmed("uncertain".into())),
3585        });
3586        app.apply(Outcome::Delivery {
3587            chat: chat.clone(),
3588            id: "100000000000000002".into(),
3589            result: Ok(json!({"found":true,"delivery_state":"delivered"})),
3590        });
3591        assert!(app.compose.is_some());
3592        app.apply(Outcome::Delivery {
3593            chat: chat.clone(),
3594            id: id.clone(),
3595            result: Ok(json!({"found":true,"delivery_state":"unknown"})),
3596        });
3597        assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3598        assert!(app.notice.text.contains("Found the original message"));
3599        app.apply(Outcome::Delivery {
3600            chat: chat.clone(),
3601            id,
3602            result: Ok(json!({"found":true,"delivery_state":"delivered"})),
3603        });
3604        assert!(app.compose.is_none());
3605        assert!(app.notice.text.contains("original client message ID"));
3606        press(&mut app, KeyCode::Char('c'));
3607        app.apply(Outcome::Sent {
3608            chat,
3609            result: Ok(json!({"id":"queued","source":"desktop","status":"Queued"})),
3610        });
3611        assert!(app.notice.text.starts_with("Queued in Teams"));
3612        assert!(!app.notice.text.starts_with("Sent"));
3613    }
3614
3615    #[test]
3616    fn composer_rejects_oversized_drafts_and_alt_enter_inserts_a_line() {
3617        let mut app = demo_app();
3618        press(&mut app, KeyCode::Char('c'));
3619        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
3620        assert_eq!(app.compose.as_ref().unwrap().text, "\n");
3621        app.paste(&"é".repeat(MAX_BODY));
3622        press(&mut app, KeyCode::Enter);
3623        assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
3624        assert!(app.notice.text.contains("Too long to send"));
3625    }
3626
3627    #[test]
3628    fn composer_hints_fit_narrow_and_wide_terminals() {
3629        for width in [50, 100] {
3630            let mut app = demo_app();
3631            app.open_selected();
3632            press(&mut app, KeyCode::Char('c'));
3633            let mut terminal = Terminal::new(TestBackend::new(width, 28)).unwrap();
3634            terminal.draw(|frame| app.draw(frame)).unwrap();
3635            let text = buffer_text(terminal.backend().buffer());
3636            assert!(text.contains("enter send"), "{text}");
3637            assert!(text.contains("esc close, keep draft"), "{text}");
3638            assert!(!text.contains("SEND THIS?"));
3639        }
3640    }
3641
3642    #[test]
3643    fn drafts_survive_closing_and_blank_ones_are_not_offered() {
3644        let mut app = demo_app();
3645        press(&mut app, KeyCode::Char('c'));
3646        press(&mut app, KeyCode::Char(' '));
3647        press(&mut app, KeyCode::Enter);
3648        assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
3649        type_text(&mut app, "draft");
3650        press(&mut app, KeyCode::Left);
3651        press(&mut app, KeyCode::Backspace);
3652        press(&mut app, KeyCode::Esc);
3653        assert!(app.compose.is_none());
3654        press(&mut app, KeyCode::Char('j'));
3655        press(&mut app, KeyCode::Char('k'));
3656        press(&mut app, KeyCode::Char('c'));
3657        let compose = app.compose.as_ref().unwrap();
3658        assert_eq!(compose.text, " drat");
3659        assert_eq!(compose.cursor, compose.text.len());
3660    }
3661
3662    #[test]
3663    fn read_only_profiles_only_explain_how_to_send() {
3664        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3665            .parse()
3666            .unwrap();
3667        let source = Source::Live {
3668            backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
3669                "fixture".into(),
3670            ))),
3671            profile: "fixture".into(),
3672            can_send: false,
3673        };
3674        let mut app = App::new(source, Theme { color: false }, now.clone());
3675        app.apply(Outcome::Chats {
3676            more: false,
3677            result: Ok(demo_chats(&now)),
3678        });
3679        press(&mut app, KeyCode::Char('c'));
3680        assert!(app.compose.is_none());
3681        assert!(app.notice.text.contains("read-only"));
3682        assert!(!frame_text(&mut app).contains("c reply"));
3683    }
3684
3685    fn header(app: &mut App, width: u16) -> Vec<String> {
3686        let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
3687        terminal.draw(|frame| app.draw(frame)).unwrap();
3688        buffer_text(terminal.backend().buffer())
3689            .lines()
3690            .take(2)
3691            .map(String::from)
3692            .collect()
3693    }
3694
3695    fn live_app(can_send: bool, profile: &str) -> App {
3696        let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3697            .parse()
3698            .unwrap();
3699        let source = Source::Live {
3700            backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
3701                "fixture".into(),
3702            ))),
3703            profile: profile.into(),
3704            can_send,
3705        };
3706        App::new(source, Theme { color: false }, now)
3707    }
3708
3709    #[test]
3710    fn the_header_shows_the_profile_send_mode_freshness_and_counts() {
3711        let mut app = demo_app();
3712        let [title, context] = header(&mut app, 100).try_into().unwrap();
3713        assert!(title.starts_with(" TEAMS   demo "));
3714        assert!(
3715            title.ends_with("  ✎ can send"),
3716            "sample data has no freshness: {title}"
3717        );
3718        assert_eq!(context, " 6 chats  ·  2 unread  ·  sample data");
3719
3720        let mut app = live_app(false, "work");
3721        let [title, context] = header(&mut app, 100).try_into().unwrap();
3722        assert!(title.ends_with(" syncing   read-only"), "{title}");
3723        assert_eq!(context.trim(), "Microsoft Graph");
3724        app.apply(Outcome::Chats {
3725            more: false,
3726            result: Ok(demo_chats(&app.now.clone())),
3727        });
3728        app.account = Some("Ari Chen".into());
3729        let [title, _] = header(&mut app, 100).try_into().unwrap();
3730        assert!(title.starts_with(" TEAMS   work  ·  Ari Chen "), "{title}");
3731        assert!(title.ends_with(" ● live   read-only"), "{title}");
3732    }
3733
3734    #[test]
3735    fn narrow_headers_keep_the_send_mode_and_drop_the_rest_in_order() {
3736        let mut app = live_app(true, "a-rather-long-profile-name-for-work");
3737        app.apply(Outcome::Chats {
3738            more: false,
3739            result: Ok(demo_chats(&app.now.clone())),
3740        });
3741        app.account = Some("Someone With A Long Display Name".into());
3742        let [title, _] = header(&mut app, 110).try_into().unwrap();
3743        assert!(title.contains("for-work  ·  Someone With"), "{title}");
3744        let [title, _] = header(&mut app, 100).try_into().unwrap();
3745        assert!(
3746            !title.contains("Someone"),
3747            "the account goes first: {title}"
3748        );
3749        assert!(title.contains("for-work "), "{title}");
3750        let [title, context] = header(&mut app, 50).try_into().unwrap();
3751        // The one-column right margin is trimmed from the frame text.
3752        assert_eq!(title.chars().count(), 49, "{title}");
3753        assert!(title.ends_with("● live   ✎ can send"), "{title}");
3754        assert!(title.contains("a-rather-long-pro"), "{title}");
3755        assert!(title.contains('…'), "the profile is shortened: {title}");
3756        assert_eq!(context, " 6 chats  ·  2 unread  ·  Microsoft Graph");
3757    }
3758
3759    #[test]
3760    fn failed_refreshes_mark_the_chats_stale_until_one_succeeds() {
3761        let mut app = live_app(false, "work");
3762        let chats = || Outcome::Chats {
3763            more: false,
3764            result: Ok(demo_chats(
3765                &"2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3766                    .parse()
3767                    .unwrap(),
3768            )),
3769        };
3770        app.apply(chats());
3771        let at = app
3772            .synced_at
3773            .clone()
3774            .expect("a successful read is recorded");
3775        assert!(!app.stale);
3776        app.fetching_chats = true;
3777        app.apply(Outcome::Chats {
3778            more: false,
3779            result: Err(AppError::Api("unavailable".into())),
3780        });
3781        assert!(app.stale);
3782        let [title, _] = header(&mut app, 100).try_into().unwrap();
3783        assert!(
3784            title.contains(&format!("stale since {}", at.strftime("%H:%M"))),
3785            "{title}"
3786        );
3787        app.apply(chats());
3788        assert!(!app.stale);
3789        let [title, _] = header(&mut app, 100).try_into().unwrap();
3790        assert!(title.contains("● live"), "{title}");
3791    }
3792
3793    #[test]
3794    fn reloads_keep_older_pages_only_when_the_fresh_page_overlaps() {
3795        let ids = |items: &[Value]| -> Vec<String> {
3796            items
3797                .iter()
3798                .map(|v| v["id"].as_str().unwrap().to_string())
3799                .collect()
3800        };
3801        let page =
3802            |names: &[&str]| -> Vec<Value> { names.iter().map(|id| json!({"id": id})).collect() };
3803        let old = page(&["c", "b", "a"]);
3804        let (items, cursor) = merge_newest(
3805            page(&["d", "c"]),
3806            Some("new".into()),
3807            old.clone(),
3808            Some("old".into()),
3809        );
3810        assert_eq!(ids(&items), ["d", "c", "b", "a"]);
3811        assert_eq!(cursor.as_deref(), Some("old"));
3812        let (items, cursor) =
3813            merge_newest(page(&["f", "e"]), Some("new".into()), old.clone(), None);
3814        assert_eq!(ids(&items), ["f", "e"], "no overlap means a possible gap");
3815        assert_eq!(cursor.as_deref(), Some("new"));
3816        let (items, cursor) = merge_newest(page(&["d", "c"]), None, old, Some("old".into()));
3817        assert_eq!(
3818            ids(&items),
3819            ["d", "c"],
3820            "a complete page replaces everything"
3821        );
3822        assert!(cursor.is_none());
3823    }
3824
3825    #[test]
3826    fn drafts_wrap_and_track_the_cursor() {
3827        assert_eq!(layout_draft("", 0, 10), (vec![String::new()], (0, 0)));
3828        let (rows, at) = layout_draft("abcdef\ngh", 8, 4);
3829        assert_eq!(rows, ["abcd", "ef", "gh"]);
3830        assert_eq!(at, (2, 1));
3831        assert_eq!(layout_draft("abcd", 4, 4).1, (0, 4));
3832        assert_eq!(layout_draft("ab\n", 3, 4).1, (1, 0));
3833        let (rows, at) = layout_draft("你好你", "你好".len(), 4);
3834        assert_eq!(rows, ["你好", "你"]);
3835        assert_eq!(at, (1, 0));
3836    }
3837
3838    #[test]
3839    fn connection_snapshot_has_a_clear_recovery_action() {
3840        let frame =
3841            connection_snapshot(80, 22, "work", true, "Your saved session has expired.").unwrap();
3842        assert!(frame.contains("SIGN-IN REQUIRED"));
3843        assert!(frame.contains("Profile: work"));
3844        assert!(frame.contains("enter / a"));
3845        assert!(frame.contains("sign in"));
3846        assert!(frame.contains("q quit"));
3847    }
3848}