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