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 {
2329 if self.color {
2330 Style::default().fg(foreground)
2331 } else {
2332 Style::default()
2333 }
2334 }
2335
2336 fn canvas(self) -> Style {
2337 self.style(Color::Rgb(222, 225, 240), Color::Rgb(15, 16, 28))
2338 }
2339
2340 fn body(self) -> Style {
2341 self.fg(Color::Rgb(222, 225, 240))
2342 }
2343
2344 fn muted(self) -> Style {
2345 self.fg(Color::Rgb(128, 134, 168))
2346 }
2347
2348 fn quote(self) -> Style {
2349 self.muted().add_modifier(Modifier::ITALIC)
2350 }
2351
2352 fn brand(self) -> Style {
2353 self.style(Color::Rgb(15, 16, 28), Color::Rgb(123, 131, 235))
2354 .add_modifier(Modifier::BOLD)
2355 }
2356
2357 fn title(self) -> Style {
2358 self.fg(Color::Rgb(242, 243, 252))
2359 .add_modifier(Modifier::BOLD)
2360 }
2361
2362 fn detail_title(self) -> Style {
2363 self.fg(Color::Rgb(170, 177, 255))
2364 .add_modifier(Modifier::BOLD)
2365 }
2366
2367 fn accent(self) -> Style {
2368 self.fg(Color::Rgb(155, 162, 255))
2369 .add_modifier(Modifier::BOLD)
2370 }
2371
2372 fn column_header(self) -> Style {
2373 self.style(Color::Rgb(150, 156, 190), Color::Rgb(24, 26, 44))
2374 .add_modifier(Modifier::BOLD)
2375 }
2376
2377 fn key(self) -> Style {
2378 self.fg(Color::Rgb(246, 183, 82))
2379 .add_modifier(Modifier::BOLD)
2380 }
2381
2382 fn rule(self) -> Style {
2383 self.fg(Color::Rgb(50, 54, 86))
2384 }
2385
2386 fn active_rule(self) -> Style {
2387 self.fg(Color::Rgb(123, 131, 235))
2388 }
2389
2390 fn selection(self) -> Style {
2391 if self.color {
2392 Style::default().bg(Color::Rgb(30, 33, 60))
2393 } else {
2394 Style::default().add_modifier(Modifier::BOLD)
2395 }
2396 }
2397
2398 fn unread(self) -> Style {
2399 self.fg(Color::Rgb(255, 128, 112))
2400 .add_modifier(Modifier::BOLD)
2401 }
2402
2403 fn success(self) -> Style {
2404 self.fg(Color::Rgb(110, 220, 170))
2405 }
2406
2407 fn badge_send(self) -> Style {
2409 self.fg(Color::Rgb(246, 183, 82))
2410 .add_modifier(Modifier::BOLD)
2411 }
2412
2413 fn figure(self) -> Style {
2415 self.body().add_modifier(Modifier::BOLD)
2416 }
2417
2418 fn unread_label(self) -> Style {
2419 self.fg(Color::Rgb(255, 128, 112))
2420 }
2421
2422 fn error(self) -> Style {
2423 self.fg(Color::Rgb(255, 126, 119))
2424 .add_modifier(Modifier::BOLD)
2425 }
2426
2427 fn sender(self, name: &str, own: bool) -> Style {
2429 const PALETTE: [Color; 6] = [
2430 Color::Rgb(122, 200, 255),
2431 Color::Rgb(240, 160, 220),
2432 Color::Rgb(130, 220, 160),
2433 Color::Rgb(250, 200, 110),
2434 Color::Rgb(190, 160, 255),
2435 Color::Rgb(110, 215, 210),
2436 ];
2437 let color = if own {
2438 Color::Rgb(155, 162, 255)
2439 } else {
2440 let hash = name.bytes().fold(0usize, |h, b| {
2441 h.wrapping_mul(31).wrapping_add(usize::from(b))
2442 });
2443 PALETTE[hash % PALETTE.len()]
2444 };
2445 self.fg(color).add_modifier(Modifier::BOLD)
2446 }
2447}
2448
2449fn demo_time(now: &Zoned, minutes_ago: i64) -> String {
2450 now.checked_sub(minutes_ago.minutes())
2451 .unwrap_or_else(|_| now.clone())
2452 .timestamp()
2453 .to_string()
2454}
2455
2456const DEMO_PEOPLE: [(&str, &str); 7] = [
2457 ("me", "Alex Morgan"),
2458 ("mina", "Mina Okafor"),
2459 ("jon", "Jon Bakker"),
2460 ("ari", "Ari Chen"),
2461 ("priya", "Priya Raman"),
2462 ("sam", "Sam Rivera"),
2463 ("lee", "Lee Novak"),
2464];
2465
2466fn demo_person(id: &str) -> Value {
2467 let name = DEMO_PEOPLE
2468 .iter()
2469 .find(|(person, _)| *person == id)
2470 .map_or(id, |(_, name)| name);
2471 json!({"id": id, "displayName": name})
2472}
2473
2474type DemoChat = (
2476 &'static str,
2477 Option<&'static str>,
2478 &'static str,
2479 &'static [&'static str],
2480 bool,
2481);
2482
2483const DEMO_CHATS: [DemoChat; 6] = [
2484 (
2485 "19:launch@thread.v2",
2486 Some("Launch room"),
2487 "group",
2488 &["me", "mina", "jon", "priya"],
2489 true,
2490 ),
2491 (
2492 "19:ari@unq.gbl.spaces",
2493 None,
2494 "oneOnOne",
2495 &["me", "ari"],
2496 true,
2497 ),
2498 (
2499 "19:design@thread.v2",
2500 Some("Design crit"),
2501 "group",
2502 &["me", "lee", "ari"],
2503 false,
2504 ),
2505 (
2506 "19:oncall@thread.v2",
2507 Some("Platform on-call"),
2508 "group",
2509 &["me", "priya", "jon", "sam", "lee"],
2510 false,
2511 ),
2512 (
2513 "19:weekly@thread.v2",
2514 Some("Weekly sync"),
2515 "meeting",
2516 &["me", "mina", "jon", "ari"],
2517 false,
2518 ),
2519 (
2520 "19:sam@unq.gbl.spaces",
2521 None,
2522 "oneOnOne",
2523 &["me", "sam"],
2524 false,
2525 ),
2526];
2527
2528fn demo_script(chat: &str) -> &'static [(i64, &'static str, &'static str)] {
2530 match chat {
2531 "19:launch@thread.v2" => &[
2532 (
2533 1460,
2534 "priya",
2535 "<p>Staging is frozen for the launch. Ping me before any deploy.</p>",
2536 ),
2537 (1452, "me", "<p>Noted, thanks <at>Priya</at>.</p>"),
2538 (
2539 43,
2540 "jon",
2541 "<p>Docs are ready. One broken link remains in the migration guide.</p>",
2542 ),
2543 (
2544 41,
2545 "me",
2546 "<p>I’ll take it. Hold the announcement until I clear the link check.</p>",
2547 ),
2548 (
2549 40,
2550 "me",
2551 "<p>Fixed in <code>docs#412</code>, the checker is green.</p>",
2552 ),
2553 (
2554 23,
2555 "mina",
2556 "<ul><li>notes reviewed</li><li>rollback tested</li><li>status page drafted</li></ul>",
2557 ),
2558 (
2559 22,
2560 "mina",
2561 "<p>The release candidate is green. I pinned the final checklist.</p>",
2562 ),
2563 ],
2564 "19:ari@unq.gbl.spaces" => &[
2565 (180, "me", "<p>Thanks for the review yesterday!</p>"),
2566 (
2567 31,
2568 "ari",
2569 "<p>Can you look at the migration guide before lunch? Two screenshots are outdated.</p>",
2570 ),
2571 ],
2572 "19:design@thread.v2" => &[
2573 (300, "lee", "<p>New onboarding flow is in Figma 🎨</p>"),
2574 (95, "me", "<p>Love the empty states. Uploaded my notes.</p>"),
2575 ],
2576 "19:oncall@thread.v2" => &[
2577 (
2578 1600,
2579 "sam",
2580 "<blockquote><p>Is the queue backlog back to normal?</p></blockquote><p>Yes, drained overnight.</p>",
2581 ),
2582 (
2583 1500,
2584 "priya",
2585 "<p>Paging is quiet. Handing over at 18:00.</p>",
2586 ),
2587 ],
2588 "19:weekly@thread.v2" => &[(
2589 4400,
2590 "mina",
2591 "<p>Agenda for Monday is in the meeting notes.</p>",
2592 )],
2593 _ => &[(16000, "sam", "<p>Here are the slides from the offsite.</p>")],
2594 }
2595}
2596
2597fn demo_message(
2598 chat: &str,
2599 index: usize,
2600 now: &Zoned,
2601 (ago, from, html): (i64, &str, &str),
2602) -> Value {
2603 json!({
2604 "id": format!("{chat}/{index}"),
2605 "messageType": "message",
2606 "createdDateTime": demo_time(now, ago),
2607 "from": {"user": demo_person(from)},
2608 "body": {"contentType": "html", "content": html},
2609 })
2610}
2611
2612fn demo_chats(now: &Zoned) -> Page {
2613 let items = DEMO_CHATS
2614 .iter()
2615 .map(|(id, topic, kind, members, unread)| {
2616 let script = demo_script(id);
2617 let last = script[script.len() - 1];
2618 let mut preview = demo_message(id, script.len() - 1, now, last);
2619 preview.as_object_mut().map(|p| p.remove("id"));
2620 let read = if *unread { last.0 + 5 } else { 0 };
2621 json!({
2622 "id": id,
2623 "topic": topic,
2624 "chatType": kind,
2625 "lastUpdatedDateTime": demo_time(now, last.0),
2626 "viewpoint": {"lastMessageReadDateTime": demo_time(now, read)},
2627 "members": members.iter().map(|m| {
2628 let person = demo_person(m);
2629 json!({"userId": person["id"], "displayName": person["displayName"]})
2630 }).collect::<Vec<_>>(),
2631 "lastMessagePreview": preview,
2632 })
2633 })
2634 .collect();
2635 demo_page(items)
2636}
2637
2638fn demo_messages(chat: &str, now: &Zoned) -> Page {
2639 demo_page(
2640 demo_script(chat)
2641 .iter()
2642 .enumerate()
2643 .rev()
2644 .map(|(index, line)| demo_message(chat, index, now, *line))
2645 .collect(),
2646 )
2647}
2648
2649fn demo_page(items: Vec<Value>) -> Page {
2650 Page {
2651 items,
2652 next_cursor: None,
2653 next_offset: None,
2654 truncated: false,
2655 source: None,
2656 viewer: Some("me".into()),
2657 }
2658}
2659
2660pub fn request_authentication(
2664 profile: &str,
2665 configured: bool,
2666 reason: &str,
2667) -> Result<TuiExit, AppError> {
2668 require_terminal("run `teams auth login --device-code` for headless sign-in")?;
2669 let theme = Theme {
2670 color: std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty()),
2671 };
2672 let _guard = TerminalGuard::enter(false)?;
2673 let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
2674 loop {
2675 terminal.draw(|frame| draw_connection(frame, theme, profile, configured, reason))?;
2676 if event::poll(Duration::from_millis(150))?
2677 && let Event::Key(key) = event::read()?
2678 && key.kind == KeyEventKind::Press
2679 {
2680 match key.code {
2681 KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
2682 KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
2683 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2684 return Ok(TuiExit::Quit);
2685 }
2686 _ => {}
2687 }
2688 }
2689 }
2690}
2691
2692pub fn connection_snapshot(
2693 width: u16,
2694 height: u16,
2695 profile: &str,
2696 configured: bool,
2697 reason: &str,
2698) -> Result<String, AppError> {
2699 let mut terminal = Terminal::new(TestBackend::new(
2700 width.max(MIN_WIDTH),
2701 height.max(MIN_HEIGHT),
2702 ))
2703 .expect("test backend is infallible");
2704 terminal
2705 .draw(|frame| draw_connection(frame, Theme { color: false }, profile, configured, reason))
2706 .expect("test backend is infallible");
2707 Ok(buffer_text(terminal.backend().buffer()))
2708}
2709
2710fn draw_connection(
2711 frame: &mut ratatui::Frame<'_>,
2712 theme: Theme,
2713 profile: &str,
2714 configured: bool,
2715 reason: &str,
2716) {
2717 let area = frame.area();
2718 frame.render_widget(Block::default().style(theme.canvas()), area);
2719 frame.render_widget(
2720 Paragraph::new(Line::from(vec![
2721 Span::styled(" TEAMS ", theme.brand()),
2722 Span::styled(" CONNECTION", theme.title()),
2723 ])),
2724 Rect { height: 1, ..area },
2725 );
2726 let (title, heading, action) = if configured {
2727 (
2728 "SIGN-IN REQUIRED",
2729 "Your Teams session needs attention",
2730 "Continue with Microsoft sign-in. Your password is never shared with teams-cli.",
2731 )
2732 } else {
2733 (
2734 "CONNECT MICROSOFT TEAMS",
2735 "Bring your Teams chats into the terminal",
2736 "Set up this profile and continue with secure Microsoft sign-in.",
2737 )
2738 };
2739 let lines = vec![
2740 Line::styled(heading, theme.title()),
2741 Line::styled(format!("Profile: {profile}"), theme.muted()),
2742 Line::raw(""),
2743 Line::styled(reason.to_string(), theme.body()),
2744 Line::styled(action, theme.muted()),
2745 Line::raw(""),
2746 Line::from(vec![
2747 Span::styled("enter / a", theme.key()),
2748 Span::styled(
2749 if configured {
2750 " sign in "
2751 } else {
2752 " set up "
2753 },
2754 theme.muted(),
2755 ),
2756 Span::styled("q", theme.key()),
2757 Span::styled(" quit", theme.muted()),
2758 ]),
2759 ];
2760 state_panel(frame, area, theme, title, lines, theme.active_rule());
2761}
2762
2763#[cfg(test)]
2764mod tests {
2765 use super::*;
2766
2767 #[test]
2768 fn debugging_restart_requires_confirmation_and_can_be_cancelled() {
2769 let mut app = demo_app();
2770 app.apply(Outcome::DebuggingRequired);
2771 press(&mut app, KeyCode::Char('y'));
2772 assert!(app.debugging_restart == Some(DebuggingRestart::Offered));
2773 press(&mut app, KeyCode::Enter);
2774 assert!(app.debugging_restart == Some(DebuggingRestart::Confirming));
2775 press(&mut app, KeyCode::Esc);
2776 assert!(app.debugging_restart == Some(DebuggingRestart::Offered));
2777 assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
2778 press(&mut app, KeyCode::Char('r'));
2779 assert!(app.debugging_restart.is_none());
2780 assert!(matches!(app.chats_load, Load::Ready));
2781 }
2782
2783 #[test]
2784 fn debugging_restart_completion_recovers_or_shows_retry() {
2785 let mut app = demo_app();
2786 app.debugging_restart = Some(DebuggingRestart::Running);
2787 app.apply(Outcome::Restarted(Err(AppError::Desktop(
2788 "Restart failed".into(),
2789 ))));
2790 assert!(app.debugging_restart.is_none());
2791 assert!(matches!(app.chats_load, Load::Failed { auth: false, .. }));
2792 app.apply(Outcome::Restarted(Ok(())));
2793 assert!(matches!(app.chats_load, Load::Ready));
2794 }
2795
2796 #[test]
2797 fn debugging_restart_panels_show_actions_and_consequences() {
2798 for (width, height) in [(50, 14), (80, 24), (120, 30)] {
2799 for state in [
2800 DebuggingRestart::Offered,
2801 DebuggingRestart::Confirming,
2802 DebuggingRestart::Running,
2803 ] {
2804 let mut app = demo_app();
2805 app.chats_load = Load::Loading;
2806 app.debugging_restart = Some(state);
2807 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
2808 terminal.draw(|frame| app.draw(frame)).unwrap();
2809 let text = buffer_text(terminal.backend().buffer());
2810 assert!(!text.contains("teams doctor"));
2811 let words = text
2812 .replace('│', " ")
2813 .split_whitespace()
2814 .collect::<Vec<_>>()
2815 .join(" ");
2816 assert!(words.contains("send as you"), "{text}");
2817 assert!(text.contains("quit"), "{text}");
2818 if state == DebuggingRestart::Confirming {
2819 assert!(text.contains("ends any call"), "{text}");
2820 assert!(text.contains("cancel"), "{text}");
2821 }
2822 }
2823 }
2824 }
2825
2826 fn demo_app() -> App {
2827 let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2828 .parse()
2829 .unwrap();
2830 let mut app = App::new(Source::Demo, Theme { color: false }, now);
2831 app.start();
2832 app
2833 }
2834
2835 fn press(app: &mut App, code: KeyCode) -> Action {
2836 app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
2837 }
2838
2839 #[test]
2840 fn snapshot_shows_the_ledger_and_the_open_conversation() {
2841 let frame = snapshot(90, 26).unwrap();
2842 assert!(frame.contains("TEAMS"));
2843 assert!(frame.contains("Launch room · group"));
2844 assert!(frame.contains("Ari Chen"));
2845 assert!(frame.contains("release candidate is green"));
2846 assert!(frame.contains("── Today ──"));
2847 assert!(frame.contains("You 09:34"));
2848 assert!(frame.contains("q quit"));
2849 }
2850
2851 #[test]
2852 fn colour_paints_every_cell_so_the_terminal_background_never_shows() {
2853 let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
2854 .parse()
2855 .unwrap();
2856 let mut app = App::new(Source::Demo, Theme { color: true }, now);
2857 app.start();
2858 let unpainted = |buffer: &ratatui::buffer::Buffer| {
2859 buffer
2860 .content()
2861 .iter()
2862 .filter(|cell| cell.bg == Color::Reset)
2863 .count()
2864 };
2865 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2866 terminal.draw(|frame| app.draw(frame)).unwrap();
2867 assert_eq!(unpainted(terminal.backend().buffer()), 0);
2868 press(&mut app, KeyCode::Enter);
2869 terminal.draw(|frame| app.draw(frame)).unwrap();
2870 assert_eq!(unpainted(terminal.backend().buffer()), 0);
2871 terminal
2872 .draw(|frame| draw_connection(frame, Theme { color: true }, "default", true, "x"))
2873 .unwrap();
2874 assert_eq!(unpainted(terminal.backend().buffer()), 0);
2875 }
2876
2877 #[test]
2878 fn narrow_terminals_take_turns_and_tiny_ones_ask_for_room() {
2879 let frame = snapshot(60, 24).unwrap();
2880 assert!(frame.contains("Launch room"));
2881 assert!(!frame.contains("rollback tested"));
2882 let mut app = demo_app();
2883 press(&mut app, KeyCode::Enter);
2884 let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
2885 terminal.draw(|frame| app.draw(frame)).unwrap();
2886 let text = buffer_text(terminal.backend().buffer());
2887 assert!(text.contains("rollback tested"));
2888 assert!(text.contains("esc chats"));
2889 let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
2890 terminal.draw(|frame| app.draw(frame)).unwrap();
2891 assert!(buffer_text(terminal.backend().buffer()).contains("NEEDS MORE ROOM"));
2892 }
2893
2894 #[test]
2895 fn filtering_narrows_the_ledger_and_esc_restores_it() {
2896 let mut app = demo_app();
2897 assert_eq!(app.visible.len(), DEMO_CHATS.len());
2898 press(&mut app, KeyCode::Char('/'));
2899 for c in "on-call".chars() {
2900 press(&mut app, KeyCode::Char(c));
2901 }
2902 assert_eq!(app.visible.len(), 1);
2903 assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
2904 press(&mut app, KeyCode::Enter);
2905 assert!(app.overlay.is_none());
2906 press(&mut app, KeyCode::Esc);
2907 assert_eq!(app.visible.len(), DEMO_CHATS.len());
2908 assert_eq!(app.selected_row().unwrap().title, "Platform on-call");
2909 }
2910
2911 #[test]
2912 fn opening_a_chat_loads_it_once_and_scroll_is_bounded() {
2913 let mut app = demo_app();
2914 press(&mut app, KeyCode::Char('j'));
2915 press(&mut app, KeyCode::Enter);
2916 assert!(app.focus == Focus::Thread);
2917 assert!(app.threads.contains_key("19:ari@unq.gbl.spaces"));
2918 for _ in 0..50 {
2919 press(&mut app, KeyCode::Char('k'));
2920 }
2921 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2922 terminal.draw(|frame| app.draw(frame)).unwrap();
2923 assert_eq!(app.scroll, 0, "a short chat cannot scroll");
2924 press(&mut app, KeyCode::Esc);
2925 assert!(app.focus == Focus::Chats);
2926 press(&mut app, KeyCode::Char('?'));
2927 terminal.draw(|frame| app.draw(frame)).unwrap();
2928 assert!(buffer_text(terminal.backend().buffer()).contains("KEYBOARD MAP"));
2929 assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::None));
2930 assert!(matches!(press(&mut app, KeyCode::Char('q')), Action::Quit));
2931 }
2932
2933 fn mouse(app: &mut App, kind: MouseEventKind, column: u16, row: u16) {
2934 app.handle_mouse(MouseEvent {
2935 kind,
2936 column,
2937 row,
2938 modifiers: KeyModifiers::NONE,
2939 });
2940 }
2941
2942 #[test]
2943 fn the_wheel_scrolls_the_pane_under_the_pointer_and_clicks_open_chats() {
2944 let mut app = demo_app();
2945 app.open_selected();
2946 let mut terminal = Terminal::new(TestBackend::new(100, 16)).unwrap();
2947 terminal.draw(|frame| app.draw(frame)).unwrap();
2948 let ledger = app.ledger_area.expect("ledger drawn");
2949 let thread = app.thread_area.expect("conversation drawn");
2950 assert!(
2951 app.thread_overflow > WHEEL_LINES,
2952 "the demo chat overflows 16 rows"
2953 );
2954
2955 assert!(app.focus == Focus::Chats);
2957 mouse(
2958 &mut app,
2959 MouseEventKind::ScrollUp,
2960 thread.x + 5,
2961 thread.y + 5,
2962 );
2963 assert_eq!(app.scroll, WHEEL_LINES);
2964 assert_eq!(app.list.selected(), Some(0), "the ledger did not move");
2965 for _ in 0..50 {
2966 mouse(
2967 &mut app,
2968 MouseEventKind::ScrollUp,
2969 thread.x + 5,
2970 thread.y + 5,
2971 );
2972 }
2973 assert_eq!(
2974 app.scroll, app.thread_overflow,
2975 "scrolling stops at the oldest line"
2976 );
2977 mouse(
2978 &mut app,
2979 MouseEventKind::ScrollDown,
2980 thread.x + 5,
2981 thread.y + 5,
2982 );
2983 assert_eq!(app.scroll, app.thread_overflow - WHEEL_LINES);
2984
2985 mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
2987 assert_eq!(app.list.selected(), Some(1));
2988 assert_eq!(app.scroll, 0);
2989 mouse(&mut app, MouseEventKind::ScrollUp, ledger.x + 2, ledger.y);
2990 assert_eq!(app.list.selected(), Some(0));
2991
2992 let left = MouseEventKind::Down(MouseButton::Left);
2994 mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
2995 assert_eq!(app.list.selected(), Some(1));
2996 assert!(app.focus == Focus::Thread);
2997 let id = app.selected_row().unwrap().id.clone();
2998 assert!(app.threads.contains_key(&id));
2999 press(&mut app, KeyCode::Tab);
3000 assert!(app.focus == Focus::Chats);
3001 mouse(&mut app, left, thread.x + 5, thread.y + 5);
3002 assert!(app.focus == Focus::Thread);
3003
3004 mouse(&mut app, MouseEventKind::ScrollDown, 0, 0);
3006 assert_eq!(app.list.selected(), Some(1));
3007 press(&mut app, KeyCode::Char('?'));
3008 mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
3009 assert_eq!(app.list.selected(), Some(1));
3010 mouse(&mut app, left, 0, 0);
3011 assert!(app.overlay.is_none(), "a click closes the help");
3012 }
3013
3014 #[test]
3015 fn the_composer_keeps_its_chat_when_the_ledger_is_clicked() {
3016 let mut app = demo_app();
3017 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3018 press(&mut app, KeyCode::Enter);
3019 press(&mut app, KeyCode::Char('c'));
3020 assert!(app.compose.is_some());
3021 terminal.draw(|frame| app.draw(frame)).unwrap();
3022 let ledger = app.ledger_area.expect("ledger drawn");
3023 let left = MouseEventKind::Down(MouseButton::Left);
3024 mouse(&mut app, left, ledger.x + 2, ledger.y + CHAT_ROW_HEIGHT + 1);
3025 mouse(&mut app, MouseEventKind::ScrollDown, ledger.x + 2, ledger.y);
3026 assert_eq!(app.list.selected(), Some(0));
3027 }
3028
3029 #[test]
3030 fn opening_a_chat_prefetches_its_neighbours_and_retries_failures() {
3031 let mut app = demo_app();
3032 press(&mut app, KeyCode::Char('j'));
3033 press(&mut app, KeyCode::Enter);
3034 let ids: Vec<String> = app
3035 .visible
3036 .iter()
3037 .map(|&i| app.rows[i].id.clone())
3038 .collect();
3039 let below = ids[2].clone();
3040 assert!(app.threads.contains_key(&ids[0]) && app.threads.contains_key(&below));
3041 assert!(!app.threads.contains_key(&ids[3]));
3042 app.threads.get_mut(&below).unwrap().load =
3043 Load::failed(&AppError::Desktop("read failed".into()));
3044 press(&mut app, KeyCode::Esc);
3045 press(&mut app, KeyCode::Char('j'));
3046 app.open_selected();
3047 assert!(matches!(app.threads[&below].load, Load::Ready));
3048 assert!(app.threads.contains_key(&ids[3]));
3049 }
3050
3051 #[test]
3052 fn auth_failures_offer_sign_in_from_inside_the_tui() {
3053 let mut app = demo_app();
3054 app.chats_load = Load::failed(&AppError::Auth("session expired".into()));
3055 let mut terminal = Terminal::new(TestBackend::new(90, 24)).unwrap();
3056 terminal.draw(|frame| app.draw(frame)).unwrap();
3057 let text = buffer_text(terminal.backend().buffer());
3058 assert!(text.contains("SIGN-IN REQUIRED"));
3059 assert!(text.contains("enter / a"));
3060 assert!(matches!(
3061 press(&mut app, KeyCode::Enter),
3062 Action::Authenticate
3063 ));
3064 }
3065
3066 fn frame_text(app: &mut App) -> String {
3067 let mut terminal = Terminal::new(TestBackend::new(100, 28)).unwrap();
3068 terminal.draw(|frame| app.draw(frame)).unwrap();
3069 buffer_text(terminal.backend().buffer())
3070 }
3071
3072 fn type_text(app: &mut App, text: &str) {
3073 for c in text.chars() {
3074 press(app, KeyCode::Char(c));
3075 }
3076 }
3077
3078 #[test]
3079 fn enter_sends_the_draft_and_the_message_appears() {
3080 let mut app = demo_app();
3081 app.open_selected();
3082 press(&mut app, KeyCode::Char('c'));
3083 assert!(app.focus == Focus::Thread);
3084 type_text(&mut app, "on it q r /");
3086 app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
3087 type_text(&mut app, "shipping");
3088 assert!(app.overlay.is_none());
3089 let compose = app.compose.as_ref().unwrap();
3090 assert_eq!(compose.text, "on it q r /\nshipping");
3091 assert!(frame_text(&mut app).contains("MESSAGE"));
3092
3093 press(&mut app, KeyCode::Enter);
3094 assert!(app.compose.is_none());
3095 assert_eq!(app.notice.text, "Sent.");
3096 let thread = &app.threads["19:launch@thread.v2"];
3097 assert_eq!(thread.items.len(), 8);
3098 assert_eq!(thread.items[0]["body"]["content"], "on it q r /\nshipping");
3099 assert!(frame_text(&mut app).contains("shipping"));
3100 }
3101
3102 #[test]
3103 fn paste_keeps_multiline_text_in_the_draft_until_enter() {
3104 let mut app = demo_app();
3105 app.open_selected();
3106 app.paste("c accidental commands\n");
3107 assert!(app.compose.is_none());
3108 press(&mut app, KeyCode::Char('c'));
3109 type_text(&mut app, "ab");
3110 press(&mut app, KeyCode::Left);
3111 app.paste("é\r\nsecond\rthird\n");
3112 let compose = app.compose.as_ref().unwrap();
3113 assert_eq!(compose.text, "aé\nsecond\nthird\nb");
3114 assert_eq!(&compose.text[..compose.cursor], "aé\nsecond\nthird\n");
3115 assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3116 press(&mut app, KeyCode::Enter);
3117 assert!(app.compose.is_none());
3118 assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 8);
3119 }
3120
3121 #[test]
3122 fn sending_ignores_extra_keys_and_paste_and_failure_keeps_the_draft() {
3123 let mut app = demo_app();
3124 app.open_selected();
3125 press(&mut app, KeyCode::Char('c'));
3126 type_text(&mut app, "keep this");
3127 app.compose.as_mut().unwrap().stage = Stage::Sending;
3128 for key in [KeyCode::Enter, KeyCode::Char('y'), KeyCode::Esc] {
3129 press(&mut app, key);
3130 }
3131 app.paste("more\n");
3132 assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3133 assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3134 app.apply(Outcome::Sent {
3135 chat: "19:launch@thread.v2".into(),
3136 result: Err(AppError::Unconfirmed(
3137 "Check the conversation before sending again".into(),
3138 )),
3139 });
3140 assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3141 assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3142 assert!(app.notice.text.contains("Check the conversation"));
3143 assert_eq!(app.threads["19:launch@thread.v2"].items.len(), 7);
3144 press(&mut app, KeyCode::Esc);
3145 press(&mut app, KeyCode::Char('c'));
3146 assert_eq!(app.compose.as_ref().unwrap().text, "keep this");
3147 }
3148
3149 #[test]
3150 fn uncertain_send_blocks_enter_paste_and_reopening_until_explicit_resend() {
3151 let mut app = demo_app();
3152 app.open_selected();
3153 press(&mut app, KeyCode::Char('c'));
3154 type_text(&mut app, "one copy");
3155 let chat = app.compose.as_ref().unwrap().chat.clone();
3156 let before = app.threads[&chat].items.len();
3157 app.pending_sends
3158 .insert(chat.clone(), "100000000000000001".into());
3159 app.apply(Outcome::Sent {
3160 chat: chat.clone(),
3161 result: Err(AppError::Unconfirmed("may arrive".into())),
3162 });
3163 for _ in 0..3 {
3164 press(&mut app, KeyCode::Enter);
3165 }
3166 app.paste("another copy\n");
3167 assert_eq!(app.compose.as_ref().unwrap().text, "one copy");
3168 assert_eq!(app.threads[&chat].items.len(), before);
3169 press(&mut app, KeyCode::Esc);
3170 press(&mut app, KeyCode::Char('c'));
3171 assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3172 press(&mut app, KeyCode::Char('r'));
3173 assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3174 assert!(app.notice.text.contains("absence does not prove failure"));
3175 press(&mut app, KeyCode::Char('s'));
3176 press(&mut app, KeyCode::Enter); assert_eq!(app.threads[&chat].items.len(), before);
3178 press(&mut app, KeyCode::Char('s'));
3179 press(&mut app, KeyCode::Char('y'));
3180 assert_eq!(app.threads[&chat].items.len(), before + 1);
3181 assert!(app.compose.is_none());
3182 assert!(!app.pending_sends.contains_key(&chat));
3183 }
3184
3185 #[test]
3186 fn delivery_check_uses_original_id_and_queued_success_is_not_labelled_sent() {
3187 let mut app = demo_app();
3188 app.open_selected();
3189 press(&mut app, KeyCode::Char('c'));
3190 type_text(&mut app, "once");
3191 let chat = app.compose.as_ref().unwrap().chat.clone();
3192 let id = "100000000000000001".to_string();
3193 app.pending_sends.insert(chat.clone(), id.clone());
3194 app.apply(Outcome::Sent {
3195 chat: chat.clone(),
3196 result: Err(AppError::Unconfirmed("uncertain".into())),
3197 });
3198 app.apply(Outcome::Delivery {
3199 chat: chat.clone(),
3200 id: "100000000000000002".into(),
3201 result: Ok(json!({"found":true,"delivery_state":"delivered"})),
3202 });
3203 assert!(app.compose.is_some());
3204 app.apply(Outcome::Delivery {
3205 chat: chat.clone(),
3206 id: id.clone(),
3207 result: Ok(json!({"found":true,"delivery_state":"unknown"})),
3208 });
3209 assert!(app.compose.as_ref().unwrap().stage == Stage::Unconfirmed);
3210 assert!(app.notice.text.contains("Found the original message"));
3211 app.apply(Outcome::Delivery {
3212 chat: chat.clone(),
3213 id,
3214 result: Ok(json!({"found":true,"delivery_state":"delivered"})),
3215 });
3216 assert!(app.compose.is_none());
3217 assert!(app.notice.text.contains("original client message ID"));
3218 press(&mut app, KeyCode::Char('c'));
3219 app.apply(Outcome::Sent {
3220 chat,
3221 result: Ok(json!({"id":"queued","source":"desktop","status":"Queued"})),
3222 });
3223 assert!(app.notice.text.starts_with("Queued in Teams"));
3224 assert!(!app.notice.text.starts_with("Sent"));
3225 }
3226
3227 #[test]
3228 fn composer_rejects_oversized_drafts_and_alt_enter_inserts_a_line() {
3229 let mut app = demo_app();
3230 press(&mut app, KeyCode::Char('c'));
3231 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
3232 assert_eq!(app.compose.as_ref().unwrap().text, "\n");
3233 app.paste(&"é".repeat(MAX_BODY));
3234 press(&mut app, KeyCode::Enter);
3235 assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
3236 assert!(app.notice.text.contains("Too long to send"));
3237 }
3238
3239 #[test]
3240 fn composer_hints_fit_narrow_and_wide_terminals() {
3241 for width in [50, 100] {
3242 let mut app = demo_app();
3243 app.open_selected();
3244 press(&mut app, KeyCode::Char('c'));
3245 let mut terminal = Terminal::new(TestBackend::new(width, 28)).unwrap();
3246 terminal.draw(|frame| app.draw(frame)).unwrap();
3247 let text = buffer_text(terminal.backend().buffer());
3248 assert!(text.contains("enter send"), "{text}");
3249 assert!(text.contains("esc close, keep draft"), "{text}");
3250 assert!(!text.contains("SEND THIS?"));
3251 }
3252 }
3253
3254 #[test]
3255 fn drafts_survive_closing_and_blank_ones_are_not_offered() {
3256 let mut app = demo_app();
3257 press(&mut app, KeyCode::Char('c'));
3258 press(&mut app, KeyCode::Char(' '));
3259 press(&mut app, KeyCode::Enter);
3260 assert!(app.compose.as_ref().unwrap().stage == Stage::Editing);
3261 type_text(&mut app, "draft");
3262 press(&mut app, KeyCode::Left);
3263 press(&mut app, KeyCode::Backspace);
3264 press(&mut app, KeyCode::Esc);
3265 assert!(app.compose.is_none());
3266 press(&mut app, KeyCode::Char('j'));
3267 press(&mut app, KeyCode::Char('k'));
3268 press(&mut app, KeyCode::Char('c'));
3269 let compose = app.compose.as_ref().unwrap();
3270 assert_eq!(compose.text, " drat");
3271 assert_eq!(compose.cursor, compose.text.len());
3272 }
3273
3274 #[test]
3275 fn read_only_profiles_only_explain_how_to_send() {
3276 let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3277 .parse()
3278 .unwrap();
3279 let source = Source::Live {
3280 backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
3281 "fixture".into(),
3282 ))),
3283 profile: "fixture".into(),
3284 can_send: false,
3285 };
3286 let mut app = App::new(source, Theme { color: false }, now.clone());
3287 app.apply(Outcome::Chats {
3288 more: false,
3289 result: Ok(demo_chats(&now)),
3290 });
3291 press(&mut app, KeyCode::Char('c'));
3292 assert!(app.compose.is_none());
3293 assert!(app.notice.text.contains("read-only"));
3294 assert!(!frame_text(&mut app).contains("c reply"));
3295 }
3296
3297 fn header(app: &mut App, width: u16) -> Vec<String> {
3298 let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
3299 terminal.draw(|frame| app.draw(frame)).unwrap();
3300 buffer_text(terminal.backend().buffer())
3301 .lines()
3302 .take(2)
3303 .map(String::from)
3304 .collect()
3305 }
3306
3307 fn live_app(can_send: bool, profile: &str) -> App {
3308 let now: Zoned = "2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3309 .parse()
3310 .unwrap();
3311 let source = Source::Live {
3312 backend: Arc::new(TeamsBackend::Graph(crate::graph::GraphClient::new(
3313 "fixture".into(),
3314 ))),
3315 profile: profile.into(),
3316 can_send,
3317 };
3318 App::new(source, Theme { color: false }, now)
3319 }
3320
3321 #[test]
3322 fn the_header_shows_the_profile_send_mode_freshness_and_counts() {
3323 let mut app = demo_app();
3324 let [title, context] = header(&mut app, 100).try_into().unwrap();
3325 assert!(title.starts_with(" TEAMS demo "));
3326 assert!(
3327 title.ends_with(" ✎ can send"),
3328 "sample data has no freshness: {title}"
3329 );
3330 assert_eq!(context, " 6 chats · 2 unread · sample data");
3331
3332 let mut app = live_app(false, "work");
3333 let [title, context] = header(&mut app, 100).try_into().unwrap();
3334 assert!(title.ends_with(" syncing read-only"), "{title}");
3335 assert_eq!(context.trim(), "Microsoft Graph");
3336 app.apply(Outcome::Chats {
3337 more: false,
3338 result: Ok(demo_chats(&app.now.clone())),
3339 });
3340 app.account = Some("Ari Chen".into());
3341 let [title, _] = header(&mut app, 100).try_into().unwrap();
3342 assert!(title.starts_with(" TEAMS work · Ari Chen "), "{title}");
3343 assert!(title.ends_with(" ● live read-only"), "{title}");
3344 }
3345
3346 #[test]
3347 fn narrow_headers_keep_the_send_mode_and_drop_the_rest_in_order() {
3348 let mut app = live_app(true, "a-rather-long-profile-name-for-work");
3349 app.apply(Outcome::Chats {
3350 more: false,
3351 result: Ok(demo_chats(&app.now.clone())),
3352 });
3353 app.account = Some("Someone With A Long Display Name".into());
3354 let [title, _] = header(&mut app, 110).try_into().unwrap();
3355 assert!(title.contains("for-work · Someone With"), "{title}");
3356 let [title, _] = header(&mut app, 100).try_into().unwrap();
3357 assert!(
3358 !title.contains("Someone"),
3359 "the account goes first: {title}"
3360 );
3361 assert!(title.contains("for-work "), "{title}");
3362 let [title, context] = header(&mut app, 50).try_into().unwrap();
3363 assert_eq!(title.chars().count(), 49, "{title}");
3365 assert!(title.ends_with("● live ✎ can send"), "{title}");
3366 assert!(title.contains("a-rather-long-pro"), "{title}");
3367 assert!(title.contains('…'), "the profile is shortened: {title}");
3368 assert_eq!(context, " 6 chats · 2 unread · Microsoft Graph");
3369 }
3370
3371 #[test]
3372 fn failed_refreshes_mark_the_chats_stale_until_one_succeeds() {
3373 let mut app = live_app(false, "work");
3374 let chats = || Outcome::Chats {
3375 more: false,
3376 result: Ok(demo_chats(
3377 &"2026-10-13T10:15:00+02:00[Europe/Amsterdam]"
3378 .parse()
3379 .unwrap(),
3380 )),
3381 };
3382 app.apply(chats());
3383 let at = app
3384 .synced_at
3385 .clone()
3386 .expect("a successful read is recorded");
3387 assert!(!app.stale);
3388 app.fetching_chats = true;
3389 app.apply(Outcome::Chats {
3390 more: false,
3391 result: Err(AppError::Api("unavailable".into())),
3392 });
3393 assert!(app.stale);
3394 let [title, _] = header(&mut app, 100).try_into().unwrap();
3395 assert!(
3396 title.contains(&format!("stale since {}", at.strftime("%H:%M"))),
3397 "{title}"
3398 );
3399 app.apply(chats());
3400 assert!(!app.stale);
3401 let [title, _] = header(&mut app, 100).try_into().unwrap();
3402 assert!(title.contains("● live"), "{title}");
3403 }
3404
3405 #[test]
3406 fn reloads_keep_older_pages_only_when_the_fresh_page_overlaps() {
3407 let ids = |items: &[Value]| -> Vec<String> {
3408 items
3409 .iter()
3410 .map(|v| v["id"].as_str().unwrap().to_string())
3411 .collect()
3412 };
3413 let page =
3414 |names: &[&str]| -> Vec<Value> { names.iter().map(|id| json!({"id": id})).collect() };
3415 let old = page(&["c", "b", "a"]);
3416 let (items, cursor) = merge_newest(
3417 page(&["d", "c"]),
3418 Some("new".into()),
3419 old.clone(),
3420 Some("old".into()),
3421 );
3422 assert_eq!(ids(&items), ["d", "c", "b", "a"]);
3423 assert_eq!(cursor.as_deref(), Some("old"));
3424 let (items, cursor) =
3425 merge_newest(page(&["f", "e"]), Some("new".into()), old.clone(), None);
3426 assert_eq!(ids(&items), ["f", "e"], "no overlap means a possible gap");
3427 assert_eq!(cursor.as_deref(), Some("new"));
3428 let (items, cursor) = merge_newest(page(&["d", "c"]), None, old, Some("old".into()));
3429 assert_eq!(
3430 ids(&items),
3431 ["d", "c"],
3432 "a complete page replaces everything"
3433 );
3434 assert!(cursor.is_none());
3435 }
3436
3437 #[test]
3438 fn drafts_wrap_and_track_the_cursor() {
3439 assert_eq!(layout_draft("", 0, 10), (vec![String::new()], (0, 0)));
3440 let (rows, at) = layout_draft("abcdef\ngh", 8, 4);
3441 assert_eq!(rows, ["abcd", "ef", "gh"]);
3442 assert_eq!(at, (2, 1));
3443 assert_eq!(layout_draft("abcd", 4, 4).1, (0, 4));
3444 assert_eq!(layout_draft("ab\n", 3, 4).1, (1, 0));
3445 let (rows, at) = layout_draft("你好你", "你好".len(), 4);
3446 assert_eq!(rows, ["你好", "你"]);
3447 assert_eq!(at, (1, 0));
3448 }
3449
3450 #[test]
3451 fn connection_snapshot_has_a_clear_recovery_action() {
3452 let frame =
3453 connection_snapshot(80, 22, "work", true, "Your saved session has expired.").unwrap();
3454 assert!(frame.contains("SIGN-IN REQUIRED"));
3455 assert!(frame.contains("Profile: work"));
3456 assert!(frame.contains("enter / a"));
3457 assert!(frame.contains("sign in"));
3458 assert!(frame.contains("q quit"));
3459 }
3460}