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