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