1use std::collections::VecDeque;
9
10use crate::mcp::{ElicitationAction, ElicitationResponse};
11use crate::permissions::ApprovalOutcome;
12
13use super::bridge::{
14 PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
15};
16use super::history::PromptHistory;
17use super::key::{Key, KeyEvent};
18use super::keymap::{Keymap, KeymapAction};
19use super::theme::Theme;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Role {
24 User,
26 Assistant,
28 Tool,
30 System,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TranscriptEntry {
38 pub role: Role,
40 pub text: String,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum VimMode {
53 #[default]
56 Insert,
57 Normal,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum InputFocus {
64 #[default]
66 Composer,
67 HistorySearch,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct HistorySearchState {
75 pub query: String,
77 pub selected: usize,
80}
81
82#[derive(Debug)]
87pub enum Modal {
88 Approval(PendingApprovalRequest),
91 ChildApproval(PendingChildApproval),
94 Elicitation {
98 request: PendingElicitation,
100 answer: String,
102 },
103 OAuthDeviceCode(PendingOAuthDisplay),
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct StatusLine {
111 pub model_label: String,
113 pub turn_active: bool,
115 pub notice: Option<String>,
118}
119
120#[derive(Debug)]
127pub enum Action {
128 InsertChar(char),
130 Backspace,
132 DeleteForward,
134 MoveLeft,
136 MoveRight,
138 MoveHome,
140 MoveEnd,
142 Newline,
144 ClearComposerLine,
147 Submit(String),
153 ScrollUp,
155 ScrollDown,
157 ToggleTheme,
159 OpenHistorySearch,
161 HistorySearchType(char),
163 HistorySearchBackspace,
165 HistorySearchNext,
167 HistorySearchPrev,
169 HistorySearchConfirm,
171 HistorySearchCancel,
173 VimSetMode(VimMode),
175 VimMoveLeft,
177 VimMoveRight,
179 VimMoveHome,
181 VimMoveEnd,
183 VimDeleteChar,
185 VimDeleteLine,
188 RequestExternalEditor,
192 ExternalEditorResult(String),
195 ExternalEditorFailed(String),
204 PasteImage(String),
209 ShowApprovalModal(PendingApprovalRequest),
212 ResolveApproval(ApprovalOutcome),
214 ShowChildApprovalModal(PendingChildApproval),
217 ResolveChildApproval(ApprovalOutcome),
219 ShowElicitationModal(PendingElicitation),
222 ElicitationType(char),
224 ElicitationBackspace,
226 ResolveElicitationAccept,
228 ResolveElicitationDecline,
230 ResolveElicitationCancel,
232 ShowOAuthModal(PendingOAuthDisplay),
234 DismissOAuthModal,
237 AppendStreamingDelta(String),
239 FinalizeStreaming,
242 PushTranscript(TranscriptEntry),
245 SetModelLabel(String),
247 SetTurnActive(bool),
249 ArmQuit,
252 Quit,
254 Noop,
260}
261
262#[derive(Debug)]
266pub struct TuiState {
267 pub input: String,
269 pub cursor: usize,
271 pub transcript: Vec<TranscriptEntry>,
273 pub streaming: Option<String>,
276 pub modal: Option<Modal>,
278 modal_queue: VecDeque<Modal>,
282 pub theme: Theme,
284 pub keymap: Keymap,
286 pub vim_enabled: bool,
290 pub vim_mode: VimMode,
292 pub history: PromptHistory,
294 pub history_search: Option<HistorySearchState>,
297 pub input_focus: InputFocus,
299 pub status: StatusLine,
301 pub scroll: usize,
303 pub should_quit: bool,
306 quit_armed: bool,
308 pub external_editor_requested: bool,
310 pub pending_images: Vec<String>,
313 pub last_submission: Option<String>,
319}
320
321impl TuiState {
322 pub fn new(theme: Theme, keymap: Keymap, vim_enabled: bool, history: PromptHistory) -> Self {
326 TuiState {
327 input: String::new(),
328 cursor: 0,
329 transcript: Vec::new(),
330 streaming: None,
331 modal: None,
332 modal_queue: VecDeque::new(),
333 theme,
334 keymap,
335 vim_enabled,
336 vim_mode: if vim_enabled {
337 VimMode::Normal
338 } else {
339 VimMode::Insert
340 },
341 history,
342 history_search: None,
343 input_focus: InputFocus::Composer,
344 status: StatusLine::default(),
345 scroll: 0,
346 should_quit: false,
347 quit_armed: false,
348 external_editor_requested: false,
349 pending_images: Vec::new(),
350 last_submission: None,
351 }
352 }
353
354 pub fn new_default() -> Self {
358 TuiState::new(
359 Theme::default(),
360 Keymap::default(),
361 false,
362 PromptHistory::new(),
363 )
364 }
365
366 pub fn handle_key(&self, key: KeyEvent) -> Vec<Action> {
373 if let Some(modal) = &self.modal {
374 return self.handle_key_in_modal(modal, key);
375 }
376 match self.input_focus {
377 InputFocus::HistorySearch => self.handle_key_in_history_search(key),
378 InputFocus::Composer => self.handle_key_in_composer(key),
379 }
380 }
381
382 fn handle_key_in_composer(&self, key: KeyEvent) -> Vec<Action> {
383 let km = &self.keymap;
384 if key == km.key_for(KeymapAction::Quit) {
385 return if self.input.is_empty() {
386 if self.quit_armed {
387 vec![Action::Quit]
388 } else {
389 vec![Action::ArmQuit]
390 }
391 } else {
392 vec![Action::MoveHome, Action::ClearComposerLine]
397 };
398 }
399 if key == km.key_for(KeymapAction::HistorySearch) {
400 return vec![Action::OpenHistorySearch];
401 }
402 if key == km.key_for(KeymapAction::ToggleTheme) {
403 return vec![Action::ToggleTheme];
404 }
405 if key == km.key_for(KeymapAction::ScrollUp) {
406 return vec![Action::ScrollUp];
407 }
408 if key == km.key_for(KeymapAction::ScrollDown) {
409 return vec![Action::ScrollDown];
410 }
411 if key == km.key_for(KeymapAction::ExternalEditor) {
412 return vec![Action::RequestExternalEditor];
413 }
414 if key == km.key_for(KeymapAction::Newline) {
415 return vec![Action::Newline];
416 }
417 if self.vim_enabled && self.vim_mode == VimMode::Normal {
418 return self.handle_key_vim_normal(key);
419 }
420 if key == km.key_for(KeymapAction::Submit) {
421 if self.input.is_empty() {
422 return vec![];
423 }
424 return vec![Action::Submit(self.input.clone())];
425 }
426 match key.key {
427 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::InsertChar(c)],
428 Key::Backspace => vec![Action::Backspace],
429 Key::Delete => vec![Action::DeleteForward],
430 Key::Left => vec![Action::MoveLeft],
431 Key::Right => vec![Action::MoveRight],
432 Key::Home => vec![Action::MoveHome],
433 Key::End => vec![Action::MoveEnd],
434 Key::Escape if self.vim_enabled => vec![Action::VimSetMode(VimMode::Normal)],
435 _ => vec![],
436 }
437 }
438
439 fn handle_key_vim_normal(&self, key: KeyEvent) -> Vec<Action> {
440 if key.ctrl || key.alt {
441 return vec![];
442 }
443 match key.key {
444 Key::Char('i') => vec![Action::VimSetMode(VimMode::Insert)],
445 Key::Char('a') => vec![Action::VimMoveRight, Action::VimSetMode(VimMode::Insert)],
446 Key::Char('o') => vec![
447 Action::VimMoveEnd,
448 Action::Newline,
449 Action::VimSetMode(VimMode::Insert),
450 ],
451 Key::Char('h') => vec![Action::VimMoveLeft],
452 Key::Char('l') => vec![Action::VimMoveRight],
453 Key::Char('0') => vec![Action::VimMoveHome],
454 Key::Char('$') => vec![Action::VimMoveEnd],
455 Key::Char('x') => vec![Action::VimDeleteChar],
456 Key::Char('d') => vec![Action::VimDeleteLine],
460 Key::Enter if self.input.is_empty() => vec![],
461 Key::Enter => vec![Action::Submit(self.input.clone())],
462 _ => vec![],
463 }
464 }
465
466 fn handle_key_in_history_search(&self, key: KeyEvent) -> Vec<Action> {
467 match key.key {
468 Key::Escape => vec![Action::HistorySearchCancel],
469 Key::Enter => vec![Action::HistorySearchConfirm],
470 Key::Up => vec![Action::HistorySearchPrev],
471 Key::Down => vec![Action::HistorySearchNext],
472 _ if key == self.keymap.key_for(KeymapAction::HistorySearch) => {
473 vec![Action::HistorySearchNext]
474 }
475 Key::Backspace => vec![Action::HistorySearchBackspace],
476 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::HistorySearchType(c)],
477 _ => vec![],
478 }
479 }
480
481 fn handle_key_in_modal(&self, modal: &Modal, key: KeyEvent) -> Vec<Action> {
482 match modal {
483 Modal::Approval(_) if !key.ctrl && !key.alt => match key.key {
493 Key::Char('y') | Key::Char('a') => {
494 vec![Action::ResolveApproval(ApprovalOutcome::Allow)]
495 }
496 Key::Char('s') => vec![Action::ResolveApproval(ApprovalOutcome::AllowForSession)],
497 Key::Char('n') | Key::Char('d') | Key::Escape => {
498 vec![Action::ResolveApproval(ApprovalOutcome::Deny)]
499 }
500 _ => vec![],
501 },
502 Modal::Approval(_) => vec![],
503 Modal::ChildApproval(_) if !key.ctrl && !key.alt => match key.key {
504 Key::Char('y') | Key::Char('a') => {
505 vec![Action::ResolveChildApproval(ApprovalOutcome::Allow)]
506 }
507 Key::Char('s') => vec![Action::ResolveChildApproval(
508 ApprovalOutcome::AllowForSession,
509 )],
510 Key::Char('n') | Key::Char('d') | Key::Escape => {
511 vec![Action::ResolveChildApproval(ApprovalOutcome::Deny)]
512 }
513 _ => vec![],
514 },
515 Modal::ChildApproval(_) => vec![],
516 Modal::Elicitation { .. } => match key.key {
517 Key::Enter => vec![Action::ResolveElicitationAccept],
518 Key::Escape => vec![Action::ResolveElicitationCancel],
519 Key::F(2) => vec![Action::ResolveElicitationDecline],
520 Key::Backspace => vec![Action::ElicitationBackspace],
521 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::ElicitationType(c)],
522 _ => vec![],
523 },
524 Modal::OAuthDeviceCode(_) => match key.key {
525 Key::Enter | Key::Escape => vec![Action::DismissOAuthModal],
526 _ => vec![],
527 },
528 }
529 }
530
531 pub fn apply(&mut self, action: Action) {
536 if !matches!(action, Action::ArmQuit | Action::Quit) {
537 if self.quit_armed {
538 self.status.notice = None;
539 }
540 self.quit_armed = false;
541 }
542 match action {
543 Action::InsertChar(c) => {
544 self.input.insert(self.cursor, c);
545 self.cursor += c.len_utf8();
546 }
547 Action::Backspace => {
548 if self.cursor > 0 {
549 let mut idx = self.cursor - 1;
550 while !self.input.is_char_boundary(idx) {
551 idx -= 1;
552 }
553 self.input.remove(idx);
554 self.cursor = idx;
555 }
556 }
557 Action::DeleteForward => {
558 if self.cursor < self.input.len() {
559 self.input.remove(self.cursor);
560 }
561 }
562 Action::MoveLeft => {
563 if self.cursor > 0 {
564 let mut idx = self.cursor - 1;
565 while !self.input.is_char_boundary(idx) {
566 idx -= 1;
567 }
568 self.cursor = idx;
569 }
570 }
571 Action::MoveRight => {
572 if self.cursor < self.input.len() {
573 let mut idx = self.cursor + 1;
574 while idx < self.input.len() && !self.input.is_char_boundary(idx) {
575 idx += 1;
576 }
577 self.cursor = idx;
578 }
579 }
580 Action::MoveHome => self.cursor = 0,
581 Action::MoveEnd => self.cursor = self.input.len(),
582 Action::Newline => {
583 self.input.insert(self.cursor, '\n');
584 self.cursor += 1;
585 }
586 Action::ClearComposerLine => {
587 self.input.clear();
588 self.cursor = 0;
589 }
590 Action::Submit(text) => {
591 self.history.push(text.clone());
592 self.transcript.push(TranscriptEntry {
593 role: Role::User,
594 text: text.clone(),
595 });
596 self.input.clear();
597 self.cursor = 0;
598 self.last_submission = Some(text);
609 }
610 Action::ScrollUp => self.scroll = self.scroll.saturating_add(1),
611 Action::ScrollDown => self.scroll = self.scroll.saturating_sub(1),
612 Action::ToggleTheme => self.theme = self.theme.toggled(),
613 Action::OpenHistorySearch => {
614 self.input_focus = InputFocus::HistorySearch;
615 self.history_search = Some(HistorySearchState::default());
616 }
617 Action::HistorySearchType(c) => {
618 if let Some(s) = &mut self.history_search {
619 s.query.push(c);
620 s.selected = 0;
621 }
622 }
623 Action::HistorySearchBackspace => {
624 if let Some(s) = &mut self.history_search {
625 s.query.pop();
626 s.selected = 0;
627 }
628 }
629 Action::HistorySearchNext => {
630 if let Some(s) = &mut self.history_search {
631 let n = self.history.search(&s.query).len();
632 if n > 0 {
633 s.selected = (s.selected + 1) % n;
634 }
635 }
636 }
637 Action::HistorySearchPrev => {
638 if let Some(s) = &mut self.history_search {
639 let n = self.history.search(&s.query).len();
640 if n > 0 {
641 s.selected = (s.selected + n - 1) % n;
642 }
643 }
644 }
645 Action::HistorySearchConfirm => {
646 if let Some(s) = self.history_search.take() {
647 if let Some(&hit) = self.history.search(&s.query).get(s.selected) {
648 self.input = hit.to_string();
649 self.cursor = self.input.len();
650 }
651 }
652 self.input_focus = InputFocus::Composer;
653 }
654 Action::HistorySearchCancel => {
655 self.history_search = None;
656 self.input_focus = InputFocus::Composer;
657 }
658 Action::VimSetMode(mode) => self.vim_mode = mode,
659 Action::VimMoveLeft => self.apply(Action::MoveLeft),
660 Action::VimMoveRight => self.apply(Action::MoveRight),
661 Action::VimMoveHome => self.apply(Action::MoveHome),
662 Action::VimMoveEnd => self.apply(Action::MoveEnd),
663 Action::VimDeleteChar => self.apply(Action::DeleteForward),
664 Action::VimDeleteLine => self.apply(Action::ClearComposerLine),
665 Action::RequestExternalEditor => self.external_editor_requested = true,
666 Action::ExternalEditorResult(text) => {
667 self.external_editor_requested = false;
668 self.input = text;
669 self.cursor = self.input.len();
670 }
671 Action::ExternalEditorFailed(message) => {
672 self.external_editor_requested = false;
673 self.transcript.push(TranscriptEntry {
674 role: Role::System,
675 text: format!("$EDITOR failed: {message}"),
676 });
677 }
678 Action::PasteImage(reference) => {
679 self.pending_images.push(reference.clone());
680 let token = format!("[image: {reference}]");
681 self.input.insert_str(self.cursor, &token);
682 self.cursor += token.len();
683 }
684 Action::ShowApprovalModal(req) => self.enqueue_or_show(Modal::Approval(req)),
685 Action::ResolveApproval(outcome) => {
686 if let Some(Modal::Approval(req)) =
687 self.take_modal_if(|m| matches!(m, Modal::Approval(_)))
688 {
689 let note = approval_note(&req.tool, req.subject.as_deref(), outcome);
690 let _ = req.reply_tx.send(outcome);
691 self.transcript.push(TranscriptEntry {
692 role: Role::System,
693 text: note,
694 });
695 }
696 self.dequeue_modal();
697 }
698 Action::ShowChildApprovalModal(req) => self.enqueue_or_show(Modal::ChildApproval(req)),
699 Action::ResolveChildApproval(outcome) => {
700 if let Some(Modal::ChildApproval(req)) =
701 self.take_modal_if(|m| matches!(m, Modal::ChildApproval(_)))
702 {
703 let note = format!(
704 "child `{}` {}",
705 req.child_agent_id,
706 approval_note(&req.tool, req.subject.as_deref(), outcome)
707 );
708 let _ = req.reply_tx.send(outcome);
709 self.transcript.push(TranscriptEntry {
710 role: Role::System,
711 text: note,
712 });
713 }
714 self.dequeue_modal();
715 }
716 Action::ShowElicitationModal(req) => self.enqueue_or_show(Modal::Elicitation {
717 request: req,
718 answer: String::new(),
719 }),
720 Action::ElicitationType(c) => {
721 if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
722 answer.push(c);
723 }
724 }
725 Action::ElicitationBackspace => {
726 if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
727 answer.pop();
728 }
729 }
730 Action::ResolveElicitationAccept => {
731 if let Some(Modal::Elicitation { request, answer }) =
732 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
733 {
734 let content = elicitation_content(&request.requested_schema, &answer);
735 self.transcript.push(TranscriptEntry {
750 role: Role::System,
751 text: format!("elicitation answered: {}", mask_elicitation_answer(&answer)),
752 });
753 let _ = request.reply_tx.send(ElicitationResponse {
754 action: ElicitationAction::Accept,
755 content: Some(content),
756 });
757 }
758 self.dequeue_modal();
759 }
760 Action::ResolveElicitationDecline => {
761 if let Some(Modal::Elicitation { request, .. }) =
762 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
763 {
764 self.transcript.push(TranscriptEntry {
765 role: Role::System,
766 text: "elicitation declined".to_string(),
767 });
768 let _ = request.reply_tx.send(ElicitationResponse {
769 action: ElicitationAction::Decline,
770 content: None,
771 });
772 }
773 self.dequeue_modal();
774 }
775 Action::ResolveElicitationCancel => {
776 if let Some(Modal::Elicitation { request, .. }) =
777 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
778 {
779 let _ = request.reply_tx.send(ElicitationResponse {
780 action: ElicitationAction::Cancel,
781 content: None,
782 });
783 }
784 self.dequeue_modal();
785 }
786 Action::ShowOAuthModal(display) => {
787 self.enqueue_or_show(Modal::OAuthDeviceCode(display))
788 }
789 Action::DismissOAuthModal => {
790 self.take_modal_if(|m| matches!(m, Modal::OAuthDeviceCode(_)));
791 self.dequeue_modal();
792 }
793 Action::AppendStreamingDelta(delta) => {
794 self.streaming
795 .get_or_insert_with(String::new)
796 .push_str(&delta);
797 }
798 Action::FinalizeStreaming => {
799 if let Some(text) = self.streaming.take() {
800 self.transcript.push(TranscriptEntry {
801 role: Role::Assistant,
802 text,
803 });
804 }
805 }
806 Action::PushTranscript(entry) => self.transcript.push(entry),
807 Action::SetModelLabel(label) => self.status.model_label = label,
808 Action::SetTurnActive(active) => self.status.turn_active = active,
809 Action::ArmQuit => {
810 self.quit_armed = true;
811 self.status.notice = Some("press Ctrl+C again to exit".to_string());
812 }
813 Action::Quit => self.should_quit = true,
814 Action::Noop => {}
815 }
816 }
817
818 pub fn on_key(&mut self, key: KeyEvent) {
829 for action in self.handle_key(key) {
830 self.apply(action);
831 }
832 }
833
834 pub fn take_submission(&mut self) -> Option<String> {
837 self.last_submission.take()
838 }
839
840 pub fn take_pending_images(&mut self) -> Vec<String> {
851 std::mem::take(&mut self.pending_images)
852 }
853
854 fn enqueue_or_show(&mut self, modal: Modal) {
855 if self.modal.is_none() {
856 self.modal = Some(modal);
857 } else {
858 self.modal_queue.push_back(modal);
859 }
860 }
861
862 fn dequeue_modal(&mut self) {
863 if self.modal.is_none() {
864 self.modal = self.modal_queue.pop_front();
865 }
866 }
867
868 fn take_modal_if(&mut self, pred: impl FnOnce(&Modal) -> bool) -> Option<Modal> {
869 if self.modal.as_ref().is_some_and(pred) {
870 self.modal.take()
871 } else {
872 None
873 }
874 }
875
876 pub fn fail_close_pending_modals(&mut self) {
899 self.modal = None;
900 self.modal_queue.clear();
901 }
902}
903
904fn approval_note(tool: &str, subject: Option<&str>, outcome: ApprovalOutcome) -> String {
905 let verdict = match outcome {
906 ApprovalOutcome::Deny => "denied",
907 ApprovalOutcome::Allow => "allowed (once)",
908 ApprovalOutcome::AllowForSession => "allowed (for session)",
909 };
910 match subject {
911 Some(s) => format!("approval: {tool} `{s}` — {verdict}"),
912 None => format!("approval: {tool} — {verdict}"),
913 }
914}
915
916fn elicitation_content(schema: &serde_json::Value, answer: &str) -> serde_json::Value {
924 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
925 if props.len() == 1 {
926 if let Some(name) = props.keys().next() {
927 return serde_json::json!({ name: answer });
928 }
929 }
930 }
931 serde_json::json!({ "value": answer })
932}
933
934fn mask_elicitation_answer(answer: &str) -> &'static str {
943 if answer.is_empty() {
944 "(empty)"
945 } else {
946 "••••"
947 }
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use std::sync::mpsc;
954
955 fn state() -> TuiState {
956 TuiState::new_default()
957 }
958
959 #[test]
962 fn typing_inserts_and_advances_cursor() {
963 let mut s = state();
964 s.on_key(KeyEvent::ch('h'));
965 s.on_key(KeyEvent::ch('i'));
966 assert_eq!(s.input, "hi");
967 assert_eq!(s.cursor, 2);
968 }
969
970 #[test]
971 fn backspace_removes_the_char_before_cursor() {
972 let mut s = state();
973 s.on_key(KeyEvent::ch('a'));
974 s.on_key(KeyEvent::ch('b'));
975 s.on_key(KeyEvent::plain(Key::Backspace));
976 assert_eq!(s.input, "a");
977 assert_eq!(s.cursor, 1);
978 }
979
980 #[test]
981 fn backspace_on_multibyte_char_removes_the_whole_char() {
982 let mut s = state();
983 for c in "hé".chars() {
984 s.on_key(KeyEvent::ch(c));
985 }
986 assert_eq!(s.cursor, "hé".len()); s.on_key(KeyEvent::plain(Key::Backspace));
988 assert_eq!(s.input, "h");
989 assert_eq!(s.cursor, 1);
990 }
991
992 #[test]
993 fn move_left_right_home_end() {
994 let mut s = state();
995 for c in "abc".chars() {
996 s.on_key(KeyEvent::ch(c));
997 }
998 s.on_key(KeyEvent::plain(Key::Home));
999 assert_eq!(s.cursor, 0);
1000 s.on_key(KeyEvent::plain(Key::Right));
1001 assert_eq!(s.cursor, 1);
1002 s.on_key(KeyEvent::plain(Key::End));
1003 assert_eq!(s.cursor, 3);
1004 s.on_key(KeyEvent::plain(Key::Left));
1005 assert_eq!(s.cursor, 2);
1006 }
1007
1008 #[test]
1009 fn submit_clears_composer_and_pushes_transcript_and_history() {
1010 let mut s = state();
1011 for c in "hello".chars() {
1012 s.on_key(KeyEvent::ch(c));
1013 }
1014 let actions = s.handle_key(KeyEvent::plain(Key::Enter));
1015 assert!(matches!(actions.as_slice(), [Action::Submit(t)] if t == "hello"));
1016 for a in actions {
1017 s.apply(a);
1018 }
1019 assert_eq!(s.input, "");
1020 assert_eq!(s.transcript.last().unwrap().text, "hello");
1021 assert_eq!(s.transcript.last().unwrap().role, Role::User);
1022 assert_eq!(s.history.search(""), vec!["hello"]);
1023 }
1024
1025 #[test]
1026 fn enter_on_empty_composer_does_nothing() {
1027 let s = state();
1028 let actions = s.handle_key(KeyEvent::plain(Key::Enter));
1029 assert!(actions.is_empty());
1030 }
1031
1032 #[test]
1033 fn newline_key_inserts_newline_without_submitting() {
1034 let mut s = state();
1035 s.on_key(KeyEvent::ch('a'));
1036 s.on_key(KeyEvent {
1037 key: Key::Enter,
1038 ctrl: false,
1039 alt: true,
1040 shift: false,
1041 });
1042 s.on_key(KeyEvent::ch('b'));
1043 assert_eq!(s.input, "a\nb");
1044 assert!(s.transcript.is_empty());
1045 }
1046
1047 #[test]
1050 fn ctrl_c_on_empty_composer_arms_then_quits_on_repeat() {
1051 let mut s = state();
1052 let a1 = s.handle_key(KeyEvent::ctrl(Key::Char('c')));
1053 assert!(matches!(a1.as_slice(), [Action::ArmQuit]));
1054 for a in a1 {
1055 s.apply(a);
1056 }
1057 assert!(!s.should_quit);
1058 assert!(s.status.notice.is_some());
1059
1060 let a2 = s.handle_key(KeyEvent::ctrl(Key::Char('c')));
1061 assert!(matches!(a2.as_slice(), [Action::Quit]));
1062 for a in a2 {
1063 s.apply(a);
1064 }
1065 assert!(s.should_quit);
1066 }
1067
1068 #[test]
1069 fn ctrl_c_disarms_after_an_intervening_keypress() {
1070 let mut s = state();
1071 s.on_key(KeyEvent::ctrl(Key::Char('c')));
1072 assert!(s.quit_armed);
1073 s.on_key(KeyEvent::ch('x'));
1074 assert!(!s.quit_armed);
1075 s.on_key(KeyEvent::ctrl(Key::Char('c')));
1078 assert!(!s.should_quit);
1079 assert_eq!(s.input, "");
1080 }
1081
1082 #[test]
1083 fn ctrl_c_on_nonempty_composer_clears_the_line() {
1084 let mut s = state();
1085 for c in "oops".chars() {
1086 s.on_key(KeyEvent::ch(c));
1087 }
1088 s.on_key(KeyEvent::ctrl(Key::Char('c')));
1089 assert_eq!(s.input, "");
1090 assert!(!s.should_quit);
1091 }
1092
1093 #[test]
1096 fn ctrl_t_toggles_theme() {
1097 let mut s = state();
1098 assert_eq!(s.theme, Theme::Dark);
1099 s.on_key(KeyEvent::ctrl(Key::Char('t')));
1100 assert_eq!(s.theme, Theme::Light);
1101 s.on_key(KeyEvent::ctrl(Key::Char('t')));
1102 assert_eq!(s.theme, Theme::Dark);
1103 }
1104
1105 #[test]
1108 fn ctrl_r_opens_history_search_and_narrows_by_typing() {
1109 let mut s = state();
1110 s.history.push("fix login bug");
1111 s.history.push("add tests");
1112 s.on_key(KeyEvent::ctrl(Key::Char('r')));
1113 assert_eq!(s.input_focus, InputFocus::HistorySearch);
1114 for c in "login".chars() {
1115 s.on_key(KeyEvent::ch(c));
1116 }
1117 assert_eq!(s.history_search.as_ref().unwrap().query, "login");
1118 s.on_key(KeyEvent::plain(Key::Enter));
1119 assert_eq!(s.input, "fix login bug");
1120 assert_eq!(s.input_focus, InputFocus::Composer);
1121 }
1122
1123 #[test]
1124 fn history_search_escape_cancels_without_changing_composer() {
1125 let mut s = state();
1126 s.history.push("something");
1127 s.on_key(KeyEvent::ctrl(Key::Char('r')));
1128 s.on_key(KeyEvent::ch('x'));
1129 s.on_key(KeyEvent::plain(Key::Escape));
1130 assert_eq!(s.input, "");
1131 assert_eq!(s.input_focus, InputFocus::Composer);
1132 assert!(s.history_search.is_none());
1133 }
1134
1135 #[test]
1138 fn vim_disabled_by_default_i_inserts_char() {
1139 let mut s = state();
1140 assert!(!s.vim_enabled);
1141 s.on_key(KeyEvent::ch('i'));
1142 assert_eq!(s.input, "i");
1143 }
1144
1145 #[test]
1146 fn vim_enabled_starts_in_normal_mode_and_i_enters_insert() {
1147 let mut s = TuiState::new(
1148 Theme::default(),
1149 Keymap::default(),
1150 true,
1151 PromptHistory::new(),
1152 );
1153 assert_eq!(s.vim_mode, VimMode::Normal);
1154 s.on_key(KeyEvent::ch('i'));
1155 assert_eq!(s.vim_mode, VimMode::Insert);
1156 assert_eq!(s.input, "");
1157 s.on_key(KeyEvent::ch('a'));
1158 assert_eq!(s.input, "a");
1159 }
1160
1161 #[test]
1162 fn vim_normal_hjkl_and_x_and_dd() {
1163 let mut s = TuiState::new(
1164 Theme::default(),
1165 Keymap::default(),
1166 true,
1167 PromptHistory::new(),
1168 );
1169 s.on_key(KeyEvent::ch('i'));
1170 for c in "abc".chars() {
1171 s.on_key(KeyEvent::ch(c));
1172 }
1173 s.on_key(KeyEvent::plain(Key::Escape));
1174 assert_eq!(s.vim_mode, VimMode::Normal);
1175 assert_eq!(s.input, "abc");
1176 s.on_key(KeyEvent::ch('h'));
1177 s.on_key(KeyEvent::ch('h'));
1178 assert_eq!(s.cursor, 1);
1179 s.on_key(KeyEvent::ch('x'));
1180 assert_eq!(s.input, "ac");
1181 s.on_key(KeyEvent::ch('d'));
1182 assert_eq!(s.input, "");
1183 }
1184
1185 #[test]
1188 fn approval_modal_allow_for_session_sends_outcome_and_clears_modal() {
1189 let mut s = state();
1190 let (tx, rx) = mpsc::channel();
1191 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1192 tool: "bash".to_string(),
1193 subject: Some("rm -rf /tmp/x".to_string()),
1194 raw_args: serde_json::json!({}),
1195 reply_tx: tx,
1196 }));
1197 assert!(matches!(s.modal, Some(Modal::Approval(_))));
1198 let actions = s.handle_key(KeyEvent::ch('s'));
1199 assert!(matches!(
1200 actions.as_slice(),
1201 [Action::ResolveApproval(ApprovalOutcome::AllowForSession)]
1202 ));
1203 for a in actions {
1204 s.apply(a);
1205 }
1206 assert!(s.modal.is_none());
1207 assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::AllowForSession));
1208 assert!(s
1209 .transcript
1210 .last()
1211 .unwrap()
1212 .text
1213 .contains("allowed (for session)"));
1214 }
1215
1216 #[test]
1217 fn approval_modal_deny_sends_deny() {
1218 let mut s = state();
1219 let (tx, rx) = mpsc::channel();
1220 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1221 tool: "bash".to_string(),
1222 subject: None,
1223 raw_args: serde_json::json!({}),
1224 reply_tx: tx,
1225 }));
1226 s.on_key(KeyEvent::ch('n'));
1227 assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Deny));
1228 }
1229
1230 #[test]
1231 fn approval_modal_escape_denies() {
1232 let mut s = state();
1233 let (tx, rx) = mpsc::channel();
1234 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1235 tool: "write_file".to_string(),
1236 subject: Some("x.txt".to_string()),
1237 raw_args: serde_json::json!({}),
1238 reply_tx: tx,
1239 }));
1240 s.on_key(KeyEvent::plain(Key::Escape));
1241 assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Deny));
1242 }
1243
1244 #[test]
1248 fn approval_modal_ignores_ctrl_a_and_ctrl_s() {
1249 let mut s = state();
1250 let (tx, rx) = mpsc::channel();
1251 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1252 tool: "bash".to_string(),
1253 subject: Some("rm -rf /tmp/x".to_string()),
1254 raw_args: serde_json::json!({}),
1255 reply_tx: tx,
1256 }));
1257 assert!(s.handle_key(KeyEvent::ctrl(Key::Char('a'))).is_empty());
1258 assert!(s.handle_key(KeyEvent::ctrl(Key::Char('s'))).is_empty());
1259 assert!(
1260 matches!(s.modal, Some(Modal::Approval(_))),
1261 "the modal must still be showing — neither chord may resolve it"
1262 );
1263 assert!(rx.try_recv().is_err(), "no reply must have been sent");
1264
1265 s.on_key(KeyEvent::ch('y'));
1268 assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Allow));
1269 }
1270
1271 #[test]
1273 fn approval_modal_ignores_alt_modified_keys() {
1274 let mut s = state();
1275 let (tx, rx) = mpsc::channel();
1276 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1277 tool: "bash".to_string(),
1278 subject: None,
1279 raw_args: serde_json::json!({}),
1280 reply_tx: tx,
1281 }));
1282 let alt_y = KeyEvent {
1283 key: Key::Char('y'),
1284 ctrl: false,
1285 alt: true,
1286 shift: false,
1287 };
1288 assert!(s.handle_key(alt_y).is_empty());
1289 assert!(rx.try_recv().is_err());
1290 }
1291
1292 #[test]
1293 fn a_second_request_queues_behind_the_first_modal() {
1294 let mut s = state();
1295 let (tx1, rx1) = mpsc::channel();
1296 let (tx2, rx2) = mpsc::channel();
1297 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1298 tool: "bash".to_string(),
1299 subject: None,
1300 raw_args: serde_json::json!({}),
1301 reply_tx: tx1,
1302 }));
1303 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1304 tool: "write_file".to_string(),
1305 subject: None,
1306 raw_args: serde_json::json!({}),
1307 reply_tx: tx2,
1308 }));
1309 assert!(rx2.try_recv().is_err());
1311 s.on_key(KeyEvent::ch('y')); assert_eq!(rx1.try_recv(), Ok(ApprovalOutcome::Allow));
1313 assert!(matches!(s.modal, Some(Modal::Approval(_))));
1315 s.on_key(KeyEvent::ch('n'));
1316 assert_eq!(rx2.try_recv(), Ok(ApprovalOutcome::Deny));
1317 }
1318
1319 #[test]
1322 fn child_approval_modal_allow_sends_outcome_tagged_with_child_id() {
1323 let mut s = state();
1324 let (tx, rx) = mpsc::channel();
1325 s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1326 child_agent_id: "agent-bg-1".to_string(),
1327 tool: "bash".to_string(),
1328 subject: Some("curl evil.example".to_string()),
1329 raw_args: serde_json::json!({}),
1330 reply_tx: tx,
1331 }));
1332 s.on_key(KeyEvent::ch('y'));
1333 assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Allow));
1334 assert!(s.transcript.last().unwrap().text.contains("agent-bg-1"));
1335 }
1336
1337 #[test]
1340 fn child_approval_modal_ignores_ctrl_a_and_ctrl_s() {
1341 let mut s = state();
1342 let (tx, rx) = mpsc::channel();
1343 s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1344 child_agent_id: "agent-bg-2".to_string(),
1345 tool: "bash".to_string(),
1346 subject: Some("curl evil.example".to_string()),
1347 raw_args: serde_json::json!({}),
1348 reply_tx: tx,
1349 }));
1350 assert!(s.handle_key(KeyEvent::ctrl(Key::Char('a'))).is_empty());
1351 assert!(s.handle_key(KeyEvent::ctrl(Key::Char('s'))).is_empty());
1352 assert!(matches!(s.modal, Some(Modal::ChildApproval(_))));
1353 assert!(rx.try_recv().is_err());
1354 }
1355
1356 #[tokio::test]
1359 async fn elicitation_modal_accept_wraps_answer_under_the_single_schema_property() {
1360 let mut s = state();
1361 let (tx, rx) = tokio::sync::oneshot::channel();
1362 s.apply(Action::ShowElicitationModal(PendingElicitation {
1363 message: "What's your name?".to_string(),
1364 requested_schema: serde_json::json!({
1365 "type": "object",
1366 "properties": { "name": {"type": "string"} }
1367 }),
1368 reply_tx: tx,
1369 }));
1370 for c in "Ada".chars() {
1371 s.on_key(KeyEvent::ch(c));
1372 }
1373 s.on_key(KeyEvent::plain(Key::Enter));
1374 assert!(s.modal.is_none());
1375 let resp = rx.await.unwrap();
1376 assert_eq!(resp.action, ElicitationAction::Accept);
1377 assert_eq!(resp.content, Some(serde_json::json!({"name": "Ada"})));
1378 }
1379
1380 #[tokio::test]
1385 async fn elicitation_modal_accept_masks_the_transcript_echo_but_not_the_reply_content() {
1386 let mut s = state();
1387 let (tx, rx) = tokio::sync::oneshot::channel();
1388 s.apply(Action::ShowElicitationModal(PendingElicitation {
1389 message: "What's the API token?".to_string(),
1390 requested_schema: serde_json::json!({
1391 "type": "object",
1392 "properties": { "token": {"type": "string"} }
1393 }),
1394 reply_tx: tx,
1395 }));
1396 for c in "sk-super-secret".chars() {
1397 s.on_key(KeyEvent::ch(c));
1398 }
1399 s.on_key(KeyEvent::plain(Key::Enter));
1400
1401 let resp = rx.await.unwrap();
1402 assert_eq!(
1403 resp.content,
1404 Some(serde_json::json!({"token": "sk-super-secret"})),
1405 "the server must still receive the real answer"
1406 );
1407
1408 let echoed = &s.transcript.last().unwrap().text;
1409 assert!(
1410 !echoed.contains("sk-super-secret"),
1411 "the transcript echo must not contain the raw answer: {echoed}"
1412 );
1413 assert!(
1414 echoed.contains("••••"),
1415 "the transcript echo must show a mask placeholder: {echoed}"
1416 );
1417 }
1418
1419 #[test]
1420 fn mask_elicitation_answer_gives_empty_its_own_placeholder() {
1421 assert_eq!(mask_elicitation_answer(""), "(empty)");
1422 assert_eq!(mask_elicitation_answer("x"), "••••");
1423 assert_eq!(mask_elicitation_answer("a very long secret token"), "••••");
1424 }
1425
1426 #[tokio::test]
1427 async fn elicitation_modal_escape_cancels() {
1428 let mut s = state();
1429 let (tx, rx) = tokio::sync::oneshot::channel();
1430 s.apply(Action::ShowElicitationModal(PendingElicitation {
1431 message: "…".to_string(),
1432 requested_schema: serde_json::json!({}),
1433 reply_tx: tx,
1434 }));
1435 s.on_key(KeyEvent::plain(Key::Escape));
1436 let resp = rx.await.unwrap();
1437 assert_eq!(resp.action, ElicitationAction::Cancel);
1438 assert_eq!(resp.content, None);
1439 }
1440
1441 #[tokio::test]
1442 async fn elicitation_modal_f2_declines() {
1443 let mut s = state();
1444 let (tx, rx) = tokio::sync::oneshot::channel();
1445 s.apply(Action::ShowElicitationModal(PendingElicitation {
1446 message: "…".to_string(),
1447 requested_schema: serde_json::json!({}),
1448 reply_tx: tx,
1449 }));
1450 s.on_key(KeyEvent::plain(Key::F(2)));
1451 let resp = rx.await.unwrap();
1452 assert_eq!(resp.action, ElicitationAction::Decline);
1453 }
1454
1455 #[test]
1456 fn elicitation_backspace_edits_the_answer_buffer() {
1457 let mut s = state();
1458 let (tx, _rx) = tokio::sync::oneshot::channel();
1459 s.apply(Action::ShowElicitationModal(PendingElicitation {
1460 message: "…".to_string(),
1461 requested_schema: serde_json::json!({}),
1462 reply_tx: tx,
1463 }));
1464 s.on_key(KeyEvent::ch('a'));
1465 s.on_key(KeyEvent::ch('b'));
1466 s.on_key(KeyEvent::plain(Key::Backspace));
1467 if let Some(Modal::Elicitation { answer, .. }) = &s.modal {
1468 assert_eq!(answer, "a");
1469 } else {
1470 panic!("expected elicitation modal");
1471 }
1472 }
1473
1474 #[test]
1477 fn oauth_modal_shows_and_dismisses_on_enter() {
1478 let mut s = state();
1479 s.apply(Action::ShowOAuthModal(PendingOAuthDisplay {
1480 server_name: "acme".to_string(),
1481 user_code: "ABCD-1234".to_string(),
1482 verification_uri: "https://example.com/device".to_string(),
1483 verification_uri_complete: None,
1484 expires_in_secs: 600,
1485 }));
1486 assert!(matches!(s.modal, Some(Modal::OAuthDeviceCode(_))));
1487 s.on_key(KeyEvent::plain(Key::Enter));
1488 assert!(s.modal.is_none());
1489 }
1490
1491 #[test]
1494 fn streaming_deltas_accumulate_and_finalize_into_transcript() {
1495 let mut s = state();
1496 s.apply(Action::AppendStreamingDelta("Hel".to_string()));
1497 s.apply(Action::AppendStreamingDelta("lo".to_string()));
1498 assert_eq!(s.streaming.as_deref(), Some("Hello"));
1499 s.apply(Action::FinalizeStreaming);
1500 assert!(s.streaming.is_none());
1501 assert_eq!(s.transcript.last().unwrap().text, "Hello");
1502 assert_eq!(s.transcript.last().unwrap().role, Role::Assistant);
1503 }
1504
1505 #[test]
1508 fn paste_image_inserts_a_placeholder_and_records_the_reference() {
1509 let mut s = state();
1510 s.apply(Action::PasteImage("/tmp/screenshot.png".to_string()));
1511 assert!(s.input.contains("[image: /tmp/screenshot.png]"));
1512 assert_eq!(s.pending_images, vec!["/tmp/screenshot.png".to_string()]);
1513 }
1514
1515 #[test]
1523 fn pending_images_survive_submit_and_are_drained_by_take_pending_images() {
1524 let mut s = state();
1525 s.apply(Action::PasteImage("/tmp/screenshot.png".to_string()));
1526 for c in "describe this".chars() {
1527 s.on_key(KeyEvent::ch(c));
1528 }
1529 s.on_key(KeyEvent::plain(Key::Enter));
1530 assert_eq!(
1531 s.take_submission().as_deref(),
1532 Some("[image: /tmp/screenshot.png]describe this")
1533 );
1534 assert_eq!(
1535 s.pending_images,
1536 vec!["/tmp/screenshot.png".to_string()],
1537 "the image must still be there for the CLI layer to drain, \
1538 right after the submission is read"
1539 );
1540 assert_eq!(
1541 s.take_pending_images(),
1542 vec!["/tmp/screenshot.png".to_string()]
1543 );
1544 assert!(
1545 s.pending_images.is_empty(),
1546 "take_pending_images must clear, not just read"
1547 );
1548 }
1549
1550 #[test]
1553 fn external_editor_request_then_result_replaces_composer() {
1554 let mut s = state();
1555 s.on_key(KeyEvent::ctrl(Key::Char('e')));
1556 assert!(s.external_editor_requested);
1557 s.apply(Action::ExternalEditorResult("edited text".to_string()));
1558 assert!(!s.external_editor_requested);
1559 assert_eq!(s.input, "edited text");
1560 }
1561
1562 #[test]
1567 fn external_editor_failed_clears_the_flag_and_notes_it_without_touching_the_composer() {
1568 let mut s = state();
1569 s.on_key(KeyEvent::ctrl(Key::Char('e')));
1570 assert!(s.external_editor_requested);
1571 for c in "unsaved draft".chars() {
1572 s.on_key(KeyEvent::ch(c));
1573 }
1574 s.apply(Action::ExternalEditorFailed(
1575 "No such file or directory (os error 2)".to_string(),
1576 ));
1577 assert!(!s.external_editor_requested);
1578 assert_eq!(
1579 s.input, "unsaved draft",
1580 "a failed editor invocation must not clobber the composer"
1581 );
1582 assert!(s
1583 .transcript
1584 .last()
1585 .unwrap()
1586 .text
1587 .contains("No such file or directory"));
1588 }
1589
1590 #[test]
1601 fn fail_close_pending_modals_drops_the_active_approval_reply_sender() {
1602 let mut s = state();
1603 let (tx, rx) = mpsc::channel();
1604 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1605 tool: "bash".to_string(),
1606 subject: Some("rm -rf /tmp/x".to_string()),
1607 raw_args: serde_json::json!({}),
1608 reply_tx: tx,
1609 }));
1610 assert!(matches!(s.modal, Some(Modal::Approval(_))));
1611
1612 s.fail_close_pending_modals();
1613
1614 assert!(s.modal.is_none());
1615 assert_eq!(
1616 rx.recv(),
1617 Err(mpsc::RecvError),
1618 "the sender must have been dropped without a reply — that's \
1619 what makes ask()'s blocked recv() resolve Deny"
1620 );
1621 }
1622
1623 #[test]
1625 fn fail_close_pending_modals_drops_the_active_child_approval_reply_sender() {
1626 let mut s = state();
1627 let (tx, rx) = mpsc::channel();
1628 s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1629 child_agent_id: "agent-bg-3".to_string(),
1630 tool: "bash".to_string(),
1631 subject: None,
1632 raw_args: serde_json::json!({}),
1633 reply_tx: tx,
1634 }));
1635 s.fail_close_pending_modals();
1636 assert!(s.modal.is_none());
1637 assert_eq!(rx.recv(), Err(mpsc::RecvError));
1638 }
1639
1640 #[tokio::test]
1646 async fn fail_close_pending_modals_drops_the_active_elicitation_reply_sender() {
1647 let mut s = state();
1648 let (tx, rx) = tokio::sync::oneshot::channel();
1649 s.apply(Action::ShowElicitationModal(PendingElicitation {
1650 message: "…".to_string(),
1651 requested_schema: serde_json::json!({}),
1652 reply_tx: tx,
1653 }));
1654 s.fail_close_pending_modals();
1655 assert!(s.modal.is_none());
1656 assert!(
1657 rx.await.is_err(),
1658 "the oneshot sender must have been dropped without a reply"
1659 );
1660 }
1661
1662 #[test]
1668 fn fail_close_pending_modals_also_drops_everything_still_queued() {
1669 let mut s = state();
1670 let (tx1, rx1) = mpsc::channel();
1671 let (tx2, rx2) = mpsc::channel();
1672 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1673 tool: "bash".to_string(),
1674 subject: None,
1675 raw_args: serde_json::json!({}),
1676 reply_tx: tx1,
1677 }));
1678 s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1679 tool: "write_file".to_string(),
1680 subject: Some("x.txt".to_string()),
1681 raw_args: serde_json::json!({}),
1682 reply_tx: tx2,
1683 }));
1684 assert!(matches!(s.modal, Some(Modal::Approval(_))));
1685 assert_eq!(s.modal_queue.len(), 1);
1686
1687 s.fail_close_pending_modals();
1688
1689 assert!(s.modal.is_none());
1690 assert_eq!(s.modal_queue.len(), 0);
1691 assert_eq!(rx1.recv(), Err(mpsc::RecvError));
1692 assert_eq!(
1693 rx2.recv(),
1694 Err(mpsc::RecvError),
1695 "the QUEUED request's sender must be dropped too, not just the active one"
1696 );
1697 }
1698}