1#![allow(
2 clippy::field_reassign_with_default,
3 clippy::let_and_return,
4 clippy::borrow_interior_mutable_const,
5 clippy::derivable_impls
6)]
7use std::io::{self, Stdout, Write};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use anyhow::Result;
16use crossterm::{
17 cursor::{Hide, Show},
18 event::{
19 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
20 KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
21 PushKeyboardEnhancementFlags,
22 },
23 execute,
24 terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
25};
26use oxicode_agent::AgentEvent;
27use oxicode_agent::config::Mode;
28use oxicode_agent::tools::TodoStateProvider;
29use oxicode_agent::tools::todo::TodoStatus;
30use oxicode_vtui::theme::{ThemeStyles, active_styles};
31use oxicode_vtui::tui::core::{
32 AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
33 InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
34 InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
35 SecurePromptConfig,
36};
37use ratatui::{
38 Frame, Terminal,
39 backend::CrosstermBackend,
40 layout::{Alignment, Rect},
41 style::{Color, Modifier, Style},
42 text::{Line, Span},
43 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
44};
45
46use crate::App;
47use crate::app::agent_hub_registry::HubEntry;
48use crate::app::agent_session::SessionEvent;
49use crate::tui_vt::slash::file_commands::FileCommand;
50use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
51
52pub struct Tui {
63 terminal: Terminal<CrosstermBackend<Stdout>>,
64 tty_ok: bool,
65}
66
67impl Tui {
68 pub fn enter() -> Result<Self> {
71 Self::set_panic_hook();
72
73 let tty_ok = enable_raw_mode().is_ok();
74 let mut stdout = io::stdout();
75
76 if tty_ok {
77 let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
81 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
82 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
83 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
84 } else {
85 KeyboardEnhancementFlags::REPORT_EVENT_TYPES
86 };
87 let _ = execute!(
88 stdout,
89 Hide,
90 EnableBracketedPaste,
91 PushKeyboardEnhancementFlags(flags)
92 );
93 let _ = stdout.flush();
94 }
95
96 let backend = CrosstermBackend::new(stdout);
97 let mut terminal = Terminal::new(backend)?;
98 if tty_ok {
99 let _ = terminal.clear();
100 }
101
102 Ok(Self { terminal, tty_ok })
103 }
104
105 pub fn exit(&mut self) -> Result<()> {
109 if self.tty_ok {
110 let _ = execute!(
111 self.terminal.backend_mut(),
112 PopKeyboardEnhancementFlags,
113 DisableBracketedPaste
114 );
115 let _ = self.terminal.show_cursor();
116 disable_raw_mode()?;
118 self.tty_ok = false;
119 }
120 Ok(())
121 }
122
123 fn set_panic_hook() {
127 let original_hook = std::panic::take_hook();
128 std::panic::set_hook(Box::new(move |panic_info| {
129 let _ = execute!(io::stdout(), Show);
130 let _ = disable_raw_mode();
131 original_hook(panic_info);
132 }));
133 }
134}
135
136impl Drop for Tui {
137 fn drop(&mut self) {
138 let _ = self.exit();
139 }
140}
141
142#[derive(Default)]
149pub struct RenderState {
150 pub input_buffer: String,
152 pub input_cursor: usize,
154 pub transcript: Vec<TranscriptLine>,
156 pub scroll_offset: usize,
159 pub header_context: InlineHeaderContext,
161 pub input_enabled: bool,
163 pub footer_left: Option<String>,
165 pub footer_right: Option<String>,
166 pub prompt_prefix: String,
168 pub placeholder: Option<String>,
170 pub shutdown_requested: bool,
172 pub message_buffer: String,
174 pub agent_hub_open: bool,
176 pub hub_entries: Vec<(String, HubEntry)>,
178 pub pending_quit: bool,
180 pub slash_popup: SlashPopup,
182 pub reasoning_stage: Option<String>,
184 pub overlay: Option<OverlayState>,
186 pub overlay_model_ids: Vec<String>,
188 pub overlay_catalog_models: Vec<(String, String)>,
191 pub overlay_providers: Vec<String>,
193 pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
196 pub queued_inputs: Vec<String>,
198 pub queue_panel_open: bool,
200 pub queue_selected: usize,
202 pub shell_mode: bool,
204 pub follow_ups: Vec<String>,
206 pub todo_items: Vec<(String, TodoStatus)>,
208 pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
211 pub vim_state: oxicode_vtui::vim::VimState,
213 pub vim_clipboard: String,
215 pub search: Option<SearchState>,
217 pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
220 pub last_esc_at: Option<std::time::Instant>,
222 pub multiline_mode: bool,
224 pub autonomy_mode: Mode,
228 pub prompt_history: Vec<String>,
230 pub history_pos: Option<usize>,
232 pub next_block_id: usize,
234 pub cancel_grace_until: Option<std::time::Instant>,
237 pub confirmation: Option<ModalConfirmation>,
241 pub tip: Option<EphemeralTip>,
244 pub cwd: PathBuf,
246 pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
248 pub seen_tips: std::collections::HashMap<&'static str, u32>,
250 pub file_commands: Vec<FileCommand>,
253 pub secure_input_origin: Option<SecureInputOrigin>,
264 pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
269 pub pending_resume: Option<PathBuf>,
274 pub session_state: Option<crate::SessionState>,
280}
281
282#[derive(Clone, Debug, PartialEq, Eq)]
287pub enum SecureInputOrigin {
288 SetKey { provider: String },
292 NewlyAdded { provider: String },
296}
297
298#[derive(Debug, Clone)]
300pub struct TranscriptLine {
301 pub kind: InlineMessageKind,
302 pub segments: Vec<InlineSegment>,
303 pub block_id: usize,
306}
307
308#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
314pub enum BlockDisplayMode {
315 Collapsed,
317 #[default]
319 Truncated,
320 Expanded,
322}
323
324#[derive(Clone, Debug)]
326pub struct SearchState {
327 pub query: String,
328 pub matches: Vec<usize>,
330 pub current: usize,
332}
333
334#[derive(Clone)]
336pub struct SlashPopupItem {
337 pub label: String,
339 pub description: String,
341 pub name: String,
343}
344
345#[derive(Default, Clone)]
350pub struct SlashPopup {
351 pub open: bool,
352 pub items: Vec<SlashPopupItem>,
353 pub selected: usize,
354}
355
356#[derive(Clone, Debug)]
360pub struct OverlayListItem {
361 pub title: String,
362 pub subtitle: Option<String>,
363 pub badge: Option<String>,
364 pub indent: u8,
365 pub search_value: Option<String>,
366 pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
368}
369
370#[derive(Clone, Debug)]
375pub struct OverlayState {
376 pub title: String,
377 pub lines: Vec<String>,
378 pub items: Vec<OverlayListItem>,
379 pub selected: usize,
380 pub search: Option<OverlaySearchState>,
381 pub secure_input: Option<OverlaySecureInput>,
382}
383
384#[derive(Clone, Debug)]
389pub struct OverlaySecureInput {
390 pub config: SecurePromptConfig,
391 pub value: String,
392 pub cursor: usize,
393}
394
395#[derive(Clone, Debug)]
399pub struct ModalConfirmation {
400 pub title: String,
401 pub message: String,
402 pub action: ConfirmationAction,
405}
406
407#[derive(Clone, Debug, PartialEq, Eq)]
409pub enum ConfirmationAction {
410 Quit,
412 ClearConversation,
414 RemoveProviderKey(String),
416}
417
418#[derive(Clone, Debug)]
422pub struct EphemeralTip {
423 pub text: String,
424 pub born_tick: u64,
426 pub ttl_ticks: u64,
428 pub key: &'static str,
431 pub ambient: bool,
435}
436
437#[derive(Clone, Debug)]
439pub struct OverlaySearchState {
440 pub label: String,
441 pub placeholder: Option<String>,
442 pub value: String,
443}
444
445impl RenderState {
446 fn new_with_header(header: InlineHeaderContext) -> Self {
447 let mut s = Self::default();
448 s.header_context = header;
449 s.prompt_prefix = "> ".to_string();
450 s.input_enabled = true;
451 s
452 }
453
454 pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
459 self.session_swapper
460 .clone()
461 .expect("RenderState::session_swapper must be initialized at TUI startup")
462 }
463
464 fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
466 let block_id = self.block_id_for_kind(kind);
467 self.transcript.push(TranscriptLine {
468 kind,
469 segments,
470 block_id,
471 });
472 }
473
474 fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
478 if let Some(last) = self.transcript.last_mut()
479 && last.kind == kind
480 {
481 last.segments.push(segment);
482 return;
483 }
484 let block_id = self.block_id_for_kind(kind);
485 self.transcript.push(TranscriptLine {
486 kind,
487 segments: vec![segment],
488 block_id,
489 });
490 }
491
492 fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
495 if let Some(last) = self.transcript.last()
496 && last.kind == kind
497 {
498 return last.block_id;
499 }
500 let id = self.next_block_id;
501 self.next_block_id += 1;
502 id
503 }
504
505 pub fn start_search(&mut self, query: &str) {
509 let needle = query.to_lowercase();
510 let matches: Vec<usize> = self
511 .transcript
512 .iter()
513 .enumerate()
514 .filter(|(_, line)| {
515 line.segments
516 .iter()
517 .any(|s| s.text.to_lowercase().contains(&needle))
518 })
519 .map(|(i, _)| i)
520 .collect();
521 self.search = Some(SearchState {
522 query: query.to_string(),
523 matches,
524 current: 0,
525 });
526 if let Some(s) = &self.search
528 && let Some(&first) = s.matches.first()
529 {
530 self.scroll_offset = first;
531 }
532 }
533
534 pub fn search_next(&mut self) {
536 if let Some(s) = &mut self.search
537 && !s.matches.is_empty()
538 {
539 s.current = (s.current + 1) % s.matches.len();
540 let line = s.matches[s.current];
541 self.scroll_offset = line;
542 }
543 }
544
545 pub fn search_prev(&mut self) {
547 if let Some(s) = &mut self.search
548 && !s.matches.is_empty()
549 {
550 if s.current == 0 {
551 s.current = s.matches.len() - 1;
552 } else {
553 s.current -= 1;
554 }
555 let line = s.matches[s.current];
556 self.scroll_offset = line;
557 }
558 }
559
560 pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
564 self.block_display
565 .get(&block_id)
566 .copied()
567 .unwrap_or_default()
568 }
569
570 pub fn cycle_block_at_view(&mut self) {
573 let offset = self.effective_offset();
574 if let Some(line) = self.transcript.get(offset) {
575 let bid = line.block_id;
576 let next = match self.block_mode(bid) {
577 BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
578 BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
579 BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
580 };
581 if next == BlockDisplayMode::Truncated {
584 self.block_display.remove(&bid);
585 } else {
586 self.block_display.insert(bid, next);
587 }
588 }
589 }
590
591 pub fn expand_all(&mut self) {
593 for bid in self.all_block_ids() {
594 self.block_display.insert(bid, BlockDisplayMode::Expanded);
595 }
596 }
597
598 pub fn fold_all(&mut self) {
600 for bid in self.all_block_ids() {
601 self.block_display.insert(bid, BlockDisplayMode::Collapsed);
602 }
603 }
604
605 pub fn truncate_all(&mut self) {
607 self.block_display.clear();
608 }
609
610 fn all_block_ids(&self) -> Vec<usize> {
612 let mut ids = Vec::new();
613 let mut prev: Option<usize> = None;
614 for l in &self.transcript {
615 if prev != Some(l.block_id) {
616 ids.push(l.block_id);
617 prev = Some(l.block_id);
618 }
619 }
620 ids
621 }
622
623 pub fn jump_next_turn(&mut self) {
627 let offset = self.effective_offset();
628 let search_after = self
629 .transcript
630 .iter()
631 .enumerate()
632 .skip(offset + 1)
633 .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
634 if let Some((idx, _)) = search_after {
635 self.scroll_offset = idx;
636 }
637 }
638
639 pub fn jump_prev_turn(&mut self) {
641 let offset = self.effective_offset();
642 let search_before = self
643 .transcript
644 .iter()
645 .enumerate()
646 .take(offset)
647 .rev()
648 .find(|(_, l)| l.kind == InlineMessageKind::User);
649 if let Some((idx, _)) = search_before {
650 self.scroll_offset = idx;
651 }
652 }
653
654 fn effective_offset(&self) -> usize {
656 if self.scroll_offset == usize::MAX {
657 self.transcript.len().saturating_sub(1)
658 } else {
659 self.scroll_offset
660 }
661 }
662
663 pub fn drain_queue_head(&mut self) {
666 if !self.queued_inputs.is_empty() {
667 self.queued_inputs.remove(0);
668 }
669 }
670
671 pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
674 let count = self.seen_tips.entry(key).or_insert(0);
675 if *count >= SEEN_CAP {
676 return;
677 }
678 *count += 1;
679 self.tip = Some(EphemeralTip {
680 text: text.to_string(),
681 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
682 ttl_ticks: ttl,
683 key,
684 ambient,
685 });
686 }
687}
688
689const SEEN_CAP: u32 = 3;
691
692pub async fn run_tui(app: App) -> Result<()> {
699 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
702 let git_branch = crate::util::git_utils::get_current_branch(&cwd);
703 super::host::activate_theme(app.settings());
704 let theme_id = oxicode_vtui::theme::active_theme_id();
706 let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
707 if validation.warnings.is_empty() {
708 tracing::debug!("theme '{theme_id}' passed contrast validation");
709 } else {
710 for w in &validation.warnings {
711 tracing::warn!("theme contrast: {w}");
712 }
713 }
714
715 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
718 let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
719 let handle = InlineHandle::new_for_tests(cmd_tx);
720
721 let session = build_agent_session(&app).await?;
725 let session_handle = session.clone_handle();
729
730 let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
734 session_handle.clone(),
735 ));
736
737 let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
745 let _sub_guard = session.subscribe(Box::new(move |event| {
746 let _ = session_tx.send(event.clone());
747 }));
748
749 let header = build_header_context(&app, &cwd, git_branch.as_deref());
751 handle.set_header_context(header.clone());
752
753 let mut tui = Tui::enter()?;
756
757 handle.set_prompt("> ".to_string(), InlineTextStyle::default());
761 handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
762
763 let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
766 header,
767 )));
768 state.lock().cwd = cwd.clone();
769 state.lock().catalog = Some(app.catalog());
770 state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
771 state.lock().todo_provider = session_handle.todo_provider();
772 state.lock().session_swapper = Some(session_swapper.clone());
773 state.lock().session_state = Some(app.session_state().clone());
774 state.lock().tip = Some(EphemeralTip {
777 text: "Press ? for shortcuts \u{00b7} /help for commands".to_string(),
778 born_tick: 0,
779 ttl_ticks: 900,
780 key: "onboarding",
781 ambient: true,
782 });
783 if std::env::var("SSH_CONNECTION").is_ok() {
785 state.lock().show_tip(
786 "ssh_wrap",
787 "Over SSH? Consider tmux to keep sessions alive",
788 600,
789 true,
790 );
791 }
792 let mode_handle = app.ask_bridge().map(|b| {
796 let handle = b.mode_handle();
797 state.lock().autonomy_mode = Mode::load(&handle);
798 handle
799 });
800 spawn_input_thread(state.clone(), evt_tx.clone(), mode_handle);
801
802 let prompt_tx = spawn_agent_worker(session_swapper.clone());
808
809 let result = run_event_loop(
810 &mut tui.terminal,
811 &mut cmd_rx,
812 &mut evt_rx,
813 &mut session_rx,
814 &handle,
815 &state,
816 &session_swapper,
817 prompt_tx.clone(),
818 )
819 .await;
820
821 drop(prompt_tx);
824 handle.shutdown();
825 drop(tui);
827
828 result
829}
830
831#[allow(clippy::too_many_arguments)]
836async fn run_event_loop(
837 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
838 cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
839 evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
840 session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
841 handle: &InlineHandle,
842 state: &Arc<parking_lot::Mutex<RenderState>>,
843 session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
844 prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
845) -> Result<()> {
846 while let Ok(cmd) = cmd_rx.try_recv() {
849 apply_command(&mut state.lock(), cmd);
850 }
851
852 {
857 let snapshot = state.lock();
858 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
859 if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
860 tracing::warn!(?err, "initial tui draw failed");
861 }
862 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
863 }
864
865 let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
871 render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
872
873 loop {
874 tokio::select! {
875 biased;
878
879 Some(cmd) = cmd_rx.recv() => {
881 let shutdown = {
882 let mut s = state.lock();
883 apply_command(&mut s, cmd)
884 };
885 if shutdown {
886 break;
887 }
888 }
889
890 Some(event) = session_rx.recv() => {
892 if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
894 let mut s = state.lock();
895 s.transcript.clear();
896 s.message_buffer.clear();
897 s.scroll_offset = usize::MAX;
898 s.append_line(
899 InlineMessageKind::Info,
900 vec![plain_segment(format!(
901 "Handoff written to {}. New session started.",
902 doc_path
903 ))],
904 );
905 let user_typed = !s.input_buffer.trim().is_empty();
906 s.input_buffer.clear();
907 s.input_cursor = 0;
908 if *auto_continue && !user_typed {
909 drop(s);
910 let _ = prompt_tx.send(format!(
911 "Read the handoff document at {} and continue \
912 from where the previous session left off.",
913 doc_path
914 ));
915 } else if user_typed {
916 drop(s);
917 handle.append_line(
918 InlineMessageKind::Info,
919 vec![plain_segment(
920 "Handoff complete. Auto-continue skipped \
921 because input was non-empty \u{2014} press \
922 Enter to submit your message in the new \
923 session."
924 .to_string(),
925 )],
926 );
927 }
928 handle_session_event(&mut state.lock(), handle, &event);
929 }
930 }
931
932 Some(evt) = evt_rx.recv() => {
934 let outcome = handle_inline_event(
935 &mut state.lock(),
936 handle,
937 &session_swapper.current(),
938 &prompt_tx,
939 evt,
940 );
941 if outcome == LoopOutcome::Exit {
942 break;
943 }
944 }
945
946 _ = tokio::signal::ctrl_c() => {
950 let outcome = {
951 let mut s = state.lock();
952 handle_interrupt(&mut s, &session_swapper.current(), handle)
953 };
954 if outcome == LoopOutcome::Exit {
955 break;
956 }
957 }
958
959 _ = render_tick.tick() => {}
962 }
963
964 if let Ok(size) = terminal.size()
966 && size.width < 40
967 {
968 let mut s = state.lock();
969 if s.tip.is_none() {
970 s.show_tip(
971 "small_screen",
972 "Terminal too narrow \u{2014} resize for full UI",
973 300,
974 true,
975 );
976 }
977 }
978 let mut snapshot = state.lock();
981 if let Some(provider) = snapshot.todo_provider.as_ref() {
984 snapshot.todo_items = flatten_todo_items(&provider.get_phases());
985 }
986 let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
987 let draw_err = terminal
988 .draw(|frame| render_frame(frame, &snapshot, handle))
989 .err();
990 let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
991 if let Some(err) = draw_err {
992 tracing::warn!(?err, "tui draw failed");
993 break;
994 }
995 }
996
997 Ok(())
998}
999
1000#[derive(PartialEq, Eq)]
1001enum LoopOutcome {
1002 Continue,
1003 Exit,
1004}
1005
1006#[derive(PartialEq, Eq, Debug)]
1011enum CancelRoute {
1012 Interrupt,
1015 Exit,
1017}
1018
1019fn route_cancel(is_streaming: bool) -> CancelRoute {
1022 if is_streaming {
1023 CancelRoute::Interrupt
1024 } else {
1025 CancelRoute::Exit
1026 }
1027}
1028
1029fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1036 match cmd {
1037 InlineCommand::AppendLine { kind, segments } => {
1038 state.append_line(kind, segments);
1039 }
1040 InlineCommand::Inline { kind, segment } => {
1041 state.inline_segment(kind, segment);
1042 }
1043 InlineCommand::ReplaceLast {
1044 count, kind, lines, ..
1045 } => {
1046 let drop = count.min(state.transcript.len());
1048 for _ in 0..drop {
1049 state.transcript.pop();
1050 }
1051 for line in lines {
1052 state.append_line(kind, line);
1053 }
1054 }
1055 InlineCommand::AppendPastedMessage { kind, text, .. } => {
1056 state.append_line(kind, vec![plain_segment(text)]);
1057 }
1058 InlineCommand::SetPrompt { prefix, .. } => {
1059 state.prompt_prefix = prefix;
1060 }
1061 InlineCommand::SetPlaceholder { hint, .. } => {
1062 state.placeholder = hint;
1063 }
1064 InlineCommand::SetHeaderContext { context } => {
1065 state.header_context = *context;
1066 }
1067 InlineCommand::SetInputStatus { left, right } => {
1068 state.footer_left = left;
1069 state.footer_right = right;
1070 }
1071 InlineCommand::SetInputEnabled(enabled) => {
1072 state.input_enabled = enabled;
1073 }
1074 InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1075 InlineCommand::SetReasoningStage(stage) => {
1076 state.reasoning_stage = stage;
1077 }
1078 InlineCommand::SetVimModeEnabled(enabled) => {
1079 state.vim_state.set_enabled(enabled);
1080 }
1081 InlineCommand::SetQueuedInputs { entries } => {
1082 state.queued_inputs = entries;
1083 }
1084 InlineCommand::ShowOverlay { request } => {
1085 state.overlay = Some(materialize_overlay(*request));
1086 }
1087 InlineCommand::CloseOverlay => {
1088 state.overlay = None;
1089 }
1090 InlineCommand::Shutdown => {
1091 state.shutdown_requested = true;
1092 return true;
1093 }
1094 _ => {
1095 tracing::trace!("unhandled InlineCommand (not rendered)");
1098 }
1099 }
1100 false
1101}
1102
1103fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1108 match request {
1109 OverlayRequest::Modal(req) => {
1110 let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1111 config: cfg,
1112 value: String::new(),
1113 cursor: 0,
1114 });
1115 OverlayState {
1116 title: req.title,
1117 lines: req.lines,
1118 items: Vec::new(),
1119 selected: 0,
1120 search: None,
1121 secure_input,
1122 }
1123 }
1124 OverlayRequest::List(req) => {
1125 let search = req.search.map(|cfg| OverlaySearchState {
1126 label: cfg.label,
1127 placeholder: cfg.placeholder,
1128 value: String::new(),
1129 });
1130 OverlayState {
1131 title: req.title,
1132 lines: req.lines,
1133 items: req.items.into_iter().map(overlay_item_from).collect(),
1134 selected: 0,
1135 search,
1136 secure_input: None,
1137 }
1138 }
1139 OverlayRequest::Wizard(req) => {
1140 let step_items = req
1144 .steps
1145 .first()
1146 .map(|s| {
1147 s.items
1148 .iter()
1149 .map(|it| overlay_item_from(it.clone()))
1150 .collect()
1151 })
1152 .unwrap_or_default();
1153 let search = req.search.map(|cfg| OverlaySearchState {
1154 label: cfg.label,
1155 placeholder: cfg.placeholder,
1156 value: String::new(),
1157 });
1158 OverlayState {
1159 title: req.title,
1160 lines: Vec::new(),
1161 items: step_items,
1162 selected: 0,
1163 search,
1164 secure_input: None,
1165 }
1166 }
1167 }
1168}
1169fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1170 OverlayListItem {
1171 title: item.title,
1172 subtitle: item.subtitle,
1173 badge: item.badge,
1174 indent: item.indent,
1175 search_value: item.search_value,
1176 selection: item.selection,
1177 }
1178}
1179
1180fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
1184 match event {
1185 SessionEvent::Agent(boxed) => {
1186 map_agent_event(handle, *boxed.clone(), state);
1187 }
1188 SessionEvent::CompactionStart { .. } => {
1189 handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1190 }
1191 SessionEvent::CompactionEnd { error_message, .. } => {
1192 handle.set_reasoning_stage(None);
1193 if let Some(msg) = error_message {
1194 handle.append_line(
1195 InlineMessageKind::Error,
1196 vec![plain_segment(format!("Compaction failed: {msg}"))],
1197 );
1198 }
1199 }
1200 SessionEvent::ThinkingLevelChanged { .. } => {
1201 }
1204 SessionEvent::QueueUpdate { .. } => {
1205 let pending = state.transcript.len();
1209 handle.set_input_status(
1210 None,
1211 Some(if pending == 0 {
1212 "ready".to_string()
1213 } else {
1214 "queued".to_string()
1215 }),
1216 );
1217 }
1218 SessionEvent::Advisor { body, .. } => {
1219 handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1220 }
1221 SessionEvent::SessionInfoChanged => {
1222 }
1225 SessionEvent::HandoffComplete { .. } => {
1226 }
1230 SessionEvent::HandoffFailed { error } => {
1231 handle.append_line(
1232 InlineMessageKind::Error,
1233 vec![plain_segment(format!("Handoff failed: {}", error))],
1234 );
1235 }
1236 }
1237}
1238
1239fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1241 match event {
1242 AgentEvent::TextChunk { text } => {
1243 state.message_buffer.push_str(&text);
1244 handle.inline(InlineMessageKind::Agent, plain_segment(text));
1245 }
1246 AgentEvent::MessageStart { .. } => {
1247 state.message_buffer.clear();
1248 }
1249 AgentEvent::MessageUpdate { delta, .. } => match &delta {
1250 oxicode_sdk::StreamDelta::Text(text) => {
1251 state.message_buffer.push_str(text);
1252 handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1253 }
1254 oxicode_sdk::StreamDelta::Thinking(text) => {
1255 let mut style = InlineTextStyle::default();
1258 style.effects |= anstyle::Effects::DIMMED;
1259 let seg = InlineSegment {
1260 text: format!("\u{2733} {text}"),
1261 style: Arc::new(style),
1262 };
1263 handle.inline(InlineMessageKind::Info, seg);
1264 }
1265 oxicode_sdk::StreamDelta::Sync => {
1266 if !state.message_buffer.is_empty() {
1268 let lines =
1269 oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1270 let count = lines.len();
1271 if count > 0 {
1272 handle.replace_last(count, InlineMessageKind::Agent, lines);
1273 }
1274 state.message_buffer.clear();
1275 }
1276 }
1277 },
1278 AgentEvent::MessageEnd { .. } => {
1279 if !state.message_buffer.is_empty() {
1281 let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1282 let count = lines.len();
1283 if count > 0 {
1284 handle.replace_last(count, InlineMessageKind::Agent, lines);
1285 }
1286 state.message_buffer.clear();
1287 }
1288 }
1289 AgentEvent::ToolStart { tool_name, .. } => {
1290 handle.append_line(
1291 InlineMessageKind::Tool,
1292 vec![plain_segment(format!("\u{2699} {tool_name}"))],
1293 );
1294 handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
1295 }
1296 AgentEvent::ToolComplete { result } => {
1297 if !try_render_diff(&result.content, handle) {
1299 let preview = preview_tool_result(&result.content);
1300 let mut style = InlineTextStyle::default();
1301 style.effects |= anstyle::Effects::DIMMED;
1302 handle.append_line(
1303 InlineMessageKind::Tool,
1304 vec![InlineSegment {
1305 text: format!("\u{2713} {preview}"),
1306 style: Arc::new(style),
1307 }],
1308 );
1309 }
1310 handle.set_reasoning_stage(None);
1311 handle.set_input_enabled(true);
1312 }
1313 AgentEvent::ToolError { error, .. } => {
1314 handle.append_line(
1315 InlineMessageKind::Error,
1316 vec![plain_segment(format!("\u{2717} {error}"))],
1317 );
1318 handle.set_reasoning_stage(None);
1319 handle.set_input_enabled(true);
1320 }
1321 AgentEvent::Error { message, .. } => {
1322 handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1323 handle.set_input_enabled(true);
1324 handle.set_input_status(None, None);
1325 }
1326 AgentEvent::Compaction { .. } => {
1327 }
1330 AgentEvent::Cancelled => {
1331 handle.set_input_enabled(true);
1332 handle.set_input_status(None, Some("cancelled".to_string()));
1333 }
1334 AgentEvent::AutoRetryStart {
1335 attempt,
1336 max_attempts,
1337 ..
1338 } => {
1339 handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1340 }
1341 AgentEvent::TurnEnd { .. } => {
1342 crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1346 state.drain_queue_head();
1350 handle.set_reasoning_stage(None);
1351 }
1352 _ => {
1353 tracing::debug!(?event, "ignored AgentEvent variant");
1357 }
1358 }
1359}
1360
1361pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
1368 match (has_key, oauth_capable) {
1369 (true, true) => vec![
1370 AuthAction::SetApiKey,
1371 AuthAction::StartOAuth,
1372 AuthAction::RemoveKey,
1373 ],
1374 (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
1375 (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
1376 (false, false) => vec![AuthAction::SetApiKey],
1377 }
1378}
1379
1380pub(crate) fn open_secure_prompt(
1395 state: &mut RenderState,
1396 handle: &InlineHandle,
1397 origin: SecureInputOrigin,
1398) {
1399 let provider = match &origin {
1400 SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
1401 provider.clone()
1402 }
1403 };
1404 state.secure_input_origin = Some(origin);
1405 handle.show_modal(
1406 format!("Set API key for {provider}"),
1407 vec![
1408 "Paste the API key. Press Enter to save, Esc to cancel.".into(),
1409 "The key is masked on screen; nothing is logged.".into(),
1410 ],
1411 Some(SecurePromptConfig {
1412 label: format!("{provider} key"),
1413 placeholder: Some("sk-...".into()),
1414 mask_input: true,
1415 }),
1416 );
1417}
1418
1419pub(crate) fn handle_auth_action(
1429 provider: &str,
1430 action: &AuthAction,
1431 auth: &Arc<crate::store::auth_storage::AuthStorage>,
1432 handle: &InlineHandle,
1433 state: &mut RenderState,
1434) -> bool {
1435 match action {
1448 AuthAction::SetApiKey => {
1449 open_secure_prompt(
1450 state,
1451 handle,
1452 SecureInputOrigin::SetKey {
1453 provider: provider.to_string(),
1454 },
1455 );
1456 true
1457 }
1458 AuthAction::StartOAuth => {
1459 let spec = match crate::provider_oauth::spec_for(provider) {
1470 Some(s) => s,
1471 None => {
1472 handle.append_line(
1473 InlineMessageKind::Error,
1474 vec![plain_segment(format!(
1475 "OAuth: no OAuth config for '{provider}'."
1476 ))],
1477 );
1478 return false;
1479 }
1480 };
1481 let provider_owned = provider.to_string();
1486 let tx = handle.clone();
1487 let auth_clone = Arc::clone(auth);
1488 tokio::spawn(async move {
1489 run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
1490 });
1491 false
1492 }
1493 AuthAction::RemoveKey => {
1494 state.confirmation = Some(ModalConfirmation {
1495 title: format!("Remove key for {provider}?"),
1496 message: " y \u{2014} remove key n / x \u{2014} cancel".into(),
1497 action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
1498 });
1499 false
1500 }
1501 }
1502}
1503
1504pub(crate) async fn run_oauth_flow(
1532 provider: String,
1533 spec: crate::provider_oauth::ProviderOAuthSpec,
1534 handle: InlineHandle,
1535 auth: Arc<crate::store::auth_storage::AuthStorage>,
1536) {
1537 use std::time::Duration;
1538 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
1547 Ok(l) => l,
1548 Err(e) => {
1549 handle.append_line(
1550 InlineMessageKind::Error,
1551 vec![plain_segment(format!(
1552 "OAuth: could not bind loopback listener for '{provider}': {e}"
1553 ))],
1554 );
1555 return;
1556 }
1557 };
1558 let port = match listener.local_addr() {
1559 Ok(addr) => addr.port(),
1560 Err(e) => {
1561 handle.append_line(
1562 InlineMessageKind::Error,
1563 vec![plain_segment(format!(
1564 "OAuth: could not read loopback port for '{provider}': {e}"
1565 ))],
1566 );
1567 return;
1568 }
1569 };
1570
1571 let (verifier, challenge) = crate::provider_oauth::pkce_pair();
1575 let state_token = crate::provider_oauth::pkce_pair().0; let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
1581 handle.append_line(
1582 InlineMessageKind::Info,
1583 vec![plain_segment(format!(
1584 "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
1585 spec.redirect_path
1586 ))],
1587 );
1588 let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
1596 Ok(()) => Duration::from_secs(120),
1597 Err(e) => {
1598 handle.append_line(
1599 InlineMessageKind::Info,
1600 vec![plain_segment(format!(
1601 "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n {auth_url}"
1602 ))],
1603 );
1604 Duration::from_secs(300)
1605 }
1606 };
1607
1608 let callback = match crate::oauth_listener::await_callback(
1611 listener,
1612 state_token.clone(),
1613 spec.redirect_path.clone(),
1614 callback_timeout,
1615 )
1616 .await
1617 {
1618 Ok(c) => c,
1619 Err(crate::oauth_listener::CallbackError::Timeout) => {
1620 handle.append_line(
1621 InlineMessageKind::Error,
1622 vec![plain_segment(format!(
1623 "OAuth: timed out waiting for '{provider}' callback (after {}s)",
1624 callback_timeout.as_secs()
1625 ))],
1626 );
1627 return;
1628 }
1629 Err(e) => {
1630 handle.append_line(
1631 InlineMessageKind::Error,
1632 vec![plain_segment(format!(
1633 "OAuth: callback failed for '{provider}': {e}"
1634 ))],
1635 );
1636 return;
1637 }
1638 };
1639
1640 let tokens =
1642 match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
1643 Ok(t) => t,
1644 Err(e) => {
1645 handle.append_line(
1646 InlineMessageKind::Error,
1647 vec![plain_segment(format!(
1648 "OAuth: token exchange failed for '{provider}': {e}"
1649 ))],
1650 );
1651 return;
1652 }
1653 };
1654
1655 let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
1661 let access_token_len = tokens.access_token.chars().count();
1662 auth.set_oauth_full(
1665 &provider,
1666 tokens.access_token,
1667 tokens.refresh_token,
1668 new_expires_at,
1669 if tokens.scopes.is_empty() {
1670 None
1671 } else {
1672 Some(tokens.scopes.join(" "))
1673 },
1674 None,
1675 );
1676 handle.append_line(
1677 InlineMessageKind::Info,
1678 vec![plain_segment(format!(
1679 "OAuth: '{provider}' logged in. Token stored ({} chars).",
1680 access_token_len
1681 ))],
1682 );
1683}
1684
1685fn handle_inline_event(
1687 state: &mut RenderState,
1688 handle: &InlineHandle,
1689 session: &crate::app::agent_session::AgentSessionHandle,
1690 prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1691 evt: InlineEvent,
1692) -> LoopOutcome {
1693 match evt {
1694 InlineEvent::Submit(text) => {
1695 if let Some(path) = state.pending_resume.take() {
1697 let swapper = state.swapper();
1698 let agent_arc = Arc::clone(&session.agent_arc());
1699 let settings = session.settings_clone();
1700 let session_state = state
1701 .session_state
1702 .clone()
1703 .expect("RenderState::session_state must be initialized at TUI startup");
1704 let path_for_log = path.clone();
1705 let handle = handle.clone();
1706 let swapper_for_swap = swapper.clone();
1707 tokio::spawn(async move {
1708 match crate::app::agent_session::resume_from_file(
1709 agent_arc,
1710 settings,
1711 session_state,
1712 &path,
1713 None,
1714 )
1715 .await
1716 {
1717 Ok(new_session) => {
1718 swapper_for_swap.swap(new_session.clone_handle());
1719 let n = new_session.messages().len();
1720 let id = new_session.session_id();
1721 handle.append_line(
1722 InlineMessageKind::Info,
1723 vec![plain_segment(format!(
1724 "Resumed session {id} ({n} messages)"
1725 ))],
1726 );
1727 }
1728 Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
1729 handle.append_line(
1730 InlineMessageKind::Error,
1731 vec![plain_segment(format!("No session file: {}", p.display()))],
1732 );
1733 }
1734 Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
1735 handle.append_line(
1736 InlineMessageKind::Error,
1737 vec![plain_segment(format!(
1738 "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
1739 Use /export to save its content, then /clear.",
1740 path_for_log.display()
1741 ))],
1742 );
1743 }
1744 }
1745 });
1746 return LoopOutcome::Continue;
1747 }
1748 let prompt = text.to_string();
1752 state.input_buffer.clear();
1753 state.input_cursor = 0;
1754 if prompt.is_empty() {
1755 return LoopOutcome::Continue;
1756 }
1757 state.pending_quit = false;
1758 if prompt.trim_start().starts_with('/') {
1762 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1763 let mut ctx = SlashCtx {
1764 session,
1765 handle,
1766 state,
1767 };
1768 return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1769 SlashOutcome::Quit => LoopOutcome::Exit,
1770 SlashOutcome::Handled => LoopOutcome::Continue,
1771 SlashOutcome::NotHandled => {
1772 if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
1774 &ctx.state.file_commands,
1775 &prompt,
1776 ) {
1777 let _ = prompt_tx.send(expanded);
1780 LoopOutcome::Continue
1781 } else {
1782 ctx.reply(
1783 InlineMessageKind::Error,
1784 format!("Unknown command: {}", prompt.trim()),
1785 );
1786 LoopOutcome::Continue
1787 }
1788 }
1789 };
1790 }
1791 state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1792 if session.is_streaming() {
1796 state.queued_inputs.push(prompt.clone());
1797 state.show_tip(
1798 "send_now",
1799 "Ctrl+Enter sends now \u{00b7} Ctrl+; manages queue",
1800 240,
1801 true,
1802 );
1803 }
1804 let _ = prompt_tx.send(prompt);
1807 }
1808 InlineEvent::Cancel => {
1809 return match route_cancel(session.is_streaming()) {
1815 CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1816 CancelRoute::Exit => LoopOutcome::Exit,
1817 };
1818 }
1819 InlineEvent::Exit => {
1820 return LoopOutcome::Exit;
1821 }
1822 InlineEvent::Interrupt => {
1823 return handle_interrupt(state, session, handle);
1824 }
1825 InlineEvent::ScrollLineUp => {
1826 state.scroll_offset = state.scroll_offset.saturating_add(1);
1827 }
1828 InlineEvent::ScrollLineDown => {
1829 state.scroll_offset = state.scroll_offset.saturating_sub(1);
1830 }
1831 InlineEvent::ScrollPageUp => {
1832 state.scroll_offset = state.scroll_offset.saturating_add(10);
1833 }
1834 InlineEvent::ScrollPageDown => {
1835 state.scroll_offset = state.scroll_offset.saturating_sub(10);
1836 }
1837 InlineEvent::CyclePrimaryAgent => {
1838 let _ = session.cycle_model();
1839 }
1840 InlineEvent::CyclePrimaryAgentPrevious => {
1841 let _ = session.cycle_model();
1844 }
1845 InlineEvent::Overlay(overlay_evt) => {
1846 use oxicode_vtui::tui::core::OverlayEvent;
1847 match overlay_evt {
1848 OverlayEvent::Submitted(sub) => {
1849 let mut opened_new_overlay = false;
1859 if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
1861 && idx < &state.overlay_model_ids.len()
1862 {
1863 let model_id = state.overlay_model_ids[*idx].clone();
1864 match session.set_model(&model_id) {
1865 Ok(()) => handle.append_line(
1866 InlineMessageKind::Info,
1867 vec![plain_segment(format!("Switched to {model_id}"))],
1868 ),
1869 Err(e) => handle.append_line(
1870 InlineMessageKind::Error,
1871 vec![plain_segment(format!("Failed to set model: {e}"))],
1872 ),
1873 }
1874 }
1875 if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
1877 {
1878 match oxicode_vtui::theme::set_active_theme(theme_id) {
1879 Ok(()) => {
1880 let label = oxicode_vtui::theme::theme_label(theme_id)
1881 .unwrap_or(theme_id.as_ref())
1882 .to_string();
1883 handle.append_line(
1884 InlineMessageKind::Info,
1885 vec![plain_segment(format!("Theme: {label}"))],
1886 );
1887 }
1888 Err(e) => handle.append_line(
1889 InlineMessageKind::Error,
1890 vec![plain_segment(format!("Unknown theme: {e}"))],
1891 ),
1892 }
1893 }
1894 if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
1896 &sub
1897 {
1898 state.input_buffer = format!("/{name} ");
1899 state.input_cursor = state.input_buffer.len();
1900 }
1901 if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
1903 &sub
1904 {
1905 match key.as_str() {
1906 "thinking_level" => {
1907 if let Some(level) = session.cycle_thinking_level() {
1908 handle.append_line(
1909 InlineMessageKind::Info,
1910 vec![plain_segment(format!("Thinking: {level:?}"))],
1911 );
1912 }
1913 }
1914 "auto_compaction" => {
1915 let enabled = !session.auto_compaction_enabled();
1916 session.set_auto_compaction(enabled);
1917 handle.append_line(
1918 InlineMessageKind::Info,
1919 vec![plain_segment(format!(
1920 "Auto-compaction: {}",
1921 if enabled { "on" } else { "off" }
1922 ))],
1923 );
1924 }
1925 "auto_retry" => {
1926 let enabled = !session.auto_retry_enabled();
1927 session.set_auto_retry(enabled);
1928 handle.append_line(
1929 InlineMessageKind::Info,
1930 vec![plain_segment(format!(
1931 "Auto-retry: {}",
1932 if enabled { "on" } else { "off" }
1933 ))],
1934 );
1935 }
1936 "advisor" => match session.toggle_advisor() {
1937 Ok(enabled) => handle.append_line(
1938 InlineMessageKind::Info,
1939 vec![plain_segment(format!(
1940 "Advisor: {}",
1941 if enabled { "on" } else { "off" }
1942 ))],
1943 ),
1944 Err(e) => handle.append_line(
1945 InlineMessageKind::Error,
1946 vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
1947 ),
1948 },
1949 _ => {}
1950 }
1951 }
1952 if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
1955 if session.is_streaming() {
1961 handle.append_line(
1962 InlineMessageKind::Error,
1963 vec![plain_segment(
1964 "Cannot resume while agent is running. Use /cancel first.",
1965 )],
1966 );
1967 } else {
1968 let path = crate::tui_vt::slash::registry::sessions_dir()
1969 .join(format!("{id}.jsonl"));
1970 if !path.is_file() {
1971 handle.append_line(
1972 InlineMessageKind::Error,
1973 vec![plain_segment(format!(
1974 "No session file: {}",
1975 path.display()
1976 ))],
1977 );
1978 } else {
1979 state.pending_resume = Some(path);
1980 }
1981 }
1982 }
1983 if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
1985 &sub
1986 && idx < &state.overlay_catalog_models.len()
1987 {
1988 let (provider, model_id) = &state.overlay_catalog_models[*idx];
1989 let full = format!("{provider}/{model_id}");
1990 match session.set_model(&full) {
1991 Ok(()) => handle.append_line(
1992 InlineMessageKind::Info,
1993 vec![plain_segment(format!("Switched to {full}"))],
1994 ),
1995 Err(e) => handle.append_line(
1996 InlineMessageKind::Error,
1997 vec![plain_segment(format!("Failed to set model: {e}"))],
1998 ),
1999 }
2000 }
2001 if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
2008 &sub
2009 && idx < &state.overlay_providers.len()
2010 {
2011 let name = state.overlay_providers[*idx].clone();
2012 let auth = crate::store::auth_storage::shared_auth_storage();
2013 let has_key = auth.has(&name);
2014 let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
2015 let actions = next_provider_actions(has_key, oauth_capable);
2016 if actions.len() == 1 {
2017 opened_new_overlay |=
2019 handle_auth_action(&name, &actions[0], &auth, handle, state);
2020 } else {
2021 let items: Vec<InlineListItem> = actions
2023 .iter()
2024 .map(|a| InlineListItem {
2025 title: match a {
2026 AuthAction::SetApiKey => "Set API key".into(),
2027 AuthAction::StartOAuth => "Login with OAuth".into(),
2028 AuthAction::RemoveKey => "Remove key".into(),
2029 },
2030 subtitle: None,
2031 badge: None,
2032 indent: 0,
2033 selection: Some(InlineListSelection::ProviderAction {
2034 provider: name.clone(),
2035 action: a.clone(),
2036 }),
2037 search_value: None,
2038 })
2039 .collect();
2040 handle.show_list_modal(
2041 name.clone(),
2042 vec!["Pick an action".into()],
2043 items,
2044 None,
2045 None,
2046 );
2047 opened_new_overlay = true;
2048 }
2049 }
2050 if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
2056 provider,
2057 action,
2058 }) = &sub
2059 {
2060 let auth = crate::store::auth_storage::shared_auth_storage();
2061 opened_new_overlay |=
2062 handle_auth_action(provider, action, &auth, handle, state);
2063 }
2064 if let OverlaySubmission::SecureInput(text) = &sub
2071 && let Some(origin) = state.secure_input_origin.take()
2072 {
2073 let provider = match &origin {
2074 SecureInputOrigin::SetKey { provider }
2075 | SecureInputOrigin::NewlyAdded { provider } => provider.clone(),
2076 };
2077 let auth = crate::store::auth_storage::shared_auth_storage();
2078 auth.set_api_key(&provider, text.clone());
2079 let msg = match origin {
2080 SecureInputOrigin::SetKey { .. } => format!(
2081 "Saved API key for '{provider}' ({} chars).",
2082 text.chars().count()
2083 ),
2084 SecureInputOrigin::NewlyAdded { .. } => format!(
2085 "Added and configured '{provider}' ({} chars).\n\
2086 Run /models to browse available models, or /providers to manage.",
2087 text.chars().count()
2088 ),
2089 };
2090 handle.append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
2091 }
2092 state.overlay_catalog_models.clear();
2093 state.overlay_providers.clear();
2094 state.overlay_model_ids.clear();
2095 if !opened_new_overlay {
2096 handle.close_overlay();
2097 }
2098 }
2099 OverlayEvent::Cancelled => {
2100 handle.close_overlay();
2101 }
2102 OverlayEvent::SelectionChanged(_) => {}
2103 }
2104 }
2105 _ => {
2106 }
2110 }
2111 LoopOutcome::Continue
2112}
2113
2114struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
2121
2122impl Drop for StreamingGuard<'_> {
2123 fn drop(&mut self) {
2124 use std::sync::atomic::Ordering;
2125 self.0.store(false, Ordering::SeqCst);
2126 }
2127}
2128
2129fn handle_interrupt(
2142 state: &mut RenderState,
2143 session: &crate::app::agent_session::AgentSessionHandle,
2144 _handle: &InlineHandle,
2145) -> LoopOutcome {
2146 if state.confirmation.is_some() {
2148 return LoopOutcome::Exit;
2149 }
2150 if state.pending_quit {
2153 state.confirmation = Some(quit_confirmation());
2154 state.pending_quit = false;
2155 return LoopOutcome::Continue;
2156 }
2157 if session.is_streaming() {
2161 let s = session.clone();
2162 tokio::spawn(async move {
2163 s.abort().await;
2164 });
2165 state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
2166 state.pending_quit = true;
2167 } else {
2168 state.footer_left = None;
2169 state.confirmation = Some(quit_confirmation());
2170 }
2171 LoopOutcome::Continue
2172}
2173
2174fn quit_confirmation() -> ModalConfirmation {
2176 ModalConfirmation {
2177 title: "Quit oxicode?".into(),
2178 message: " y \u{2014} quit now n / x \u{2014} stay".into(),
2179 action: ConfirmationAction::Quit,
2180 }
2181}
2182
2183pub(super) fn clear_confirmation() -> ModalConfirmation {
2185 ModalConfirmation {
2186 title: "Clear conversation?".into(),
2187 message: " y \u{2014} clear all n / x \u{2014} cancel".into(),
2188 action: ConfirmationAction::ClearConversation,
2189 }
2190}
2191
2192fn spawn_input_thread(
2198 state: Arc<parking_lot::Mutex<RenderState>>,
2199 evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2200 mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
2201) -> std::thread::JoinHandle<()> {
2202 std::thread::spawn(move || {
2203 loop {
2211 match event::poll(std::time::Duration::from_millis(50)) {
2212 Ok(true) => {}
2213 Ok(false) => continue,
2214 Err(_) => break,
2215 }
2216 let event = match event::read() {
2217 Ok(ev) => ev,
2218 Err(_) => continue,
2219 };
2220
2221 let mut pasted = String::new();
2224 let mut key_event = None;
2225 match event {
2226 Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
2227 Event::Paste(p) => pasted = p,
2228 _ => {}
2229 }
2230
2231 if !pasted.is_empty() {
2232 let routed_to_secure = {
2237 let mut s = state.lock();
2238 if let Some(overlay) = s.overlay.as_mut() {
2239 if let Some(secure) = overlay.secure_input.as_mut() {
2240 let (v, c) = insert_paste_into_secure_input(
2241 &secure.value,
2242 secure.cursor,
2243 &pasted,
2244 );
2245 secure.value = v;
2246 secure.cursor = c;
2247 true
2248 } else {
2249 false
2250 }
2251 } else {
2252 false
2253 }
2254 };
2255 if routed_to_secure {
2256 continue;
2257 }
2258 let mut s = state.lock();
2259 let cursor = s.input_cursor;
2260 s.input_buffer.insert_str(cursor, &pasted);
2261 s.input_cursor = cursor + pasted.len();
2262 refresh_input_popups(&mut s);
2269 continue;
2270 }
2271
2272 let Some(key) = key_event else { continue };
2273
2274 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
2278 let _ = evt_tx.send(InlineEvent::Interrupt);
2279 continue;
2280 }
2281
2282 if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
2284 let mut s = state.lock();
2285 s.multiline_mode = !s.multiline_mode;
2286 continue;
2287 }
2288
2289 if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
2291 let mut s = state.lock();
2292 s.overlay = Some(build_command_palette());
2293 continue;
2294 }
2295
2296 if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
2298 let mut s = state.lock();
2299 s.queue_panel_open = !s.queue_panel_open;
2300 if s.queue_panel_open {
2301 s.queue_selected = 0;
2302 }
2303 continue;
2304 }
2305
2306 if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
2308 let mut s = state.lock();
2309 s.fold_all();
2310 continue;
2311 }
2312
2313 if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
2316 let submitted = {
2317 let mut s = state.lock();
2318 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2319 format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
2320 } else {
2321 std::mem::take(&mut s.input_buffer)
2322 };
2323 s.input_cursor = 0;
2324 s.slash_popup = SlashPopup::default();
2325 s.history_pos = None;
2326 if !buf.is_empty() && !buf.starts_with('/') {
2327 s.prompt_history.insert(0, buf.clone());
2328 s.prompt_history.truncate(100);
2329 }
2330 buf
2331 };
2332 if !submitted.is_empty() {
2333 let _ = evt_tx.send(InlineEvent::Interrupt);
2334 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2335 }
2336 continue;
2337 }
2338
2339 {
2342 let s = state.lock();
2343 if s.confirmation.is_some() {
2344 drop(s);
2345 handle_confirmation_key(&state, &evt_tx, key.code);
2346 continue;
2347 }
2348 }
2349
2350 {
2356 let s = state.lock();
2357 if s.overlay.is_some() {
2358 drop(s);
2359 if handle_overlay_key(&state, &evt_tx, key.code) {
2360 continue;
2361 }
2362 }
2363 }
2364
2365 {
2369 let s = state.lock();
2370 if s.file_search.is_some() {
2371 drop(s);
2372 if handle_file_search_key(&state, &evt_tx, key.code) {
2373 continue;
2374 }
2375 }
2376 }
2377
2378 match key.code {
2379 KeyCode::BackTab => {
2381 if let Some(h) = &mode_handle {
2382 let new_mode = Mode::load(h).toggle();
2383 h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
2384 let label = new_mode.label();
2385 let detail = if new_mode.is_auto() {
2386 "autonomous — no questions, runs to completion"
2387 } else {
2388 "interactive — may ask questions"
2389 };
2390 let mut s = state.lock();
2391 s.autonomy_mode = new_mode;
2392 s.tip = Some(EphemeralTip {
2393 text: format!("Mode: {label} — {detail}"),
2394 born_tick: 0,
2395 ttl_ticks: 240,
2396 key: "mode_toggle",
2397 ambient: false,
2398 });
2399 }
2400 continue;
2401 }
2402 KeyCode::Enter => {
2403 let send = !state.lock().multiline_mode
2406 || key
2407 .modifiers
2408 .contains(crossterm::event::KeyModifiers::SHIFT);
2409
2410 if !send {
2411 let mut s = state.lock();
2412 let cursor = s.input_cursor;
2413 s.input_buffer.insert(cursor, '\n');
2414 s.input_cursor = cursor + 1;
2415 continue;
2416 }
2417
2418 let shell_cmd = state.lock().shell_mode;
2420 if shell_cmd {
2421 let submitted = {
2422 let mut s = state.lock();
2423 let buf = std::mem::take(&mut s.input_buffer);
2424 s.input_cursor = 0;
2425 s.shell_mode = false;
2426 s.history_pos = None;
2427 if !buf.is_empty() {
2428 s.prompt_history.insert(0, buf.clone());
2429 s.prompt_history.truncate(100);
2430 }
2431 buf
2432 };
2433 if !submitted.is_empty() {
2434 let prompt = format!("Run this shell command: `{submitted}`");
2435 let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
2436 }
2437 continue;
2438 }
2439
2440 let submitted = {
2441 let mut s = state.lock();
2442 let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2443 let item = &s.slash_popup.items[s.slash_popup.selected];
2444 format!("/{}", item.name)
2445 } else {
2446 std::mem::take(&mut s.input_buffer)
2447 };
2448 s.input_cursor = 0;
2449 s.slash_popup = SlashPopup::default();
2450 s.history_pos = None;
2451 if !buf.is_empty() && !buf.starts_with('/') {
2453 s.prompt_history.insert(0, buf.clone());
2454 s.prompt_history.truncate(100);
2455 }
2456 buf
2457 };
2458 let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2459 }
2460 KeyCode::Esc => {
2461 let mut s = state.lock();
2468 if s.shell_mode {
2469 s.shell_mode = false;
2470 s.input_buffer.clear();
2471 s.input_cursor = 0;
2472 } else if s.slash_popup.open {
2473 s.slash_popup = SlashPopup::default();
2474 } else if !s.input_buffer.is_empty() {
2475 let now = std::time::Instant::now();
2476 let is_double = s
2477 .last_esc_at
2478 .map(|t| now.duration_since(t).as_millis() < 800)
2479 .unwrap_or(false);
2480 if is_double {
2481 s.input_buffer.clear();
2482 s.input_cursor = 0;
2483 s.last_esc_at = None;
2484 } else {
2485 s.last_esc_at = Some(now);
2486 s.tip = Some(EphemeralTip {
2489 text: "Press Esc again to clear input".to_string(),
2490 born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
2491 ttl_ticks: 120,
2492 key: "esc_clear",
2493 ambient: false,
2494 });
2495 }
2496 } else {
2497 let now = std::time::Instant::now();
2498 let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
2499 if in_grace {
2500 } else {
2502 s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
2503 s.last_esc_at = None;
2504 drop(s);
2505 let _ = evt_tx.send(InlineEvent::Cancel);
2506 }
2507 }
2508 }
2509 KeyCode::Tab => {
2510 let mut s = state.lock();
2513 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2514 let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
2515 s.input_buffer = format!("/{} ", name);
2516 s.input_cursor = s.input_buffer.len();
2517 refresh_input_popups(&mut s);
2518 }
2519 }
2520 KeyCode::Backspace => {
2521 let mut s = state.lock();
2522 if s.input_cursor > 0 {
2523 let cursor = s.input_cursor;
2524 let prev = s
2527 .input_buffer
2528 .char_indices()
2529 .take_while(|(i, _)| *i < cursor)
2530 .last()
2531 .map(|(i, _)| i)
2532 .unwrap_or(0);
2533 s.input_buffer.replace_range(prev..cursor, "");
2534 s.input_cursor = prev;
2535 }
2536 refresh_input_popups(&mut s);
2537 }
2538 KeyCode::Delete => {
2539 let mut s = state.lock();
2540 if s.input_cursor < s.input_buffer.len() {
2541 let cursor = s.input_cursor;
2542 let next = s.input_buffer[cursor..]
2543 .char_indices()
2544 .nth(1)
2545 .map(|(i, _)| cursor + i)
2546 .unwrap_or(s.input_buffer.len());
2547 s.input_buffer.replace_range(cursor..next, "");
2548 }
2549 refresh_input_popups(&mut s);
2550 }
2551 KeyCode::Left => {
2552 let mut s = state.lock();
2553 s.input_cursor = s.input_cursor.saturating_sub(1);
2554 }
2555 KeyCode::Right => {
2556 let mut s = state.lock();
2557 let len = s.input_buffer.len();
2558 s.input_cursor = (s.input_cursor + 1).min(len);
2559 }
2560 KeyCode::Up => {
2561 let mut s = state.lock();
2562 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2563 let len = s.slash_popup.items.len();
2564 s.slash_popup.selected = if s.slash_popup.selected == 0 {
2565 len - 1
2566 } else {
2567 s.slash_popup.selected - 1
2568 };
2569 } else if s.queue_panel_open
2570 && !s.queued_inputs.is_empty()
2571 && s.input_buffer.is_empty()
2572 {
2573 s.queue_selected = if s.queue_selected == 0 {
2574 s.queued_inputs.len() - 1
2575 } else {
2576 s.queue_selected - 1
2577 };
2578 } else if s.input_buffer.is_empty() && !s.prompt_history.is_empty() {
2579 let pos = s.history_pos.unwrap_or(0);
2581 let next = (pos + 1).min(s.prompt_history.len() - 1);
2582 s.history_pos = Some(next);
2583 s.input_buffer = s.prompt_history[next].clone();
2584 s.input_cursor = s.input_buffer.len();
2585 } else {
2586 drop(s);
2587 let _ = evt_tx.send(InlineEvent::ScrollLineUp);
2588 }
2589 }
2590 KeyCode::Down => {
2591 let mut s = state.lock();
2592 if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2593 let len = s.slash_popup.items.len();
2594 s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
2595 0
2596 } else {
2597 s.slash_popup.selected + 1
2598 };
2599 } else if s.queue_panel_open
2600 && !s.queued_inputs.is_empty()
2601 && s.input_buffer.is_empty()
2602 {
2603 s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
2604 0
2605 } else {
2606 s.queue_selected + 1
2607 };
2608 } else {
2609 drop(s);
2610 let _ = evt_tx.send(InlineEvent::ScrollLineDown);
2611 }
2612 }
2613 KeyCode::PageUp => {
2614 let _ = evt_tx.send(InlineEvent::ScrollPageUp);
2615 }
2616 KeyCode::PageDown => {
2617 let _ = evt_tx.send(InlineEvent::ScrollPageDown);
2618 }
2619 KeyCode::Char(ch) => {
2620 let mut s = state.lock();
2621 if s.file_search.is_some()
2625 && ch == '!'
2626 && s.input_buffer[..s.input_cursor].ends_with('@')
2627 {
2628 let cwd = s.cwd.clone();
2629 if let Some(fs) = s.file_search.as_mut() {
2630 fs.toggle_hidden(&cwd);
2631 }
2632 continue;
2633 }
2634 if s.agent_hub_open && ch == 'q' {
2635 s.agent_hub_open = false;
2636 } else if s.vim_state.enabled() && !s.slash_popup.open {
2637 let s = &mut *s;
2640 let vkey =
2641 crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
2642 let mut editor = InputEditor {
2643 buffer: &mut s.input_buffer,
2644 cursor: &mut s.input_cursor,
2645 };
2646 let outcome = oxicode_vtui::vim::handle_key(
2647 &mut s.vim_state,
2648 &mut editor,
2649 &mut s.vim_clipboard,
2650 &vkey,
2651 );
2652 if outcome.handled {
2653 refresh_input_popups(s);
2654 } else {
2655 let cursor = s.input_cursor;
2656 s.input_buffer.insert(cursor, ch);
2657 s.input_cursor = cursor + ch.len_utf8();
2658 refresh_input_popups(s);
2659 }
2660 } else if s.input_buffer.is_empty() && !s.slash_popup.open {
2661 if ch == '!' && !s.shell_mode {
2663 s.shell_mode = true;
2664 continue;
2665 }
2666 if s.queue_panel_open && !s.queued_inputs.is_empty() {
2670 let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
2671 match ch {
2672 'x' | 'X' => {
2673 s.queued_inputs.remove(idx);
2674 if s.queue_selected >= s.queued_inputs.len()
2675 && !s.queued_inputs.is_empty()
2676 {
2677 s.queue_selected = s.queued_inputs.len() - 1;
2678 }
2679 continue;
2680 }
2681 'e' => {
2682 let entry = s.queued_inputs.remove(idx);
2683 s.input_buffer = entry;
2684 s.input_cursor = s.input_buffer.len();
2685 s.queue_panel_open = false;
2686 continue;
2687 }
2688 'J' => {
2689 if idx + 1 < s.queued_inputs.len() {
2690 s.queued_inputs.swap(idx, idx + 1);
2691 s.queue_selected = idx + 1;
2692 }
2693 continue;
2694 }
2695 'K' => {
2696 if idx > 0 {
2697 s.queued_inputs.swap(idx, idx - 1);
2698 s.queue_selected = idx - 1;
2699 }
2700 continue;
2701 }
2702 _ => {} }
2704 }
2705 match ch {
2710 '?' => {
2711 s.overlay = Some(OverlayState {
2712 title: "Keyboard Shortcuts".into(),
2713 lines: cheatsheet_lines(),
2714 items: vec![],
2715 selected: 0,
2716 search: None,
2717 secure_input: None,
2718 });
2719 }
2720 'e' => s.cycle_block_at_view(),
2721 'E' => s.expand_all(),
2722 'J' => s.jump_next_turn(),
2723 'K' => s.jump_prev_turn(),
2724 'n' if s.search.is_some() => s.search_next(),
2725 'N' if s.search.is_some() => s.search_prev(),
2726 _ => {
2727 let cursor = s.input_cursor;
2728 s.input_buffer.insert(cursor, ch);
2729 s.input_cursor = cursor + ch.len_utf8();
2730 refresh_input_popups(&mut s);
2731 }
2732 }
2733 } else {
2734 let cursor = s.input_cursor;
2735 s.input_buffer.insert(cursor, ch);
2736 s.input_cursor = cursor + ch.len_utf8();
2737 refresh_input_popups(&mut s);
2738 }
2739 if s.tip.is_none() && s.input_buffer.to_lowercase().contains("plan") {
2741 s.show_tip(
2742 "plan_nudge",
2743 "Try /compact to summarize and plan ahead",
2744 180,
2745 true,
2746 );
2747 }
2748 }
2749 _ => {}
2750 }
2751 }
2752 })
2753}
2754
2755fn handle_confirmation_key(
2759 state: &Arc<parking_lot::Mutex<RenderState>>,
2760 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2761 code: KeyCode,
2762) {
2763 let mut s = state.lock();
2764 let Some(confirm) = s.confirmation.clone() else {
2765 return;
2766 };
2767 match code {
2768 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2769 s.confirmation = None;
2770 drop(s);
2771 match confirm.action {
2772 ConfirmationAction::Quit => {
2773 let _ = evt_tx.send(InlineEvent::Exit);
2774 }
2775 ConfirmationAction::ClearConversation => {
2776 let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2780 }
2781 ConfirmationAction::RemoveProviderKey(name) => {
2782 let _ = evt_tx.send(InlineEvent::Submit(
2786 format!("/providers remove {name} --yes").into(),
2787 ));
2788 }
2789 }
2790 }
2791 KeyCode::Char('n')
2792 | KeyCode::Char('N')
2793 | KeyCode::Char('x')
2794 | KeyCode::Char('X')
2795 | KeyCode::Esc => {
2796 s.confirmation = None;
2797 }
2798 _ => {}
2799 }
2800}
2801
2802fn handle_overlay_key(
2807 state: &Arc<parking_lot::Mutex<RenderState>>,
2808 evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2809 code: KeyCode,
2810) -> bool {
2811 use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
2812
2813 let mut s = state.lock();
2814 let Some(overlay) = s.overlay.as_mut() else {
2815 return false;
2816 };
2817 if let Some(secure) = overlay.secure_input.as_mut() {
2820 match code {
2821 KeyCode::Backspace => {
2822 let (v, c) = backspace_secure_input(&secure.value, secure.cursor);
2823 secure.value = v;
2824 secure.cursor = c;
2825 }
2826 KeyCode::Left => {
2827 if secure.cursor > 0 {
2828 let mut p = secure.cursor - 1;
2829 while !secure.value.is_char_boundary(p) {
2830 p -= 1;
2831 }
2832 secure.cursor = p;
2833 }
2834 }
2835 KeyCode::Right => {
2836 if secure.cursor < secure.value.len() {
2837 let mut n = secure.cursor + 1;
2838 while n < secure.value.len() && !secure.value.is_char_boundary(n) {
2839 n += 1;
2840 }
2841 secure.cursor = n;
2842 }
2843 }
2844 KeyCode::Enter => {
2845 let submission = OverlaySubmission::SecureInput(secure.value.clone());
2846 drop(s);
2847 state.lock().overlay = None;
2848 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
2849 }
2850 KeyCode::Esc => {
2851 drop(s);
2852 state.lock().overlay = None;
2853 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2854 }
2855 KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
2856 let (v, n) = insert_char_into_secure_input(&secure.value, secure.cursor, ch);
2857 secure.value = v;
2858 secure.cursor = n;
2859 }
2860 _ => {} }
2862 return true;
2863 }
2864
2865 match code {
2866 KeyCode::Esc => {
2867 drop(s);
2869 state.lock().overlay = None;
2870 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2871 }
2872 KeyCode::Enter => {
2873 let submission = if let Some(item) = overlay.items.get(overlay.selected) {
2876 match item.selection.clone() {
2877 Some(sel) => sel,
2878 None => {
2879 return true;
2885 }
2886 }
2887 } else {
2888 drop(s);
2889 state.lock().overlay = None;
2890 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2891 return true;
2892 };
2893 let title = overlay.title.clone();
2894 let selected = overlay.selected;
2895 drop(s);
2896 state.lock().overlay = None;
2897 tracing::debug!(
2898 overlay = %title,
2899 selected,
2900 "overlay submitted"
2901 );
2902 let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
2903 OverlaySubmission::Selection(submission),
2904 )));
2905 }
2906 KeyCode::Up => {
2907 let len = overlay_filtered_indices(overlay).len();
2908 if len == 0 {
2909 return true;
2910 }
2911 let pos = overlay_filtered_indices(overlay)
2912 .iter()
2913 .position(|&i| i == overlay.selected)
2914 .unwrap_or(0);
2915 let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
2916 overlay.selected = overlay_filtered_indices(overlay)[new_pos];
2917 }
2918 KeyCode::Down => {
2919 let filtered = overlay_filtered_indices(overlay);
2920 let len = filtered.len();
2921 if len == 0 {
2922 return true;
2923 }
2924 let pos = filtered
2925 .iter()
2926 .position(|&i| i == overlay.selected)
2927 .unwrap_or(0);
2928 let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
2929 overlay.selected = filtered[new_pos];
2930 }
2931 KeyCode::Backspace => {
2932 if let Some(search) = overlay.search.as_mut() {
2933 search.value.pop();
2934 overlay.selected = 0;
2935 }
2936 }
2937 KeyCode::Char(ch) => {
2938 if let Some(search) = overlay.search.as_mut() {
2939 search.value.push(ch);
2940 overlay.selected = 0;
2941 }
2942 }
2943 _ => {
2944 }
2946 }
2947 true
2948}
2949
2950fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
2955 let needle = overlay
2956 .search
2957 .as_ref()
2958 .map(|s| s.value.to_lowercase())
2959 .unwrap_or_default();
2960 if needle.is_empty() {
2961 return (0..overlay.items.len()).collect();
2962 }
2963 overlay
2964 .items
2965 .iter()
2966 .enumerate()
2967 .filter_map(|(idx, item)| {
2968 let title_hit = item.title.to_lowercase().contains(&needle);
2969 let sv_hit = item
2970 .search_value
2971 .as_deref()
2972 .map(|v| v.to_lowercase().contains(&needle))
2973 .unwrap_or(false);
2974 if title_hit || sv_hit { Some(idx) } else { None }
2975 })
2976 .collect()
2977}
2978
2979fn handle_file_search_key(
2985 state: &Arc<parking_lot::Mutex<RenderState>>,
2986 _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2987 code: KeyCode,
2988) -> bool {
2989 match code {
2990 KeyCode::Up => {
2991 let mut s = state.lock();
2992 if let Some(fs) = s.file_search.as_mut() {
2993 fs.up();
2994 true
2995 } else {
2996 false
2997 }
2998 }
2999 KeyCode::Down => {
3000 let mut s = state.lock();
3001 if let Some(fs) = s.file_search.as_mut() {
3002 fs.down();
3003 true
3004 } else {
3005 false
3006 }
3007 }
3008 KeyCode::Tab | KeyCode::Enter => {
3009 let mut s = state.lock();
3010 if s.file_search
3011 .as_ref()
3012 .and_then(|fs| fs.selected_result())
3013 .is_some()
3014 {
3015 accept_file_search(&mut s, false);
3016 true
3017 } else {
3018 s.file_search = None;
3020 false
3021 }
3022 }
3023 KeyCode::Esc => {
3024 let mut s = state.lock();
3025 s.file_search = None;
3026 true
3027 }
3028 _ => false,
3029 }
3030}
3031
3032fn spawn_agent_worker(
3038 session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
3039) -> tokio::sync::mpsc::UnboundedSender<String> {
3040 let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
3041
3042 std::thread::spawn(move || {
3043 let runtime = match tokio::runtime::Builder::new_current_thread()
3044 .enable_all()
3045 .build()
3046 {
3047 Ok(rt) => rt,
3048 Err(err) => {
3049 tracing::error!(?err, "failed to build agent worker runtime");
3050 return;
3051 }
3052 };
3053
3054 runtime.block_on(async move {
3055 let local = tokio::task::LocalSet::new();
3056 local
3057 .run_until(async move {
3058 while let Some(prompt) = prompt_rx.recv().await {
3059 run_one_prompt(&session_swapper.current(), prompt).await;
3060 }
3061 })
3062 .await;
3063 });
3064 });
3065
3066 prompt_tx
3067}
3068
3069async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
3070 let session_for_forward = session.clone();
3071 let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
3072
3073 let forwarder = std::thread::spawn(move || {
3077 while let Ok(event) = event_rx.recv() {
3078 session_for_forward.forward_event_to_extensions(&event);
3079 }
3080 });
3081
3082 use std::sync::atomic::Ordering;
3086 session.reset_should_stop();
3087 let streaming = session.streaming_flag();
3088 streaming.store(true, Ordering::SeqCst);
3089 let _stream_guard = StreamingGuard(&streaming);
3090
3091 let agent = session.agent_ref();
3092 let local = tokio::task::LocalSet::new();
3093 let result = local
3094 .run_until(agent.run_with_channel(prompt, event_tx))
3095 .await;
3096
3097 let _ = forwarder.join();
3100 if let Err(err) = result {
3101 tracing::warn!(?err, "agent run failed");
3102 }
3103}
3104
3105fn build_header_context(
3114 app: &App,
3115 cwd: &std::path::Path,
3116 git_branch: Option<&str>,
3117) -> InlineHeaderContext {
3118 let workspace_name = cwd
3119 .file_name()
3120 .map(|n| n.to_string_lossy().into_owned())
3121 .unwrap_or_else(|| "oxicode".to_string());
3122 let model_id = app.model_id();
3123 let provider = model_id
3124 .split_once('/')
3125 .map(|(p, _)| p.to_string())
3126 .unwrap_or_else(|| "Provider".to_string());
3127 let branch = git_branch.unwrap_or("\u{2014}").to_string();
3128 let mut ctx = InlineHeaderContext::default();
3129 ctx.app_name = "oxicode".to_string();
3130 ctx.provider = provider;
3131 ctx.model = model_id.clone();
3132 ctx.git = format!("git: {workspace_name}@{branch}");
3133 ctx.tools = "Tools: ready".to_string();
3134 ctx.search_tools = Some(InlineHeaderStatusBadge {
3135 text: workspace_name,
3136 tone: InlineHeaderStatusTone::Ready,
3137 });
3138 ctx.persistent_memory = Some(InlineHeaderStatusBadge {
3139 text: branch,
3140 tone: InlineHeaderStatusTone::Ready,
3141 });
3142 ctx.editor_context = Some(model_id);
3143 ctx
3144}
3145
3146async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
3149 use crate::app::agent_session_runtime::{
3150 CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
3151 create_agent_session_from_services, create_agent_session_services,
3152 };
3153 use crate::store::session::SessionManager;
3154
3155 let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
3156 let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
3157 let services = create_agent_session_services(
3158 CreateAgentSessionServicesOptions::new(cwd.clone()),
3159 Some(hook_runner),
3160 )?;
3161 let services = Arc::new(services);
3162
3163 let model_id = app.model_id();
3164 let tools = app.agent_tools();
3165
3166 let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
3167
3168 let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
3169 services,
3170 session_manager,
3171 model_id: if model_id.is_empty() {
3172 None
3173 } else {
3174 Some(model_id)
3175 },
3176 thinking_level: None,
3177 scoped_models: Vec::new(),
3178 tool_registry: Some(tools),
3179 session_state: Some(app.session_state().clone()),
3182 })
3183 .await?;
3184
3185 if let Some(msg) = result.model_fallback_message {
3186 tracing::warn!(message = %msg, "agent session model fallback");
3187 }
3188 Ok(result.session)
3189}
3190
3191fn cheatsheet_lines() -> Vec<String> {
3197 vec![
3198 "".into(),
3199 " Navigation".into(),
3200 " j / ↓ Scroll down".into(),
3201 " k / ↑ Scroll up".into(),
3202 " J (Shift+j) Next turn".into(),
3203 " K (Shift+k) Previous turn".into(),
3204 " PgDn / PgUp Page scroll".into(),
3205 " g / G Top / bottom".into(),
3206 "".into(),
3207 " Blocks".into(),
3208 " e Cycle block (collapse/truncate/expand)".into(),
3209 " E Expand all blocks".into(),
3210 " Ctrl+E Collapse all blocks".into(),
3211 "".into(),
3212 " Search".into(),
3213 " /find <q> Search transcript".into(),
3214 " n / N Next / previous match".into(),
3215 "".into(),
3216 " Commands".into(),
3217 " /theme Cycle color theme".into(),
3218 " /model Pick a model".into(),
3219 " /vim Toggle vim mode".into(),
3220 " /compact Compact context".into(),
3221 " /clear Clear conversation".into(),
3222 " Ctrl+C Cancel run (then y to quit)".into(),
3223 " Ctrl+Enter Send now (abort + submit)".into(),
3224 " Ctrl+M Toggle multiline input".into(),
3225 " Shift+Tab Toggle Auto mode (no questions, runs to end)".into(),
3226 " Ctrl+; Toggle queue panel".into(),
3227 "".into(),
3228 " Special Input".into(),
3229 " @ File picker (fuzzy search)".into(),
3230 " @! Toggle hidden files in picker".into(),
3231 " ! Shell mode (bash command)".into(),
3232 ]
3233}
3234
3235fn build_command_palette() -> OverlayState {
3238 use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
3239
3240 let catalog = SlashRegistry::builtin_commands();
3241 let mut items: Vec<InlineListItem> = catalog
3242 .iter()
3243 .map(|(name, desc, aliases)| {
3244 let title = if aliases.is_empty() {
3245 format!("/{name}")
3246 } else {
3247 format!(
3248 "/{name} ({})",
3249 aliases
3250 .iter()
3251 .map(|a| format!("/{a}"))
3252 .collect::<Vec<_>>()
3253 .join(", ")
3254 )
3255 };
3256 InlineListItem {
3257 title,
3258 subtitle: Some(desc.to_string()),
3259 badge: None,
3260 indent: 0,
3261 selection: Some(InlineListSelection::SlashCommand(name.to_string())),
3262 search_value: Some(format!("{name} {desc}")),
3263 }
3264 })
3265 .collect();
3266 items.sort_by(|a, b| a.title.cmp(&b.title));
3267
3268 OverlayState {
3269 title: "Command Palette".into(),
3270 lines: vec!["Type to filter, Enter to select".into()],
3271 items: items
3272 .into_iter()
3273 .map(|item| OverlayListItem {
3274 title: item.title,
3275 subtitle: item.subtitle,
3276 badge: item.badge,
3277 indent: item.indent,
3278 search_value: item.search_value,
3279 selection: item.selection,
3280 })
3281 .collect(),
3282 selected: 0,
3283 search: Some(OverlaySearchState {
3284 label: "search".into(),
3285 placeholder: Some("filter commands\u{2026}".into()),
3286 value: String::new(),
3287 }),
3288 secure_input: None,
3289 }
3290}
3291
3292static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3294static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
3296const TITLE_SPINNER: &[&str] = &[
3298 "\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
3299];
3300
3301fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
3304 let phase =
3305 (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
3306 let s = phase.sin();
3307 s * s
3308}
3309
3310fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
3312 match (base, target) {
3313 (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
3314 let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
3315 let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
3316 let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
3317 Color::Rgb(r, g, b)
3318 }
3319 _ => base,
3320 }
3321}
3322
3323fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
3325 match kind {
3326 InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
3327 InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
3328 InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
3329 InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
3330 InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
3331 InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
3332 InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
3333 InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
3334 }
3335}
3336
3337fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
3343 let area = frame.area();
3344 let bg = active_styles().background;
3349 frame
3350 .buffer_mut()
3351 .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
3352 let layout = super::frame_layout::render_chrome(frame, area, state);
3353 let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3354 {
3356 let running = state.reasoning_stage.is_some();
3357 let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
3358 if running || was_running {
3359 let title = if running {
3360 let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
3361 let model = state
3362 .header_context
3363 .editor_context
3364 .as_deref()
3365 .unwrap_or("oxicode");
3366 format!("{spin} oxicode \u{2014} {model}")
3367 } else {
3368 "oxicode".to_string()
3369 };
3370 use std::io::Write;
3371 let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
3372 let _ = std::io::stderr().flush();
3373 }
3374 }
3375 render_transcript(frame, layout.scrollback, state, tick);
3376 if !state.queued_inputs.is_empty() {
3377 render_queue_pane(frame, layout.scrollback, state);
3378 }
3379 if !state.todo_items.is_empty() {
3380 render_todo_pane(frame, layout.scrollback, &state.todo_items);
3381 }
3382 if !state.follow_ups.is_empty() {
3383 render_follow_ups(frame, layout.prompt, &state.follow_ups);
3384 }
3385 if let Some(stage) = &state.reasoning_stage {
3386 render_reasoning_indicator(frame, layout.prompt, stage);
3387 }
3388 render_composer(frame, layout.prompt, state);
3389 let occluded = state.overlay.is_some() || state.confirmation.is_some();
3391 if let Some(tip) = &state.tip
3392 && tip_is_visible(tip, tick)
3393 && !(tip.ambient && occluded)
3394 {
3395 render_tip(frame, layout.prompt, &tip.text);
3396 }
3397 if state.slash_popup.open {
3398 render_slash_popup(frame, layout.prompt, state);
3399 }
3400 if state.file_search.is_some() {
3401 render_file_search_dropdown(frame, layout.prompt, state);
3402 }
3403 if state.agent_hub_open {
3404 render_agent_hub(frame, area, state);
3405 }
3406 if let Some(overlay) = &state.overlay {
3407 render_overlay(frame, area, overlay);
3408 }
3409 if let Some(confirm) = &state.confirmation {
3410 render_confirmation(frame, area, confirm);
3411 }
3412}
3413
3414fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
3416 let styles = active_styles();
3417 let accent = color_from_anstyle(styles.error.get_fg_color());
3418 let inner_w = confirm
3419 .title
3420 .chars()
3421 .count()
3422 .max(confirm.message.chars().count())
3423 .max(36) as u16;
3424 let width = inner_w + 4;
3425 let height = 5;
3426 let x = area.x + area.width.saturating_sub(width) / 2;
3427 let y = area.y + area.height.saturating_sub(height) / 2;
3428 let popup_area = Rect {
3429 x,
3430 y,
3431 width,
3432 height,
3433 };
3434 let block = Block::default()
3435 .borders(Borders::ALL)
3436 .border_type(BorderType::Rounded)
3437 .title(Span::styled(
3438 format!(" {} ", confirm.title),
3439 Style::default().fg(accent).bold(),
3440 ))
3441 .border_style(Style::default().fg(accent));
3442 let msg = Line::styled(
3443 confirm.message.clone(),
3444 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
3445 );
3446 frame.render_widget(
3447 Paragraph::new(vec![Line::default(), msg]).block(block),
3448 popup_area,
3449 );
3450}
3451
3452fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3456 let rows = state.hub_entries.len() as u16;
3457 let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
3458 let width = area.width.clamp(30, 80);
3459 let rect = Rect {
3460 x: area.x + (area.width.saturating_sub(width)) / 2,
3461 y: area.y + (area.height.saturating_sub(height)) / 2,
3462 width,
3463 height,
3464 };
3465 frame.render_widget(Clear, rect);
3466
3467 let title = Line::from(Span::styled(
3468 " Agent Hub ",
3469 Style::default().add_modifier(Modifier::BOLD),
3470 ));
3471 let block = Block::default().borders(Borders::ALL).title(title);
3472
3473 let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
3474 vec![ListItem::new(Line::from(Span::raw(
3475 "No agents registered.",
3476 )))]
3477 } else {
3478 state
3479 .hub_entries
3480 .iter()
3481 .map(|(id, e)| {
3482 ListItem::new(Line::from(vec![
3483 Span::raw(format!("{:?} ", e.kind)),
3484 Span::raw(e.display_name.clone()),
3485 Span::raw(format!(" — {:?} ({})", e.status, id)),
3486 ]))
3487 })
3488 .collect()
3489 };
3490 frame.render_widget(List::new(items).block(block), rect);
3491}
3492
3493fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
3498 let styles = active_styles();
3499 if let Some(secure) = &overlay.secure_input {
3502 let lines_count = overlay.lines.len();
3504 let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
3506 let width = area.width.clamp(30, 80);
3507 let rect = Rect {
3508 x: area.x + (area.width.saturating_sub(width)) / 2,
3509 y: area.y + (area.height.saturating_sub(height)) / 2,
3510 width,
3511 height,
3512 };
3513 frame.render_widget(Clear, rect);
3514
3515 let title = Line::from(Span::styled(
3516 format!(" {} ", overlay.title),
3517 Style::default()
3518 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3519 .add_modifier(Modifier::BOLD),
3520 ));
3521 let block = Block::default()
3522 .borders(Borders::ALL)
3523 .border_type(BorderType::Rounded)
3524 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3525 .title(title);
3526 let inner = block.inner(rect);
3527 frame.render_widget(&block, rect);
3528
3529 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3530 let fg = color_from_anstyle(Some(styles.foreground));
3531
3532 let mut row = inner.top();
3533 for line_text in &overlay.lines {
3534 let row_area = Rect {
3535 x: inner.left(),
3536 y: row,
3537 width: inner.width,
3538 height: 1,
3539 };
3540 let line = Line::from(Span::styled(
3541 line_text.clone(),
3542 Style::default().fg(secondary),
3543 ));
3544 frame.render_widget(Paragraph::new(line), row_area);
3545 row = row.saturating_add(1);
3546 }
3547
3548 let label = &secure.config.label;
3551 let display: String = if secure.value.is_empty() {
3552 secure
3553 .config
3554 .placeholder
3555 .clone()
3556 .unwrap_or_else(|| "(empty)".to_string())
3557 } else if secure.config.mask_input {
3558 "*".repeat(secure.value.chars().count())
3562 } else {
3563 secure.value.clone()
3564 };
3565 let row_area = Rect {
3566 x: inner.left(),
3567 y: row,
3568 width: inner.width,
3569 height: 1,
3570 };
3571 let line = Line::from(vec![
3572 Span::styled(format!("{label}: "), Style::default().fg(secondary)),
3573 Span::styled(display, Style::default().fg(fg)),
3574 ]);
3575 frame.render_widget(Paragraph::new(line), row_area);
3576 return;
3577 }
3578 let visible_max = (area.height as usize).saturating_sub(6).max(3);
3579
3580 let filtered: Vec<usize> = match &overlay.search {
3582 Some(search) if !search.value.is_empty() => {
3583 let needle = search.value.to_lowercase();
3584 overlay
3585 .items
3586 .iter()
3587 .enumerate()
3588 .filter_map(|(idx, item)| {
3589 let title_match = item.title.to_lowercase().contains(&needle);
3590 let sv_match = item
3591 .search_value
3592 .as_deref()
3593 .map(|v| v.to_lowercase().contains(&needle))
3594 .unwrap_or(false);
3595 if title_match || sv_match {
3596 Some(idx)
3597 } else {
3598 None
3599 }
3600 })
3601 .collect()
3602 }
3603 _ => (0..overlay.items.len()).collect(),
3604 };
3605
3606 let has_search = overlay.search.is_some();
3607 let lines_count = overlay.lines.len();
3608 let items_count = filtered.len().min(visible_max);
3609 let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
3610 let desired_h = height_inner.saturating_add(2); let height = desired_h.min(area.height.saturating_sub(2));
3612 let width = area.width.clamp(30, 80);
3613 let rect = Rect {
3614 x: area.x + (area.width.saturating_sub(width)) / 2,
3615 y: area.y + (area.height.saturating_sub(height)) / 2,
3616 width,
3617 height,
3618 };
3619 frame.render_widget(Clear, rect);
3620
3621 let title = Line::from(Span::styled(
3622 format!(" {} ", overlay.title),
3623 Style::default()
3624 .fg(color_from_anstyle(styles.primary.get_fg_color()))
3625 .add_modifier(Modifier::BOLD),
3626 ));
3627 let block = Block::default()
3628 .borders(Borders::ALL)
3629 .border_type(BorderType::Rounded)
3630 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3631 .title(title);
3632 let inner = block.inner(rect);
3633 frame.render_widget(&block, rect);
3634
3635 let primary = color_from_anstyle(styles.primary.get_fg_color());
3636 let fg = color_from_anstyle(Some(styles.foreground));
3637 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3638
3639 let selected_filtered_pos = filtered
3641 .iter()
3642 .position(|&idx| idx == overlay.selected)
3643 .unwrap_or(0);
3644
3645 let mut row = inner.top();
3646 if let Some(search) = &overlay.search {
3648 let prompt = format!("{}: {}", search.label, search.value);
3649 let line = Line::from(vec![
3650 Span::styled(
3651 format!("{}: ", search.label),
3652 Style::default().fg(secondary),
3653 ),
3654 Span::styled(
3655 if search.value.is_empty() {
3656 search
3657 .placeholder
3658 .clone()
3659 .unwrap_or_else(|| "type to filter\u{2026}".to_string())
3660 } else {
3661 search.value.clone()
3662 },
3663 if search.value.is_empty() {
3664 Style::default().fg(secondary).add_modifier(Modifier::DIM)
3665 } else {
3666 Style::default().fg(fg)
3667 },
3668 ),
3669 ]);
3670 let _ = prompt; let row_area = Rect {
3672 x: inner.left(),
3673 y: row,
3674 width: inner.width,
3675 height: 1,
3676 };
3677 frame.render_widget(Paragraph::new(line), row_area);
3678 row = row.saturating_add(1);
3679 }
3680
3681 for line_text in &overlay.lines {
3683 let row_area = Rect {
3684 x: inner.left(),
3685 y: row,
3686 width: inner.width,
3687 height: 1,
3688 };
3689 let line = Line::from(Span::styled(
3690 line_text.clone(),
3691 Style::default().fg(secondary),
3692 ));
3693 frame.render_widget(Paragraph::new(line), row_area);
3694 row = row.saturating_add(1);
3695 }
3696
3697 if filtered.is_empty() {
3699 let row_area = Rect {
3700 x: inner.left(),
3701 y: row,
3702 width: inner.width,
3703 height: 1,
3704 };
3705 let empty_text = if overlay.search.is_some() {
3706 " (no matches)"
3707 } else {
3708 " (no items)"
3709 };
3710 frame.render_widget(
3711 Paragraph::new(Line::from(Span::styled(
3712 empty_text,
3713 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3714 ))),
3715 row_area,
3716 );
3717 } else {
3718 for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
3719 let item = &overlay.items[item_idx];
3720 let is_selected = display_idx == selected_filtered_pos;
3721 let marker = if is_selected { "\u{25b8} " } else { " " };
3722 let indent = " ".repeat(item.indent as usize);
3723 let item_style = if is_selected {
3724 Style::default().fg(primary).add_modifier(Modifier::BOLD)
3725 } else {
3726 Style::default().fg(fg)
3727 };
3728 let mut spans = vec![
3729 Span::styled(marker, item_style),
3730 Span::styled(indent, item_style),
3731 Span::styled(item.title.clone(), item_style),
3732 ];
3733 if let Some(badge) = &item.badge {
3734 spans.push(Span::raw(" "));
3735 spans.push(Span::styled(
3736 badge.clone(),
3737 Style::default().fg(secondary).add_modifier(Modifier::DIM),
3738 ));
3739 }
3740 if let Some(subtitle) = &item.subtitle {
3741 spans.push(Span::raw(" "));
3742 spans.push(Span::styled(
3743 subtitle.clone(),
3744 Style::default().fg(secondary),
3745 ));
3746 }
3747 let line = Line::from(spans);
3748 let row_area = Rect {
3749 x: inner.left(),
3750 y: row,
3751 width: inner.width,
3752 height: 1,
3753 };
3754 frame.render_widget(Paragraph::new(line), row_area);
3755 row = row.saturating_add(1);
3756 }
3757 }
3758}
3759
3760fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
3761 if state.transcript.is_empty() {
3762 render_welcome(frame, area);
3763 return;
3764 }
3765 let styles = active_styles();
3766 let bg_color = color_from_anstyle(Some(styles.background));
3767
3768 let accent_w: u16 = 1;
3770 let scrollbar_w: u16 = 1;
3771 let content_area = Rect {
3772 x: area.x + accent_w,
3773 y: area.y,
3774 width: area.width.saturating_sub(accent_w + scrollbar_w),
3775 height: area.height,
3776 };
3777
3778 let search_set: std::collections::HashSet<usize> = state
3781 .search
3782 .as_ref()
3783 .map(|s| s.matches.iter().copied().collect())
3784 .unwrap_or_default();
3785 let current_match = state
3786 .search
3787 .as_ref()
3788 .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
3789
3790 let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
3791 Vec::with_capacity(state.transcript.len());
3792 const TRUNC_TAIL: usize = 3;
3797 let dim_style = Style::default()
3798 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3799 .add_modifier(Modifier::DIM);
3800
3801 let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
3802 for (idx, tl) in state.transcript.iter().enumerate() {
3803 if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
3804 blocks.last_mut().unwrap().1.push((idx, tl));
3805 } else {
3806 blocks.push((tl.block_id, vec![(idx, tl)]));
3807 }
3808 }
3809
3810 for (block_id, lines) in &blocks {
3811 let mode = state.block_mode(*block_id);
3812 let len = lines.len();
3813 match mode {
3814 BlockDisplayMode::Collapsed => {
3815 let &(idx, tl) = &lines[0];
3816 let is_match = search_set.contains(&idx);
3817 let line =
3818 transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
3819 display.push((idx, tl.kind, line));
3820 }
3821 BlockDisplayMode::Expanded => {
3822 for &(idx, tl) in lines {
3823 let is_match = search_set.contains(&idx);
3824 let line = transcript_line_marked(
3825 tl,
3826 &styles,
3827 false,
3828 is_match,
3829 current_match == Some(idx),
3830 );
3831 display.push((idx, tl.kind, line));
3832 }
3833 }
3834 BlockDisplayMode::Truncated => {
3835 if len <= TRUNC_TAIL + 1 {
3836 for &(idx, tl) in lines {
3838 let is_match = search_set.contains(&idx);
3839 let line = transcript_line_marked(
3840 tl,
3841 &styles,
3842 false,
3843 is_match,
3844 current_match == Some(idx),
3845 );
3846 display.push((idx, tl.kind, line));
3847 }
3848 } else {
3849 let &(hidx, htl) = &lines[0];
3851 let is_match = search_set.contains(&hidx);
3852 let line = transcript_line_marked(
3853 htl,
3854 &styles,
3855 false,
3856 is_match,
3857 current_match == Some(hidx),
3858 );
3859 display.push((hidx, htl.kind, line));
3860 let hidden = len - 1 - TRUNC_TAIL;
3862 let gap = Line::styled(format!(" \u{2026} +{hidden} lines"), dim_style);
3863 display.push((hidx, htl.kind, gap));
3864 for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
3866 let is_match = search_set.contains(&idx);
3867 let line = transcript_line_marked(
3868 tl,
3869 &styles,
3870 false,
3871 is_match,
3872 current_match == Some(idx),
3873 );
3874 display.push((idx, tl.kind, line));
3875 }
3876 }
3877 }
3878 }
3879 }
3880
3881 let total = display.len();
3883 let raw_start = if state.scroll_offset == usize::MAX {
3884 total.saturating_sub(content_area.height as usize)
3885 } else {
3886 display
3887 .iter()
3888 .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
3889 .unwrap_or(total.saturating_sub(1))
3890 };
3891 let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
3892
3893 let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
3897 let bid = state.transcript.get(*orig_idx)?.block_id;
3898 let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
3899 (first_idx != *orig_idx).then_some(first_idx)
3900 });
3901 let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
3902 let body_top = content_area.top() + sticky_h;
3903
3904 let running = state.reasoning_stage.is_some();
3906 const WAVE_ROWS: u16 = 32;
3907 const WAVE_SPEED: f64 = 0.15;
3908
3909 const FADE_ROWS: usize = 5;
3914 let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
3915 let sticky_bid = state.transcript[sidx].block_id;
3916 let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
3919 state
3920 .transcript
3921 .get(*orig_idx)
3922 .map(|l| l.block_id != sticky_bid)
3923 .unwrap_or(false)
3924 });
3925 match next_offset {
3926 Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
3927 _ => 1.0,
3928 }
3929 } else {
3930 1.0
3931 };
3932
3933 if let Some(sidx) = sticky_first {
3936 let tl = &state.transcript[sidx];
3937 let accent_base = accent_color_for_kind(tl.kind, &styles);
3938 let rail_blend = 0.7 * sticky_opacity;
3939 let bg_blend = 0.1 * sticky_opacity;
3940 if sticky_opacity > 0.05
3941 && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
3942 {
3943 cell.set_char('\u{2503}');
3944 cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
3945 }
3946 let line = transcript_line_marked(tl, &styles, false, false, false);
3947 let row = Rect {
3948 x: content_area.x,
3949 y: content_area.top(),
3950 width: content_area.width,
3951 height: 1,
3952 };
3953 if bg_blend > 0.01 {
3954 frame.buffer_mut().set_style(
3955 row,
3956 Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
3957 );
3958 }
3959 frame.render_widget(Paragraph::new(line), row);
3960 }
3961
3962 let mut y = body_top;
3964 let width = content_area.width.max(1) as usize;
3965 let mut visual_row: u16 = 0;
3966 for (_, kind, line) in display.into_iter().skip(start) {
3967 if y >= content_area.bottom() {
3968 break;
3969 }
3970 let text_w = line.width();
3971 let wrapped_h = if text_w == 0 {
3972 1
3973 } else {
3974 text_w.div_ceil(width).max(1) as u16
3975 };
3976
3977 let accent_base = accent_color_for_kind(kind, &styles);
3979 for row_offset in 0..wrapped_h {
3980 let paint_y = y + row_offset;
3981 if paint_y >= content_area.bottom() {
3982 break;
3983 }
3984 let brightness = if running {
3985 0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
3986 } else {
3987 0.7
3988 };
3989 let rail_color = blend_rgb(bg_color, accent_base, brightness);
3990 if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
3991 cell.set_char('\u{2503}'); cell.set_style(Style::default().fg(rail_color));
3993 }
3994 }
3995
3996 let row = Rect {
3997 x: content_area.x,
3998 y,
3999 width: content_area.width,
4000 height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
4001 };
4002 frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
4003 y += wrapped_h;
4004 visual_row += wrapped_h;
4005 }
4006
4007 let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
4010 if total > body_viewport {
4011 let follow = state.scroll_offset == usize::MAX;
4012 render_scrollbar(
4013 frame,
4014 area.right().saturating_sub(1),
4015 area.top(),
4016 area.height,
4017 total,
4018 body_viewport,
4019 start,
4020 follow,
4021 &styles,
4022 bg_color,
4023 );
4024 }
4025}
4026
4027#[allow(clippy::too_many_arguments)]
4032fn render_scrollbar(
4033 frame: &mut Frame,
4034 x: u16,
4035 top: u16,
4036 height: u16,
4037 total: usize,
4038 viewport: usize,
4039 start: usize,
4040 follow: bool,
4041 styles: &ThemeStyles,
4042 bg: Color,
4043) {
4044 if height == 0 {
4045 return;
4046 }
4047 let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
4048 let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
4049 .max(1)
4050 .min(height);
4051 let track_h = height.saturating_sub(thumb_h);
4052 let thumb_y = (ratio * track_h as f64).round() as u16;
4053
4054 let accent = color_from_anstyle(styles.primary.get_fg_color());
4055 let thumb_color = if follow {
4057 blend_rgb(bg, accent, 0.35)
4058 } else {
4059 accent
4060 };
4061 let rail_color = blend_rgb(bg, accent, 0.1);
4062
4063 for row in 0..height {
4064 let y = top + row;
4065 let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
4066 let (ch, color) = if is_thumb {
4067 ('\u{2588}', thumb_color) } else {
4069 ('\u{2502}', rail_color) };
4071 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
4072 cell.set_char(ch);
4073 cell.set_style(Style::default().fg(color));
4074 }
4075 }
4076}
4077
4078fn transcript_line_marked<'a>(
4081 line: &'a TranscriptLine,
4082 styles: &'a ThemeStyles,
4083 folded: bool,
4084 is_match: bool,
4085 is_current: bool,
4086) -> Line<'a> {
4087 let (kind_style, marker) = match line.kind {
4088 InlineMessageKind::Agent => (
4089 Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
4090 "\u{25cf}", ),
4092 InlineMessageKind::User => (
4093 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4094 "\u{276f}", ),
4096 InlineMessageKind::Tool => (
4097 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4098 "\u{2699}", ),
4100 InlineMessageKind::Error => (
4101 Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
4102 "\u{2717}", ),
4104 InlineMessageKind::Warning => (
4105 Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
4106 "\u{26a0}", ),
4108 InlineMessageKind::Info => (
4109 Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
4110 "\u{2139}", ),
4112 InlineMessageKind::Policy => (
4113 Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
4114 "\u{25c6}", ),
4116 InlineMessageKind::Pty => (
4117 Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
4118 "\u{258c}", ),
4120 };
4121
4122 let prefix = if folded {
4124 format!("\u{25b8} {} ", marker) } else {
4126 format!("{} ", marker)
4127 };
4128
4129 let highlight = if is_current {
4131 Some(Style::default().reversed())
4132 } else if is_match {
4133 Some(Style::default().add_modifier(Modifier::UNDERLINED))
4134 } else {
4135 None
4136 };
4137
4138 let mut spans = Vec::with_capacity(line.segments.len() + 1);
4139 spans.push(Span::styled(prefix, kind_style));
4140 for segment in &line.segments {
4141 let mut style = segment_style(segment, kind_style, styles);
4142 if let Some(h) = highlight {
4143 style = style.patch(h);
4144 }
4145 spans.push(Span::styled(segment.text.clone(), style));
4146 }
4147 Line::from(spans)
4148}
4149
4150fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
4151 let mut style = fallback;
4152 let inline = segment.style.as_ref();
4153 if let Some(color) = inline.color {
4154 style = style.fg(color_from_anstyle(Some(color)));
4155 } else {
4156 style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
4160 }
4161 if inline.effects.contains(anstyle::Effects::BOLD) {
4162 style = style.add_modifier(Modifier::BOLD);
4163 }
4164 if inline.effects.contains(anstyle::Effects::ITALIC) {
4165 style = style.add_modifier(Modifier::ITALIC);
4166 }
4167 if inline.effects.contains(anstyle::Effects::UNDERLINE) {
4168 style = style.add_modifier(Modifier::UNDERLINED);
4169 }
4170 if inline.effects.contains(anstyle::Effects::DIMMED) {
4171 style = style.add_modifier(Modifier::DIM);
4172 }
4173 style
4174}
4175
4176fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
4177 let styles = active_styles();
4178 let prefix_style = Style::default()
4179 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4180 .bold();
4181 let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
4182
4183 let prefix = state.prompt_prefix.clone();
4184 let body = state.input_buffer.clone();
4185 let placeholder = state.placeholder.clone();
4186
4187 let mut line_spans = Vec::new();
4188 if let Some(label) = state.vim_state.status_label() {
4189 line_spans.push(Span::styled(
4190 format!("[{label}] "),
4191 Style::default()
4192 .fg(color_from_anstyle(styles.tool.get_fg_color()))
4193 .add_modifier(Modifier::BOLD),
4194 ));
4195 }
4196 if state.autonomy_mode.is_auto() {
4197 line_spans.push(Span::styled(
4198 "[auto] ",
4199 Style::default()
4200 .fg(Color::Yellow)
4201 .add_modifier(Modifier::BOLD),
4202 ));
4203 }
4204 line_spans.push(Span::styled(prefix, prefix_style));
4205 if state.shell_mode {
4206 line_spans.push(Span::styled(
4207 "! ",
4208 Style::default()
4209 .fg(Color::Yellow)
4210 .add_modifier(Modifier::BOLD),
4211 ));
4212 }
4213 if body.is_empty()
4214 && let Some(ph) = placeholder
4215 {
4216 line_spans.push(Span::styled(
4217 ph,
4218 Style::default()
4219 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4220 .dim(),
4221 ));
4222 } else {
4223 line_spans.push(Span::styled(body, text_style));
4224 }
4225 let block = Block::default()
4226 .borders(Borders::ALL)
4227 .border_type(BorderType::Rounded)
4228 .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
4229 let paragraph = Paragraph::new(Line::from(line_spans))
4230 .block(block)
4231 .wrap(Wrap { trim: false });
4232 frame.render_widget(paragraph, area);
4233
4234 if state.input_enabled {
4237 let vim_off = state
4238 .vim_state
4239 .status_label()
4240 .map(|l| format!("[{l}] ").chars().count() as u16)
4241 .unwrap_or(0);
4242 let shell_off = if state.shell_mode { 2 } else { 0 };
4243 let mode_off = if state.autonomy_mode.is_auto() { 7 } else { 0 };
4244 let cursor_x = area.left()
4245 + 1
4246 + vim_off
4247 + mode_off
4248 + shell_off
4249 + state.prompt_prefix.chars().count() as u16
4250 + state.input_cursor as u16;
4251 let cursor_y = area.top() + 1;
4252 frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
4253 }
4254}
4255
4256fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
4259 let styles = active_styles();
4260 let primary = color_from_anstyle(styles.primary.get_fg_color());
4261 let fg = color_from_anstyle(Some(styles.foreground));
4262 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4263
4264 if area.width >= 90 {
4267 use oxicode_vtui::design::layout::WelcomeLayout;
4268 let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
4269 let logo_area = if layout.has_hero_box() {
4270 layout.hero_logo
4271 } else {
4272 layout.logo
4273 };
4274 if logo_area.height > 0 {
4275 frame.render_widget(
4276 Paragraph::new(Line::from(Span::styled(
4277 "\u{25cf} oxicode",
4278 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4279 )))
4280 .alignment(Alignment::Center),
4281 logo_area,
4282 );
4283 }
4284 if layout.tip.height > 0 {
4285 frame.render_widget(
4286 Paragraph::new(Line::from(Span::styled(
4287 "Type a message to begin, or press / for commands.",
4288 Style::default().fg(fg),
4289 )))
4290 .alignment(Alignment::Center),
4291 layout.tip,
4292 );
4293 }
4294 if layout.version.height > 0 {
4295 frame.render_widget(
4296 Paragraph::new(Line::from(Span::styled(
4297 format!("v{}", env!("CARGO_PKG_VERSION")),
4298 Style::default().fg(secondary).add_modifier(Modifier::DIM),
4299 )))
4300 .alignment(Alignment::Center),
4301 layout.version,
4302 );
4303 }
4304 return;
4305 }
4306
4307 let version = env!("CARGO_PKG_VERSION");
4309 let text = vec![
4310 Line::from(""),
4311 Line::from(""),
4312 Line::from(Span::styled(
4313 "\u{25cf} oxicode",
4314 Style::default().fg(primary).add_modifier(Modifier::BOLD),
4315 )),
4316 Line::from(""),
4317 Line::from(Span::styled(
4318 "Type a message to begin, or press / for commands.",
4319 Style::default().fg(fg),
4320 )),
4321 Line::from(Span::styled(
4322 format!("v{version} \u{2014} /help for commands"),
4323 Style::default().fg(secondary),
4324 )),
4325 ];
4326 frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
4327}
4328
4329fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
4331 let styles = active_styles();
4332 let indicator_area = Rect {
4333 x: composer_area.x,
4334 y: composer_area.top().saturating_sub(1),
4335 width: composer_area.width,
4336 height: 1,
4337 };
4338 let spinner = "\u{25cc}"; let line = Line::from(vec![
4340 Span::styled(
4341 format!("{spinner} "),
4342 Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4343 ),
4344 Span::styled(
4345 stage.to_string(),
4346 Style::default()
4347 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4348 .add_modifier(Modifier::DIM),
4349 ),
4350 ]);
4351 frame.render_widget(Paragraph::new(line), indicator_area);
4352}
4353
4354fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
4356 let styles = active_styles();
4357 let entries = &state.queued_inputs;
4358 let interactive = state.queue_panel_open;
4359 let selected = state.queue_selected.min(entries.len().saturating_sub(1));
4360 let height = entries.len() as u16 + 1;
4361 let area = Rect {
4362 x: scrollback.x,
4363 y: scrollback.y,
4364 width: scrollback.width,
4365 height,
4366 };
4367 let info = color_from_anstyle(styles.info.get_fg_color());
4368 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4369 let primary = color_from_anstyle(styles.primary.get_fg_color());
4370 let items: Vec<Line<'_>> = entries
4371 .iter()
4372 .enumerate()
4373 .map(|(i, e)| {
4374 let prefix = if interactive {
4375 format!("#{} ", i + 1)
4376 } else {
4377 "\u{2261} ".to_string()
4378 };
4379 let prefix_style = if interactive && i == selected {
4380 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4381 } else {
4382 Style::default().fg(info)
4383 };
4384 let text_style = if interactive && i == selected {
4385 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4386 } else {
4387 Style::default().fg(secondary)
4388 };
4389 let marker = if interactive && i == selected {
4390 "\u{25b8} " } else {
4392 " "
4393 };
4394 Line::from(vec![
4395 Span::styled(prefix, prefix_style),
4396 Span::styled(marker, prefix_style),
4397 Span::styled(e.clone(), text_style),
4398 ])
4399 })
4400 .collect();
4401 frame.render_widget(
4402 Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
4403 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4404 )),
4405 area,
4406 );
4407}
4408
4409fn flatten_todo_items(
4411 phases: &[oxicode_agent::tools::todo::TodoPhase],
4412) -> Vec<(String, TodoStatus)> {
4413 phases
4414 .iter()
4415 .flat_map(|p| p.tasks.iter().map(|t| (t.content.clone(), t.status)))
4416 .collect()
4417}
4418
4419fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, TodoStatus)]) {
4421 let styles = active_styles();
4422 let height = items.len() as u16 + 1;
4423 let area = Rect {
4424 x: scrollback.x,
4425 y: scrollback.y,
4426 width: scrollback.width,
4427 height,
4428 };
4429 let lines: Vec<Line<'_>> = items
4430 .iter()
4431 .map(|(text, status)| {
4432 let (marker, color) = match status {
4436 TodoStatus::Completed => ("\u{2611}", Some(styles.foreground)), TodoStatus::InProgress => ("\u{25B6}", styles.primary.get_fg_color()), TodoStatus::Blocked => ("\u{23F8}", styles.info.get_fg_color()), TodoStatus::Abandoned => ("\u{2717}", styles.error.get_fg_color()), TodoStatus::Pending => ("\u{2610}", styles.secondary.get_fg_color()), };
4442 let text_style = if *status == TodoStatus::Completed {
4443 Style::default()
4444 .fg(color_from_anstyle(Some(styles.foreground)))
4445 .add_modifier(Modifier::CROSSED_OUT)
4446 } else {
4447 Style::default().fg(color_from_anstyle(Some(styles.foreground)))
4448 };
4449 Line::from(vec![
4450 Span::styled(
4451 format!("{marker} "),
4452 Style::default().fg(color_from_anstyle(color)),
4453 ),
4454 Span::styled(text.clone(), text_style),
4455 ])
4456 })
4457 .collect();
4458 frame.render_widget(Paragraph::new(lines), area);
4459}
4460
4461fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
4463 let styles = active_styles();
4464 let area = Rect {
4465 x: composer_area.x,
4466 y: composer_area.top().saturating_sub(1),
4467 width: composer_area.width,
4468 height: 1,
4469 };
4470 let mut spans = vec![Span::styled(
4471 "Suggestions: ",
4472 Style::default()
4473 .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4474 .add_modifier(Modifier::DIM),
4475 )];
4476 for (i, chip) in chips.iter().enumerate() {
4477 if i > 0 {
4478 spans.push(Span::raw(" "));
4479 }
4480 spans.push(Span::styled(
4481 format!("\u{25b8} {chip}"),
4482 Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4483 ));
4484 }
4485 frame.render_widget(Paragraph::new(Line::from(spans)), area);
4486}
4487
4488fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
4490 now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
4491}
4492
4493fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
4495 let styles = active_styles();
4496 let area = Rect {
4497 x: composer_area.x,
4498 y: composer_area.top().saturating_sub(1),
4499 width: composer_area.width,
4500 height: 1,
4501 };
4502 let line = Line::styled(
4503 format!(" \u{2139} {text}"),
4504 Style::default()
4505 .fg(color_from_anstyle(styles.info.get_fg_color()))
4506 .add_modifier(Modifier::DIM),
4507 );
4508 frame.render_widget(Paragraph::new(line), area);
4509}
4510
4511fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4514 let styles = active_styles();
4515 let items = &state.slash_popup.items;
4516 if items.is_empty() {
4517 return;
4518 }
4519
4520 let max_visible = 8usize;
4521 let visible = items.len().min(max_visible);
4522 let popup_h = visible as u16 + 2; let width = composer_area.width.min(64);
4524 let popup_area = Rect {
4525 x: composer_area.left(),
4526 y: composer_area.top().saturating_sub(popup_h),
4527 width,
4528 height: popup_h,
4529 };
4530 frame.render_widget(Clear, popup_area);
4531
4532 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4533 let title = Line::from(Span::styled(
4534 " Commands ",
4535 Style::default()
4536 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4537 .add_modifier(Modifier::BOLD),
4538 ));
4539 let block = Block::default()
4540 .borders(Borders::ALL)
4541 .border_type(BorderType::Rounded)
4542 .border_style(Style::default().fg(border_color))
4543 .title(title);
4544 let inner = block.inner(popup_area);
4545 frame.render_widget(&block, popup_area);
4546
4547 let max_label = items
4549 .iter()
4550 .take(visible)
4551 .map(|i| i.label.chars().count())
4552 .max()
4553 .unwrap_or(0);
4554
4555 let primary = color_from_anstyle(styles.primary.get_fg_color());
4556 let fg = color_from_anstyle(Some(styles.foreground));
4557 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4558
4559 for (i, item) in items.iter().take(visible).enumerate() {
4560 let is_selected = i == state.slash_popup.selected;
4561 let y = inner.top() + i as u16;
4562 let row_area = Rect {
4563 x: inner.left(),
4564 y,
4565 width: inner.width,
4566 height: 1,
4567 };
4568
4569 let marker = if is_selected { "\u{25b8} " } else { " " }; let label_style = if is_selected {
4571 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4572 } else {
4573 Style::default().fg(fg)
4574 };
4575 let label_padded = format!("{:<width$}", item.label, width = max_label);
4576 let line = Line::from(vec![
4577 Span::styled(marker, label_style),
4578 Span::styled(label_padded, label_style),
4579 Span::raw(" "),
4580 Span::styled(&item.description, Style::default().fg(secondary)),
4581 ]);
4582 frame.render_widget(Paragraph::new(line), row_area);
4583 }
4584}
4585
4586fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4590 let styles = active_styles();
4591 let Some(fs) = &state.file_search else {
4592 return;
4593 };
4594 let items = &fs.results;
4595 if items.is_empty() {
4596 return;
4597 }
4598
4599 let max_visible = 10usize;
4600 let visible = items.len().min(max_visible);
4601 let popup_h = visible as u16 + 2; let width = composer_area.width.min(72);
4603 let popup_area = Rect {
4604 x: composer_area.left(),
4605 y: composer_area.top().saturating_sub(popup_h),
4606 width,
4607 height: popup_h,
4608 };
4609 frame.render_widget(Clear, popup_area);
4610
4611 let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4612 let title_str = if fs.hidden_mode {
4613 " Files (hidden) "
4614 } else {
4615 " Files "
4616 };
4617 let title = Line::from(Span::styled(
4618 title_str,
4619 Style::default()
4620 .fg(color_from_anstyle(styles.primary.get_fg_color()))
4621 .add_modifier(Modifier::BOLD),
4622 ));
4623 let block = Block::default()
4624 .borders(Borders::ALL)
4625 .border_type(BorderType::Rounded)
4626 .border_style(Style::default().fg(border_color))
4627 .title(title);
4628 let inner = block.inner(popup_area);
4629 frame.render_widget(&block, popup_area);
4630
4631 let primary = color_from_anstyle(styles.primary.get_fg_color());
4632 let fg = color_from_anstyle(Some(styles.foreground));
4633 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4634
4635 for (i, result) in items.iter().take(visible).enumerate() {
4636 let is_selected = i == fs.selected;
4637 let y = inner.top() + i as u16;
4638 let row_area = Rect {
4639 x: inner.left(),
4640 y,
4641 width: inner.width,
4642 height: 1,
4643 };
4644
4645 let marker = if is_selected { "\u{25b8} " } else { " " }; let path_style = if is_selected {
4647 Style::default().fg(primary).add_modifier(Modifier::BOLD)
4648 } else {
4649 Style::default().fg(fg)
4650 };
4651 let line = Line::from(vec![
4652 Span::styled(marker, path_style),
4653 Span::styled(&result.path, path_style),
4654 ]);
4655 frame.render_widget(Paragraph::new(line), row_area);
4656 }
4657
4658 if popup_h >= 4 {
4660 let hint_y = inner.bottom();
4661 let hint_area = Rect {
4662 x: inner.left(),
4663 y: hint_y,
4664 width: inner.width,
4665 height: 1,
4666 };
4667 let count = items.len();
4668 let hint = format!("{count} files \u{00b7} Tab accept Esc cancel");
4669 let _ = secondary; frame.render_widget(
4671 Paragraph::new(Line::from(Span::styled(
4672 hint,
4673 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4674 )))
4675 .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
4676 hint_area,
4677 );
4678 }
4679}
4680
4681struct InputEditor<'a> {
4687 buffer: &'a mut String,
4688 cursor: &'a mut usize,
4689}
4690
4691impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
4692 fn content(&self) -> &str {
4693 self.buffer
4694 }
4695 fn cursor(&self) -> usize {
4696 *self.cursor
4697 }
4698 fn set_cursor(&mut self, pos: usize) {
4699 *self.cursor = pos.min(self.buffer.len());
4700 }
4701 fn move_left(&mut self) {
4702 *self.cursor = self.cursor.saturating_sub(1);
4703 }
4704 fn move_right(&mut self) {
4705 let len = self.buffer.len();
4706 *self.cursor = (*self.cursor + 1).min(len);
4707 }
4708 fn delete_char_forward(&mut self) {
4709 let cursor = *self.cursor;
4710 if cursor < self.buffer.len() {
4711 let next = self.buffer[cursor..]
4712 .char_indices()
4713 .nth(1)
4714 .map(|(i, _)| cursor + i)
4715 .unwrap_or(self.buffer.len());
4716 self.buffer.replace_range(cursor..next, "");
4717 }
4718 }
4719 fn insert_text(&mut self, text: &str) {
4720 let cursor = *self.cursor;
4721 self.buffer.insert_str(cursor, text);
4722 *self.cursor = cursor + text.len();
4723 }
4724 fn replace(&mut self, content: String, cursor: usize) {
4725 *self.buffer = content;
4726 *self.cursor = cursor.min(self.buffer.len());
4727 }
4728}
4729
4730pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
4735 InlineSegment {
4736 text: text.into(),
4737 style: Arc::new(InlineTextStyle::default()),
4738 }
4739}
4740
4741pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
4742 if offset == usize::MAX {
4743 return total.saturating_sub(viewport);
4744 }
4745 let max_start = total.saturating_sub(viewport);
4747 offset.min(max_start)
4748}
4749
4750fn insert_char_into_secure_input(value: &str, cursor: usize, ch: char) -> (String, usize) {
4761 let mut s = String::with_capacity(value.len() + ch.len_utf8());
4762 s.push_str(&value[..cursor]);
4763 s.push(ch);
4764 s.push_str(&value[cursor..]);
4765 (s, cursor + ch.len_utf8())
4766}
4767
4768fn backspace_secure_input(value: &str, cursor: usize) -> (String, usize) {
4772 if cursor == 0 {
4773 return (value.to_string(), 0);
4774 }
4775 let mut prev = cursor - 1;
4777 while !value.is_char_boundary(prev) {
4778 prev -= 1;
4779 }
4780 let mut s = String::with_capacity(value.len() - (cursor - prev));
4781 s.push_str(&value[..prev]);
4782 s.push_str(&value[cursor..]);
4783 (s, prev)
4784}
4785
4786fn insert_paste_into_secure_input(value: &str, cursor: usize, paste: &str) -> (String, usize) {
4791 let trimmed = paste.trim_end_matches('\n');
4792 let filtered: String = trimmed
4793 .chars()
4794 .filter(|c| c.is_ascii_graphic() || *c == ' ')
4795 .collect();
4796 let mut s = String::with_capacity(value.len() + filtered.len());
4797 s.push_str(&value[..cursor]);
4798 s.push_str(&filtered);
4799 s.push_str(&value[cursor..]);
4800 (s, cursor + filtered.len())
4801}
4802
4803fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
4816 let builtins = SlashRegistry::builtin_commands();
4817 let builtin_names: std::collections::HashSet<&str> =
4818 builtins.iter().map(|(n, _, _)| *n).collect();
4819
4820 let mut items: Vec<SlashPopupItem> = builtins
4821 .into_iter()
4822 .filter(|(name, _, aliases)| {
4823 token.is_empty()
4824 || name.starts_with(token)
4825 || aliases.iter().any(|a| a.starts_with(token))
4826 })
4827 .map(|(name, desc, aliases)| {
4828 let mut label = format!("/{name}");
4829 for a in &aliases {
4830 label.push_str(&format!(", /{a}"));
4831 }
4832 SlashPopupItem {
4833 label,
4834 description: desc.to_string(),
4835 name: name.to_string(),
4836 }
4837 })
4838 .collect();
4839
4840 for fc in file_commands {
4842 if builtin_names.contains(fc.name.as_str())
4843 || fc
4844 .aliases
4845 .iter()
4846 .any(|alias| builtin_names.contains(alias.as_str()))
4847 {
4848 continue;
4849 }
4850 if token.is_empty()
4851 || fc.name.starts_with(token)
4852 || fc.aliases.iter().any(|a| a.starts_with(token))
4853 {
4854 let mut label = format!("/{}", fc.name);
4855 for a in &fc.aliases {
4856 label.push_str(&format!(", /{a}"));
4857 }
4858 items.push(SlashPopupItem {
4859 label,
4860 description: fc.description.clone(),
4861 name: fc.name.clone(),
4862 });
4863 }
4864 }
4865
4866 items
4867}
4868
4869fn refresh_slash_popup(state: &mut RenderState) {
4874 let buf = state.input_buffer.clone();
4875 let active = buf.starts_with('/') && !buf[1..].contains(' ');
4876 if !active {
4877 state.slash_popup.open = false;
4878 state.slash_popup.items.clear();
4879 state.slash_popup.selected = 0;
4880 return;
4881 }
4882 let token = &buf[1..];
4883 let items = slash_filter(token, &state.file_commands);
4884 state.slash_popup.open = !items.is_empty();
4885 if items.is_empty() {
4886 state.slash_popup.selected = 0;
4887 } else {
4888 state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
4889 }
4890 state.slash_popup.items = items;
4891}
4892fn refresh_input_popups(state: &mut RenderState) {
4896 refresh_slash_popup(state);
4897 refresh_file_search(state);
4898}
4899
4900fn refresh_file_search(state: &mut RenderState) {
4906 use crate::tui_vt::file_search;
4907 if state.slash_popup.open {
4909 state.file_search = None;
4910 return;
4911 }
4912 match file_search::parse_at_cursor(&state.input_buffer, state.input_cursor) {
4913 Some(token) => match &mut state.file_search {
4914 None => {
4915 let cwd = state.cwd.clone();
4916 state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
4917 }
4918 Some(fs) => {
4919 if fs.query != token.path_query {
4920 fs.refresh(&token.path_query);
4921 }
4922 }
4923 },
4924 None => state.file_search = None,
4925 }
4926}
4927
4928fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
4933 use crate::tui_vt::file_search;
4934 let Some(fs) = &state.file_search else {
4935 return false;
4936 };
4937 let Some(result) = fs.selected_result().cloned() else {
4938 return false;
4939 };
4940 let at_offset = fs.at_offset;
4941 let text = file_search::insertion_text(&result.path, None, line_mode);
4942 let cursor_end = state.input_cursor;
4943 state
4945 .input_buffer
4946 .replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
4947 state.input_cursor = at_offset + text.len();
4948 state.file_search = None;
4949 true
4950}
4951
4952fn preview_tool_result(content: &str) -> String {
4953 const MAX: usize = 500;
4954 if content.chars().count() <= MAX {
4955 return content.to_string();
4956 }
4957 let truncated: String = content.chars().take(MAX).collect();
4958 format!("{truncated}\u{2026}")
4959}
4960
4961fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
4965 let lines: Vec<&str> = content.lines().collect();
4966 if !lines.iter().any(|l| l.starts_with("@@")) {
4970 return false;
4971 }
4972 let additions = lines
4973 .iter()
4974 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
4975 .count();
4976 let deletions = lines
4977 .iter()
4978 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
4979 .count();
4980 if additions + deletions < 2 {
4981 return false;
4982 }
4983
4984 let styles = active_styles();
4985 let green = styles.secondary.get_fg_color();
4986 let red = styles.error.get_fg_color();
4987 const MAX_DIFF_LINES: usize = 30;
4988
4989 let mut hdr_style = InlineTextStyle::default();
4991 hdr_style.effects |= anstyle::Effects::DIMMED;
4992 handle.append_line(
4993 InlineMessageKind::Tool,
4994 vec![InlineSegment {
4995 text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
4996 style: Arc::new(hdr_style),
4997 }],
4998 );
4999
5000 for line in lines.iter().take(MAX_DIFF_LINES) {
5002 let mut style = InlineTextStyle::default();
5003 if line.starts_with('+') && !line.starts_with("+++") {
5004 style.color = green;
5005 } else if line.starts_with('-') && !line.starts_with("---") {
5006 style.color = red;
5007 } else {
5008 style.effects |= anstyle::Effects::DIMMED;
5009 }
5010 handle.append_line(
5011 InlineMessageKind::Tool,
5012 vec![InlineSegment {
5013 text: format!(" {line}"),
5014 style: Arc::new(style),
5015 }],
5016 );
5017 }
5018
5019 if lines.len() > MAX_DIFF_LINES {
5020 let mut more_style = InlineTextStyle::default();
5021 more_style.effects |= anstyle::Effects::DIMMED;
5022 handle.append_line(
5023 InlineMessageKind::Tool,
5024 vec![InlineSegment {
5025 text: format!(" \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
5026 style: Arc::new(more_style),
5027 }],
5028 );
5029 }
5030
5031 true
5032}
5033
5034fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
5035 match color {
5036 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
5037 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
5038 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
5039 None => Color::Reset,
5040 }
5041}
5042fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
5043 use anstyle::AnsiColor as A;
5044 match color {
5045 A::Black => Color::Black,
5046 A::Red => Color::Red,
5047 A::Green => Color::Green,
5048 A::Yellow => Color::Yellow,
5049 A::Blue => Color::Blue,
5050 A::Magenta => Color::Magenta,
5051 A::Cyan => Color::Cyan,
5052 A::White => Color::Gray,
5053 A::BrightBlack => Color::DarkGray,
5054 A::BrightRed => Color::LightRed,
5055 A::BrightGreen => Color::LightGreen,
5056 A::BrightYellow => Color::LightYellow,
5057 A::BrightBlue => Color::LightBlue,
5058 A::BrightMagenta => Color::LightMagenta,
5059 A::BrightCyan => Color::LightCyan,
5060 A::BrightWhite => Color::White,
5061 }
5062}
5063
5064#[allow(dead_code, clippy::declare_interior_mutable_const)]
5067const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
5068
5069#[cfg(test)]
5070mod slash_popup_tests {
5071 use super::*;
5072
5073 #[test]
5074 fn empty_token_lists_all_commands() {
5075 let items = slash_filter("", &[]);
5076 assert!(items.len() >= 7);
5078 assert!(items.iter().any(|i| i.name == "quit"));
5079 assert!(items.iter().any(|i| i.name == "clear"));
5080 assert!(items.iter().any(|i| i.name == "model"));
5081 }
5082
5083 #[test]
5084 fn prefix_filter_matches_name() {
5085 let items = slash_filter("qu", &[]);
5086 assert_eq!(items.len(), 1);
5087 assert_eq!(items[0].name, "quit");
5088 assert!(items[0].label.contains("/quit"));
5089 }
5090
5091 #[test]
5092 fn prefix_filter_matches_alias() {
5093 let items = slash_filter("cl", &[]);
5095 let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
5096 assert!(names.contains(&"clear"));
5097 }
5098
5099 #[test]
5100 fn file_commands_appear_in_filter() {
5101 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5102 "review",
5103 "---\ndescription: proj cmd\naliases: cr\n---\nbody",
5104 );
5105 let items = slash_filter("", &[fc]);
5106 assert!(items.iter().any(|i| i.name == "review"));
5107 assert!(items.iter().any(|i| i.name == "quit")); }
5109
5110 #[test]
5111 fn file_commands_filtered_by_prefix() {
5112 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5113 "review",
5114 "---\ndescription: x\n---\nbody",
5115 );
5116 let items = slash_filter("rev", &[fc]);
5117 assert!(items.iter().any(|i| i.name == "review"));
5118 }
5119
5120 #[test]
5121 fn file_commands_shadowed_by_builtins_are_dropped() {
5122 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5127 "quit",
5128 "---\ndescription: hijack\n---\nbody",
5129 );
5130 let items = slash_filter("", &[fc]);
5131 let quit_count = items.iter().filter(|i| i.name == "quit").count();
5132 assert_eq!(quit_count, 1, "shadowed file command must not appear");
5133 assert!(
5135 items
5136 .iter()
5137 .any(|i| i.name == "quit" && !i.description.contains("hijack"))
5138 );
5139 }
5140
5141 #[test]
5142 fn file_commands_with_builtin_aliases_are_dropped() {
5143 let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5144 "review",
5145 "---\ndescription: hijack\naliases: quit\n---\nbody",
5146 );
5147 let items = slash_filter("", &[fc]);
5148 assert!(!items.iter().any(|item| item.name == "review"));
5149 }
5150
5151 #[test]
5152 fn popup_opens_on_slash() {
5153 let mut state = RenderState::default();
5154 state.input_buffer = "/".to_string();
5155 refresh_input_popups(&mut state);
5156 assert!(state.slash_popup.open);
5157 assert!(!state.slash_popup.items.is_empty());
5158 }
5159
5160 #[test]
5161 fn popup_closes_on_space() {
5162 let mut state = RenderState::default();
5163 state.input_buffer = "/quit ".to_string();
5164 refresh_input_popups(&mut state);
5165 assert!(!state.slash_popup.open);
5166 }
5167
5168 #[test]
5169 fn popup_closes_on_non_slash() {
5170 let mut state = RenderState::default();
5171 state.input_buffer = "hello".to_string();
5172 refresh_input_popups(&mut state);
5173 assert!(!state.slash_popup.open);
5174 }
5175
5176 #[test]
5177 fn popup_filters_as_user_types() {
5178 let mut state = RenderState::default();
5179 state.input_buffer = "/m".to_string();
5180 refresh_input_popups(&mut state);
5181 assert!(state.slash_popup.open);
5182 assert!(
5185 state
5186 .slash_popup
5187 .items
5188 .iter()
5189 .all(|i| i.name.starts_with('m'))
5190 );
5191 }
5192
5193 #[test]
5194 fn popup_selection_clamps_on_shrink() {
5195 let mut state = RenderState::default();
5196 state.input_buffer = "/".to_string();
5197 refresh_input_popups(&mut state);
5198 let full_count = state.slash_popup.items.len();
5199 state.slash_popup.selected = full_count - 1;
5200 state.input_buffer = "/qu".to_string();
5202 refresh_input_popups(&mut state);
5203 assert!(state.slash_popup.selected < state.slash_popup.items.len());
5204 }
5205}
5206
5207#[cfg(test)]
5208mod render_tests {
5209 use super::*;
5210 use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
5211 use ratatui::{Terminal, backend::TestBackend};
5212 use tokio::sync::mpsc;
5213
5214 fn render_frame_to_string(state: &RenderState) -> String {
5219 let backend = TestBackend::new(80, 24);
5220 let mut terminal = Terminal::new(backend).expect("backend");
5221 let (tx, _rx) = mpsc::unbounded_channel();
5222 let handle = InlineHandle::new_for_tests(tx);
5223 terminal
5224 .draw(|f| render_frame(f, state, &handle))
5225 .expect("draw");
5226 let buf = terminal.backend().buffer();
5227 let area = buf.area();
5228 let mut out = String::new();
5229 for y in 0..area.height {
5230 for x in 0..area.width {
5231 if let Some(cell) = buf.cell((x, y)) {
5232 out.push_str(cell.symbol());
5233 }
5234 }
5235 out.push('\n');
5236 }
5237 out
5238 }
5239
5240 #[test]
5241 fn welcome_screen_shown_when_transcript_empty() {
5242 let state = RenderState::default();
5243 let rendered = render_frame_to_string(&state);
5244 assert!(
5245 rendered.contains("oxicode"),
5246 "welcome banner must appear when transcript is empty"
5247 );
5248 }
5249
5250 #[test]
5251 fn composer_is_painted() {
5252 let mut state = RenderState::default();
5256 state.input_enabled = true;
5257 state.prompt_prefix = "> ".to_string();
5258 let rendered = render_frame_to_string(&state);
5259 assert!(
5260 rendered.contains('>'),
5261 "composer prompt prefix must be painted"
5262 );
5263 }
5264
5265 #[test]
5266 fn slash_popup_renders_command_list() {
5267 let mut state = RenderState::default();
5268 state.slash_popup.open = true;
5269 state.slash_popup.items = slash_filter("", &[]);
5270 let rendered = render_frame_to_string(&state);
5271 assert!(rendered.contains("Commands"), "popup title must render");
5272 assert!(rendered.contains("/quit"), "popup must list /quit");
5273 }
5274
5275 #[test]
5276 fn composer_and_popup_render_together() {
5277 let mut state = RenderState::default();
5278 state.prompt_prefix = "> ".to_string();
5279 state.input_buffer = "/qu".to_string();
5280 state.slash_popup.open = true;
5281 state.slash_popup.items = slash_filter("qu", &[]);
5282 let rendered = render_frame_to_string(&state);
5283 assert!(rendered.contains("Commands"), "popup must render");
5284 assert!(rendered.contains("/quit"), "popup must list /quit");
5285 assert!(rendered.contains('>'), "composer must still render");
5286 }
5287
5288 #[test]
5289 fn transcript_wraps_long_lines() {
5290 let mut state = RenderState::default();
5292 state.transcript.push(TranscriptLine {
5293 kind: InlineMessageKind::Agent,
5294 segments: vec![plain_segment(
5295 "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
5296 )],
5297 block_id: 0,
5298 });
5299 let backend = TestBackend::new(40, 24);
5300 let mut terminal = Terminal::new(backend).expect("backend");
5301 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5302 let handle = InlineHandle::new_for_tests(tx);
5303 terminal
5304 .draw(|f| render_frame(f, &state, &handle))
5305 .expect("draw");
5306 let buf = terminal.backend().buffer();
5307 let mut full = String::new();
5310 for y in 0..buf.area.height {
5311 for x in 0..buf.area.width {
5312 if let Some(cell) = buf.cell((x, y)) {
5313 full.push_str(cell.symbol());
5314 }
5315 }
5316 full.push('\n');
5317 }
5318 assert!(
5319 full.contains("wrap"),
5320 "long line must wrap, not clip — text should be visible past col 40"
5321 );
5322 }
5323
5324 fn sample_overlay_items() -> Vec<OverlayListItem> {
5327 vec![
5328 OverlayListItem {
5329 title: "model-a".to_string(),
5330 subtitle: Some("first".to_string()),
5331 badge: Some("ready".to_string()),
5332 indent: 0,
5333 search_value: None,
5334 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
5335 },
5336 OverlayListItem {
5337 title: "model-b".to_string(),
5338 subtitle: None,
5339 badge: None,
5340 indent: 0,
5341 search_value: None,
5342 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
5343 },
5344 OverlayListItem {
5345 title: "model-c".to_string(),
5346 subtitle: None,
5347 badge: None,
5348 indent: 0,
5349 search_value: None,
5350 selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
5351 },
5352 ]
5353 }
5354
5355 #[test]
5356 fn overlay_renders_title_and_items() {
5357 let mut state = RenderState::default();
5358 state.overlay = Some(OverlayState {
5359 title: "Select model".to_string(),
5360 lines: vec!["Pick one".to_string()],
5361 items: sample_overlay_items(),
5362 selected: 0,
5363 search: None,
5364 secure_input: None,
5365 });
5366 let rendered = render_frame_to_string(&state);
5367 assert!(
5368 rendered.contains("Select model"),
5369 "overlay title must render"
5370 );
5371 assert!(rendered.contains("model-a"), "first item must render");
5372 assert!(rendered.contains("model-b"), "second item must render");
5373 assert!(rendered.contains("model-c"), "third item must render");
5374 assert!(
5375 rendered.contains("Pick one"),
5376 "descriptive line must render"
5377 );
5378 }
5379
5380 #[test]
5381 fn overlay_search_filters_items() {
5382 let mut state = RenderState::default();
5383 state.overlay = Some(OverlayState {
5384 title: "Select".to_string(),
5385 lines: Vec::new(),
5386 items: sample_overlay_items(),
5387 selected: 0,
5388 search: Some(OverlaySearchState {
5389 label: "filter".to_string(),
5390 placeholder: Some("type".to_string()),
5391 value: "model-b".to_string(),
5392 }),
5393 secure_input: None,
5394 });
5395 let rendered = render_frame_to_string(&state);
5396 assert!(rendered.contains("model-b"), "matching item must render");
5397 assert!(
5398 !rendered.contains("model-a"),
5399 "non-matching item must not render (got: {})",
5400 rendered
5401 );
5402 assert!(
5403 !rendered.contains("model-c"),
5404 "non-matching item must not render"
5405 );
5406 }
5407
5408 #[test]
5409 fn overlay_keyboard_nav_moves_selection() {
5410 let mut state = RenderState::default();
5411 state.overlay = Some(OverlayState {
5412 title: "Select".to_string(),
5413 lines: Vec::new(),
5414 items: sample_overlay_items(),
5415 selected: 0,
5416 search: None,
5417 secure_input: None,
5418 });
5419 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5420 let (tx, mut _rx) = mpsc::unbounded_channel();
5421
5422 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5424
5425 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5427 assert!(consumed, "Down must be consumed while overlay is open");
5428 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
5429
5430 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5432 assert!(consumed);
5433 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5434
5435 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5437 assert!(consumed);
5438 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5439
5440 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
5442 assert!(consumed);
5443 assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5444
5445 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5447 assert!(consumed);
5448 assert!(
5449 state_arc.lock().overlay.is_none(),
5450 "overlay must be cleared after Enter"
5451 );
5452 let evt = _rx.try_recv().expect("submit event must arrive");
5453 match evt {
5454 InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
5455 other => panic!("expected Submitted overlay event, got {other:?}"),
5456 }
5457 }
5458
5459 #[test]
5460 fn overlay_esc_closes_and_emits_cancelled() {
5461 let mut state = RenderState::default();
5462 state.overlay = Some(OverlayState {
5463 title: "Select".to_string(),
5464 lines: Vec::new(),
5465 items: sample_overlay_items(),
5466 selected: 0,
5467 search: None,
5468 secure_input: None,
5469 });
5470 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5471 let (tx, mut rx) = mpsc::unbounded_channel();
5472
5473 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
5474 assert!(consumed);
5475 assert!(
5476 state_arc.lock().overlay.is_none(),
5477 "overlay must be cleared after Esc"
5478 );
5479 let evt = rx.try_recv().expect("cancel event must arrive");
5480 assert!(
5481 matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
5482 "expected Cancelled overlay event"
5483 );
5484 }
5485
5486 #[test]
5487 fn overlay_enter_on_readonly_item_is_noop() {
5488 let mut state = RenderState::default();
5492 state.overlay = Some(OverlayState {
5493 title: "Tools".to_string(),
5494 lines: Vec::new(),
5495 items: vec![OverlayListItem {
5496 title: "read".to_string(),
5497 subtitle: Some("Read a file".to_string()),
5498 badge: None,
5499 indent: 0,
5500 search_value: None,
5501 selection: None,
5502 }],
5503 selected: 0,
5504 search: None,
5505 secure_input: None,
5506 });
5507 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5508 let (tx, mut rx) = mpsc::unbounded_channel();
5509
5510 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5511 assert!(consumed, "Enter must be consumed even on read-only items");
5512 assert!(
5513 state_arc.lock().overlay.is_some(),
5514 "overlay must stay open when Enter hits a read-only item"
5515 );
5516 assert!(
5517 rx.try_recv().is_err(),
5518 "no overlay event must be emitted for a read-only Enter"
5519 );
5520 }
5521
5522 #[test]
5523 fn overlay_chars_route_to_search_field() {
5524 let mut state = RenderState::default();
5525 state.overlay = Some(OverlayState {
5526 title: "Select".to_string(),
5527 lines: Vec::new(),
5528 items: sample_overlay_items(),
5529 selected: 0,
5530 search: Some(OverlaySearchState {
5531 label: "filter".to_string(),
5532 placeholder: None,
5533 value: String::new(),
5534 }),
5535 secure_input: None,
5536 });
5537 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5538 let (tx, _rx) = mpsc::unbounded_channel();
5539
5540 handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
5541 handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
5542 handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
5543 let value = state_arc
5544 .lock()
5545 .overlay
5546 .as_ref()
5547 .unwrap()
5548 .search
5549 .as_ref()
5550 .unwrap()
5551 .value
5552 .clone();
5553 assert_eq!(value, "m", "Backspace should drop last char");
5554 }
5555
5556 #[test]
5557 fn overlay_key_no_op_when_no_overlay_open() {
5558 let state = RenderState::default();
5559 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5560 let (tx, _rx) = mpsc::unbounded_channel();
5561 let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5562 assert!(
5563 !consumed,
5564 "handle_overlay_key must return false when no overlay is open"
5565 );
5566 }
5567
5568 #[test]
5569 fn apply_command_show_overlay_populates_state() {
5570 use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
5571 let mut state = RenderState::default();
5572 let items = vec![
5573 InlineListItem {
5574 title: "alpha".to_string(),
5575 subtitle: None,
5576 badge: None,
5577 indent: 0,
5578 selection: None,
5579 search_value: None,
5580 },
5581 InlineListItem {
5582 title: "beta".to_string(),
5583 subtitle: None,
5584 badge: None,
5585 indent: 0,
5586 selection: None,
5587 search_value: None,
5588 },
5589 ];
5590 let request = OverlayRequest::List(ListOverlayRequest {
5591 title: "Pick".to_string(),
5592 lines: vec!["desc".to_string()],
5593 footer_hint: None,
5594 items,
5595 selected: None,
5596 search: None,
5597 hotkeys: Vec::new(),
5598 });
5599 let shutdown = apply_command(
5600 &mut state,
5601 InlineCommand::ShowOverlay {
5602 request: Box::new(request),
5603 },
5604 );
5605 assert!(!shutdown, "ShowOverlay must not request shutdown");
5606 let overlay = state.overlay.as_ref().expect("overlay must be Some");
5607 assert_eq!(overlay.title, "Pick");
5608 assert_eq!(overlay.items.len(), 2);
5609 assert_eq!(overlay.items[0].title, "alpha");
5610 assert_eq!(overlay.items[1].title, "beta");
5611 assert_eq!(overlay.lines.len(), 1);
5612
5613 apply_command(&mut state, InlineCommand::CloseOverlay);
5615 assert!(state.overlay.is_none(), "CloseOverlay must clear state");
5616 }
5617
5618 #[test]
5619 fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
5620 use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
5621 let request = OverlayRequest::Modal(ModalOverlayRequest {
5622 title: "API key".into(),
5623 lines: vec!["Paste your key".into()],
5624 secure_prompt: Some(SecurePromptConfig {
5625 label: "Key".into(),
5626 placeholder: Some("sk-...".into()),
5627 mask_input: true,
5628 }),
5629 });
5630 let state = materialize_overlay(request);
5631 let secure = state
5632 .secure_input
5633 .expect("secure_input must be Some when secure_prompt is Some");
5634 assert_eq!(secure.config.label, "Key");
5635 assert!(secure.config.mask_input);
5636 assert_eq!(secure.value, "");
5637 assert_eq!(secure.cursor, 0);
5638 }
5639
5640 #[test]
5641 fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
5642 use oxicode_vtui::tui::core::ModalOverlayRequest;
5643 let request = OverlayRequest::Modal(ModalOverlayRequest {
5644 title: "Confirm".into(),
5645 lines: vec!["y/n".into()],
5646 secure_prompt: None,
5647 });
5648 let state = materialize_overlay(request);
5649 assert!(
5650 state.secure_input.is_none(),
5651 "secure_input must be None when secure_prompt is None"
5652 );
5653 }
5654
5655 fn three_block_transcript() -> Vec<TranscriptLine> {
5658 vec![
5660 TranscriptLine {
5661 kind: InlineMessageKind::User,
5662 segments: vec![plain_segment("hi")],
5663 block_id: 0,
5664 },
5665 TranscriptLine {
5666 kind: InlineMessageKind::Agent,
5667 segments: vec![plain_segment("hello")],
5668 block_id: 1,
5669 },
5670 TranscriptLine {
5671 kind: InlineMessageKind::Agent,
5672 segments: vec![plain_segment("world")],
5673 block_id: 1,
5674 },
5675 TranscriptLine {
5676 kind: InlineMessageKind::User,
5677 segments: vec![plain_segment("bye")],
5678 block_id: 2,
5679 },
5680 ]
5681 }
5682
5683 #[test]
5684 fn default_block_mode_is_truncated() {
5685 let state = RenderState::default();
5686 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5687 assert!(state.block_display.is_empty(), "default needs no map entry");
5688 }
5689
5690 #[test]
5691 fn fold_all_collapses_every_block() {
5692 let mut state = RenderState::default();
5693 state.transcript = three_block_transcript();
5694 state.fold_all();
5695 assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
5696 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5697 assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
5698 assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
5699 }
5700
5701 #[test]
5702 fn expand_all_after_fold_all_shows_expanded() {
5703 let mut state = RenderState::default();
5704 state.transcript = three_block_transcript();
5705 state.fold_all();
5706 state.expand_all();
5707 assert_eq!(state.block_display.len(), 3);
5708 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5709 assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
5710 }
5711
5712 #[test]
5713 fn truncate_all_resets_to_default() {
5714 let mut state = RenderState::default();
5715 state.transcript = three_block_transcript();
5716 state.fold_all();
5717 state.truncate_all();
5718 assert!(state.block_display.is_empty());
5719 assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
5720 }
5721
5722 #[test]
5723 fn fold_all_on_empty_transcript_is_noop() {
5724 let mut state = RenderState::default();
5725 state.fold_all();
5726 assert!(state.block_display.is_empty());
5727 }
5728
5729 #[test]
5730 fn cycle_block_advances_through_three_states() {
5731 let mut state = RenderState::default();
5732 state.transcript = three_block_transcript();
5733 state.scroll_offset = 0; state.cycle_block_at_view();
5736 assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5737 state.cycle_block_at_view();
5739 assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5740 state.cycle_block_at_view();
5742 assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5743 assert!(!state.block_display.contains_key(&0));
5744 }
5745
5746 #[test]
5747 fn cancel_grace_field_defaults_none() {
5748 let state = RenderState::default();
5749 assert!(
5750 state.cancel_grace_until.is_none(),
5751 "cancel_grace_until must default to None"
5752 );
5753 }
5754
5755 #[test]
5756 fn cancel_routes_to_interrupt_when_streaming() {
5757 assert_eq!(
5758 route_cancel(true),
5759 CancelRoute::Interrupt,
5760 "Esc while streaming must route through the interrupt path"
5761 );
5762 }
5763
5764 #[test]
5765 fn cancel_routes_to_exit_when_idle() {
5766 assert_eq!(
5767 route_cancel(false),
5768 CancelRoute::Exit,
5769 "Esc while idle must exit immediately (one-press quit)"
5770 );
5771 }
5772 #[test]
5773 fn scrollbar_paints_thumb_when_content_overflows() {
5774 let mut state = RenderState::default();
5777 for i in 0..40u32 {
5778 state.transcript.push(TranscriptLine {
5779 kind: InlineMessageKind::Agent,
5780 segments: vec![plain_segment(format!("line {i}"))],
5781 block_id: i as usize,
5782 });
5783 }
5784 let rendered = render_frame_to_string(&state);
5785 assert!(
5786 rendered.contains('\u{2588}'),
5787 "scrollbar thumb (█) must render when transcript overflows the viewport"
5788 );
5789 }
5790
5791 #[test]
5792 fn scrollbar_absent_when_content_fits_viewport() {
5793 let mut state = RenderState::default();
5795 state.transcript.push(TranscriptLine {
5796 kind: InlineMessageKind::Agent,
5797 segments: vec![plain_segment("hi")],
5798 block_id: 0,
5799 });
5800 let rendered = render_frame_to_string(&state);
5801 assert!(
5802 !rendered.contains('\u{2588}'),
5803 "no scrollbar thumb when content fits the viewport"
5804 );
5805 }
5806
5807 #[test]
5810 fn confirmation_modal_renders_title() {
5811 let mut state = RenderState::default();
5812 state.confirmation = Some(quit_confirmation());
5813 let rendered = render_frame_to_string(&state);
5814 assert!(
5815 rendered.contains("Quit oxicode?"),
5816 "confirmation title must render"
5817 );
5818 }
5819
5820 #[test]
5821 fn confirmation_yes_sends_exit_and_closes() {
5822 let mut state = RenderState::default();
5823 state.confirmation = Some(quit_confirmation());
5824 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5825 let (tx, mut rx) = mpsc::unbounded_channel();
5826 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
5827 assert!(
5828 state_arc.lock().confirmation.is_none(),
5829 "yes must close the modal"
5830 );
5831 let ev = rx.try_recv().expect("yes must send an event");
5832 assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
5833 }
5834
5835 #[test]
5836 fn confirmation_no_closes_without_event() {
5837 let mut state = RenderState::default();
5838 state.confirmation = Some(quit_confirmation());
5839 let state_arc = Arc::new(parking_lot::Mutex::new(state));
5840 let (tx, mut rx) = mpsc::unbounded_channel();
5841 handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
5842 assert!(
5843 state_arc.lock().confirmation.is_none(),
5844 "no must close the modal"
5845 );
5846 assert!(rx.try_recv().is_err(), "no must not send an event");
5847 }
5848 #[test]
5851 fn tip_banner_renders_when_active() {
5852 let mut state = RenderState::default();
5853 let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
5854 state.tip = Some(EphemeralTip {
5855 text: "hello-tip-marker".to_string(),
5856 born_tick: now_tick,
5857 ttl_ticks: 100,
5858 key: "test",
5859 ambient: false,
5860 });
5861 let rendered = render_frame_to_string(&state);
5862 assert!(
5863 rendered.contains("hello-tip-marker"),
5864 "active tip must render above the composer"
5865 );
5866 }
5867
5868 #[test]
5869 fn tip_visible_within_ttl_window() {
5870 let tip = EphemeralTip {
5871 text: "x".to_string(),
5872 born_tick: 10,
5873 ttl_ticks: 5,
5874 key: "test",
5875 ambient: false,
5876 };
5877 assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
5878 assert!(
5879 !tip_is_visible(&tip, 15),
5880 "at TTL boundary (born + ttl) must expire"
5881 );
5882 assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
5883 }
5884
5885 #[test]
5888 fn sticky_header_pins_block_head_when_scrolled_into_body() {
5889 let mut state = RenderState::default();
5892 for i in 0..40u32 {
5893 state.transcript.push(TranscriptLine {
5894 kind: InlineMessageKind::Agent,
5895 segments: vec![plain_segment(format!("body-line-{i:02}"))],
5896 block_id: 0,
5897 });
5898 }
5899 state.scroll_offset = 10;
5900 let rendered = render_frame_to_string(&state);
5901 assert!(
5902 rendered.contains("body-line-00"),
5903 "sticky header must pin the block head when scrolled into the body"
5904 );
5905 }
5906
5907 #[test]
5908 fn sticky_header_absent_when_viewport_at_block_head() {
5909 let mut state = RenderState::default();
5911 for i in 0..40u32 {
5912 state.transcript.push(TranscriptLine {
5913 kind: InlineMessageKind::Agent,
5914 segments: vec![plain_segment(format!("head-line-{i:02}"))],
5915 block_id: 0,
5916 });
5917 }
5918 state.scroll_offset = 0;
5919 let rendered = render_frame_to_string(&state);
5920 assert!(rendered.contains("head-line-00"));
5923 }
5924
5925 #[test]
5928 fn turn_end_drains_queue_head() {
5929 let mut state = RenderState::default();
5930 state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
5931 state.drain_queue_head();
5932 assert_eq!(
5933 state.queued_inputs.len(),
5934 1,
5935 "drain_queue_head must drop the head (now running)"
5936 );
5937 assert_eq!(state.queued_inputs[0], "queued-2");
5938 }
5939
5940 #[test]
5943 fn render_frame_paints_transcript_content() {
5944 let mut state = RenderState::default();
5948 state.transcript.push(TranscriptLine {
5949 kind: InlineMessageKind::Agent,
5950 segments: vec![plain_segment("frame-content-marker-xyz")],
5951 block_id: 0,
5952 });
5953 let rendered = render_frame_to_string(&state);
5954 assert!(
5955 rendered.contains("frame-content-marker-xyz"),
5956 "render_frame must paint transcript content"
5957 );
5958 }
5959
5960 #[test]
5961 fn file_search_dropdown_renders_results() {
5962 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5963 let mut state = RenderState::default();
5964 state.input_enabled = true;
5965 state.file_search = Some(FileSearchState {
5966 query: "main".into(),
5967 at_offset: 0,
5968 hidden_mode: false,
5969 results: vec![
5970 FileSearchResult {
5971 path: "src/main.rs".into(),
5972 score: 100,
5973 },
5974 FileSearchResult {
5975 path: "tests/main.rs".into(),
5976 score: 50,
5977 },
5978 ],
5979 selected: 0,
5980 index: vec![],
5981 line_mode: false,
5982 });
5983 let rendered = render_frame_to_string(&state);
5984 assert!(rendered.contains("Files"), "dropdown title must render");
5985 assert!(
5986 rendered.contains("src/main.rs"),
5987 "dropdown must show file paths"
5988 );
5989 }
5990
5991 #[test]
5992 fn file_search_dropdown_hidden_mode_title() {
5993 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5994 let mut state = RenderState::default();
5995 state.input_enabled = true;
5996 state.file_search = Some(FileSearchState {
5997 query: "".into(),
5998 at_offset: 0,
5999 hidden_mode: true,
6000 results: vec![FileSearchResult {
6001 path: ".env".into(),
6002 score: 0,
6003 }],
6004 selected: 0,
6005 index: vec![],
6006 line_mode: false,
6007 });
6008 let rendered = render_frame_to_string(&state);
6009 assert!(
6010 rendered.contains("hidden"),
6011 "hidden mode must be indicated in title"
6012 );
6013 }
6014
6015 #[test]
6016 fn file_search_and_composer_render_together() {
6017 use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
6018 let mut state = RenderState::default();
6019 state.input_enabled = true;
6020 state.prompt_prefix = "> ".into();
6021 state.input_buffer = "@main".into();
6022 state.input_cursor = 5;
6023 state.file_search = Some(FileSearchState {
6024 query: "main".into(),
6025 at_offset: 0,
6026 hidden_mode: false,
6027 results: vec![FileSearchResult {
6028 path: "src/main.rs".into(),
6029 score: 100,
6030 }],
6031 selected: 0,
6032 index: vec![],
6033 line_mode: false,
6034 });
6035 let rendered = render_frame_to_string(&state);
6036 assert!(rendered.contains('>'), "composer must still render");
6038 assert!(
6039 rendered.contains("src/main.rs"),
6040 "dropdown must render alongside composer"
6041 );
6042 }
6043
6044 #[test]
6045 fn flatten_todo_items_preserves_order_and_status() {
6046 use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
6047 let phases = vec![
6048 TodoPhase {
6049 name: "A".into(),
6050 tasks: vec![
6051 TodoItem {
6052 content: "write code".into(),
6053 status: TodoStatus::InProgress,
6054 notes: None,
6055 block_reason: None,
6056 },
6057 TodoItem {
6058 content: "write tests".into(),
6059 status: TodoStatus::Pending,
6060 notes: None,
6061 block_reason: None,
6062 },
6063 ],
6064 },
6065 TodoPhase {
6066 name: "B".into(),
6067 tasks: vec![TodoItem {
6068 content: "waiting on review".into(),
6069 status: TodoStatus::Blocked,
6070 notes: None,
6071 block_reason: None,
6072 }],
6073 },
6074 ];
6075 let flat = flatten_todo_items(&phases);
6076 assert_eq!(flat.len(), 3);
6077 assert_eq!(flat[0], ("write code".to_string(), TodoStatus::InProgress));
6078 assert_eq!(flat[1], ("write tests".to_string(), TodoStatus::Pending));
6079 assert_eq!(
6080 flat[2],
6081 ("waiting on review".to_string(), TodoStatus::Blocked)
6082 );
6083 }
6084
6085 #[test]
6086 fn todo_pane_renders_when_items_present() {
6087 let mut state = RenderState::default();
6090 state.todo_items = vec![
6091 ("active task".to_string(), TodoStatus::InProgress),
6092 ("open task".to_string(), TodoStatus::Pending),
6093 ];
6094 let rendered = render_frame_to_string(&state);
6095 assert!(
6096 rendered.contains("active task"),
6097 "in-progress task must render"
6098 );
6099 assert!(rendered.contains("open task"), "pending task must render");
6100 assert!(
6102 rendered.contains('\u{25B6}'),
6103 "in-progress glyph must render"
6104 );
6105 }
6106
6107 #[test]
6108 fn todo_pane_hidden_when_empty() {
6109 let state = RenderState::default();
6110 let rendered = render_frame_to_string(&state);
6111 assert!(!rendered.contains('\u{2611}'), "no checkmark when empty");
6113 }
6114
6115 #[test]
6116 fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
6117 use ratatui::{Terminal, backend::TestBackend};
6118 let backend = TestBackend::new(80, 24);
6119 let mut terminal = Terminal::new(backend).unwrap();
6120 let overlay = OverlayState {
6121 title: "OpenAI key".into(),
6122 lines: vec!["Paste your API key".into()],
6123 items: Vec::new(),
6124 selected: 0,
6125 search: None,
6126 secure_input: Some(OverlaySecureInput {
6127 config: SecurePromptConfig {
6128 label: "Key".into(),
6129 placeholder: Some("sk-...".into()),
6130 mask_input: true,
6131 },
6132 value: "sk-abc".into(),
6133 cursor: 6,
6134 }),
6135 };
6136 terminal
6137 .draw(|f| render_overlay(f, f.area(), &overlay))
6138 .unwrap();
6139 let buf = terminal.backend().buffer().clone();
6140 let text: String = buf
6142 .content()
6143 .iter()
6144 .map(|c| c.symbol())
6145 .collect::<Vec<_>>()
6146 .join("");
6147 assert!(text.contains("Key:"));
6148 assert!(text.contains("******"));
6149 assert!(!text.contains("sk-abc"));
6150 }
6151
6152 #[test]
6153 fn render_overlay_secure_input_placeholder_when_empty() {
6154 use ratatui::{Terminal, backend::TestBackend};
6155 let backend = TestBackend::new(80, 24);
6156 let mut terminal = Terminal::new(backend).unwrap();
6157 let overlay = OverlayState {
6158 title: "OpenAI key".into(),
6159 lines: vec!["Paste your API key".into()],
6160 items: Vec::new(),
6161 selected: 0,
6162 search: None,
6163 secure_input: Some(OverlaySecureInput {
6164 config: SecurePromptConfig {
6165 label: "Key".into(),
6166 placeholder: Some("sk-...".into()),
6167 mask_input: true,
6168 },
6169 value: String::new(),
6170 cursor: 0,
6171 }),
6172 };
6173 terminal
6174 .draw(|f| render_overlay(f, f.area(), &overlay))
6175 .unwrap();
6176 let buf = terminal.backend().buffer().clone();
6177 let text: String = buf
6178 .content()
6179 .iter()
6180 .map(|c| c.symbol())
6181 .collect::<Vec<_>>()
6182 .join("");
6183 assert!(text.contains("sk-..."));
6184 }
6185}
6186
6187#[cfg(test)]
6188mod secure_input_tests {
6189 use super::*;
6190 use oxicode_vtui::tui::core::OverlaySubmission;
6191
6192 #[test]
6193 fn overlay_submission_secure_input_is_routed_to_host() {
6194 let _ = OverlaySubmission::SecureInput("sk-test".into());
6197 let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
6198 assert!(serialized.contains("SecureInput"));
6199 }
6200
6201 #[test]
6202 fn providers_action_matrix_branches_correctly() {
6203 assert_eq!(
6207 next_provider_actions(true, true),
6208 vec![
6209 AuthAction::SetApiKey,
6210 AuthAction::StartOAuth,
6211 AuthAction::RemoveKey,
6212 ],
6213 "has key + oauth-capable: replace, oauth, remove"
6214 );
6215 assert_eq!(
6216 next_provider_actions(true, false),
6217 vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
6218 "has key, key-only provider: replace, remove"
6219 );
6220 assert_eq!(
6221 next_provider_actions(false, true),
6222 vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
6223 "no key + oauth-capable: set key, oauth"
6224 );
6225 assert_eq!(
6226 next_provider_actions(false, false),
6227 vec![AuthAction::SetApiKey],
6228 "no key + key-only provider: set key only"
6229 );
6230 }
6231
6232 #[test]
6233 fn insert_char_at_middle() {
6234 let (s, c) = insert_char_into_secure_input("abcd", 2, 'X');
6235 assert_eq!(s, "abXcd");
6236 assert_eq!(c, 3);
6237 }
6238
6239 #[test]
6240 fn backspace_at_start_is_noop() {
6241 let (s, c) = backspace_secure_input("abc", 0);
6242 assert_eq!(s, "abc");
6243 assert_eq!(c, 0);
6244 }
6245
6246 #[test]
6247 fn backspace_at_middle() {
6248 let (s, c) = backspace_secure_input("abcd", 2);
6249 assert_eq!(s, "acd");
6250 assert_eq!(c, 1);
6251 }
6252
6253 #[test]
6254 fn paste_strips_trailing_newline_and_drops_non_ascii() {
6255 let (s, c) = insert_paste_into_secure_input("ab", 2, "sk-xyz\nABC\u{1F600}");
6256 assert_eq!(s, "absk-xyzABC");
6257 assert_eq!(c, 11);
6258 }
6259
6260 #[test]
6261 fn insert_at_end_appends() {
6262 let (s, c) = insert_char_into_secure_input("hello", 5, '!');
6263 assert_eq!(s, "hello!");
6264 assert_eq!(c, 6);
6265 }
6266}
6267
6268#[cfg(test)]
6279mod provider_overlay_tests {
6280 use super::*;
6281 use crate::app::agent_session::{AgentSession, AgentSessionHandle};
6282 use crate::store::session::SessionManager;
6283 use crate::store::settings::Settings;
6284 use oxicode_agent::{Agent, AgentConfig};
6285 use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
6286 use oxicode_vtui::tui::core::OverlayEvent;
6287 use std::pin::Pin;
6288 use std::sync::Arc;
6289 use std::task::{Context as TaskContext, Poll};
6290
6291 struct EmptyStream;
6294 impl futures::Stream for EmptyStream {
6295 type Item = ProviderEvent;
6296 fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
6297 Poll::Ready(None)
6298 }
6299 }
6300
6301 struct StubProvider;
6302 impl Provider for StubProvider {
6303 fn stream<'a>(
6304 &'a self,
6305 _model: &'a oxicode_sdk::Model,
6306 _context: &'a oxicode_sdk::Context,
6307 _options: Option<oxicode_sdk::StreamOptions>,
6308 ) -> Pin<
6309 Box<
6310 dyn Future<
6311 Output = Result<
6312 Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
6313 ProviderError,
6314 >,
6315 > + Send
6316 + 'a,
6317 >,
6318 > {
6319 Box::pin(async move {
6320 Ok::<_, ProviderError>(Box::pin(EmptyStream)
6321 as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
6322 })
6323 }
6324 }
6325
6326 fn make_session() -> AgentSessionHandle {
6327 let provider = Arc::new(StubProvider);
6328 let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
6329 let agent = Arc::new(Agent::new(
6330 provider,
6331 config,
6332 Arc::new(oxicode_agent::ToolRegistry::new()),
6333 ));
6334 let settings = Settings::default();
6335 let session_manager = SessionManager::in_memory("/tmp/test_providers");
6336 let session = AgentSession::new(
6337 agent,
6338 settings,
6339 session_manager,
6340 "/tmp/test_providers".to_string(),
6341 crate::SessionState::default(),
6342 );
6343 session.clone_handle()
6344 }
6345
6346 fn summarise(cmds: &[InlineCommand]) -> String {
6349 let mut show = 0;
6350 let mut close = 0;
6351 let mut other = 0;
6352 for c in cmds {
6353 match c {
6354 InlineCommand::ShowOverlay { .. } => show += 1,
6355 InlineCommand::CloseOverlay => close += 1,
6356 _ => other += 1,
6357 }
6358 }
6359 format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
6360 }
6361
6362 #[test]
6369 fn provider_row_opens_action_menu_without_close() {
6370 let session = make_session();
6374 let mut state = RenderState::default();
6375 state.overlay_providers = vec!["openai".to_string()];
6376 state.overlay = Some(OverlayState {
6377 title: "Providers".to_string(),
6378 lines: Vec::new(),
6379 items: Vec::new(),
6380 selected: 0,
6381 search: None,
6382 secure_input: None,
6383 });
6384
6385 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6386 let handle = InlineHandle::new_for_tests(cmd_tx);
6387 let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6388
6389 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6390 InlineListSelection::ProviderRow(0),
6391 )));
6392 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6393
6394 let cmds: Vec<InlineCommand> = {
6395 let mut out = Vec::new();
6396 while let Ok(cmd) = cmd_rx.try_recv() {
6397 out.push(cmd);
6398 }
6399 out
6400 };
6401 let show_count = cmds
6402 .iter()
6403 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6404 .count();
6405 assert_eq!(
6406 show_count,
6407 1,
6408 "submitting a provider row must ShowOverlay exactly once (commands: {})",
6409 summarise(&cmds)
6410 );
6411
6412 let show_idx = cmds
6416 .iter()
6417 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6418 .expect("ShowOverlay must be present");
6419 let trailing = &cmds[show_idx + 1..];
6420 assert!(
6421 !trailing
6422 .iter()
6423 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6424 "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
6425 summarise(&cmds)
6426 );
6427
6428 assert!(
6431 state.overlay_providers.is_empty(),
6432 "overlay_providers must be cleared after dispatch (got {:?})",
6433 state.overlay_providers
6434 );
6435 }
6436
6437 #[test]
6444 fn provider_row_set_api_key_opens_secure_prompt_without_close() {
6445 let session = make_session();
6447 let mut state = RenderState::default();
6448 state.overlay_providers = vec!["cerebras".to_string()];
6449 state.overlay = Some(OverlayState {
6450 title: "Providers".to_string(),
6451 lines: Vec::new(),
6452 items: Vec::new(),
6453 selected: 0,
6454 search: None,
6455 secure_input: None,
6456 });
6457
6458 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6459 let handle = InlineHandle::new_for_tests(cmd_tx);
6460 let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6461
6462 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6463 InlineListSelection::ProviderRow(0),
6464 )));
6465 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6466
6467 let cmds: Vec<InlineCommand> = {
6468 let mut out = Vec::new();
6469 while let Ok(cmd) = cmd_rx.try_recv() {
6470 out.push(cmd);
6471 }
6472 out
6473 };
6474 let show_count = cmds
6475 .iter()
6476 .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6477 .count();
6478 assert_eq!(
6479 show_count,
6480 1,
6481 "submitting a provider row must ShowOverlay exactly once (commands: {})",
6482 summarise(&cmds)
6483 );
6484
6485 let show_idx = cmds
6486 .iter()
6487 .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6488 .expect("ShowOverlay must be present");
6489 let trailing = &cmds[show_idx + 1..];
6490 assert!(
6491 !trailing
6492 .iter()
6493 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6494 "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
6495 summarise(&cmds)
6496 );
6497
6498 assert_eq!(
6502 state.secure_input_origin,
6503 Some(SecureInputOrigin::SetKey {
6504 provider: "cerebras".to_string(),
6505 }),
6506 "secure_input_origin must be stashed by SetApiKey"
6507 );
6508 }
6509
6510 #[test]
6514 fn catalog_model_selection_still_closes_overlay() {
6515 let session = make_session();
6516 let mut state = RenderState::default();
6517 state.overlay_catalog_models = vec![(
6518 "anthropic".to_string(),
6519 "claude-sonnet-4-20250514".to_string(),
6520 )];
6521 state.overlay = Some(OverlayState {
6522 title: "Models".to_string(),
6523 lines: Vec::new(),
6524 items: Vec::new(),
6525 selected: 0,
6526 search: None,
6527 secure_input: None,
6528 });
6529
6530 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6531 let handle = InlineHandle::new_for_tests(cmd_tx);
6532 let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6533
6534 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6535 InlineListSelection::CatalogModel(0),
6536 )));
6537 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6538
6539 let cmds: Vec<InlineCommand> = {
6540 let mut out = Vec::new();
6541 while let Ok(cmd) = cmd_rx.try_recv() {
6542 out.push(cmd);
6543 }
6544 out
6545 };
6546 assert!(
6547 cmds.iter()
6548 .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6549 "catalog model selection must close the overlay (commands: {})",
6550 summarise(&cmds)
6551 );
6552 }
6553
6554 fn secure_input_origin_carries_provider_independently_of_variant() {
6560 let set = SecureInputOrigin::SetKey {
6561 provider: "openai".to_string(),
6562 };
6563 let added = SecureInputOrigin::NewlyAdded {
6564 provider: "minimax".to_string(),
6565 };
6566 assert_eq!(
6570 match &set {
6571 SecureInputOrigin::SetKey { provider }
6572 | SecureInputOrigin::NewlyAdded { provider } => provider,
6573 },
6574 "openai"
6575 );
6576 assert_eq!(
6577 match &added {
6578 SecureInputOrigin::SetKey { provider }
6579 | SecureInputOrigin::NewlyAdded { provider } => provider,
6580 },
6581 "minimax"
6582 );
6583 assert_ne!(set, added);
6585 }
6586
6587 #[test]
6596 fn session_picker_resume_refused_while_streaming() {
6597 let session = make_session();
6598 session
6602 .streaming_flag()
6603 .store(true, std::sync::atomic::Ordering::SeqCst);
6604
6605 let mut state = RenderState::default();
6606 assert!(
6608 state.pending_resume.is_none(),
6609 "precondition: pending_resume must start None"
6610 );
6611
6612 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6613 let handle = InlineHandle::new_for_tests(cmd_tx);
6614 let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6615
6616 let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6617 InlineListSelection::Session("some-id".to_string()),
6618 )));
6619 let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6620
6621 assert!(
6623 state.pending_resume.is_none(),
6624 "streaming session must not enqueue pending_resume (got {:?})",
6625 state.pending_resume
6626 );
6627
6628 let mut cmds: Vec<InlineCommand> = Vec::new();
6630 while let Ok(cmd) = cmd_rx.try_recv() {
6631 cmds.push(cmd);
6632 }
6633 let mut found_error = false;
6634 let mut error_text = String::new();
6635 for cmd in &cmds {
6636 if let InlineCommand::AppendLine { kind, segments } = cmd
6637 && matches!(kind, InlineMessageKind::Error)
6638 {
6639 error_text = segments
6640 .iter()
6641 .map(|s| s.text.as_str())
6642 .collect::<Vec<_>>()
6643 .join("");
6644 found_error = true;
6645 }
6646 }
6647 assert!(
6648 found_error,
6649 "expected an error AppendLine (commands: {})",
6650 summarise(&cmds)
6651 );
6652 assert!(
6653 error_text.contains("Cannot resume while agent is running"),
6654 "error text must match the direct-path wording (got {error_text:?})"
6655 );
6656
6657 session
6660 .streaming_flag()
6661 .store(false, std::sync::atomic::Ordering::SeqCst);
6662 }
6663}