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