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