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